Idle sessions sign themselves out; a KVM's login is remembered; the compose editor knows YAML
A session unused for seven days is dropped, the Sessions list marks a day's idleness and can sign all of those out at once. The hub keeps a KVM's own cookies from any browser that has them and lends them to one that hasn't, on the wire and back to the page. The stack editor and the new-stack dialog use CodeEditor: line numbers, YAML and .env colour under a real textarea, Tab/Shift+Tab/Enter indentation, and a key bar on touch screens. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
faaa6aaf9d
commit
f3ca3d8f73
@@ -0,0 +1,150 @@
|
||||
// A textarea that knows what it is holding: line numbers in a gutter, YAML (or a .env)
|
||||
// coloured underneath the text, and the keys a compose file needs — Tab indents by two,
|
||||
// Shift+Tab outdents, Enter keeps the indentation of the line above (one step deeper after
|
||||
// a key or a list dash), and a phone, whose keyboard has none of those, gets a small bar of
|
||||
// them. The text itself stays in a real textarea, so selection, undo and the keyboard are
|
||||
// the browser's own; the colour is a <pre> drawn behind it, kept in step line for line.
|
||||
import { useEffect, useMemo, useRef, useState, ReactNode } from 'react'
|
||||
|
||||
export type Language = 'yaml' | 'env' | 'plain'
|
||||
|
||||
interface Props {
|
||||
value: string; onChange: (v: string) => void; language?: Language; className?: string
|
||||
readOnly?: boolean; placeholder?: string; spellCheck?: boolean
|
||||
}
|
||||
|
||||
const INDENT = ' '
|
||||
const coarse = () => typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches
|
||||
|
||||
// One line of YAML, split into spans by what each part is. Deliberately shallow: a compose
|
||||
// file is keys, scalars, lists and comments; anything cleverer is a parser, which this is not.
|
||||
function yamlLine(line: string): ReactNode[] {
|
||||
const out: ReactNode[] = []
|
||||
let i = 0
|
||||
const push = (cls: string, text: string) => { if (text) out.push(cls ? <span key={out.length} className={cls}>{text}</span> : text) }
|
||||
const m = /^(\s*)(-\s+)?/.exec(line)!
|
||||
push('', m[1]); push('y-dash', m[2] ?? ''); i = m[0].length
|
||||
const rest = line.slice(i)
|
||||
if (rest.startsWith('#')) { push('y-comment', rest); return out }
|
||||
// a key: up to the first colon that is followed by a space or the end
|
||||
const key = /^("[^"]*"|'[^']*'|[^\s:#][^:#]*?)(:)(?=\s|$)/.exec(rest)
|
||||
let tail = rest
|
||||
if (key) { push('y-key', key[1]); push('y-colon', ':'); tail = rest.slice(key[0].length) }
|
||||
// the value, with a trailing comment cut off
|
||||
let value = tail, comment = ''
|
||||
const c = / #.*$/.exec(tail) ?? (tail.startsWith('#') ? /#.*$/.exec(tail) : null)
|
||||
if (c) { value = tail.slice(0, c.index); comment = tail.slice(c.index) }
|
||||
const v = value.trim()
|
||||
const lead = value.slice(0, value.length - value.trimStart().length)
|
||||
push('', lead)
|
||||
if (!v) { /* nothing */ }
|
||||
else if (/^(["']).*\1$/.test(v)) push('y-string', v)
|
||||
else if (/^(true|false|yes|no|on|off|null|~)$/i.test(v)) push('y-bool', v)
|
||||
else if (/^[-+]?(\d[\d_]*(\.\d+)?|\.\d+)([eE][-+]?\d+)?$/.test(v) || /^0x[0-9a-fA-F]+$/.test(v)) push('y-number', v)
|
||||
else if (/^[&*!]/.test(v)) push('y-anchor', v)
|
||||
else if (/^[|>][-+]?\d*$/.test(v)) push('y-block', v)
|
||||
else push(key ? 'y-value' : (m[2] ? 'y-value' : ''), v)
|
||||
push('', value.slice(lead.length + v.length))
|
||||
push('y-comment', comment)
|
||||
return out
|
||||
}
|
||||
|
||||
function envLine(line: string): ReactNode[] {
|
||||
if (/^\s*#/.test(line)) return [<span key="c" className="y-comment">{line}</span>]
|
||||
const m = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_.-]*)(=)(.*)$/.exec(line)
|
||||
if (!m) return [line]
|
||||
return [m[1], <span key="k" className="y-key">{m[2]}</span>, <span key="e" className="y-colon">=</span>, <span key="v" className="y-string">{m[4]}</span>]
|
||||
}
|
||||
|
||||
function highlight(text: string, lang: Language): ReactNode[] {
|
||||
const lines = text.split('\n')
|
||||
const f = lang === 'yaml' ? yamlLine : lang === 'env' ? envLine : (l: string) => [l] as ReactNode[]
|
||||
return lines.map((l, i) => <div key={i} className="code-line">{f(l)}{l === '' ? '' : ''}</div>)
|
||||
}
|
||||
|
||||
// setText replaces [start, end) in a textarea with s, through the browser's own edit so Undo
|
||||
// still works, and leaves the caret where asked.
|
||||
function setText(ta: HTMLTextAreaElement, start: number, end: number, s: string, caret: number | [number, number]) {
|
||||
ta.focus()
|
||||
ta.setSelectionRange(start, end)
|
||||
let done = false
|
||||
try { done = document.execCommand('insertText', false, s) } catch { /* not every browser */ }
|
||||
if (!done || ta.value.slice(start, start + s.length) !== s) { ta.setRangeText(s, start, end, 'end'); ta.dispatchEvent(new Event('input', { bubbles: true })) }
|
||||
if (Array.isArray(caret)) ta.setSelectionRange(caret[0], caret[1]); else ta.setSelectionRange(caret, caret)
|
||||
}
|
||||
|
||||
export default function CodeEditor({ value, onChange, language = 'yaml', className = '', readOnly, placeholder, spellCheck = false }: Props) {
|
||||
const ta = useRef<HTMLTextAreaElement>(null)
|
||||
const pre = useRef<HTMLPreElement>(null)
|
||||
const gutter = useRef<HTMLDivElement>(null)
|
||||
const [touch] = useState(coarse)
|
||||
const lines = useMemo(() => value.split('\n').length, [value])
|
||||
const painted = useMemo(() => highlight(value, language), [value, language])
|
||||
|
||||
// the colour and the numbers follow the text's own scrolling
|
||||
const sync = () => { const t = ta.current; if (!t) return; if (pre.current) { pre.current.scrollTop = t.scrollTop; pre.current.scrollLeft = t.scrollLeft } if (gutter.current) gutter.current.scrollTop = t.scrollTop }
|
||||
useEffect(sync, [value])
|
||||
|
||||
// lines touched by the selection, for indent and outdent
|
||||
const lineRange = (t: HTMLTextAreaElement) => {
|
||||
const s = t.value.lastIndexOf('\n', t.selectionStart - 1) + 1
|
||||
let e = t.value.indexOf('\n', Math.max(t.selectionEnd - (t.selectionEnd > t.selectionStart && t.value[t.selectionEnd - 1] === '\n' ? 1 : 0), t.selectionStart))
|
||||
if (e < 0) e = t.value.length
|
||||
return [s, e] as const
|
||||
}
|
||||
const indent = (out: boolean) => {
|
||||
const t = ta.current; if (!t || readOnly) return
|
||||
const [s, e] = lineRange(t)
|
||||
const block = t.value.slice(s, e)
|
||||
const sel = t.selectionStart !== t.selectionEnd || block.includes('\n')
|
||||
if (!sel && !out) { const at = t.selectionStart; setText(t, at, at, INDENT, at + INDENT.length); return }
|
||||
const next = block.split('\n').map((l) => (out ? l.replace(/^ {1,2}/, '') : INDENT + l)).join('\n')
|
||||
if (next === block) return
|
||||
const d0 = out ? -(block.length - next.length) : INDENT.length
|
||||
setText(t, s, e, next, sel ? [s, s + next.length] : [Math.max(s, t.selectionStart + d0), Math.max(s, t.selectionStart + d0)])
|
||||
}
|
||||
const comment = () => {
|
||||
const t = ta.current; if (!t || readOnly) return
|
||||
const [s, e] = lineRange(t)
|
||||
const block = t.value.slice(s, e), ls = block.split('\n')
|
||||
const all = ls.every((l) => /^\s*#/.test(l) || !l.trim())
|
||||
const next = ls.map((l) => (!l.trim() ? l : all ? l.replace(/^(\s*)# ?/, '$1') : l.replace(/^(\s*)/, '$1# '))).join('\n')
|
||||
setText(t, s, e, next, [s, s + next.length])
|
||||
}
|
||||
const newline = () => {
|
||||
const t = ta.current; if (!t || readOnly) return
|
||||
const at = t.selectionStart
|
||||
const lineStart = t.value.lastIndexOf('\n', at - 1) + 1
|
||||
const line = t.value.slice(lineStart, at)
|
||||
let ind = /^\s*/.exec(line)![0]
|
||||
// one step deeper after a key or a list item that opens a block
|
||||
if (language === 'yaml' && /(:|^\s*-)\s*(#.*)?$/.test(line) && line.trim() && !/^\s*#/.test(line)) ind += INDENT
|
||||
setText(t, at, t.selectionEnd, '\n' + ind, at + 1 + ind.length)
|
||||
}
|
||||
const onKey = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); indent(e.shiftKey) }
|
||||
else if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { e.preventDefault(); newline() }
|
||||
else if (e.key === '/' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); comment() }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'code ' + className + (readOnly ? ' ro' : '')}>
|
||||
<div className="code-gutter" ref={gutter} aria-hidden="true">{Array.from({ length: lines }, (_, i) => <div key={i}>{i + 1}</div>)}</div>
|
||||
<div className="code-body">
|
||||
<pre className="code-paint" ref={pre} aria-hidden="true">{painted}</pre>
|
||||
<textarea ref={ta} className="code-text" value={value} onChange={(e) => onChange(e.target.value)} onScroll={sync} onKeyDown={onKey}
|
||||
readOnly={readOnly} placeholder={placeholder} spellCheck={spellCheck} autoCapitalize="off" autoCorrect="off" autoComplete="off" wrap="off" />
|
||||
</div>
|
||||
{touch && !readOnly && (
|
||||
<div className="code-keys" onMouseDown={(e) => e.preventDefault()}>
|
||||
<button type="button" title="outdent" onClick={() => indent(true)}>⇤</button>
|
||||
<button type="button" title="indent" onClick={() => indent(false)}>⇥</button>
|
||||
<button type="button" title="comment" onClick={comment}>#</button>
|
||||
<button type="button" title="list item" onClick={() => { const t = ta.current; if (t) setText(t, t.selectionStart, t.selectionEnd, '- ', t.selectionStart + 2) }}>-</button>
|
||||
<button type="button" title="colon" onClick={() => { const t = ta.current; if (t) setText(t, t.selectionStart, t.selectionEnd, ': ', t.selectionStart + 2) }}>:</button>
|
||||
<button type="button" title="quotes" onClick={() => { const t = ta.current; if (t) setText(t, t.selectionStart, t.selectionEnd, '"' + t.value.slice(t.selectionStart, t.selectionEnd) + '"', t.selectionEnd + 1) }}>"</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+2
-1
@@ -5,6 +5,7 @@ import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 're
|
||||
import { api, DockerContainer, DockerEvent, DockerHealth, DockerInfo, DockerLogLine, ImageCheck, MachineUpdates, Stack, StackDiff, UpdateJob, UpdateJobRow, wsURLPath } from './api'
|
||||
import TaskOutput from './TaskOutput'
|
||||
import StackNew, { DupFrom } from './StackNew'
|
||||
import CodeEditor from './CodeEditor'
|
||||
import { Sensor, fetchHistories, fmtValue, useSensorStream } from './sensors'
|
||||
import { useHashSel } from './hashsel'
|
||||
import { fmtWindowSec, useHistorySec, useRefreshMs } from './prefs'
|
||||
@@ -357,7 +358,7 @@ function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name
|
||||
<div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{file === 'env' ? (st.host_file.replace(/[^/]+$/, '') + '.env') : (st.host_files?.[file] ?? st.host_file)}{st.held_by && <span className="muted"> · kept on <b>{st.held_by}</b>, deployed to this machine over {st.remote}</span>}{st.via && <span className="muted"> · compose runs inside <b>{st.via}</b>, as it was deployed</span>}{st.has_env && ' · has a .env beside it'}</div>
|
||||
{((st.host_files?.length ?? 0) > 1 || st.has_env) && <div className="chips" title="this stack is deployed from several files, in this order; Deploy uses all of them">{st.host_files!.map((f, i) => <button key={f} className={'chip' + (i === file ? ' on' : '')} onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile(i) }}>{f.split('/').pop()}</button>)}{st.has_env && <button className={'chip' + (file === 'env' ? ' on' : '')} title="the .env beside the compose file: variables compose substitutes" onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile('env') }}>.env</button>}</div>}
|
||||
{file === 'env' && !reveal ? <div className="env-masked"><pre className="editor stack-editor">{maskEnv(text)}</pre><button className="btn" onClick={() => setReveal(true)}>Reveal and edit</button></div> :
|
||||
<textarea className="editor stack-editor" value={text} onChange={(e) => setText(e.target.value)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />}
|
||||
<CodeEditor className="stack-editor" language={file === 'env' ? 'env' : 'yaml'} value={text} onChange={setText} />}
|
||||
{err && <div className="error" style={{ whiteSpace: 'pre-wrap' }}>{err}{/changed on disk/.test(err) && <> <button className="link" onClick={async () => { if (await askYes('Reload the file from disk? Your text in the box is replaced.', 'Reload')) load() }}>reload it</button></>}</div>}
|
||||
<div className="svc-actions">
|
||||
<Btn className="btn on" disabled={!dirty} onClick={save}>Save</Btn>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api, StackHome } from './api'
|
||||
import { askYes, Btn, Loading, useEscape } from './ui'
|
||||
import CodeEditor from './CodeEditor'
|
||||
|
||||
interface Form { image: string; ports: string; volumes: string; env: string; labels: string; restart: string; command: string; network: string }
|
||||
const EMPTY: Form = { image: '', ports: '', volumes: '', env: '', labels: '', restart: 'unless-stopped', command: '', network: '' }
|
||||
@@ -119,7 +120,7 @@ export default function StackNew({ mode, from, networks = [], taken, onClose, on
|
||||
<label className="muted">Command (optional)<input className="mono" value={form.command} onChange={set('command')} spellCheck={false} autoCapitalize="off" /></label>
|
||||
<pre className="unit-file stack-preview">{built}</pre>
|
||||
</> : <>
|
||||
<textarea className="editor stack-editor" value={text} onChange={(e) => setText(e.target.value)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />
|
||||
<CodeEditor className="stack-editor" language="yaml" value={text} onChange={setText} />
|
||||
{from?.hasEnv && <label className="check"><input type="checkbox" checked={copyEnv} onChange={(e) => setCopyEnv(e.target.checked)} /> <span>Copy {from.name}'s .env too <span className="muted">— copied on the machine itself; its values are never shown here</span></span></label>}
|
||||
{!(from && copyEnv) && <label className="muted">.env (optional) — variables the compose file reads; kept readable by root only
|
||||
<textarea className="mono" rows={2} value={env} onChange={(e) => setEnv(e.target.value)} placeholder={'SECRET=…'} spellCheck={false} />
|
||||
|
||||
+13
-2
@@ -133,6 +133,13 @@ function Sessions() {
|
||||
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
const others = list?.filter((s) => !s.current).length ?? 0
|
||||
// a day unused is the line between a device that is used and a script that signed in once
|
||||
const IDLE_H = 24
|
||||
const idle = list?.filter((s) => !s.current && Date.now() - new Date(s.last_seen).getTime() > IDLE_H * 3600e3).length ?? 0
|
||||
const revokeIdle = async () => {
|
||||
if (!await askYes(`Sign out ${idle} ${idle === 1 ? 'session' : 'sessions'} not used in the last ${IDLE_H} hours? Devices used since then, and this one, stay signed in.`, 'Revoke')) return
|
||||
try { const r = await api.revokeIdleSessions(IDLE_H); toast(`${r.revoked} signed out`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
const revokeOthers = async () => {
|
||||
if (!await askYes(`Sign out ${others} other ${others === 1 ? 'device' : 'devices'}? This one stays signed in.`)) return
|
||||
try { const r = await api.revokeOtherSessions(); toast(`${r.revoked} signed out`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
@@ -145,11 +152,15 @@ function Sessions() {
|
||||
{list && list.map((s) => (
|
||||
<div className="row" key={s.id}>
|
||||
<span className="k">{device(s.agent)}{s.current && <span className="badge" style={{ marginLeft: 6 }}>this device</span>}</span>
|
||||
<span className="muted small">{s.ip ?? '?'} · seen {ago(s.last_seen)} · since {new Date(s.created).toLocaleDateString()}</span>
|
||||
<span className="muted small">{s.ip ?? '?'} · seen {ago(s.last_seen)} · since {new Date(s.created).toLocaleDateString()}{!s.current && Date.now() - new Date(s.last_seen).getTime() > IDLE_H * 3600e3 ? <span className="badge" style={{ marginLeft: 6 }}>idle</span> : null}</span>
|
||||
<Btn className={'btn' + (s.current ? '' : ' danger')} onClick={() => revoke(s)}>{s.current ? 'Sign out' : 'Revoke'}</Btn>
|
||||
</div>
|
||||
))}
|
||||
{others > 0 && <div className="svc-actions" style={{ marginTop: 8 }}><Btn className="btn danger" onClick={revokeOthers}>Sign out everywhere else</Btn></div>}
|
||||
{others > 0 && <div className="svc-actions" style={{ marginTop: 8 }}>
|
||||
{idle > 0 && <Btn className="btn" onClick={revokeIdle}>Sign out {idle} idle</Btn>}
|
||||
<Btn className="btn danger" onClick={revokeOthers}>Sign out everywhere else</Btn>
|
||||
<span className="muted small">A session unused for a week signs itself out.</span>
|
||||
</div>}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -344,6 +344,7 @@ export const api = {
|
||||
sessions: () => req<Session[]>('GET', '/api/sessions', undefined, true),
|
||||
revokeSession: (id: string) => req<void>('DELETE', `/api/sessions/${encodeURIComponent(id)}`, undefined, true),
|
||||
revokeOtherSessions: () => req<{ revoked: number }>('POST', '/api/sessions/revoke-others', undefined, true),
|
||||
revokeIdleSessions: (hours = 24) => req<{ revoked: number }>('POST', `/api/sessions/revoke-idle?hours=${hours}`, undefined, true),
|
||||
logout: () => req<void>('POST', '/api/logout', undefined, true),
|
||||
meHere: () => req<Me>('GET', '/api/me', undefined, true),
|
||||
me: () => req<Me>('GET', '/api/me'),
|
||||
|
||||
+22
-1
@@ -607,7 +607,28 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; }
|
||||
.docker .svc-list { min-width: 0; max-width: 100%; } .docker .svc-rows { overflow-x: hidden; } .docker .svc-name .d { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.docker:not(.has-sel) .svc-list { flex: 1 1 0; }
|
||||
.stack-editor { flex: 1; min-height: 46vh; font-size: 13px; tab-size: 2; white-space: pre; overflow: auto; }
|
||||
@media (max-width: 800px) { .docker .svc-list { width: 100%; flex: 1 1 auto; } .stack-editor { min-height: 52vh; font-size: 12px; } }
|
||||
|
||||
/* The code editor: a gutter of line numbers, a painted copy of the text behind a textarea
|
||||
whose own text is transparent, both in the same monospace at the same size so they line up
|
||||
glyph for glyph. .code takes the sizing class the textarea used to (.stack-editor), so it
|
||||
sits where that did. */
|
||||
.code { display: flex; align-items: stretch; background: var(--term-bg); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; position: relative; padding: 0; min-height: 260px; }
|
||||
.code.ro .code-text { color: var(--muted); }
|
||||
.code-gutter { flex: 0 0 auto; min-width: 2.6em; padding: 10px 6px 10px 0; text-align: right; color: var(--muted); opacity: .7; background: var(--bg2); border-right: 1px solid var(--border); overflow: hidden; user-select: none; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }
|
||||
.code-body { flex: 1; position: relative; min-width: 0; }
|
||||
.code-paint, .code-text { margin: 0; padding: 10px; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; tab-size: 2; white-space: pre; letter-spacing: 0; word-spacing: 0; }
|
||||
.code-paint { position: absolute; inset: 0; overflow: hidden; color: var(--fg); pointer-events: none; }
|
||||
.code-text { position: absolute; inset: 0; width: 100%; height: 100%; background: transparent; color: transparent; caret-color: var(--fg); border: 0; outline: 0; resize: none; overflow: auto; }
|
||||
.code-text::placeholder { color: var(--muted); }
|
||||
.code-text::selection { background: rgba(127, 127, 127, .35); }
|
||||
.stack-editor.code, .stack-editor .code-paint, .stack-editor .code-text, .stack-editor .code-gutter { font-size: 13px; }
|
||||
.y-key { color: var(--accent2); } .y-colon, .y-dash { color: var(--muted); } .y-string { color: var(--ok); } .y-number, .y-bool { color: var(--s2); } .y-comment { color: var(--muted); font-style: italic; } .y-anchor, .y-block { color: var(--accent); } .y-value { color: var(--fg); }
|
||||
/* a phone has no Tab key: a bar of the strokes a compose file needs, pinned to the editor's foot */
|
||||
.code-keys { position: absolute; left: 0; right: 0; bottom: 0; display: flex; gap: 6px; padding: 6px 8px; background: var(--bg2); border-top: 1px solid var(--border); z-index: 1; }
|
||||
.code-keys button { min-width: 40px; height: 32px; padding: 0 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg3); color: var(--fg); font: 15px ui-monospace, monospace; }
|
||||
.code-keys ~ * { display: none; }
|
||||
@media (pointer: coarse) { .code .code-body { padding-bottom: 45px; } .code .code-gutter { padding-bottom: 55px; } }
|
||||
@media (max-width: 800px) { .docker .svc-list { width: 100%; flex: 1 1 auto; } .stack-editor { min-height: 52vh; font-size: 12px; } .stack-editor.code, .stack-editor .code-paint, .stack-editor .code-text, .stack-editor .code-gutter { font-size: 12px; } }
|
||||
|
||||
/* stacks: diff, images, .env */
|
||||
.stack-extra { display: flex; flex-direction: column; gap: 6px; border-top: 1px solid var(--border); padding-top: 10px; } .stack-extra .sub-head .k { font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
|
||||
|
||||
Reference in New Issue
Block a user