HASH 607772a649b9
DATE 2026-07-17
SUBJECT web: form frame, display components, nav buttons
FILES 23 CHANGED HASH 607772a649b9
DATE 2026-07-17
SUBJECT web: form frame, display components,
nav buttons
FILES 23 CHANGED ┌─ ooknet-design/src/components/Button/Button.astro ──────────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Button/Button.astro b/ooknet-design/src/ │
│ components/Button/Button.astro │
│ index f6ac8a9..556296c 100644 │
│ --- a/ooknet-design/src/components/Button/Button.astro │
│ +++ b/ooknet-design/src/components/Button/Button.astro │
│ @@ -2,8 +2,8 @@ │
│ import type { Props } from "./Button"; │
│ import "./Button.scss"; │
│ │
│ -const { label, href, type = "button", variant = "default", disabled = false } = A │
│ stro.props as Props; │
│ +const { label, href, type = "button", variant = "default", disabled = false, curr │
│ ent = false } = Astro.props as Props; │
│ --- │
│ {href && !disabled │
│ - ? <a href={href} class:list={["btn", variant]}>[ {label} ]</a> │
│ + ? <a href={href} class:list={["btn", variant]} aria-current={current ? "page" : │
│ undefined}>[ {label} ]</a> │
│ : <button type={type} class:list={["btn", variant]} disabled={disabled}>[ {labe │
│ l} ]</button>} │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ ...omponents/Button/Button.astro ── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Bu │
│ tton/Button.astro b/ooknet-design/src/compon │
│ ents/Button/Button.astro │
│ index f6ac8a9..556296c 100644 │
│ --- a/ooknet-design/src/components/Button/Bu │
│ tton.astro │
│ +++ b/ooknet-design/src/components/Button/Bu │
│ tton.astro │
│ @@ -2,8 +2,8 @@ │
│ import type { Props } from "./Button"; │
│ import "./Button.scss"; │
│ │
│ -const { label, href, type = "button", varia │
│ nt = "default", disabled = false } = Astro.p │
│ rops as Props; │
│ +const { label, href, type = "button", varia │
│ nt = "default", disabled = false, current = │
│ false } = Astro.props as Props; │
│ --- │
│ {href && !disabled │
│ - ? <a href={href} class:list={["btn", vari │
│ ant]}>[ {label} ]</a> │
│ + ? <a href={href} class:list={["btn", vari │
│ ant]} aria-current={current ? "page" : undef │
│ ined}>[ {label} ]</a> │
│ : <button type={type} class:list={["btn", │
│ variant]} disabled={disabled}>[ {label} ]</ │
│ button>} │
└──────────────────────────────────────────────┘ ┌─ ooknet-design/src/components/Button/Button.ts ─────────────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Button/Button.ts b/ooknet-design/src/com │
│ ponents/Button/Button.ts │
│ index b2adbc9..7f9650b 100644 │
│ --- a/ooknet-design/src/components/Button/Button.ts │
│ +++ b/ooknet-design/src/components/Button/Button.ts │
│ @@ -3,9 +3,11 @@ import { len } from "../../lib/ascii"; │
│ export interface Props { │
│ label: string; │
│ href?: string; │
│ - type?: "button" | "submit"; │
│ + type?: "button" | "submit" | "reset"; │
│ variant?: "default" | "primary"; │
│ disabled?: boolean; │
│ + /** Marks a link as the current page (aria-current). */ │
│ + current?: boolean; │
│ } │
│ │
│ // "[ " + label + " ]" │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ ...c/components/Button/Button.ts ── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Bu │
│ tton/Button.ts b/ooknet-design/src/component │
│ s/Button/Button.ts │
│ index b2adbc9..7f9650b 100644 │
│ --- a/ooknet-design/src/components/Button/Bu │
│ tton.ts │
│ +++ b/ooknet-design/src/components/Button/Bu │
│ tton.ts │
│ @@ -3,9 +3,11 @@ import { len } from "../../ │
│ lib/ascii"; │
│ export interface Props { │
│ label: string; │
│ href?: string; │
│ - type?: "button" | "submit"; │
│ + type?: "button" | "submit" | "reset"; │
│ variant?: "default" | "primary"; │
│ disabled?: boolean; │
│ + /** Marks a link as the current page (ari │
│ a-current). */ │
│ + current?: boolean; │
│ } │
│ │
│ // "[ " + label + " ]" │
└──────────────────────────────────────────────┘ ┌─ ooknet-design/src/components/FormFrame/FormFrame.astro ────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/FormFrame/FormFrame.astro b/ooknet-desig │
│ n/src/components/FormFrame/FormFrame.astro │
│ new file mode 100644 │
│ index 0000000..abadfc8 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/FormFrame/FormFrame.astro │
│ @@ -0,0 +1,87 @@ │
│ +--- │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import TextField from "../TextField/TextField.astro"; │
│ +import Select from "../Select/Select.astro"; │
│ +import { selectWidth } from "../Select/Select"; │
│ +import Radio from "../Radio/Radio.astro"; │
│ +import { radioWidth } from "../Radio/Radio"; │
│ +import Checkbox from "../Checkbox/Checkbox.astro"; │
│ +import { checkboxWidth } from "../Checkbox/Checkbox"; │
│ +import Button from "../Button/Button.astro"; │
│ +import { buttonWidth } from "../Button/Button"; │
│ +import { G, checkGrid, len, pad } from "../../lib/ascii"; │
│ +import { MOBILE_FRAME_W } from "../../lib/config"; │
│ +import { formGeometry, FORM_LABEL_W, FORM_INDENT, type Props } from "./FormFrame" │
│ ; │
│ +import "./FormFrame.scss"; │
│ + │
│ +const { title, fields, width = MOBILE_FRAME_W, action, method } = Astro.props as │
│ Props; │
│ +const { inner, contentW, defaultCells, textareaCells } = formGeometry(width); │
│ + │
│ +const ctx = `FormFrame(${title})`; │
│ +const open = `${G.V}${" ".repeat(FORM_INDENT)}`; │
│ +const close = (used: number) => `${" ".repeat(Math.max(0, contentW - used))}${G.V │
│ }`; │
│ +const lbl = (s: string) => pad(s, FORM_LABEL_W); │
│ + │
│ +const top = checkGrid( │
│ + `${G.TL}${G.H} ${title} ${G.H.repeat(Math.max(0, inner - 3 - len(title)))}${G.T │
│ R}`, │
│ + width, │
│ + ctx, │
│ +); │
│ +const blank = `${G.V}${" ".repeat(inner)}${G.V}`; │
│ +const bottom = `${G.BL}${G.H.repeat(inner)}${G.BR}`; │
│ +--- │
│ +<form class="form-frame" action={action} method={method}> │
│ + <Pre>{top}</Pre> │
│ + <Pre>{blank}</Pre> │
│ + {fields.map((f) => { │
│ + if (f.kind === "gap") return <Pre>{blank}</Pre>; │
│ + │
│ + if (f.kind === "text" || f.kind === "password") { │
│ + const cells = f.cells ?? defaultCells; │
│ + return <Pre>{open}{lbl(f.label)}<TextField name={f.name} cells={cells} type │
│ ={f.kind} />{close(FORM_LABEL_W + cells)}</Pre>; │
│ + } │
│ + │
│ + if (f.kind === "select") { │
│ + const cells = f.cells ?? defaultCells - 6; │
│ + return <Pre>{open}{lbl(f.label)}<Select name={f.name} options={f.options} c │
│ ells={cells} value={f.value} />{close(FORM_LABEL_W + selectWidth(cells))}</Pre>; │
│ + } │
│ + │
│ + if (f.kind === "radios") { │
│ + return f.options.map((opt, i) => ( │
│ + <Pre>{open}{i === 0 ? lbl(f.label) : " ".repeat(FORM_LABEL_W)}<Radio labe │
│ l={opt} name={f.name} value={opt.toLowerCase()} checked={f.value === opt} />{close │
│ (FORM_LABEL_W + radioWidth(opt))}</Pre> │
│ + )); │
│ + } │
│ + │
│ + if (f.kind === "checkbox") { │
│ + return <Pre>{open}<Checkbox label={f.label} name={f.name} checked={f.checke │
│ d} />{close(checkboxWidth(f.label))}</Pre>; │
│ + } │
│ + │
│ + if (f.kind === "textarea") { │
│ + const rows = f.rows ?? 4; │
│ + return ( │
│ + <Fragment> │
│ + <Pre>{open}{checkGrid(f.label, contentW, ctx)}{close(len(f.label))}</Pr │
│ e> │
│ + <div class="form-textarea"> │
│ + {Array.from({ length: rows }, () => ( │
│ + <Pre>{blank}</Pre> │
│ + ))} │
│ + <textarea │
│ + name={f.name} │
│ + rows={rows} │
│ + spellcheck="false" │
│ + aria-label={f.label} │
│ + style={`left: calc(var(--cell-w) * ${1 + FORM_INDENT}); width: calc │
│ (var(--cell-w) * ${textareaCells}); height: calc(var(--cell-h) * ${rows})`} │
│ + ></textarea> │
│ + </div> │
│ + </Fragment> │
│ + ); │
│ + } │
│ + │
│ + const used = f.buttons.reduce((s, b, i) => s + buttonWidth(b.label) + (i ? 2 │
│ : 0), 0); │
│ + return <Pre>{open}{f.buttons.map((b, i) => ( │
│ + <Fragment>{i > 0 ? " " : ""}<Button label={b.label} type={b.type ?? "butto │
│ n"} variant={b.variant} /></Fragment> │
│ + ))}{close(used)}</Pre>; │
│ + })} │
│ + <Pre>{blank}</Pre> │
│ + <Pre>{bottom}</Pre> │
│ +</form> │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ ...nts/FormFrame/FormFrame.astro ── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Fo │
│ rmFrame/FormFrame.astro b/ooknet-design/src/ │
│ components/FormFrame/FormFrame.astro │
│ new file mode 100644 │
│ index 0000000..abadfc8 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/FormFrame │
│ /FormFrame.astro │
│ @@ -0,0 +1,87 @@ │
│ +--- │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import TextField from "../TextField/TextFie │
│ ld.astro"; │
│ +import Select from "../Select/Select.astro" │
│ ; │
│ +import { selectWidth } from "../Select/Sele │
│ ct"; │
│ +import Radio from "../Radio/Radio.astro"; │
│ +import { radioWidth } from "../Radio/Radio" │
│ ; │
│ +import Checkbox from "../Checkbox/Checkbox. │
│ astro"; │
│ +import { checkboxWidth } from "../Checkbox/ │
│ Checkbox"; │
│ +import Button from "../Button/Button.astro" │
│ ; │
│ +import { buttonWidth } from "../Button/Butt │
│ on"; │
│ +import { G, checkGrid, len, pad } from "../ │
│ ../lib/ascii"; │
│ +import { MOBILE_FRAME_W } from "../../lib/c │
│ onfig"; │
│ +import { formGeometry, FORM_LABEL_W, FORM_I │
│ NDENT, type Props } from "./FormFrame"; │
│ +import "./FormFrame.scss"; │
│ + │
│ +const { title, fields, width = MOBILE_FRAME │
│ _W, action, method } = Astro.props as Props; │
│ +const { inner, contentW, defaultCells, text │
│ areaCells } = formGeometry(width); │
│ + │
│ +const ctx = `FormFrame(${title})`; │
│ +const open = `${G.V}${" ".repeat(FORM_INDEN │
│ T)}`; │
│ +const close = (used: number) => `${" ".repe │
│ at(Math.max(0, contentW - used))}${G.V}`; │
│ +const lbl = (s: string) => pad(s, FORM_LABE │
│ L_W); │
│ + │
│ +const top = checkGrid( │
│ + `${G.TL}${G.H} ${title} ${G.H.repeat(Math │
│ .max(0, inner - 3 - len(title)))}${G.TR}`, │
│ + width, │
│ + ctx, │
│ +); │
│ +const blank = `${G.V}${" ".repeat(inner)}${ │
│ G.V}`; │
│ +const bottom = `${G.BL}${G.H.repeat(inner)} │
│ ${G.BR}`; │
│ +--- │
│ +<form class="form-frame" action={action} me │
│ thod={method}> │
│ + <Pre>{top}</Pre> │
│ + <Pre>{blank}</Pre> │
│ + {fields.map((f) => { │
│ + if (f.kind === "gap") return <Pre>{blan │
│ k}</Pre>; │
│ + │
│ + if (f.kind === "text" || f.kind === "pa │
│ ssword") { │
│ + const cells = f.cells ?? defaultCells │
│ ; │
│ + return <Pre>{open}{lbl(f.label)}<Text │
│ Field name={f.name} cells={cells} type={f.ki │
│ nd} />{close(FORM_LABEL_W + cells)}</Pre>; │
│ + } │
│ + │
│ + if (f.kind === "select") { │
│ + const cells = f.cells ?? defaultCells │
│ - 6; │
│ + return <Pre>{open}{lbl(f.label)}<Sele │
│ ct name={f.name} options={f.options} cells={ │
│ cells} value={f.value} />{close(FORM_LABEL_W │
│ + selectWidth(cells))}</Pre>; │
│ + } │
│ + │
│ + if (f.kind === "radios") { │
│ + return f.options.map((opt, i) => ( │
│ + <Pre>{open}{i === 0 ? lbl(f.label) │
│ : " ".repeat(FORM_LABEL_W)}<Radio label={opt │
│ } name={f.name} value={opt.toLowerCase()} ch │
│ ecked={f.value === opt} />{close(FORM_LABEL_ │
│ W + radioWidth(opt))}</Pre> │
│ + )); │
│ + } │
│ + │
│ + if (f.kind === "checkbox") { │
│ + return <Pre>{open}<Checkbox label={f. │
│ label} name={f.name} checked={f.checked} />{ │
│ close(checkboxWidth(f.label))}</Pre>; │
│ + } │
│ + │
│ + if (f.kind === "textarea") { │
│ + const rows = f.rows ?? 4; │
│ + return ( │
│ + <Fragment> │
│ + <Pre>{open}{checkGrid(f.label, co │
│ ntentW, ctx)}{close(len(f.label))}</Pre> │
│ + <div class="form-textarea"> │
│ + {Array.from({ length: rows }, ( │
│ ) => ( │
│ + <Pre>{blank}</Pre> │
│ + ))} │
│ + <textarea │
│ + name={f.name} │
│ + rows={rows} │
│ + spellcheck="false" │
│ + aria-label={f.label} │
│ + style={`left: calc(var(--cell │
│ -w) * ${1 + FORM_INDENT}); width: calc(var(- │
│ -cell-w) * ${textareaCells}); height: calc(v │
│ ar(--cell-h) * ${rows})`} │
│ + ></textarea> │
│ + </div> │
│ + </Fragment> │
│ + ); │
│ + } │
│ + │
│ + const used = f.buttons.reduce((s, b, i) │
│ => s + buttonWidth(b.label) + (i ? 2 : 0), │
│ 0); │
│ + return <Pre>{open}{f.buttons.map((b, i) │
│ => ( │
│ + <Fragment>{i > 0 ? " " : ""}<Button │
│ label={b.label} type={b.type ?? "button"} va │
│ riant={b.variant} /></Fragment> │
│ + ))}{close(used)}</Pre>; │
│ + })} │
│ + <Pre>{blank}</Pre> │
│ + <Pre>{bottom}</Pre> │
│ +</form> │
└──────────────────────────────────────────────┘ ┌─ ooknet-design/src/components/FormFrame/FormFrame.scss ─────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/FormFrame/FormFrame.scss b/ooknet-design │
│ /src/components/FormFrame/FormFrame.scss │
│ new file mode 100644 │
│ index 0000000..00e5694 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/FormFrame/FormFrame.scss │
│ @@ -0,0 +1,38 @@ │
│ +.form-frame { │
│ + margin: 0; │
│ +} │
│ + │
│ +// The textarea floats over pre-rendered blank frame rows so the box │
│ +// borders stay build-time strings; offsets are set inline in cells. │
│ +.form-textarea { │
│ + position: relative; │
│ + │
│ + textarea { │
│ + position: absolute; │
│ + top: 0; │
│ + font-family: var(--mono-frame); │
│ + font-size: var(--fs); │
│ + line-height: var(--lh); │
│ + color: inherit; │
│ + background: transparent; │
│ + border: 0; │
│ + outline: 0; │
│ + padding: 0; │
│ + margin: 0; │
│ + resize: none; │
│ + overflow: auto; │
│ + caret-color: currentColor; │
│ + // match pre.ascii rendering so the weight reads the same │
│ + -webkit-font-smoothing: antialiased; │
│ + │
│ + &::placeholder { │
│ + color: var(--ink-soft); │
│ + opacity: 1; │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed currentColor; │
│ + outline-offset: 2px; │
│ + } │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ ...ents/FormFrame/FormFrame.scss ── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Fo │
│ rmFrame/FormFrame.scss b/ooknet-design/src/c │
│ omponents/FormFrame/FormFrame.scss │
│ new file mode 100644 │
│ index 0000000..00e5694 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/FormFrame │
│ /FormFrame.scss │
│ @@ -0,0 +1,38 @@ │
│ +.form-frame { │
│ + margin: 0; │
│ +} │
│ + │
│ +// The textarea floats over pre-rendered bl │
│ ank frame rows so the box │
│ +// borders stay build-time strings; offsets │
│ are set inline in cells. │
│ +.form-textarea { │
│ + position: relative; │
│ + │
│ + textarea { │
│ + position: absolute; │
│ + top: 0; │
│ + font-family: var(--mono-frame); │
│ + font-size: var(--fs); │
│ + line-height: var(--lh); │
│ + color: inherit; │
│ + background: transparent; │
│ + border: 0; │
│ + outline: 0; │
│ + padding: 0; │
│ + margin: 0; │
│ + resize: none; │
│ + overflow: auto; │
│ + caret-color: currentColor; │
│ + // match pre.ascii rendering so the wei │
│ ght reads the same │
│ + -webkit-font-smoothing: antialiased; │
│ + │
│ + &::placeholder { │
│ + color: var(--ink-soft); │
│ + opacity: 1; │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed currentColor; │
│ + outline-offset: 2px; │
│ + } │
│ + } │
│ +} │
└──────────────────────────────────────────────┘ ┌─ ooknet-design/src/components/FormFrame/FormFrame.ts ───────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/FormFrame/FormFrame.ts b/ooknet-design/s │
│ rc/components/FormFrame/FormFrame.ts │
│ new file mode 100644 │
│ index 0000000..1d760e0 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/FormFrame/FormFrame.ts │
│ @@ -0,0 +1,42 @@ │
│ +import type { SelectOption } from "../Select/Select"; │
│ + │
│ +export type ButtonSpec = { │
│ + label: string; │
│ + type?: "button" | "submit" | "reset"; │
│ + variant?: "default" | "primary"; │
│ +}; │
│ + │
│ +export type Field = │
│ + | { kind: "text" | "password"; label: string; name: string; cells?: number } │
│ + | { kind: "textarea"; label: string; name: string; rows?: number } │
│ + | { kind: "select"; label: string; name: string; options: (SelectOption | strin │
│ g)[]; cells?: number; value?: string } │
│ + | { kind: "radios"; label: string; name: string; options: string[]; value?: str │
│ ing } │
│ + | { kind: "checkbox"; label: string; name: string; checked?: boolean } │
│ + | { kind: "buttons"; buttons: ButtonSpec[] } │
│ + | { kind: "gap" }; │
│ + │
│ +export interface Props { │
│ + title: string; │
│ + fields: Field[]; │
│ + /** Box width in cells. Defaults to the narrow frame so one render │
│ + * serves both viewports (inputs can't dual-render). */ │
│ + width?: number; │
│ + action?: string; │
│ + method?: string; │
│ +} │
│ + │
│ +export const FORM_LABEL_W = 12; │
│ +export const FORM_INDENT = 2; │
│ + │
│ +export function formGeometry(width: number) { │
│ + const inner = width - 2; │
│ + const contentW = inner - FORM_INDENT; │
│ + return { │
│ + inner, │
│ + contentW, │
│ + /** Default input width: fills the row after the label column. */ │
│ + defaultCells: contentW - FORM_LABEL_W, │
│ + /** Textarea spans the content area minus a right-hand indent. */ │
│ + textareaCells: contentW - FORM_INDENT, │
│ + }; │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ ...onents/FormFrame/FormFrame.ts ── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Fo │
│ rmFrame/FormFrame.ts b/ooknet-design/src/com │
│ ponents/FormFrame/FormFrame.ts │
│ new file mode 100644 │
│ index 0000000..1d760e0 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/FormFrame │
│ /FormFrame.ts │
│ @@ -0,0 +1,42 @@ │
│ +import type { SelectOption } from "../Selec │
│ t/Select"; │
│ + │
│ +export type ButtonSpec = { │
│ + label: string; │
│ + type?: "button" | "submit" | "reset"; │
│ + variant?: "default" | "primary"; │
│ +}; │
│ + │
│ +export type Field = │
│ + | { kind: "text" | "password"; label: str │
│ ing; name: string; cells?: number } │
│ + | { kind: "textarea"; label: string; name │
│ : string; rows?: number } │
│ + | { kind: "select"; label: string; name: │
│ string; options: (SelectOption | string)[]; │
│ cells?: number; value?: string } │
│ + | { kind: "radios"; label: string; name: │
│ string; options: string[]; value?: string } │
│ + | { kind: "checkbox"; label: string; name │
│ : string; checked?: boolean } │
│ + | { kind: "buttons"; buttons: ButtonSpec[ │
│ ] } │
│ + | { kind: "gap" }; │
│ + │
│ +export interface Props { │
│ + title: string; │
│ + fields: Field[]; │
│ + /** Box width in cells. Defaults to the n │
│ arrow frame so one render │
│ + * serves both viewports (inputs can't d │
│ ual-render). */ │
│ + width?: number; │
│ + action?: string; │
│ + method?: string; │
│ +} │
│ + │
│ +export const FORM_LABEL_W = 12; │
│ +export const FORM_INDENT = 2; │
│ + │
│ +export function formGeometry(width: number) │
│ { │
│ + const inner = width - 2; │
│ + const contentW = inner - FORM_INDENT; │
│ + return { │
│ + inner, │
│ + contentW, │
│ + /** Default input width: fills the row │
│ after the label column. */ │
│ + defaultCells: contentW - FORM_LABEL_W, │
│ + /** Textarea spans the content area min │
│ us a right-hand indent. */ │
│ + textareaCells: contentW - FORM_INDENT, │
│ + }; │
│ +} │
└──────────────────────────────────────────────┘ ┌─ ooknet-design/src/components/NavBar/NavBar.astro ──────────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/NavBar/NavBar.astro b/ooknet-design/src/ │
│ components/NavBar/NavBar.astro │
│ index 6517d0d..8382305 100644 │
│ --- a/ooknet-design/src/components/NavBar/NavBar.astro │
│ +++ b/ooknet-design/src/components/NavBar/NavBar.astro │
│ @@ -1,6 +1,6 @@ │
│ --- │
│ import Pre from "../Pre/Pre.astro"; │
│ -import NavButton from "../NavButton/NavButton.astro"; │
│ +import Button from "../Button/Button.astro"; │
│ import NavLanguage from "../NavLanguage/NavLanguage.astro"; │
│ import { FRAME_W } from "../../lib/config"; │
│ import { NAV_ITEMS, NAV_GAP, NAV_LANG_W, isActive, itemsWidth, type Props } from │
│ "./NavBar"; │
│ @@ -17,4 +17,4 @@ const padW = Math.max(1, width - itemsWidth(items, gap) - langW) │
│ ; │
│ const padStr = " ".repeat(padW); │
│ const gapStr = " ".repeat(gap); │
│ --- │
│ -<Pre class="nav-bar">{items.map((it, i) => (<Fragment>{i > 0 ? gapStr : ""}<NavBu │
│ tton label={it.label} href={it.href} active={isActive(it.href, path)} /></Fragment │
│ >))}{padStr}{lang && <NavLanguage />}</Pre> │
│ +<Pre class="nav-bar">{items.map((it, i) => (<Fragment>{i > 0 ? gapStr : ""}<Butto │
│ n label={it.label} href={it.href} variant={isActive(it.href, path) ? "primary" : " │
│ default"} current={isActive(it.href, path)} /></Fragment>))}{padStr}{lang && <NavL │
│ anguage />}</Pre> │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ ...omponents/NavBar/NavBar.astro ── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Na │
│ vBar/NavBar.astro b/ooknet-design/src/compon │
│ ents/NavBar/NavBar.astro │
│ index 6517d0d..8382305 100644 │
│ --- a/ooknet-design/src/components/NavBar/Na │
│ vBar.astro │
│ +++ b/ooknet-design/src/components/NavBar/Na │
│ vBar.astro │
│ @@ -1,6 +1,6 @@ │
│ --- │
│ import Pre from "../Pre/Pre.astro"; │
│ -import NavButton from "../NavButton/NavButt │
│ on.astro"; │
│ +import Button from "../Button/Button.astro" │
│ ; │
│ import NavLanguage from "../NavLanguage/Nav │
│ Language.astro"; │
│ import { FRAME_W } from "../../lib/config"; │
│ import { NAV_ITEMS, NAV_GAP, NAV_LANG_W, is │
│ Active, itemsWidth, type Props } from "./Nav │
│ Bar"; │
│ @@ -17,4 +17,4 @@ const padW = Math.max(1, w │
│ idth - itemsWidth(items, gap) - langW); │
│ const padStr = " ".repeat(padW); │
│ const gapStr = " ".repeat(gap); │
│ --- │
│ -<Pre class="nav-bar">{items.map((it, i) => │
│ (<Fragment>{i > 0 ? gapStr : ""}<NavButton l │
│ abel={it.label} href={it.href} active={isAct │
│ ive(it.href, path)} /></Fragment>))}{padStr} │
│ {lang && <NavLanguage />}</Pre> │
│ +<Pre class="nav-bar">{items.map((it, i) => │
│ (<Fragment>{i > 0 ? gapStr : ""}<Button labe │
│ l={it.label} href={it.href} variant={isActiv │
│ e(it.href, path) ? "primary" : "default"} cu │
│ rrent={isActive(it.href, path)} /></Fragment │
│ >))}{padStr}{lang && <NavLanguage />}</Pre> │
└──────────────────────────────────────────────┘ ┌─ ooknet-design/src/components/NavBar/NavBar.ts ─────────────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/NavBar/NavBar.ts b/ooknet-design/src/com │
│ ponents/NavBar/NavBar.ts │
│ index b3824fe..98c9f7c 100644 │
│ --- a/ooknet-design/src/components/NavBar/NavBar.ts │
│ +++ b/ooknet-design/src/components/NavBar/NavBar.ts │
│ @@ -1,6 +1,6 @@ │
│ -// Nav row data and width math. Each button renders as a 2-char prefix │
│ -// ("? " when active, two spaces otherwise) plus its label, so row width │
│ -// doesn't change with the active item. │
│ +// Nav row data and width math. Items render as [ LABEL ] buttons, so │
│ +// each is buttonWidth(label) cells regardless of active state. │
│ +import { buttonWidth } from "../Button/Button"; │
│ │
│ export interface NavItem { │
│ label: string; │
│ @@ -11,7 +11,7 @@ export interface Props { │
│ width?: number; │
│ items?: NavItem[]; │
│ /** Spaces between buttons. Defaults to NAV_GAP (4); narrower frames │
│ - * pass 2 to fit four labels in 48 cells. */ │
│ + * pass 1 to fit four bracketed labels in 48 cells. */ │
│ gap?: number; │
│ /** Whether to render the EN label and reserve space for it. */ │
│ lang?: boolean; │
│ @@ -27,9 +27,6 @@ export const NAV_ITEMS: NavItem[] = [ │
│ export const NAV_GAP = 4; // spaces between buttons │
│ export const NAV_LANG_W = 2; // visible width of "EN" │
│ │
│ -/** Width of one nav button as rendered (2-char prefix + label). */ │
│ -export const buttonWidth = (label: string) => 2 + label.length; │
│ - │
│ /** Total width of all buttons + inter-button gaps. */ │
│ export function itemsWidth(items: NavItem[], gap = NAV_GAP): number { │
│ return items.reduce( │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ ...c/components/NavBar/NavBar.ts ── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Na │
│ vBar/NavBar.ts b/ooknet-design/src/component │
│ s/NavBar/NavBar.ts │
│ index b3824fe..98c9f7c 100644 │
│ --- a/ooknet-design/src/components/NavBar/Na │
│ vBar.ts │
│ +++ b/ooknet-design/src/components/NavBar/Na │
│ vBar.ts │
│ @@ -1,6 +1,6 @@ │
│ -// Nav row data and width math. Each button │
│ renders as a 2-char prefix │
│ -// ("? " when active, two spaces otherwise) │
│ plus its label, so row width │
│ -// doesn't change with the active item. │
│ +// Nav row data and width math. Items rende │
│ r as [ LABEL ] buttons, so │
│ +// each is buttonWidth(label) cells regardl │
│ ess of active state. │
│ +import { buttonWidth } from "../Button/Butt │
│ on"; │
│ │
│ export interface NavItem { │
│ label: string; │
│ @@ -11,7 +11,7 @@ export interface Props { │
│ width?: number; │
│ items?: NavItem[]; │
│ /** Spaces between buttons. Defaults to N │
│ AV_GAP (4); narrower frames │
│ - * pass 2 to fit four labels in 48 cells │
│ . */ │
│ + * pass 1 to fit four bracketed labels i │
│ n 48 cells. */ │
│ gap?: number; │
│ /** Whether to render the EN label and re │
│ serve space for it. */ │
│ lang?: boolean; │
│ @@ -27,9 +27,6 @@ export const NAV_ITEMS: Na │
│ vItem[] = [ │
│ export const NAV_GAP = 4; // spaces b │
│ etween buttons │
│ export const NAV_LANG_W = 2; // visible │
│ width of "EN" │
│ │
│ -/** Width of one nav button as rendered (2- │
│ char prefix + label). */ │
│ -export const buttonWidth = (label: string) │
│ => 2 + label.length; │
│ - │
│ /** Total width of all buttons + inter-butt │
│ on gaps. */ │
│ export function itemsWidth(items: NavItem[] │
│ , gap = NAV_GAP): number { │
│ return items.reduce( │
└──────────────────────────────────────────────┘ ┌─ ooknet-design/src/components/NavButton/NavButton.astro ────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/NavButton/NavButton.astro b/ooknet-desig │
│ n/src/components/NavButton/NavButton.astro │
│ deleted file mode 100644 │
│ index 062f0e4..0000000 │
│ --- a/ooknet-design/src/components/NavButton/NavButton.astro │
│ +++ /dev/null │
│ @@ -1,8 +0,0 @@ │
│ ---- │
│ -import "./NavButton.scss"; │
│ -import type { Props } from "./NavButton"; │
│ - │
│ -const { label, href, active = false } = Astro.props as Props; │
│ -const prefix = active ? "? " : " "; │
│ ---- │
│ -<a href={href} class:list={["nav-button", { active }]}>{prefix}{label}</a> │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ ...nts/NavButton/NavButton.astro ── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Na │
│ vButton/NavButton.astro b/ooknet-design/src/ │
│ components/NavButton/NavButton.astro │
│ deleted file mode 100644 │
│ index 062f0e4..0000000 │
│ --- a/ooknet-design/src/components/NavButton │
│ /NavButton.astro │
│ +++ /dev/null │
│ @@ -1,8 +0,0 @@ │
│ ---- │
│ -import "./NavButton.scss"; │
│ -import type { Props } from "./NavButton"; │
│ - │
│ -const { label, href, active = false } = Ast │
│ ro.props as Props; │
│ -const prefix = active ? "? " : " "; │
│ ---- │
│ -<a href={href} class:list={["nav-button", { │
│ active }]}>{prefix}{label}</a> │
└──────────────────────────────────────────────┘ ┌─ ooknet-design/src/components/NavButton/NavButton.ts ───────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/NavButton/NavButton.ts b/ooknet-design/s │
│ rc/components/NavButton/NavButton.ts │
│ deleted file mode 100644 │
│ index 383854b..0000000 │
│ --- a/ooknet-design/src/components/NavButton/NavButton.ts │
│ +++ /dev/null │
│ @@ -1,5 +0,0 @@ │
│ -export interface Props { │
│ - label: string; │
│ - href: string; │
│ - active?: boolean; │
│ -} │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ ...onents/NavButton/NavButton.ts ── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Na │
│ vButton/NavButton.ts b/ooknet-design/src/com │
│ ponents/NavButton/NavButton.ts │
│ deleted file mode 100644 │
│ index 383854b..0000000 │
│ --- a/ooknet-design/src/components/NavButton │
│ /NavButton.ts │
│ +++ /dev/null │
│ @@ -1,5 +0,0 @@ │
│ -export interface Props { │
│ - label: string; │
│ - href: string; │
│ - active?: boolean; │
│ -} │
└──────────────────────────────────────────────┘ ┌─ ooknet-design/src/components/Progress/Progress.astro ──────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Progress/Progress.astro b/ooknet-design/ │
│ src/components/Progress/Progress.astro │
│ new file mode 100644 │
│ index 0000000..8103a3a │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Progress/Progress.astro │
│ @@ -0,0 +1,6 @@ │
│ +--- │
│ +import { buildProgress, type Props } from "./Progress"; │
│ + │
│ +const { value, cells = 20 } = Astro.props as Props; │
│ +--- │
│ +<span class="progress" role="progressbar" aria-valuenow={Math.round(value)} aria- │
│ valuemin="0" aria-valuemax="100">{buildProgress(value, cells)}</span> │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ ...nents/Progress/Progress.astro ── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Pr │
│ ogress/Progress.astro b/ooknet-design/src/co │
│ mponents/Progress/Progress.astro │
│ new file mode 100644 │
│ index 0000000..8103a3a │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Progress/ │
│ Progress.astro │
│ @@ -0,0 +1,6 @@ │
│ +--- │
│ +import { buildProgress, type Props } from " │
│ ./Progress"; │
│ + │
│ +const { value, cells = 20 } = Astro.props a │
│ s Props; │
│ +--- │
│ +<span class="progress" role="progressbar" a │
│ ria-valuenow={Math.round(value)} aria-valuem │
│ in="0" aria-valuemax="100">{buildProgress(va │
│ lue, cells)}</span> │
└──────────────────────────────────────────────┘ ┌─ ooknet-design/src/components/Progress/Progress.test.ts ────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Progress/Progress.test.ts b/ooknet-desig │
│ n/src/components/Progress/Progress.test.ts │
│ new file mode 100644 │
│ index 0000000..92bf071 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Progress/Progress.test.ts │
│ @@ -0,0 +1,22 @@ │
│ +import { describe, expect, it } from "vitest"; │
│ +import { buildProgress, progressWidth } from "./Progress"; │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +describe("buildProgress", () => { │
│ + it("renders the exact declared width at any value", () => { │
│ + for (const v of [0, 8, 50, 99, 100]) { │
│ + expect(len(buildProgress(v, 20))).toBe(progressWidth(20)); │
│ + } │
│ + }); │
│ + │
│ + it("fills proportionally", () => { │
│ + expect(buildProgress(0, 10)).toBe("[░░░░░░░░░░] 0%"); │
│ + expect(buildProgress(50, 10)).toBe("[█████░░░░░] 50%"); │
│ + expect(buildProgress(100, 10)).toBe("[██████████] 100%"); │
│ + }); │
│ + │
│ + it("clamps out-of-range values", () => { │
│ + expect(buildProgress(-5, 10)).toBe(buildProgress(0, 10)); │
│ + expect(buildProgress(250, 10)).toBe(buildProgress(100, 10)); │
│ + }); │
│ +}); │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ ...nts/Progress/Progress.test.ts ── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Pr │
│ ogress/Progress.test.ts b/ooknet-design/src/ │
│ components/Progress/Progress.test.ts │
│ new file mode 100644 │
│ index 0000000..92bf071 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Progress/ │
│ Progress.test.ts │
│ @@ -0,0 +1,22 @@ │
│ +import { describe, expect, it } from "vites │
│ t"; │
│ +import { buildProgress, progressWidth } fro │
│ m "./Progress"; │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +describe("buildProgress", () => { │
│ + it("renders the exact declared width at a │
│ ny value", () => { │
│ + for (const v of [0, 8, 50, 99, 100]) { │
│ + expect(len(buildProgress(v, 20))).toB │
│ e(progressWidth(20)); │
│ + } │
│ + }); │
│ + │
│ + it("fills proportionally", () => { │
│ + expect(buildProgress(0, 10)).toBe("[░░░ │
│ ░░░░░░░] 0%"); │
│ + expect(buildProgress(50, 10)).toBe("[██ │
│ ███░░░░░] 50%"); │
│ + expect(buildProgress(100, 10)).toBe("[█ │
│ █████████] 100%"); │
│ + }); │
│ + │
│ + it("clamps out-of-range values", () => { │
│ + expect(buildProgress(-5, 10)).toBe(buil │
│ dProgress(0, 10)); │
│ + expect(buildProgress(250, 10)).toBe(bui │
│ ldProgress(100, 10)); │
│ + }); │
│ +}); │
└──────────────────────────────────────────────┘ ┌─ ooknet-design/src/components/Progress/Progress.ts ─────────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Progress/Progress.ts b/ooknet-design/src │
│ /components/Progress/Progress.ts │
│ new file mode 100644 │
│ index 0000000..45aba15 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Progress/Progress.ts │
│ @@ -0,0 +1,15 @@ │
│ +export interface Props { │
│ + /** 0-100. */ │
│ + value: number; │
│ + /** Bar width in cells, excluding brackets and the percentage. */ │
│ + cells?: number; │
│ +} │
│ + │
│ +export function buildProgress(value: number, cells = 20): string { │
│ + const v = Math.max(0, Math.min(100, Math.round(value))); │
│ + const filled = Math.round((v / 100) * cells); │
│ + return `[${"█".repeat(filled)}${"░".repeat(cells - filled)}] ${String(v).padSta │
│ rt(3)}%`; │
│ +} │
│ + │
│ +// "[" + cells + "] 100%" │
│ +export const progressWidth = (cells = 20) => cells + 7; │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ ...mponents/Progress/Progress.ts ── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Pr │
│ ogress/Progress.ts b/ooknet-design/src/compo │
│ nents/Progress/Progress.ts │
│ new file mode 100644 │
│ index 0000000..45aba15 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Progress/ │
│ Progress.ts │
│ @@ -0,0 +1,15 @@ │
│ +export interface Props { │
│ + /** 0-100. */ │
│ + value: number; │
│ + /** Bar width in cells, excluding bracket │
│ s and the percentage. */ │
│ + cells?: number; │
│ +} │
│ + │
│ +export function buildProgress(value: number │
│ , cells = 20): string { │
│ + const v = Math.max(0, Math.min(100, Math. │
│ round(value))); │
│ + const filled = Math.round((v / 100) * cel │
│ ls); │
│ + return `[${"█".repeat(filled)}${"░".repea │
│ t(cells - filled)}] ${String(v).padStart(3)} │
│ %`; │
│ +} │
│ + │
│ +// "[" + cells + "] 100%" │
│ +export const progressWidth = (cells = 20) = │
│ > cells + 7; │
└──────────────────────────────────────────────┘ ┌─ ooknet-design/src/components/Spinner/Spinner.astro ────────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Spinner/Spinner.astro b/ooknet-design/sr │
│ c/components/Spinner/Spinner.astro │
│ new file mode 100644 │
│ index 0000000..13f8f31 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Spinner/Spinner.astro │
│ @@ -0,0 +1,14 @@ │
│ +--- │
│ +// Cycles | / - \ in place. Astro dedupes the script across instances, │
│ +// so every [data-spinner] on the page ticks from one interval. │
│ +--- │
│ +<span class="spinner" data-spinner aria-hidden="true">|</span> │
│ + │
│ +<script> │
│ + const FRAMES = "|/-\\"; │
│ + let i = 0; │
│ + setInterval(() => { │
│ + i = (i + 1) % FRAMES.length; │
│ + for (const el of document.querySelectorAll("[data-spinner]")) el.textContent │
│ = FRAMES[i]; │
│ + }, 150); │
│ +</script> │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ ...ponents/Spinner/Spinner.astro ── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Sp │
│ inner/Spinner.astro b/ooknet-design/src/comp │
│ onents/Spinner/Spinner.astro │
│ new file mode 100644 │
│ index 0000000..13f8f31 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Spinner/S │
│ pinner.astro │
│ @@ -0,0 +1,14 @@ │
│ +--- │
│ +// Cycles | / - \ in place. Astro dedupes t │
│ he script across instances, │
│ +// so every [data-spinner] on the page tick │
│ s from one interval. │
│ +--- │
│ +<span class="spinner" data-spinner aria-hid │
│ den="true">|</span> │
│ + │
│ +<script> │
│ + const FRAMES = "|/-\\"; │
│ + let i = 0; │
│ + setInterval(() => { │
│ + i = (i + 1) % FRAMES.length; │
│ + for (const el of document.querySelector │
│ All("[data-spinner]")) el.textContent = FRAM │
│ ES[i]; │
│ + }, 150); │
│ +</script> │
└──────────────────────────────────────────────┘ ┌─ ooknet-design/src/components/Status/Status.astro ──────────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Status/Status.astro b/ooknet-design/src/ │
│ components/Status/Status.astro │
│ new file mode 100644 │
│ index 0000000..328c91b │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Status/Status.astro │
│ @@ -0,0 +1,7 @@ │
│ +--- │
│ +import { STATUS_TAG, type Props } from "./Status"; │
│ +import "./Status.scss"; │
│ + │
│ +const { message, variant = "ok" } = Astro.props as Props; │
│ +--- │
│ +<span class:list={["status", `status-${variant}`]} role="status">{STATUS_TAG[vari │
│ ant]}{" " + message}</span> │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ ...omponents/Status/Status.astro ── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/St │
│ atus/Status.astro b/ooknet-design/src/compon │
│ ents/Status/Status.astro │
│ new file mode 100644 │
│ index 0000000..328c91b │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Status/St │
│ atus.astro │
│ @@ -0,0 +1,7 @@ │
│ +--- │
│ +import { STATUS_TAG, type Props } from "./S │
│ tatus"; │
│ +import "./Status.scss"; │
│ + │
│ +const { message, variant = "ok" } = Astro.p │
│ rops as Props; │
│ +--- │
│ +<span class:list={["status", `status-${vari │
│ ant}`]} role="status">{STATUS_TAG[variant]}{ │
│ " " + message}</span> │
└──────────────────────────────────────────────┘ ┌─ ooknet-design/src/components/Status/Status.scss ───────────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Status/Status.scss b/ooknet-design/src/c │
│ omponents/Status/Status.scss │
│ new file mode 100644 │
│ index 0000000..7bec491 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Status/Status.scss │
│ @@ -0,0 +1,11 @@ │
│ +.status { │
│ + white-space: pre; │
│ + │
│ + &.status-error { │
│ + font-weight: 700; │
│ + } │
│ + │
│ + &.status-pending { │
│ + color: var(--ink-soft); │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ ...components/Status/Status.scss ── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/St │
│ atus/Status.scss b/ooknet-design/src/compone │
│ nts/Status/Status.scss │
│ new file mode 100644 │
│ index 0000000..7bec491 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Status/St │
│ atus.scss │
│ @@ -0,0 +1,11 @@ │
│ +.status { │
│ + white-space: pre; │
│ + │
│ + &.status-error { │
│ + font-weight: 700; │
│ + } │
│ + │
│ + &.status-pending { │
│ + color: var(--ink-soft); │
│ + } │
│ +} │
└──────────────────────────────────────────────┘ ┌─ ooknet-design/src/components/Status/Status.ts ─────────────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Status/Status.ts b/ooknet-design/src/com │
│ ponents/Status/Status.ts │
│ new file mode 100644 │
│ index 0000000..45a74a5 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Status/Status.ts │
│ @@ -0,0 +1,12 @@ │
│ +export type StatusVariant = "ok" | "error" | "pending"; │
│ + │
│ +export interface Props { │
│ + message: string; │
│ + variant?: StatusVariant; │
│ +} │
│ + │
│ +export const STATUS_TAG: Record<StatusVariant, string> = { │
│ + ok: "[ OK ]", │
│ + error: "[ !! ]", │
│ + pending: "[ .. ]", │
│ +}; │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ ...c/components/Status/Status.ts ── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/St │
│ atus/Status.ts b/ooknet-design/src/component │
│ s/Status/Status.ts │
│ new file mode 100644 │
│ index 0000000..45a74a5 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Status/St │
│ atus.ts │
│ @@ -0,0 +1,12 @@ │
│ +export type StatusVariant = "ok" | "error" │
│ | "pending"; │
│ + │
│ +export interface Props { │
│ + message: string; │
│ + variant?: StatusVariant; │
│ +} │
│ + │
│ +export const STATUS_TAG: Record<StatusVaria │
│ nt, string> = { │
│ + ok: "[ OK ]", │
│ + error: "[ !! ]", │
│ + pending: "[ .. ]", │
│ +}; │
└──────────────────────────────────────────────┘ ┌─ ooknet-design/src/components/TopHeader/TopHeader.astro ────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/TopHeader/TopHeader.astro b/ooknet-desig │
│ n/src/components/TopHeader/TopHeader.astro │
│ index 1bb8d38..1d7ecc2 100644 │
│ --- a/ooknet-design/src/components/TopHeader/TopHeader.astro │
│ +++ b/ooknet-design/src/components/TopHeader/TopHeader.astro │
│ @@ -34,7 +34,7 @@ const PLACEHOLDER_NARROW = "_".repeat(18); │
│ <ul class="masthead-search-results" hidden data-search-results></ul> │
│ </div> │
│ <Pre>{rule(MOBILE_FRAME_W)}</Pre> │
│ - <NavBar width={MOBILE_FRAME_W} gap={2} lang={false} /> │
│ + <NavBar width={MOBILE_FRAME_W} gap={1} lang={false} /> │
│ <Pre>{ruleD(MOBILE_FRAME_W)}</Pre> │
│ </div> │
│ │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ ...nts/TopHeader/TopHeader.astro ── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/To │
│ pHeader/TopHeader.astro b/ooknet-design/src/ │
│ components/TopHeader/TopHeader.astro │
│ index 1bb8d38..1d7ecc2 100644 │
│ --- a/ooknet-design/src/components/TopHeader │
│ /TopHeader.astro │
│ +++ b/ooknet-design/src/components/TopHeader │
│ /TopHeader.astro │
│ @@ -34,7 +34,7 @@ const PLACEHOLDER_NARROW = │
│ "_".repeat(18); │
│ <ul class="masthead-search-results" hid │
│ den data-search-results></ul> │
│ </div> │
│ <Pre>{rule(MOBILE_FRAME_W)}</Pre> │
│ - <NavBar width={MOBILE_FRAME_W} gap={2} la │
│ ng={false} /> │
│ + <NavBar width={MOBILE_FRAME_W} gap={1} la │
│ ng={false} /> │
│ <Pre>{ruleD(MOBILE_FRAME_W)}</Pre> │
│ </div> │
│ │
└──────────────────────────────────────────────┘ ┌─ ooknet-design/src/components/Tree/Tree.astro ──────────────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Tree/Tree.astro b/ooknet-design/src/comp │
│ onents/Tree/Tree.astro │
│ new file mode 100644 │
│ index 0000000..1dc4c54 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Tree/Tree.astro │
│ @@ -0,0 +1,11 @@ │
│ +--- │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import { layoutTree, type Props } from "./Tree"; │
│ +import "./Tree.scss"; │
│ + │
│ +const { nodes } = Astro.props as Props; │
│ +const rows = layoutTree(nodes); │
│ +--- │
│ +{rows.map((r) => ( │
│ + <Pre class="tree-row">{r.prefix}{r.href ? <a class="tree-link" href={r.href}>{r │
│ .label}</a> : r.label}</Pre> │
│ +))} │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ ...rc/components/Tree/Tree.astro ── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Tr │
│ ee/Tree.astro b/ooknet-design/src/components │
│ /Tree/Tree.astro │
│ new file mode 100644 │
│ index 0000000..1dc4c54 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Tree/Tree │
│ .astro │
│ @@ -0,0 +1,11 @@ │
│ +--- │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import { layoutTree, type Props } from "./T │
│ ree"; │
│ +import "./Tree.scss"; │
│ + │
│ +const { nodes } = Astro.props as Props; │
│ +const rows = layoutTree(nodes); │
│ +--- │
│ +{rows.map((r) => ( │
│ + <Pre class="tree-row">{r.prefix}{r.href ? │
│ <a class="tree-link" href={r.href}>{r.label │
│ }</a> : r.label}</Pre> │
│ +))} │
└──────────────────────────────────────────────┘ ┌─ ooknet-design/src/components/Tree/Tree.scss ───────────────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/NavButton/NavButton.scss b/ooknet-design │
│ /src/components/Tree/Tree.scss │
│ similarity index 79% │
│ rename from ooknet-design/src/components/NavButton/NavButton.scss │
│ rename to ooknet-design/src/components/Tree/Tree.scss │
│ index 21862f2..38aeccf 100644 │
│ --- a/ooknet-design/src/components/NavButton/NavButton.scss │
│ +++ b/ooknet-design/src/components/Tree/Tree.scss │
│ @@ -1,4 +1,4 @@ │
│ -.nav-button { │
│ +.tree-link { │
│ color: var(--ink); │
│ text-decoration: none; │
│ │
│ @@ -7,10 +7,6 @@ │
│ text-underline-offset: 3px; │
│ } │
│ │
│ - &.active { │
│ - font-weight: 600; │
│ - } │
│ - │
│ &:focus-visible { │
│ outline: 1px dashed var(--ink); │
│ outline-offset: 1px; │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ ...src/components/Tree/Tree.scss ── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Na │
│ vButton/NavButton.scss b/ooknet-design/src/c │
│ omponents/Tree/Tree.scss │
│ similarity index 79% │
│ rename from ooknet-design/src/components/Nav │
│ Button/NavButton.scss │
│ rename to ooknet-design/src/components/Tree/ │
│ Tree.scss │
│ index 21862f2..38aeccf 100644 │
│ --- a/ooknet-design/src/components/NavButton │
│ /NavButton.scss │
│ +++ b/ooknet-design/src/components/Tree/Tree │
│ .scss │
│ @@ -1,4 +1,4 @@ │
│ -.nav-button { │
│ +.tree-link { │
│ color: var(--ink); │
│ text-decoration: none; │
│ │
│ @@ -7,10 +7,6 @@ │
│ text-underline-offset: 3px; │
│ } │
│ │
│ - &.active { │
│ - font-weight: 600; │
│ - } │
│ - │
│ &:focus-visible { │
│ outline: 1px dashed var(--ink); │
│ outline-offset: 1px; │
└──────────────────────────────────────────────┘ ┌─ ooknet-design/src/components/Tree/Tree.test.ts ────────────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Tree/Tree.test.ts b/ooknet-design/src/co │
│ mponents/Tree/Tree.test.ts │
│ new file mode 100644 │
│ index 0000000..bd485de │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Tree/Tree.test.ts │
│ @@ -0,0 +1,41 @@ │
│ +import { describe, expect, it } from "vitest"; │
│ +import { layoutTree, type TreeNode } from "./Tree"; │
│ + │
│ +const TREE: TreeNode[] = [ │
│ + { │
│ + label: "/INFO", │
│ + children: [ │
│ + { label: "GRAPH", children: [{ label: "ENTROPY" }, { label: "SPECTRAL" }] } │
│ , │
│ + { label: "AI" }, │
│ + ], │
│ + }, │
│ +]; │
│ + │
│ +describe("layoutTree", () => { │
│ + it("renders depth 0 bare and branches below it", () => { │
│ + const rows = layoutTree(TREE); │
│ + expect(rows.map((r) => r.prefix + r.label)).toEqual([ │
│ + "/INFO", │
│ + "├── GRAPH", │
│ + "│ ├── ENTROPY", │
│ + "│ └── SPECTRAL", │
│ + "└── AI", │
│ + ]); │
│ + }); │
│ + │
│ + it("uses └── for the last sibling and pads descendants past it", () => { │
│ + const rows = layoutTree([ │
│ + { label: "R", children: [{ label: "LAST", children: [{ label: "LEAF" }] }] │
│ }, │
│ + ]); │
│ + expect(rows.map((r) => r.prefix + r.label)).toEqual(["R", "└── LAST", " └─ │
│ ─ LEAF"]); │
│ + }); │
│ + │
│ + it("carries href through to the row", () => { │
│ + const rows = layoutTree([{ label: "A", href: "/a/" }]); │
│ + expect(rows[0].href).toBe("/a/"); │
│ + }); │
│ + │
│ + it("handles empty input", () => { │
│ + expect(layoutTree([])).toEqual([]); │
│ + }); │
│ +}); │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ .../components/Tree/Tree.test.ts ── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Tr │
│ ee/Tree.test.ts b/ooknet-design/src/componen │
│ ts/Tree/Tree.test.ts │
│ new file mode 100644 │
│ index 0000000..bd485de │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Tree/Tree │
│ .test.ts │
│ @@ -0,0 +1,41 @@ │
│ +import { describe, expect, it } from "vites │
│ t"; │
│ +import { layoutTree, type TreeNode } from " │
│ ./Tree"; │
│ + │
│ +const TREE: TreeNode[] = [ │
│ + { │
│ + label: "/INFO", │
│ + children: [ │
│ + { label: "GRAPH", children: [{ label: │
│ "ENTROPY" }, { label: "SPECTRAL" }] }, │
│ + { label: "AI" }, │
│ + ], │
│ + }, │
│ +]; │
│ + │
│ +describe("layoutTree", () => { │
│ + it("renders depth 0 bare and branches bel │
│ ow it", () => { │
│ + const rows = layoutTree(TREE); │
│ + expect(rows.map((r) => r.prefix + r.lab │
│ el)).toEqual([ │
│ + "/INFO", │
│ + "├── GRAPH", │
│ + "│ ├── ENTROPY", │
│ + "│ └── SPECTRAL", │
│ + "└── AI", │
│ + ]); │
│ + }); │
│ + │
│ + it("uses └── for the last sibling and pad │
│ s descendants past it", () => { │
│ + const rows = layoutTree([ │
│ + { label: "R", children: [{ label: "LA │
│ ST", children: [{ label: "LEAF" }] }] }, │
│ + ]); │
│ + expect(rows.map((r) => r.prefix + r.lab │
│ el)).toEqual(["R", "└── LAST", " └── LEAF │
│ "]); │
│ + }); │
│ + │
│ + it("carries href through to the row", () │
│ => { │
│ + const rows = layoutTree([{ label: "A", │
│ href: "/a/" }]); │
│ + expect(rows[0].href).toBe("/a/"); │
│ + }); │
│ + │
│ + it("handles empty input", () => { │
│ + expect(layoutTree([])).toEqual([]); │
│ + }); │
│ +}); │
└──────────────────────────────────────────────┘ ┌─ ooknet-design/src/components/Tree/Tree.ts ─────────────────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Tree/Tree.ts b/ooknet-design/src/compone │
│ nts/Tree/Tree.ts │
│ new file mode 100644 │
│ index 0000000..07be091 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Tree/Tree.ts │
│ @@ -0,0 +1,35 @@ │
│ +export interface TreeNode { │
│ + label: string; │
│ + href?: string; │
│ + children?: TreeNode[]; │
│ +} │
│ + │
│ +export interface TreeRow { │
│ + prefix: string; │
│ + label: string; │
│ + href?: string; │
│ +} │
│ + │
│ +export interface Props { │
│ + nodes: TreeNode[]; │
│ +} │
│ + │
│ +// Depth 0 renders bare (a root line); deeper levels get branch glyphs. │
│ +export function layoutTree(nodes: TreeNode[]): TreeRow[] { │
│ + const out: TreeRow[] = []; │
│ + const walk = (ns: TreeNode[], prefix: string, depth: number) => { │
│ + ns.forEach((n, i) => { │
│ + const last = i === ns.length - 1; │
│ + out.push({ │
│ + prefix: depth === 0 ? "" : prefix + (last ? "└── " : "├── "), │
│ + label: n.label, │
│ + href: n.href, │
│ + }); │
│ + if (n.children?.length) { │
│ + walk(n.children, depth === 0 ? "" : prefix + (last ? " " : "│ "), de │
│ pth + 1); │
│ + } │
│ + }); │
│ + }; │
│ + walk(nodes, "", 0); │
│ + return out; │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ ...n/src/components/Tree/Tree.ts ── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/components/Tr │
│ ee/Tree.ts b/ooknet-design/src/components/Tr │
│ ee/Tree.ts │
│ new file mode 100644 │
│ index 0000000..07be091 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Tree/Tree │
│ .ts │
│ @@ -0,0 +1,35 @@ │
│ +export interface TreeNode { │
│ + label: string; │
│ + href?: string; │
│ + children?: TreeNode[]; │
│ +} │
│ + │
│ +export interface TreeRow { │
│ + prefix: string; │
│ + label: string; │
│ + href?: string; │
│ +} │
│ + │
│ +export interface Props { │
│ + nodes: TreeNode[]; │
│ +} │
│ + │
│ +// Depth 0 renders bare (a root line); deep │
│ er levels get branch glyphs. │
│ +export function layoutTree(nodes: TreeNode[ │
│ ]): TreeRow[] { │
│ + const out: TreeRow[] = []; │
│ + const walk = (ns: TreeNode[], prefix: str │
│ ing, depth: number) => { │
│ + ns.forEach((n, i) => { │
│ + const last = i === ns.length - 1; │
│ + out.push({ │
│ + prefix: depth === 0 ? "" : prefix + │
│ (last ? "└── " : "├── "), │
│ + label: n.label, │
│ + href: n.href, │
│ + }); │
│ + if (n.children?.length) { │
│ + walk(n.children, depth === 0 ? "" : │
│ prefix + (last ? " " : "│ "), depth + │
│ 1); │
│ + } │
│ + }); │
│ + }; │
│ + walk(nodes, "", 0); │
│ + return out; │
│ +} │
└──────────────────────────────────────────────┘ ┌─ ooknet-design/src/lib/ascii.ts ────────────────────────────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/lib/ascii.ts b/ooknet-design/src/lib/ascii.ts │
│ index afdd52d..5bc1c47 100644 │
│ --- a/ooknet-design/src/lib/ascii.ts │
│ +++ b/ooknet-design/src/lib/ascii.ts │
│ @@ -13,11 +13,12 @@ export const len = (s: unknown): number => [...String(s ?? "") │
│ ].length; │
│ │
│ // Codepoints known to render exactly one cell wide in the frame font │
│ // (fontsource Cascadia Mono, latin subset): printable ASCII + tab, │
│ -// Latin-1 supplement, box drawing, and the measured extras - - - ... ↑ ↓. │
│ +// Latin-1 supplement, box drawing + block elements, and the measured │
│ +// extras - - - ... ↑ ↓. │
│ // Anything else - emoji, CJK, glyphs the subset lacks (?, ?, ?) - bends │
│ // a frame border. Measure a new glyph against "0" in the browser before │
│ // adding it here. │
│ -const ON_GRID = /^[\t\x20-\x7E\u00A0-\u00FF\u2013\u2014\u2022\u2026\u2122\u2191\u │
│ 2193\u2500-\u257F]$/u; │
│ +const ON_GRID = /^[\t\x20-\x7E\u00A0-\u00FF\u2013\u2014\u2022\u2026\u2122\u2191\u │
│ 2193\u2500-\u259F]$/u; │
│ │
│ /** Throws unless every line of `text` fits `maxW` cells and uses only │
│ * on-grid glyphs. Returns `text` so call sites can wrap in place. */ │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ ooknet-design/src/lib/ascii.ts ──── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/lib/ascii.ts │
│ b/ooknet-design/src/lib/ascii.ts │
│ index afdd52d..5bc1c47 100644 │
│ --- a/ooknet-design/src/lib/ascii.ts │
│ +++ b/ooknet-design/src/lib/ascii.ts │
│ @@ -13,11 +13,12 @@ export const len = (s: u │
│ nknown): number => [...String(s ?? "")].leng │
│ th; │
│ │
│ // Codepoints known to render exactly one c │
│ ell wide in the frame font │
│ // (fontsource Cascadia Mono, latin subset) │
│ : printable ASCII + tab, │
│ -// Latin-1 supplement, box drawing, and the │
│ measured extras - - - ... ↑ ↓. │
│ +// Latin-1 supplement, box drawing + block │
│ elements, and the measured │
│ +// extras - - - ... ↑ ↓. │
│ // Anything else - emoji, CJK, glyphs the s │
│ ubset lacks (?, ?, ?) - bends │
│ // a frame border. Measure a new glyph agai │
│ nst "0" in the browser before │
│ // adding it here. │
│ -const ON_GRID = /^[\t\x20-\x7E\u00A0-\u00FF │
│ \u2013\u2014\u2022\u2026\u2122\u2191\u2193\u │
│ 2500-\u257F]$/u; │
│ +const ON_GRID = /^[\t\x20-\x7E\u00A0-\u00FF │
│ \u2013\u2014\u2022\u2026\u2122\u2191\u2193\u │
│ 2500-\u259F]$/u; │
│ │
│ /** Throws unless every line of `text` fits │
│ `maxW` cells and uses only │
│ * on-grid glyphs. Returns `text` so call │
│ sites can wrap in place. */ │
└──────────────────────────────────────────────┘ ┌─ ooknet-design/src/pages/components.astro ──────────────────────────────── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/pages/components.astro b/ooknet-design/src/pages/co │
│ mponents.astro │
│ index 899ca73..106ca2c 100644 │
│ --- a/ooknet-design/src/pages/components.astro │
│ +++ b/ooknet-design/src/pages/components.astro │
│ @@ -4,36 +4,59 @@ import TopHeader from "../components/TopHeader/TopHeader.astro"; │
│ import Frame from "../components/Frame/Frame.astro"; │
│ import Pre from "../components/Pre/Pre.astro"; │
│ import Button from "../components/Button/Button.astro"; │
│ -import { buttonWidth } from "../components/Button/Button"; │
│ import TextField from "../components/TextField/TextField.astro"; │
│ import Checkbox from "../components/Checkbox/Checkbox.astro"; │
│ import Radio from "../components/Radio/Radio.astro"; │
│ -import { radioWidth } from "../components/Radio/Radio"; │
│ import Select from "../components/Select/Select.astro"; │
│ -import { selectWidth } from "../components/Select/Select"; │
│ -import { G, rule, ruleD, row2, pad, len } from "../lib/ascii"; │
│ -import { MOBILE_FRAME_W } from "../lib/config"; │
│ +import FormFrame from "../components/FormFrame/FormFrame.astro"; │
│ +import type { Field } from "../components/FormFrame/FormFrame"; │
│ +import Tree from "../components/Tree/Tree.astro"; │
│ +import type { TreeNode } from "../components/Tree/Tree"; │
│ +import Progress from "../components/Progress/Progress.astro"; │
│ +import Spinner from "../components/Spinner/Spinner.astro"; │
│ +import Status from "../components/Status/Status.astro"; │
│ +import { rule, ruleD, row2, pad } from "../lib/ascii"; │
│ │
│ const section = (title: string, right: string) => (w: number) => │
│ [row2(title, right, w), ruleD(w)].join("\n"); │
│ │
│ const LABEL_W = 12; │
│ │
│ -// The form box is MOBILE_FRAME_W wide so one render serves both │
│ -// viewports - interactive fields can't dual-render without duplicating │
│ -// input names. │
│ -const B = MOBILE_FRAME_W; │
│ -const inner = B - 2; │
│ -const boxTop = (t: string) => `${G.TL}${G.H} ${t} ${G.H.repeat(inner - 3 - len(t) │
│ )}${G.TR}`; │
│ -const boxBlank = `${G.V}${" ".repeat(inner)}${G.V}`; │
│ -const boxBottom = `${G.BL}${rule(inner)}${G.BR}`; │
│ -const open = `${G.V} `; │
│ -const close = (used: number) => `${" ".repeat(Math.max(0, inner - 2 - used))}${G. │
│ V}`; │
│ +const REPORT_FIELDS: Field[] = [ │
│ + { kind: "text", label: "CALLSIGN", name: "callsign", cells: 26 }, │
│ + { kind: "text", label: "FREQUENCY", name: "frequency", cells: 26 }, │
│ + { kind: "select", label: "CHANNEL", name: "channel", options: ["ALPHA", "BRAVO" │
│ , "OOKNET-9"], cells: 20, value: "ALPHA" }, │
│ + { kind: "gap" }, │
│ + { kind: "radios", label: "PRIORITY", name: "priority", options: ["ROUTINE", "UR │
│ GENT", "FLASH"], value: "ROUTINE" }, │
│ + { kind: "gap" }, │
│ + { kind: "checkbox", label: "ENCRYPT TRANSMISSION", name: "encrypt", checked: tr │
│ ue }, │
│ + { kind: "checkbox", label: "NOTIFY ON REPLY", name: "notify" }, │
│ + { kind: "gap" }, │
│ + { kind: "textarea", label: "MESSAGE", name: "message", rows: 4 }, │
│ + { kind: "gap" }, │
│ + { kind: "buttons", buttons: [{ label: "TRANSMIT", variant: "primary" }, { label │
│ : "CLEAR", type: "reset" }] }, │
│ +]; │
│ │
│ -const FIELD_CELLS = 26; │
│ -const fieldUsed = LABEL_W + FIELD_CELLS; │
│ -const chanUsed = LABEL_W + selectWidth(20); │
│ -const submitUsed = buttonWidth("TRANSMIT") + 2 + buttonWidth("CLEAR"); │
│ +const FILED_TREE: TreeNode[] = [ │
│ + { │
│ + label: "/INFO", │
│ + children: [ │
│ + { │
│ + label: "GRAPH", │
│ + children: [{ label: "ENTROPY", children: [{ label: "BOUND - N217", href: │
│ "/notes/n217-entropy-bounds/" }] }], │
│ + }, │
│ + { │
│ + label: "WINDOWS", │
│ + children: [{ label: "PERF", children: [{ label: "TSS - N218", href: "/not │
│ es/collect-data-to-analyze-and-troubleshoot-performance-scenarios/" }] }], │
│ + }, │
│ + { label: "AI", children: [{ label: "TROUBLESHOOTING - N219", href: "/notes/ │
│ n219-using-ai-to-troubleshoot/" }] }, │
│ + ], │
│ + }, │
│ + { │
│ + label: "/META", │
│ + children: [{ label: "STYLE", children: [{ label: "SHEET - N0", href: "/notes/ │
│ n0-style-sheet/" }] }], │
│ + }, │
│ +]; │
│ --- │
│ <ArticleLayout title="OOKNET KB - Components"> │
│ <TopHeader /> │
│ @@ -67,24 +90,32 @@ const submitUsed = buttonWidth("TRANSMIT") + 2 + buttonWidth(" │
│ CLEAR"); │
│ <Pre>{pad("CHANNEL", LABEL_W)}<Select name="demo-channel" cells={20} options={[ │
│ "ALPHA", "BRAVO", "OOKNET-9"]} value="ALPHA" /></Pre> │
│ <Pre>{" "}</Pre> │
│ │
│ - <Frame build={section("COMPOSED FORM", "48-cell box")} /> │
│ - <Pre>{" "}</Pre> │
│ - <Pre>{boxTop("FIELD REPORT")}</Pre> │
│ - <Pre>{boxBlank}</Pre> │
│ - <Pre>{open}{pad("CALLSIGN", LABEL_W)}<TextField name="callsign" cells={FIELD_CE │
│ LLS} />{close(fieldUsed)}</Pre> │
│ - <Pre>{open}{pad("FREQUENCY", LABEL_W)}<TextField name="frequency" cells={FIELD_ │
│ CELLS} />{close(fieldUsed)}</Pre> │
│ - <Pre>{open}{pad("CHANNEL", LABEL_W)}<Select name="channel" cells={20} options={ │
│ ["ALPHA", "BRAVO", "OOKNET-9"]} value="ALPHA" />{close(chanUsed)}</Pre> │
│ - <Pre>{boxBlank}</Pre> │
│ - <Pre>{open}{pad("PRIORITY", LABEL_W)}<Radio label="ROUTINE" name="priority" val │
│ ue="routine" checked />{close(LABEL_W + radioWidth("ROUTINE"))}</Pre> │
│ - <Pre>{open}{" ".repeat(LABEL_W)}<Radio label="URGENT" name="priority" value="ur │
│ gent" />{close(LABEL_W + radioWidth("URGENT"))}</Pre> │
│ - <Pre>{open}{" ".repeat(LABEL_W)}<Radio label="FLASH" name="priority" value="fla │
│ sh" />{close(LABEL_W + radioWidth("FLASH"))}</Pre> │
│ - <Pre>{boxBlank}</Pre> │
│ - <Pre>{open}<Checkbox label="ENCRYPT TRANSMISSION" name="encrypt" checked />{clo │
│ se(len("ENCRYPT TRANSMISSION") + 4)}</Pre> │
│ - <Pre>{open}<Checkbox label="NOTIFY ON REPLY" name="notify" />{close(len("NOTIFY │
│ ON REPLY") + 4)}</Pre> │
│ - <Pre>{boxBlank}</Pre> │
│ - <Pre>{open}<Button label="TRANSMIT" variant="primary" />{" "}<Button label="CL │
│ EAR" />{close(submitUsed)}</Pre> │
│ - <Pre>{boxBlank}</Pre> │
│ - <Pre>{boxBottom}</Pre> │
│ + <Frame build={section("FORM FRAME", "FormFrame.astro")} /> │
│ <Pre>{" "}</Pre> │
│ + <FormFrame title="FIELD REPORT" fields={REPORT_FIELDS} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre><Status variant="ok" message="TRANSMISSION QUEUED" /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("TREE", "Tree.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Tree nodes={FILED_TREE} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("PROGRESS", "Progress.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{pad("REINDEX", LABEL_W)}<Progress value={57} /></Pre> │
│ + <Pre>{pad("UPLINK", LABEL_W)}<Progress value={100} /></Pre> │
│ + <Pre>{pad("DECAY", LABEL_W)}<Progress value={8} /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("STATUS", "Status.astro / Spinner.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre><Status variant="ok" message="TRANSMISSION QUEUED" /></Pre> │
│ + <Pre><Status variant="error" message="FREQUENCY OUT OF BAND" /></Pre> │
│ + <Pre><Status variant="pending" message="AWAITING PEER CHECK" /></Pre> │
│ + <Pre><Spinner />{" SYNCING INDEX"}</Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ <Frame build={(w) => rule(w)} /> │
│ </ArticleLayout> │
└────────────────────────────────────────────────────────────────────────────────────┘ ┌─ ...gn/src/pages/components.astro ── [ COPY ] ─┐
│ diff --git a/ooknet-design/src/pages/compone │
│ nts.astro b/ooknet-design/src/pages/componen │
│ ts.astro │
│ index 899ca73..106ca2c 100644 │
│ --- a/ooknet-design/src/pages/components.ast │
│ ro │
│ +++ b/ooknet-design/src/pages/components.ast │
│ ro │
│ @@ -4,36 +4,59 @@ import TopHeader from "../ │
│ components/TopHeader/TopHeader.astro"; │
│ import Frame from "../components/Frame/Fram │
│ e.astro"; │
│ import Pre from "../components/Pre/Pre.astr │
│ o"; │
│ import Button from "../components/Button/Bu │
│ tton.astro"; │
│ -import { buttonWidth } from "../components/ │
│ Button/Button"; │
│ import TextField from "../components/TextFi │
│ eld/TextField.astro"; │
│ import Checkbox from "../components/Checkbo │
│ x/Checkbox.astro"; │
│ import Radio from "../components/Radio/Radi │
│ o.astro"; │
│ -import { radioWidth } from "../components/R │
│ adio/Radio"; │
│ import Select from "../components/Select/Se │
│ lect.astro"; │
│ -import { selectWidth } from "../components/ │
│ Select/Select"; │
│ -import { G, rule, ruleD, row2, pad, len } f │
│ rom "../lib/ascii"; │
│ -import { MOBILE_FRAME_W } from "../lib/conf │
│ ig"; │
│ +import FormFrame from "../components/FormFr │
│ ame/FormFrame.astro"; │
│ +import type { Field } from "../components/F │
│ ormFrame/FormFrame"; │
│ +import Tree from "../components/Tree/Tree.a │
│ stro"; │
│ +import type { TreeNode } from "../component │
│ s/Tree/Tree"; │
│ +import Progress from "../components/Progres │
│ s/Progress.astro"; │
│ +import Spinner from "../components/Spinner/ │
│ Spinner.astro"; │
│ +import Status from "../components/Status/St │
│ atus.astro"; │
│ +import { rule, ruleD, row2, pad } from "../ │
│ lib/ascii"; │
│ │
│ const section = (title: string, right: stri │
│ ng) => (w: number) => │
│ [row2(title, right, w), ruleD(w)].join("\ │
│ n"); │
│ │
│ const LABEL_W = 12; │
│ │
│ -// The form box is MOBILE_FRAME_W wide so o │
│ ne render serves both │
│ -// viewports - interactive fields can't dua │
│ l-render without duplicating │
│ -// input names. │
│ -const B = MOBILE_FRAME_W; │
│ -const inner = B - 2; │
│ -const boxTop = (t: string) => `${G.TL}${G.H │
│ } ${t} ${G.H.repeat(inner - 3 - len(t))}${G. │
│ TR}`; │
│ -const boxBlank = `${G.V}${" ".repeat(inner) │
│ }${G.V}`; │
│ -const boxBottom = `${G.BL}${rule(inner)}${G │
│ .BR}`; │
│ -const open = `${G.V} `; │
│ -const close = (used: number) => `${" ".repe │
│ at(Math.max(0, inner - 2 - used))}${G.V}`; │
│ +const REPORT_FIELDS: Field[] = [ │
│ + { kind: "text", label: "CALLSIGN", name: │
│ "callsign", cells: 26 }, │
│ + { kind: "text", label: "FREQUENCY", name: │
│ "frequency", cells: 26 }, │
│ + { kind: "select", label: "CHANNEL", name: │
│ "channel", options: ["ALPHA", "BRAVO", "OOK │
│ NET-9"], cells: 20, value: "ALPHA" }, │
│ + { kind: "gap" }, │
│ + { kind: "radios", label: "PRIORITY", name │
│ : "priority", options: ["ROUTINE", "URGENT", │
│ "FLASH"], value: "ROUTINE" }, │
│ + { kind: "gap" }, │
│ + { kind: "checkbox", label: "ENCRYPT TRANS │
│ MISSION", name: "encrypt", checked: true }, │
│ + { kind: "checkbox", label: "NOTIFY ON REP │
│ LY", name: "notify" }, │
│ + { kind: "gap" }, │
│ + { kind: "textarea", label: "MESSAGE", nam │
│ e: "message", rows: 4 }, │
│ + { kind: "gap" }, │
│ + { kind: "buttons", buttons: [{ label: "TR │
│ ANSMIT", variant: "primary" }, { label: "CLE │
│ AR", type: "reset" }] }, │
│ +]; │
│ │
│ -const FIELD_CELLS = 26; │
│ -const fieldUsed = LABEL_W + FIELD_CELLS; │
│ -const chanUsed = LABEL_W + selectWidth(20); │
│ -const submitUsed = buttonWidth("TRANSMIT") │
│ + 2 + buttonWidth("CLEAR"); │
│ +const FILED_TREE: TreeNode[] = [ │
│ + { │
│ + label: "/INFO", │
│ + children: [ │
│ + { │
│ + label: "GRAPH", │
│ + children: [{ label: "ENTROPY", chil │
│ dren: [{ label: "BOUND - N217", href: "/note │
│ s/n217-entropy-bounds/" }] }], │
│ + }, │
│ + { │
│ + label: "WINDOWS", │
│ + children: [{ label: "PERF", childre │
│ n: [{ label: "TSS - N218", href: "/notes/col │
│ lect-data-to-analyze-and-troubleshoot-perfor │
│ mance-scenarios/" }] }], │
│ + }, │
│ + { label: "AI", children: [{ label: "T │
│ ROUBLESHOOTING - N219", href: "/notes/n219-u │
│ sing-ai-to-troubleshoot/" }] }, │
│ + ], │
│ + }, │
│ + { │
│ + label: "/META", │
│ + children: [{ label: "STYLE", children: │
│ [{ label: "SHEET - N0", href: "/notes/n0-sty │
│ le-sheet/" }] }], │
│ + }, │
│ +]; │
│ --- │
│ <ArticleLayout title="OOKNET KB - Component │
│ s"> │
│ <TopHeader /> │
│ @@ -67,24 +90,32 @@ const submitUsed = butto │
│ nWidth("TRANSMIT") + 2 + buttonWidth("CLEAR" │
│ ); │
│ <Pre>{pad("CHANNEL", LABEL_W)}<Select nam │
│ e="demo-channel" cells={20} options={["ALPHA │
│ ", "BRAVO", "OOKNET-9"]} value="ALPHA" /></P │
│ re> │
│ <Pre>{" "}</Pre> │
│ │
│ - <Frame build={section("COMPOSED FORM", "4 │
│ 8-cell box")} /> │
│ - <Pre>{" "}</Pre> │
│ - <Pre>{boxTop("FIELD REPORT")}</Pre> │
│ - <Pre>{boxBlank}</Pre> │
│ - <Pre>{open}{pad("CALLSIGN", LABEL_W)}<Tex │
│ tField name="callsign" cells={FIELD_CELLS} / │
│ >{close(fieldUsed)}</Pre> │
│ - <Pre>{open}{pad("FREQUENCY", LABEL_W)}<Te │
│ xtField name="frequency" cells={FIELD_CELLS} │
│ />{close(fieldUsed)}</Pre> │
│ - <Pre>{open}{pad("CHANNEL", LABEL_W)}<Sele │
│ ct name="channel" cells={20} options={["ALPH │
│ A", "BRAVO", "OOKNET-9"]} value="ALPHA" />{c │
│ lose(chanUsed)}</Pre> │
│ - <Pre>{boxBlank}</Pre> │
│ - <Pre>{open}{pad("PRIORITY", LABEL_W)}<Rad │
│ io label="ROUTINE" name="priority" value="ro │
│ utine" checked />{close(LABEL_W + radioWidth │
│ ("ROUTINE"))}</Pre> │
│ - <Pre>{open}{" ".repeat(LABEL_W)}<Radio la │
│ bel="URGENT" name="priority" value="urgent" │
│ />{close(LABEL_W + radioWidth("URGENT"))}</P │
│ re> │
│ - <Pre>{open}{" ".repeat(LABEL_W)}<Radio la │
│ bel="FLASH" name="priority" value="flash" /> │
│ {close(LABEL_W + radioWidth("FLASH"))}</Pre> │
│ - <Pre>{boxBlank}</Pre> │
│ - <Pre>{open}<Checkbox label="ENCRYPT TRANS │
│ MISSION" name="encrypt" checked />{close(len │
│ ("ENCRYPT TRANSMISSION") + 4)}</Pre> │
│ - <Pre>{open}<Checkbox label="NOTIFY ON REP │
│ LY" name="notify" />{close(len("NOTIFY ON RE │
│ PLY") + 4)}</Pre> │
│ - <Pre>{boxBlank}</Pre> │
│ - <Pre>{open}<Button label="TRANSMIT" varia │
│ nt="primary" />{" "}<Button label="CLEAR" / │
│ >{close(submitUsed)}</Pre> │
│ - <Pre>{boxBlank}</Pre> │
│ - <Pre>{boxBottom}</Pre> │
│ + <Frame build={section("FORM FRAME", "Form │
│ Frame.astro")} /> │
│ <Pre>{" "}</Pre> │
│ + <FormFrame title="FIELD REPORT" fields={R │
│ EPORT_FIELDS} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre><Status variant="ok" message="TRANSM │
│ ISSION QUEUED" /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("TREE", "Tree.astro │
│ ")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Tree nodes={FILED_TREE} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("PROGRESS", "Progre │
│ ss.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{pad("REINDEX", LABEL_W)}<Progress v │
│ alue={57} /></Pre> │
│ + <Pre>{pad("UPLINK", LABEL_W)}<Progress va │
│ lue={100} /></Pre> │
│ + <Pre>{pad("DECAY", LABEL_W)}<Progress val │
│ ue={8} /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("STATUS", "Status.a │
│ stro / Spinner.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre><Status variant="ok" message="TRANSM │
│ ISSION QUEUED" /></Pre> │
│ + <Pre><Status variant="error" message="FRE │
│ QUENCY OUT OF BAND" /></Pre> │
│ + <Pre><Status variant="pending" message="A │
│ WAITING PEER CHECK" /></Pre> │
│ + <Pre><Spinner />{" SYNCING INDEX"}</Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ <Frame build={(w) => rule(w)} /> │
│ </ArticleLayout> │
└──────────────────────────────────────────────┘ [ BACK TO LOG ] ──────────────────────────────────────────────────────────────────────────────────────
OOKNET ────────────────────────────────────────────────
OOKNET