HASH 63dfaae77353
DATE 2026-07-16
SUBJECT web: form components, markdown table/callout/figure transforms
FILES 31 CHANGED
HASH 63dfaae77353
DATE 2026-07-16
SUBJECT web: form components, markdown
table/callout/figure transforms
FILES 31 CHANGED
┌─ ooknet-design/public/figures/n0-frame-schematic.svg ──────────────────────┐
│ diff --git a/ooknet-design/public/figures/n0-frame-schematic.svg b/ooknet-design/p │
│ ublic/figures/n0-frame-schematic.svg │
│ new file mode 100644 │
│ index 0000000..7b49833 │
│ --- /dev/null │
│ +++ b/ooknet-design/public/figures/n0-frame-schematic.svg │
│ @@ -0,0 +1,17 @@ │
│ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 240" font-family="monosp │
│ ace" font-size="14"> │
│ + <g fill="none" stroke="#8a8378" stroke-width="1.5"> │
│ + <rect x="20" y="20" width="600" height="200" stroke-dasharray="6 4"/> │
│ + <rect x="60" y="55" width="240" height="130"/> │
│ + <rect x="340" y="55" width="240" height="130"/> │
│ + <line x1="300" y1="120" x2="340" y2="120"/> │
│ + </g> │
│ + <g fill="#8a8378"> │
│ + <text x="40" y="44">VIEWPORT</text> │
│ + <text x="80" y="80">FRAME_W = 86</text> │
│ + <text x="80" y="102">build(width)</text> │
│ + <text x="360" y="80">MOBILE_FRAME_W = 48</text> │
│ + <text x="360" y="102">build(width)</text> │
│ + <text x="80" y="165">.frame-wide</text> │
│ + <text x="360" y="165">.frame-narrow</text> │
│ + </g> │
│ +</svg> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...igures/n0-frame-schematic.svg ───┐
│ diff --git a/ooknet-design/public/figures/n0 │
│ -frame-schematic.svg b/ooknet-design/public/ │
│ figures/n0-frame-schematic.svg │
│ new file mode 100644 │
│ index 0000000..7b49833 │
│ --- /dev/null │
│ +++ b/ooknet-design/public/figures/n0-frame- │
│ schematic.svg │
│ @@ -0,0 +1,17 @@ │
│ +<svg xmlns="http://www.w3.org/2000/svg" vie │
│ wBox="0 0 640 240" font-family="monospace" f │
│ ont-size="14"> │
│ + <g fill="none" stroke="#8a8378" stroke-wi │
│ dth="1.5"> │
│ + <rect x="20" y="20" width="600" height= │
│ "200" stroke-dasharray="6 4"/> │
│ + <rect x="60" y="55" width="240" height= │
│ "130"/> │
│ + <rect x="340" y="55" width="240" height │
│ ="130"/> │
│ + <line x1="300" y1="120" x2="340" y2="12 │
│ 0"/> │
│ + </g> │
│ + <g fill="#8a8378"> │
│ + <text x="40" y="44">VIEWPORT</text> │
│ + <text x="80" y="80">FRAME_W = 86</text> │
│ + <text x="80" y="102">build(width)</text │
│ > │
│ + <text x="360" y="80">MOBILE_FRAME_W = 4 │
│ 8</text> │
│ + <text x="360" y="102">build(width)</tex │
│ t> │
│ + <text x="80" y="165">.frame-wide</text> │
│ + <text x="360" y="165">.frame-narrow</te │
│ xt> │
│ + </g> │
│ +</svg> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/ArticlePagination/ArticlePagination.astro ───┐
│ diff --git a/ooknet-design/src/components/ArticlePagination/ArticlePagination.astr │
│ o b/ooknet-design/src/components/ArticlePagination/ArticlePagination.astro │
│ new file mode 100644 │
│ index 0000000..78e78ce │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/ArticlePagination/ArticlePagination.astro │
│ @@ -0,0 +1,22 @@ │
│ +--- │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import Button from "../Button/Button.astro"; │
│ +import { buttonWidth } from "../Button/Button"; │
│ +import { FRAME_W, MOBILE_FRAME_W } from "../../lib/config"; │
│ +import type { Props } from "./ArticlePagination"; │
│ + │
│ +const { prev = null, next = null } = Astro.props as Props; │
│ + │
│ +// ASCII < > rather than ?/?: the fontsource Cascadia latin subset only │
│ +// ships U+2191/U+2193, so horizontal arrows fall off-grid. │
│ +const prevLabel = prev ? `< ${prev.note}` : null; │
│ +const nextLabel = next ? `${next.note} >` : null; │
│ +const gap = (w: number) => │
│ + Math.max(1, w - (prevLabel ? buttonWidth(prevLabel) : 0) - (nextLabel ? buttonW │
│ idth(nextLabel) : 0)); │
│ +--- │
│ +{(prev || next) && ( │
│ + <Fragment> │
│ + <Pre class="frame-wide">{prevLabel && prev && <Button label={prevLabel} href= │
│ {`/notes/${prev.id}/`} />}{" ".repeat(gap(FRAME_W))}{nextLabel && next && <Button │
│ label={nextLabel} href={`/notes/${next.id}/`} />}</Pre> │
│ + <Pre class="frame-narrow">{prevLabel && prev && <Button label={prevLabel} hre │
│ f={`/notes/${prev.id}/`} />}{" ".repeat(gap(MOBILE_FRAME_W))}{nextLabel && next && │
│ <Button label={nextLabel} href={`/notes/${next.id}/`} />}</Pre> │
│ + </Fragment> │
│ +)} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...ation/ArticlePagination.astro ───┐
│ diff --git a/ooknet-design/src/components/Ar │
│ ticlePagination/ArticlePagination.astro b/oo │
│ knet-design/src/components/ArticlePagination │
│ /ArticlePagination.astro │
│ new file mode 100644 │
│ index 0000000..78e78ce │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/ArticlePa │
│ gination/ArticlePagination.astro │
│ @@ -0,0 +1,22 @@ │
│ +--- │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import Button from "../Button/Button.astro" │
│ ; │
│ +import { buttonWidth } from "../Button/Butt │
│ on"; │
│ +import { FRAME_W, MOBILE_FRAME_W } from ".. │
│ /../lib/config"; │
│ +import type { Props } from "./ArticlePagina │
│ tion"; │
│ + │
│ +const { prev = null, next = null } = Astro. │
│ props as Props; │
│ + │
│ +// ASCII < > rather than ?/?: the fontsourc │
│ e Cascadia latin subset only │
│ +// ships U+2191/U+2193, so horizontal arrow │
│ s fall off-grid. │
│ +const prevLabel = prev ? `< ${prev.note}` : │
│ null; │
│ +const nextLabel = next ? `${next.note} >` : │
│ null; │
│ +const gap = (w: number) => │
│ + Math.max(1, w - (prevLabel ? buttonWidth( │
│ prevLabel) : 0) - (nextLabel ? buttonWidth(n │
│ extLabel) : 0)); │
│ +--- │
│ +{(prev || next) && ( │
│ + <Fragment> │
│ + <Pre class="frame-wide">{prevLabel && p │
│ rev && <Button label={prevLabel} href={`/not │
│ es/${prev.id}/`} />}{" ".repeat(gap(FRAME_W) │
│ )}{nextLabel && next && <Button label={nextL │
│ abel} href={`/notes/${next.id}/`} />}</Pre> │
│ + <Pre class="frame-narrow">{prevLabel && │
│ prev && <Button label={prevLabel} href={`/n │
│ otes/${prev.id}/`} />}{" ".repeat(gap(MOBILE │
│ _FRAME_W))}{nextLabel && next && <Button lab │
│ el={nextLabel} href={`/notes/${next.id}/`} / │
│ >}</Pre> │
│ + </Fragment> │
│ +)} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/ArticlePagination/ArticlePagination.ts ──────┐
│ diff --git a/ooknet-design/src/components/ArticlePagination/ArticlePagination.ts b │
│ /ooknet-design/src/components/ArticlePagination/ArticlePagination.ts │
│ new file mode 100644 │
│ index 0000000..2136cfe │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/ArticlePagination/ArticlePagination.ts │
│ @@ -0,0 +1,9 @@ │
│ +export interface PageLink { │
│ + id: string; │
│ + note: string; │
│ +} │
│ + │
│ +export interface Props { │
│ + prev?: PageLink | null; │
│ + next?: PageLink | null; │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...gination/ArticlePagination.ts ───┐
│ diff --git a/ooknet-design/src/components/Ar │
│ ticlePagination/ArticlePagination.ts b/ookne │
│ t-design/src/components/ArticlePagination/Ar │
│ ticlePagination.ts │
│ new file mode 100644 │
│ index 0000000..2136cfe │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/ArticlePa │
│ gination/ArticlePagination.ts │
│ @@ -0,0 +1,9 @@ │
│ +export interface PageLink { │
│ + id: string; │
│ + note: string; │
│ +} │
│ + │
│ +export interface Props { │
│ + prev?: PageLink | null; │
│ + next?: PageLink | null; │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/ArticleTags/ArticleTags.astro ───────────────┐
│ diff --git a/ooknet-design/src/components/ArticleTags/ArticleTags.astro b/ooknet-d │
│ esign/src/components/ArticleTags/ArticleTags.astro │
│ new file mode 100644 │
│ index 0000000..466d6f8 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/ArticleTags/ArticleTags.astro │
│ @@ -0,0 +1,25 @@ │
│ +--- │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import Badge from "../Badge/Badge.astro"; │
│ +import { pad } from "../../lib/ascii"; │
│ +import { FRAME_W, MOBILE_FRAME_W } from "../../lib/config"; │
│ +import { layoutTags, TAGS_LABEL_W, TAG_GAP, type Props } from "./ArticleTags"; │
│ + │
│ +const { fm } = Astro.props as Props; │
│ +const tags = fm.meta.tags.map((t) => t.toUpperCase()); │
│ +const href = (t: string) => `/?q=${encodeURIComponent(t.toLowerCase())}`; │
│ +const gapStr = " ".repeat(TAG_GAP); │
│ + │
│ +const wide = layoutTags(tags, FRAME_W); │
│ +const narrow = layoutTags(tags, MOBILE_FRAME_W); │
│ +--- │
│ +{tags.length > 0 && ( │
│ + <Fragment> │
│ + <div class="frame-wide">{wide.map((row, i) => ( │
│ + <Pre>{pad(i === 0 ? "TAGS" : "", TAGS_LABEL_W)}{row.map((t, j) => (<Fragmen │
│ t>{j > 0 ? gapStr : ""}<Badge label={t} href={href(t)} /></Fragment>))}</Pre> │
│ + ))}</div> │
│ + <div class="frame-narrow">{narrow.map((row, i) => ( │
│ + <Pre>{pad(i === 0 ? "TAGS" : "", TAGS_LABEL_W)}{row.map((t, j) => (<Fragmen │
│ t>{j > 0 ? gapStr : ""}<Badge label={t} href={href(t)} /></Fragment>))}</Pre> │
│ + ))}</div> │
│ + </Fragment> │
│ +)} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...ArticleTags/ArticleTags.astro ───┐
│ diff --git a/ooknet-design/src/components/Ar │
│ ticleTags/ArticleTags.astro b/ooknet-design/ │
│ src/components/ArticleTags/ArticleTags.astro │
│ new file mode 100644 │
│ index 0000000..466d6f8 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/ArticleTa │
│ gs/ArticleTags.astro │
│ @@ -0,0 +1,25 @@ │
│ +--- │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import Badge from "../Badge/Badge.astro"; │
│ +import { pad } from "../../lib/ascii"; │
│ +import { FRAME_W, MOBILE_FRAME_W } from ".. │
│ /../lib/config"; │
│ +import { layoutTags, TAGS_LABEL_W, TAG_GAP, │
│ type Props } from "./ArticleTags"; │
│ + │
│ +const { fm } = Astro.props as Props; │
│ +const tags = fm.meta.tags.map((t) => t.toUp │
│ perCase()); │
│ +const href = (t: string) => `/?q=${encodeUR │
│ IComponent(t.toLowerCase())}`; │
│ +const gapStr = " ".repeat(TAG_GAP); │
│ + │
│ +const wide = layoutTags(tags, FRAME_W); │
│ +const narrow = layoutTags(tags, MOBILE_FRAM │
│ E_W); │
│ +--- │
│ +{tags.length > 0 && ( │
│ + <Fragment> │
│ + <div class="frame-wide">{wide.map((row, │
│ i) => ( │
│ + <Pre>{pad(i === 0 ? "TAGS" : "", TAGS │
│ _LABEL_W)}{row.map((t, j) => (<Fragment>{j > │
│ 0 ? gapStr : ""}<Badge label={t} href={href │
│ (t)} /></Fragment>))}</Pre> │
│ + ))}</div> │
│ + <div class="frame-narrow">{narrow.map(( │
│ row, i) => ( │
│ + <Pre>{pad(i === 0 ? "TAGS" : "", TAGS │
│ _LABEL_W)}{row.map((t, j) => (<Fragment>{j > │
│ 0 ? gapStr : ""}<Badge label={t} href={href │
│ (t)} /></Fragment>))}</Pre> │
│ + ))}</div> │
│ + </Fragment> │
│ +)} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/ArticleTags/ArticleTags.ts ──────────────────┐
│ diff --git a/ooknet-design/src/components/ArticleTags/ArticleTags.ts b/ooknet-desi │
│ gn/src/components/ArticleTags/ArticleTags.ts │
│ new file mode 100644 │
│ index 0000000..9aa85fa │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/ArticleTags/ArticleTags.ts │
│ @@ -0,0 +1,31 @@ │
│ +import { badgeWidth } from "../Badge/Badge"; │
│ +import type { NoteFrontmatter } from "../../lib/types"; │
│ + │
│ +export interface Props { │
│ + fm: NoteFrontmatter; │
│ +} │
│ + │
│ +export const TAGS_LABEL_W = 12; │
│ +export const TAG_GAP = 2; │
│ + │
│ +// Wrap badges into rows that fit the frame; continuation rows indent │
│ +// under the first badge. │
│ +export function layoutTags(tags: string[], width: number): string[][] { │
│ + const avail = width - TAGS_LABEL_W; │
│ + const rows: string[][] = []; │
│ + let row: string[] = []; │
│ + let used = 0; │
│ + for (const tag of tags) { │
│ + const w = badgeWidth(tag) + (row.length ? TAG_GAP : 0); │
│ + if (row.length && used + w > avail) { │
│ + rows.push(row); │
│ + row = [tag]; │
│ + used = badgeWidth(tag); │
│ + } else { │
│ + row.push(tag); │
│ + used += w; │
│ + } │
│ + } │
│ + if (row.length) rows.push(row); │
│ + return rows; │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...ts/ArticleTags/ArticleTags.ts ───┐
│ diff --git a/ooknet-design/src/components/Ar │
│ ticleTags/ArticleTags.ts b/ooknet-design/src │
│ /components/ArticleTags/ArticleTags.ts │
│ new file mode 100644 │
│ index 0000000..9aa85fa │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/ArticleTa │
│ gs/ArticleTags.ts │
│ @@ -0,0 +1,31 @@ │
│ +import { badgeWidth } from "../Badge/Badge" │
│ ; │
│ +import type { NoteFrontmatter } from "../.. │
│ /lib/types"; │
│ + │
│ +export interface Props { │
│ + fm: NoteFrontmatter; │
│ +} │
│ + │
│ +export const TAGS_LABEL_W = 12; │
│ +export const TAG_GAP = 2; │
│ + │
│ +// Wrap badges into rows that fit the frame │
│ ; continuation rows indent │
│ +// under the first badge. │
│ +export function layoutTags(tags: string[], │
│ width: number): string[][] { │
│ + const avail = width - TAGS_LABEL_W; │
│ + const rows: string[][] = []; │
│ + let row: string[] = []; │
│ + let used = 0; │
│ + for (const tag of tags) { │
│ + const w = badgeWidth(tag) + (row.length │
│ ? TAG_GAP : 0); │
│ + if (row.length && used + w > avail) { │
│ + rows.push(row); │
│ + row = [tag]; │
│ + used = badgeWidth(tag); │
│ + } else { │
│ + row.push(tag); │
│ + used += w; │
│ + } │
│ + } │
│ + if (row.length) rows.push(row); │
│ + return rows; │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Badge/Badge.astro ───────────────────────────┐
│ diff --git a/ooknet-design/src/components/Badge/Badge.astro b/ooknet-design/src/co │
│ mponents/Badge/Badge.astro │
│ new file mode 100644 │
│ index 0000000..4f7319e │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Badge/Badge.astro │
│ @@ -0,0 +1,7 @@ │
│ +--- │
│ +import type { Props } from "./Badge"; │
│ +import "./Badge.scss"; │
│ + │
│ +const { label, href } = Astro.props as Props; │
│ +--- │
│ +{href ? <a class="badge" href={href}>[{label}]</a> : <span class="badge">[{label} │
│ ]</span>} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ .../components/Badge/Badge.astro ───┐
│ diff --git a/ooknet-design/src/components/Ba │
│ dge/Badge.astro b/ooknet-design/src/componen │
│ ts/Badge/Badge.astro │
│ new file mode 100644 │
│ index 0000000..4f7319e │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Badge/Bad │
│ ge.astro │
│ @@ -0,0 +1,7 @@ │
│ +--- │
│ +import type { Props } from "./Badge"; │
│ +import "./Badge.scss"; │
│ + │
│ +const { label, href } = Astro.props as Prop │
│ s; │
│ +--- │
│ +{href ? <a class="badge" href={href}>[{labe │
│ l}]</a> : <span class="badge">[{label}]</spa │
│ n>} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Badge/Badge.scss ────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Badge/Badge.scss b/ooknet-design/src/com │
│ ponents/Badge/Badge.scss │
│ new file mode 100644 │
│ index 0000000..243b572 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Badge/Badge.scss │
│ @@ -0,0 +1,15 @@ │
│ +.badge { │
│ + color: var(--ink); │
│ + text-decoration: none; │
│ + white-space: pre; │
│ + │
│ + &:hover { │
│ + text-decoration: underline; │
│ + text-underline-offset: 3px; │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...c/components/Badge/Badge.scss ───┐
│ diff --git a/ooknet-design/src/components/Ba │
│ dge/Badge.scss b/ooknet-design/src/component │
│ s/Badge/Badge.scss │
│ new file mode 100644 │
│ index 0000000..243b572 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Badge/Bad │
│ ge.scss │
│ @@ -0,0 +1,15 @@ │
│ +.badge { │
│ + color: var(--ink); │
│ + text-decoration: none; │
│ + white-space: pre; │
│ + │
│ + &:hover { │
│ + text-decoration: underline; │
│ + text-underline-offset: 3px; │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Badge/Badge.ts ──────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Badge/Badge.ts b/ooknet-design/src/compo │
│ nents/Badge/Badge.ts │
│ new file mode 100644 │
│ index 0000000..e73c0b9 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Badge/Badge.ts │
│ @@ -0,0 +1,9 @@ │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +export interface Props { │
│ + label: string; │
│ + href?: string; │
│ +} │
│ + │
│ +// "[" + label + "]" │
│ +export const badgeWidth = (label: string) => len(label) + 2; │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...src/components/Badge/Badge.ts ───┐
│ diff --git a/ooknet-design/src/components/Ba │
│ dge/Badge.ts b/ooknet-design/src/components/ │
│ Badge/Badge.ts │
│ new file mode 100644 │
│ index 0000000..e73c0b9 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Badge/Bad │
│ ge.ts │
│ @@ -0,0 +1,9 @@ │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +export interface Props { │
│ + label: string; │
│ + href?: string; │
│ +} │
│ + │
│ +// "[" + label + "]" │
│ +export const badgeWidth = (label: string) = │
│ > len(label) + 2; │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Button/Button.astro ─────────────────────────┐
│ diff --git a/ooknet-design/src/components/Button/Button.astro b/ooknet-design/src/ │
│ components/Button/Button.astro │
│ new file mode 100644 │
│ index 0000000..f6ac8a9 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Button/Button.astro │
│ @@ -0,0 +1,9 @@ │
│ +--- │
│ +import type { Props } from "./Button"; │
│ +import "./Button.scss"; │
│ + │
│ +const { label, href, type = "button", variant = "default", disabled = false } = A │
│ stro.props as Props; │
│ +--- │
│ +{href && !disabled │
│ + ? <a href={href} class:list={["btn", variant]}>[ {label} ]</a> │
│ + : <button type={type} class:list={["btn", variant]} disabled={disabled}>[ {labe │
│ l} ]</button>} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...omponents/Button/Button.astro ───┐
│ diff --git a/ooknet-design/src/components/Bu │
│ tton/Button.astro b/ooknet-design/src/compon │
│ ents/Button/Button.astro │
│ new file mode 100644 │
│ index 0000000..f6ac8a9 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Button/Bu │
│ tton.astro │
│ @@ -0,0 +1,9 @@ │
│ +--- │
│ +import type { Props } from "./Button"; │
│ +import "./Button.scss"; │
│ + │
│ +const { label, href, type = "button", varia │
│ nt = "default", disabled = false } = Astro.p │
│ rops as Props; │
│ +--- │
│ +{href && !disabled │
│ + ? <a href={href} class:list={["btn", vari │
│ ant]}>[ {label} ]</a> │
│ + : <button type={type} class:list={["btn", │
│ variant]} disabled={disabled}>[ {label} ]</ │
│ button>} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Button/Button.scss ──────────────────────────┐
│ diff --git a/ooknet-design/src/components/Button/Button.scss b/ooknet-design/src/c │
│ omponents/Button/Button.scss │
│ new file mode 100644 │
│ index 0000000..3c493f6 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Button/Button.scss │
│ @@ -0,0 +1,33 @@ │
│ +.btn { │
│ + font: inherit; │
│ + color: var(--ink); │
│ + background: none; │
│ + border: 0; │
│ + padding: 0; │
│ + margin: 0; │
│ + cursor: pointer; │
│ + white-space: pre; │
│ + text-decoration: none; │
│ + │
│ + &:hover:not(:disabled) { │
│ + text-decoration: underline; │
│ + text-underline-offset: 3px; │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ + │
│ + &.primary { │
│ + font-weight: 700; │
│ + background: var(--ink); │
│ + color: var(--paper); │
│ + } │
│ + │
│ + &:disabled { │
│ + color: var(--ink-soft); │
│ + opacity: 0.55; │
│ + cursor: not-allowed; │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...components/Button/Button.scss ───┐
│ diff --git a/ooknet-design/src/components/Bu │
│ tton/Button.scss b/ooknet-design/src/compone │
│ nts/Button/Button.scss │
│ new file mode 100644 │
│ index 0000000..3c493f6 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Button/Bu │
│ tton.scss │
│ @@ -0,0 +1,33 @@ │
│ +.btn { │
│ + font: inherit; │
│ + color: var(--ink); │
│ + background: none; │
│ + border: 0; │
│ + padding: 0; │
│ + margin: 0; │
│ + cursor: pointer; │
│ + white-space: pre; │
│ + text-decoration: none; │
│ + │
│ + &:hover:not(:disabled) { │
│ + text-decoration: underline; │
│ + text-underline-offset: 3px; │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ + │
│ + &.primary { │
│ + font-weight: 700; │
│ + background: var(--ink); │
│ + color: var(--paper); │
│ + } │
│ + │
│ + &:disabled { │
│ + color: var(--ink-soft); │
│ + opacity: 0.55; │
│ + cursor: not-allowed; │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Button/Button.ts ────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Button/Button.ts b/ooknet-design/src/com │
│ ponents/Button/Button.ts │
│ new file mode 100644 │
│ index 0000000..b2adbc9 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Button/Button.ts │
│ @@ -0,0 +1,12 @@ │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +export interface Props { │
│ + label: string; │
│ + href?: string; │
│ + type?: "button" | "submit"; │
│ + variant?: "default" | "primary"; │
│ + disabled?: boolean; │
│ +} │
│ + │
│ +// "[ " + label + " ]" │
│ +export const buttonWidth = (label: string) => len(label) + 4; │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...c/components/Button/Button.ts ───┐
│ diff --git a/ooknet-design/src/components/Bu │
│ tton/Button.ts b/ooknet-design/src/component │
│ s/Button/Button.ts │
│ new file mode 100644 │
│ index 0000000..b2adbc9 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Button/Bu │
│ tton.ts │
│ @@ -0,0 +1,12 @@ │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +export interface Props { │
│ + label: string; │
│ + href?: string; │
│ + type?: "button" | "submit"; │
│ + variant?: "default" | "primary"; │
│ + disabled?: boolean; │
│ +} │
│ + │
│ +// "[ " + label + " ]" │
│ +export const buttonWidth = (label: string) │
│ => len(label) + 4; │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Checkbox/Checkbox.astro ─────────────────────┐
│ diff --git a/ooknet-design/src/components/Checkbox/Checkbox.astro b/ooknet-design/ │
│ src/components/Checkbox/Checkbox.astro │
│ new file mode 100644 │
│ index 0000000..4b977ba │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Checkbox/Checkbox.astro │
│ @@ -0,0 +1,7 @@ │
│ +--- │
│ +import type { Props } from "./Checkbox"; │
│ +import "./Checkbox.scss"; │
│ + │
│ +const { label, name, checked = false, disabled = false } = Astro.props as Props; │
│ +--- │
│ +<label class="checkbox"><input type="checkbox" name={name} checked={checked} disa │
│ bled={disabled} /><span class="checkbox-mark" aria-hidden="true"></span>{" " + lab │
│ el}</label> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...nents/Checkbox/Checkbox.astro ───┐
│ diff --git a/ooknet-design/src/components/Ch │
│ eckbox/Checkbox.astro b/ooknet-design/src/co │
│ mponents/Checkbox/Checkbox.astro │
│ new file mode 100644 │
│ index 0000000..4b977ba │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Checkbox/ │
│ Checkbox.astro │
│ @@ -0,0 +1,7 @@ │
│ +--- │
│ +import type { Props } from "./Checkbox"; │
│ +import "./Checkbox.scss"; │
│ + │
│ +const { label, name, checked = false, disab │
│ led = false } = Astro.props as Props; │
│ +--- │
│ +<label class="checkbox"><input type="checkb │
│ ox" name={name} checked={checked} disabled={ │
│ disabled} /><span class="checkbox-mark" aria │
│ -hidden="true"></span>{" " + label}</label> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Checkbox/Checkbox.scss ──────────────────────┐
│ diff --git a/ooknet-design/src/components/Checkbox/Checkbox.scss b/ooknet-design/s │
│ rc/components/Checkbox/Checkbox.scss │
│ new file mode 100644 │
│ index 0000000..636dee4 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Checkbox/Checkbox.scss │
│ @@ -0,0 +1,38 @@ │
│ +.checkbox { │
│ + cursor: pointer; │
│ + white-space: pre; │
│ + │
│ + // The native input stays focusable but takes no grid cells; the mark │
│ + // renders its state. │
│ + input { │
│ + position: absolute; │
│ + opacity: 0; │
│ + width: 1px; │
│ + height: 1px; │
│ + margin: 0; │
│ + pointer-events: none; │
│ + } │
│ + │
│ + .checkbox-mark::before { │
│ + content: "[ ]"; │
│ + } │
│ + input:checked + .checkbox-mark::before { │
│ + content: "[x]"; │
│ + } │
│ + │
│ + input:focus-visible + .checkbox-mark { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ + │
│ + &:hover:not(:has(input:disabled)) { │
│ + text-decoration: underline; │
│ + text-underline-offset: 3px; │
│ + } │
│ + │
│ + &:has(input:disabled) { │
│ + color: var(--ink-soft); │
│ + opacity: 0.55; │
│ + cursor: not-allowed; │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...onents/Checkbox/Checkbox.scss ───┐
│ diff --git a/ooknet-design/src/components/Ch │
│ eckbox/Checkbox.scss b/ooknet-design/src/com │
│ ponents/Checkbox/Checkbox.scss │
│ new file mode 100644 │
│ index 0000000..636dee4 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Checkbox/ │
│ Checkbox.scss │
│ @@ -0,0 +1,38 @@ │
│ +.checkbox { │
│ + cursor: pointer; │
│ + white-space: pre; │
│ + │
│ + // The native input stays focusable but t │
│ akes no grid cells; the mark │
│ + // renders its state. │
│ + input { │
│ + position: absolute; │
│ + opacity: 0; │
│ + width: 1px; │
│ + height: 1px; │
│ + margin: 0; │
│ + pointer-events: none; │
│ + } │
│ + │
│ + .checkbox-mark::before { │
│ + content: "[ ]"; │
│ + } │
│ + input:checked + .checkbox-mark::before { │
│ + content: "[x]"; │
│ + } │
│ + │
│ + input:focus-visible + .checkbox-mark { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ + │
│ + &:hover:not(:has(input:disabled)) { │
│ + text-decoration: underline; │
│ + text-underline-offset: 3px; │
│ + } │
│ + │
│ + &:has(input:disabled) { │
│ + color: var(--ink-soft); │
│ + opacity: 0.55; │
│ + cursor: not-allowed; │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Checkbox/Checkbox.ts ────────────────────────┐
│ diff --git a/ooknet-design/src/components/Checkbox/Checkbox.ts b/ooknet-design/src │
│ /components/Checkbox/Checkbox.ts │
│ new file mode 100644 │
│ index 0000000..74cff37 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Checkbox/Checkbox.ts │
│ @@ -0,0 +1,11 @@ │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +export interface Props { │
│ + label: string; │
│ + name: string; │
│ + checked?: boolean; │
│ + disabled?: boolean; │
│ +} │
│ + │
│ +// "[x] " + label │
│ +export const checkboxWidth = (label: string) => len(label) + 4; │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...mponents/Checkbox/Checkbox.ts ───┐
│ diff --git a/ooknet-design/src/components/Ch │
│ eckbox/Checkbox.ts b/ooknet-design/src/compo │
│ nents/Checkbox/Checkbox.ts │
│ new file mode 100644 │
│ index 0000000..74cff37 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Checkbox/ │
│ Checkbox.ts │
│ @@ -0,0 +1,11 @@ │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +export interface Props { │
│ + label: string; │
│ + name: string; │
│ + checked?: boolean; │
│ + disabled?: boolean; │
│ +} │
│ + │
│ +// "[x] " + label │
│ +export const checkboxWidth = (label: string │
│ ) => len(label) + 4; │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Prose/Prose.scss ────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Prose/Prose.scss b/ooknet-design/src/com │
│ ponents/Prose/Prose.scss │
│ index 9a1306b..5553e92 100644 │
│ --- a/ooknet-design/src/components/Prose/Prose.scss │
│ +++ b/ooknet-design/src/components/Prose/Prose.scss │
│ @@ -73,6 +73,49 @@ │
│ } │
│ } │
│ │
│ + // [!NOTE]-style callouts. Still a blockquote (the left rail above │
│ + // applies); the build-time rule spans draw the top/bottom edges, so │
│ + // the rail is inset one line at each end and the indent uses cell │
│ + // units to keep the corner glyphs on the frame grid. │
│ + blockquote.callout { │
│ + font-style: normal; │
│ + color: var(--ink); │
│ + padding-left: calc(var(--cell-w) * 3); │
│ + padding-right: calc(var(--cell-w) * 3); │
│ + │
│ + // Left rail from the base blockquote, inset one line at each end so │
│ + // it meets the build-time corner rules; ::after mirrors it on the │
│ + // right to close the box. │
│ + &::before { │
│ + top: var(--cell-h); │
│ + bottom: var(--cell-h); │
│ + } │
│ + │
│ + &::after { │
│ + content: "│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\ │
│ A│\A│\A│\A│\A│\A│\A│\A│"; │
│ + position: absolute; │
│ + right: 0; │
│ + top: var(--cell-h); │
│ + bottom: var(--cell-h); │
│ + font-family: var(--mono-frame); │
│ + font-style: normal; │
│ + color: var(--ink); │
│ + white-space: pre; │
│ + overflow: hidden; │
│ + } │
│ + │
│ + .callout-rule { │
│ + display: block; │
│ + font-family: var(--mono-frame); │
│ + line-height: var(--lh); │
│ + margin-left: calc(var(--cell-w) * -3); │
│ + margin-right: calc(var(--cell-w) * -3); │
│ + white-space: pre; │
│ + overflow: hidden; │
│ + color: var(--ink); │
│ + } │
│ + } │
│ + │
│ ul, ol { │
│ margin: 0 0 1em; │
│ padding-left: 0; │
│ @@ -135,4 +178,31 @@ │
│ } │
│ } │
│ } │
│ + │
│ + pre.ascii.table-frame { │
│ + font-family: var(--mono-frame); │
│ + margin: 1.4em 0; │
│ + } │
│ + │
│ + .figure-frame { │
│ + margin: 1.4em 0; │
│ + │
│ + img { │
│ + display: block; │
│ + width: 100%; │
│ + max-width: var(--frame-w); │
│ + height: auto; │
│ + margin: 0.5em 0; │
│ + } │
│ + │
│ + .fig-rule, │
│ + .fig-caption { │
│ + display: block; │
│ + font-family: var(--mono-frame); │
│ + white-space: pre; │
│ + overflow: hidden; │
│ + color: var(--ink); │
│ + } │
│ + .fig-caption { color: var(--ink-soft); } │
│ + } │
│ } │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...c/components/Prose/Prose.scss ───┐
│ diff --git a/ooknet-design/src/components/Pr │
│ ose/Prose.scss b/ooknet-design/src/component │
│ s/Prose/Prose.scss │
│ index 9a1306b..5553e92 100644 │
│ --- a/ooknet-design/src/components/Prose/Pro │
│ se.scss │
│ +++ b/ooknet-design/src/components/Prose/Pro │
│ se.scss │
│ @@ -73,6 +73,49 @@ │
│ } │
│ } │
│ │
│ + // [!NOTE]-style callouts. Still a blockq │
│ uote (the left rail above │
│ + // applies); the build-time rule spans dr │
│ aw the top/bottom edges, so │
│ + // the rail is inset one line at each end │
│ and the indent uses cell │
│ + // units to keep the corner glyphs on the │
│ frame grid. │
│ + blockquote.callout { │
│ + font-style: normal; │
│ + color: var(--ink); │
│ + padding-left: calc(var(--cell-w) * 3); │
│ + padding-right: calc(var(--cell-w) * 3); │
│ + │
│ + // Left rail from the base blockquote, │
│ inset one line at each end so │
│ + // it meets the build-time corner rules │
│ ; ::after mirrors it on the │
│ + // right to close the box. │
│ + &::before { │
│ + top: var(--cell-h); │
│ + bottom: var(--cell-h); │
│ + } │
│ + │
│ + &::after { │
│ + content: "│\A│\A│\A│\A│\A│\A│\A│\A│\A │
│ │\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\ │
│ A│\A│\A│\A│\A│\A│"; │
│ + position: absolute; │
│ + right: 0; │
│ + top: var(--cell-h); │
│ + bottom: var(--cell-h); │
│ + font-family: var(--mono-frame); │
│ + font-style: normal; │
│ + color: var(--ink); │
│ + white-space: pre; │
│ + overflow: hidden; │
│ + } │
│ + │
│ + .callout-rule { │
│ + display: block; │
│ + font-family: var(--mono-frame); │
│ + line-height: var(--lh); │
│ + margin-left: calc(var(--cell-w) * -3) │
│ ; │
│ + margin-right: calc(var(--cell-w) * -3 │
│ ); │
│ + white-space: pre; │
│ + overflow: hidden; │
│ + color: var(--ink); │
│ + } │
│ + } │
│ + │
│ ul, ol { │
│ margin: 0 0 1em; │
│ padding-left: 0; │
│ @@ -135,4 +178,31 @@ │
│ } │
│ } │
│ } │
│ + │
│ + pre.ascii.table-frame { │
│ + font-family: var(--mono-frame); │
│ + margin: 1.4em 0; │
│ + } │
│ + │
│ + .figure-frame { │
│ + margin: 1.4em 0; │
│ + │
│ + img { │
│ + display: block; │
│ + width: 100%; │
│ + max-width: var(--frame-w); │
│ + height: auto; │
│ + margin: 0.5em 0; │
│ + } │
│ + │
│ + .fig-rule, │
│ + .fig-caption { │
│ + display: block; │
│ + font-family: var(--mono-frame); │
│ + white-space: pre; │
│ + overflow: hidden; │
│ + color: var(--ink); │
│ + } │
│ + .fig-caption { color: var(--ink-soft); │
│ } │
│ + } │
│ } │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Radio/Radio.astro ───────────────────────────┐
│ diff --git a/ooknet-design/src/components/Radio/Radio.astro b/ooknet-design/src/co │
│ mponents/Radio/Radio.astro │
│ new file mode 100644 │
│ index 0000000..4db3db0 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Radio/Radio.astro │
│ @@ -0,0 +1,7 @@ │
│ +--- │
│ +import type { Props } from "./Radio"; │
│ +import "./Radio.scss"; │
│ + │
│ +const { label, name, value, checked = false, disabled = false } = Astro.props as │
│ Props; │
│ +--- │
│ +<label class="radio"><input type="radio" name={name} value={value} checked={check │
│ ed} disabled={disabled} /><span class="radio-mark" aria-hidden="true"></span>{" " │
│ + label}</label> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ .../components/Radio/Radio.astro ───┐
│ diff --git a/ooknet-design/src/components/Ra │
│ dio/Radio.astro b/ooknet-design/src/componen │
│ ts/Radio/Radio.astro │
│ new file mode 100644 │
│ index 0000000..4db3db0 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Radio/Rad │
│ io.astro │
│ @@ -0,0 +1,7 @@ │
│ +--- │
│ +import type { Props } from "./Radio"; │
│ +import "./Radio.scss"; │
│ + │
│ +const { label, name, value, checked = false │
│ , disabled = false } = Astro.props as Props; │
│ +--- │
│ +<label class="radio"><input type="radio" na │
│ me={name} value={value} checked={checked} di │
│ sabled={disabled} /><span class="radio-mark" │
│ aria-hidden="true"></span>{" " + label}</la │
│ bel> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Radio/Radio.scss ────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Radio/Radio.scss b/ooknet-design/src/com │
│ ponents/Radio/Radio.scss │
│ new file mode 100644 │
│ index 0000000..f2ae83c │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Radio/Radio.scss │
│ @@ -0,0 +1,36 @@ │
│ +.radio { │
│ + cursor: pointer; │
│ + white-space: pre; │
│ + │
│ + input { │
│ + position: absolute; │
│ + opacity: 0; │
│ + width: 1px; │
│ + height: 1px; │
│ + margin: 0; │
│ + pointer-events: none; │
│ + } │
│ + │
│ + .radio-mark::before { │
│ + content: "( )"; │
│ + } │
│ + input:checked + .radio-mark::before { │
│ + content: "(-)"; │
│ + } │
│ + │
│ + input:focus-visible + .radio-mark { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ + │
│ + &:hover:not(:has(input:disabled)) { │
│ + text-decoration: underline; │
│ + text-underline-offset: 3px; │
│ + } │
│ + │
│ + &:has(input:disabled) { │
│ + color: var(--ink-soft); │
│ + opacity: 0.55; │
│ + cursor: not-allowed; │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...c/components/Radio/Radio.scss ───┐
│ diff --git a/ooknet-design/src/components/Ra │
│ dio/Radio.scss b/ooknet-design/src/component │
│ s/Radio/Radio.scss │
│ new file mode 100644 │
│ index 0000000..f2ae83c │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Radio/Rad │
│ io.scss │
│ @@ -0,0 +1,36 @@ │
│ +.radio { │
│ + cursor: pointer; │
│ + white-space: pre; │
│ + │
│ + input { │
│ + position: absolute; │
│ + opacity: 0; │
│ + width: 1px; │
│ + height: 1px; │
│ + margin: 0; │
│ + pointer-events: none; │
│ + } │
│ + │
│ + .radio-mark::before { │
│ + content: "( )"; │
│ + } │
│ + input:checked + .radio-mark::before { │
│ + content: "(-)"; │
│ + } │
│ + │
│ + input:focus-visible + .radio-mark { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ + │
│ + &:hover:not(:has(input:disabled)) { │
│ + text-decoration: underline; │
│ + text-underline-offset: 3px; │
│ + } │
│ + │
│ + &:has(input:disabled) { │
│ + color: var(--ink-soft); │
│ + opacity: 0.55; │
│ + cursor: not-allowed; │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Radio/Radio.ts ──────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Radio/Radio.ts b/ooknet-design/src/compo │
│ nents/Radio/Radio.ts │
│ new file mode 100644 │
│ index 0000000..51ec8a6 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Radio/Radio.ts │
│ @@ -0,0 +1,12 @@ │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +export interface Props { │
│ + label: string; │
│ + name: string; │
│ + value: string; │
│ + checked?: boolean; │
│ + disabled?: boolean; │
│ +} │
│ + │
│ +// "(-) " + label │
│ +export const radioWidth = (label: string) => len(label) + 4; │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...src/components/Radio/Radio.ts ───┐
│ diff --git a/ooknet-design/src/components/Ra │
│ dio/Radio.ts b/ooknet-design/src/components/ │
│ Radio/Radio.ts │
│ new file mode 100644 │
│ index 0000000..51ec8a6 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Radio/Rad │
│ io.ts │
│ @@ -0,0 +1,12 @@ │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +export interface Props { │
│ + label: string; │
│ + name: string; │
│ + value: string; │
│ + checked?: boolean; │
│ + disabled?: boolean; │
│ +} │
│ + │
│ +// "(-) " + label │
│ +export const radioWidth = (label: string) = │
│ > len(label) + 4; │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Select/Select.astro ─────────────────────────┐
│ diff --git a/ooknet-design/src/components/Select/Select.astro b/ooknet-design/src/ │
│ components/Select/Select.astro │
│ new file mode 100644 │
│ index 0000000..d9d10eb │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Select/Select.astro │
│ @@ -0,0 +1,10 @@ │
│ +--- │
│ +import type { Props, SelectOption } from "./Select"; │
│ +import "./Select.scss"; │
│ + │
│ +const { name, options, cells, value } = Astro.props as Props; │
│ +const opts: SelectOption[] = options.map((o) => │
│ + typeof o === "string" ? { value: o, label: o } : o, │
│ +); │
│ +--- │
│ +<span class="select">{"[ "}<select name={name} style={`width: calc(var(--cell-w) │
│ * ${cells})`}>{opts.map((o) => <option value={o.value} selected={o.value === value │
│ }>{o.label}</option>)}</select>{" ↓ ]"}</span> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...omponents/Select/Select.astro ───┐
│ diff --git a/ooknet-design/src/components/Se │
│ lect/Select.astro b/ooknet-design/src/compon │
│ ents/Select/Select.astro │
│ new file mode 100644 │
│ index 0000000..d9d10eb │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Select/Se │
│ lect.astro │
│ @@ -0,0 +1,10 @@ │
│ +--- │
│ +import type { Props, SelectOption } from ". │
│ /Select"; │
│ +import "./Select.scss"; │
│ + │
│ +const { name, options, cells, value } = Ast │
│ ro.props as Props; │
│ +const opts: SelectOption[] = options.map((o │
│ ) => │
│ + typeof o === "string" ? { value: o, label │
│ : o } : o, │
│ +); │
│ +--- │
│ +<span class="select">{"[ "}<select name={na │
│ me} style={`width: calc(var(--cell-w) * ${ce │
│ lls})`}>{opts.map((o) => <option value={o.va │
│ lue} selected={o.value === value}>{o.label}< │
│ /option>)}</select>{" ↓ ]"}</span> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Select/Select.scss ──────────────────────────┐
│ diff --git a/ooknet-design/src/components/Select/Select.scss b/ooknet-design/src/c │
│ omponents/Select/Select.scss │
│ new file mode 100644 │
│ index 0000000..2a42ca4 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Select/Select.scss │
│ @@ -0,0 +1,19 @@ │
│ +.select { │
│ + white-space: pre; │
│ + │
│ + select { │
│ + appearance: none; │
│ + font: inherit; │
│ + color: inherit; │
│ + background: transparent; │
│ + border: 0; │
│ + padding: 0; │
│ + margin: 0; │
│ + cursor: pointer; │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed currentColor; │
│ + outline-offset: 2px; │
│ + } │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...components/Select/Select.scss ───┐
│ diff --git a/ooknet-design/src/components/Se │
│ lect/Select.scss b/ooknet-design/src/compone │
│ nts/Select/Select.scss │
│ new file mode 100644 │
│ index 0000000..2a42ca4 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Select/Se │
│ lect.scss │
│ @@ -0,0 +1,19 @@ │
│ +.select { │
│ + white-space: pre; │
│ + │
│ + select { │
│ + appearance: none; │
│ + font: inherit; │
│ + color: inherit; │
│ + background: transparent; │
│ + border: 0; │
│ + padding: 0; │
│ + margin: 0; │
│ + cursor: pointer; │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed currentColor; │
│ + outline-offset: 2px; │
│ + } │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Select/Select.ts ────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Select/Select.ts b/ooknet-design/src/com │
│ ponents/Select/Select.ts │
│ new file mode 100644 │
│ index 0000000..8e3599c │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Select/Select.ts │
│ @@ -0,0 +1,17 @@ │
│ +export interface SelectOption { │
│ + value: string; │
│ + label: string; │
│ +} │
│ + │
│ +export interface Props { │
│ + name: string; │
│ + options: (SelectOption | string)[]; │
│ + /** Width of the value area in monospace cells. */ │
│ + cells: number; │
│ + value?: string; │
│ +} │
│ + │
│ +// "[ " + value area + " ↓ ]". The arrow must be a glyph Cascadia Mono │
│ +// actually ships (U+2193) - U+25BE falls back to a wider font and │
│ +// breaks the grid. │
│ +export const selectWidth = (cells: number) => cells + 6; │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...c/components/Select/Select.ts ───┐
│ diff --git a/ooknet-design/src/components/Se │
│ lect/Select.ts b/ooknet-design/src/component │
│ s/Select/Select.ts │
│ new file mode 100644 │
│ index 0000000..8e3599c │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Select/Se │
│ lect.ts │
│ @@ -0,0 +1,17 @@ │
│ +export interface SelectOption { │
│ + value: string; │
│ + label: string; │
│ +} │
│ + │
│ +export interface Props { │
│ + name: string; │
│ + options: (SelectOption | string)[]; │
│ + /** Width of the value area in monospace │
│ cells. */ │
│ + cells: number; │
│ + value?: string; │
│ +} │
│ + │
│ +// "[ " + value area + " ↓ ]". The arrow mu │
│ st be a glyph Cascadia Mono │
│ +// actually ships (U+2193) - U+25BE falls b │
│ ack to a wider font and │
│ +// breaks the grid. │
│ +export const selectWidth = (cells: number) │
│ => cells + 6; │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/TextField/TextField.astro ───────────────────┐
│ diff --git a/ooknet-design/src/components/TextField/TextField.astro b/ooknet-desig │
│ n/src/components/TextField/TextField.astro │
│ new file mode 100644 │
│ index 0000000..68b0f1a │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/TextField/TextField.astro │
│ @@ -0,0 +1,8 @@ │
│ +--- │
│ +import type { Props } from "./TextField"; │
│ +import "./TextField.scss"; │
│ + │
│ +const { name, cells, type = "text", value } = Astro.props as Props; │
│ +const seat = "_".repeat(cells); │
│ +--- │
│ +<input class="text-field" type={type} name={name} value={value} placeholder={seat │
│ } maxlength={cells} autocomplete="off" spellcheck="false" style={`width: calc(var( │
│ --cell-w) * ${cells})`} aria-label={name} /> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...nts/TextField/TextField.astro ───┐
│ diff --git a/ooknet-design/src/components/Te │
│ xtField/TextField.astro b/ooknet-design/src/ │
│ components/TextField/TextField.astro │
│ new file mode 100644 │
│ index 0000000..68b0f1a │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/TextField │
│ /TextField.astro │
│ @@ -0,0 +1,8 @@ │
│ +--- │
│ +import type { Props } from "./TextField"; │
│ +import "./TextField.scss"; │
│ + │
│ +const { name, cells, type = "text", value } │
│ = Astro.props as Props; │
│ +const seat = "_".repeat(cells); │
│ +--- │
│ +<input class="text-field" type={type} name= │
│ {name} value={value} placeholder={seat} maxl │
│ ength={cells} autocomplete="off" spellcheck= │
│ "false" style={`width: calc(var(--cell-w) * │
│ ${cells})`} aria-label={name} /> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/TextField/TextField.scss ────────────────────┐
│ diff --git a/ooknet-design/src/components/TextField/TextField.scss b/ooknet-design │
│ /src/components/TextField/TextField.scss │
│ new file mode 100644 │
│ index 0000000..d8ea7b3 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/TextField/TextField.scss │
│ @@ -0,0 +1,22 @@ │
│ +// Occupies exactly `cells` monospace cells inside a pre.ascii row; the │
│ +// underscore placeholder stands in for the empty seat. │
│ +.text-field { │
│ + font: inherit; │
│ + color: inherit; │
│ + background: transparent; │
│ + border: 0; │
│ + outline: 0; │
│ + padding: 0; │
│ + margin: 0; │
│ + caret-color: currentColor; │
│ + │
│ + &::placeholder { │
│ + color: var(--ink-soft); │
│ + opacity: 1; │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed currentColor; │
│ + outline-offset: 2px; │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...ents/TextField/TextField.scss ───┐
│ diff --git a/ooknet-design/src/components/Te │
│ xtField/TextField.scss b/ooknet-design/src/c │
│ omponents/TextField/TextField.scss │
│ new file mode 100644 │
│ index 0000000..d8ea7b3 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/TextField │
│ /TextField.scss │
│ @@ -0,0 +1,22 @@ │
│ +// Occupies exactly `cells` monospace cells │
│ inside a pre.ascii row; the │
│ +// underscore placeholder stands in for the │
│ empty seat. │
│ +.text-field { │
│ + font: inherit; │
│ + color: inherit; │
│ + background: transparent; │
│ + border: 0; │
│ + outline: 0; │
│ + padding: 0; │
│ + margin: 0; │
│ + caret-color: currentColor; │
│ + │
│ + &::placeholder { │
│ + color: var(--ink-soft); │
│ + opacity: 1; │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed currentColor; │
│ + outline-offset: 2px; │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/TextField/TextField.ts ──────────────────────┐
│ diff --git a/ooknet-design/src/components/TextField/TextField.ts b/ooknet-design/s │
│ rc/components/TextField/TextField.ts │
│ new file mode 100644 │
│ index 0000000..d624502 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/TextField/TextField.ts │
│ @@ -0,0 +1,8 @@ │
│ +export interface Props { │
│ + name: string; │
│ + /** Input width in monospace cells. Also caps input length so typed │
│ + * text can't overflow the grid. */ │
│ + cells: number; │
│ + type?: string; │
│ + value?: string; │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...onents/TextField/TextField.ts ───┐
│ diff --git a/ooknet-design/src/components/Te │
│ xtField/TextField.ts b/ooknet-design/src/com │
│ ponents/TextField/TextField.ts │
│ new file mode 100644 │
│ index 0000000..d624502 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/TextField │
│ /TextField.ts │
│ @@ -0,0 +1,8 @@ │
│ +export interface Props { │
│ + name: string; │
│ + /** Input width in monospace cells. Also │
│ caps input length so typed │
│ + * text can't overflow the grid. */ │
│ + cells: number; │
│ + type?: string; │
│ + value?: string; │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/TopHeader/TopHeader.astro ───────────────────┐
│ diff --git a/ooknet-design/src/components/TopHeader/TopHeader.astro b/ooknet-desig │
│ n/src/components/TopHeader/TopHeader.astro │
│ index 1713dc7..1bb8d38 100644 │
│ --- a/ooknet-design/src/components/TopHeader/TopHeader.astro │
│ +++ b/ooknet-design/src/components/TopHeader/TopHeader.astro │
│ @@ -149,4 +149,16 @@ const PLACEHOLDER_NARROW = "_".repeat(18); │
│ document.addEventListener("input", onInput); │
│ document.addEventListener("keydown", onKeydown); │
│ document.addEventListener("click", onDocClick); │
│ + │
│ + // Tag badges link to /?q=<tag>; prefill the search and show results. │
│ + const q = new URLSearchParams(window.location.search).get("q"); │
│ + if (q) { │
│ + const visible = Array.from(document.querySelectorAll<HTMLInputElement>("[data │
│ -search-input]")) │
│ + .find((el) => el.offsetParent !== null); │
│ + if (visible) { │
│ + visible.value = q.slice(0, Number(visible.dataset.cells) || 20); │
│ + visible.dispatchEvent(new Event("input", { bubbles: true })); │
│ + visible.focus(); │
│ + } │
│ + } │
│ </script> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...nts/TopHeader/TopHeader.astro ───┐
│ diff --git a/ooknet-design/src/components/To │
│ pHeader/TopHeader.astro b/ooknet-design/src/ │
│ components/TopHeader/TopHeader.astro │
│ index 1713dc7..1bb8d38 100644 │
│ --- a/ooknet-design/src/components/TopHeader │
│ /TopHeader.astro │
│ +++ b/ooknet-design/src/components/TopHeader │
│ /TopHeader.astro │
│ @@ -149,4 +149,16 @@ const PLACEHOLDER_NARRO │
│ W = "_".repeat(18); │
│ document.addEventListener("input", onInpu │
│ t); │
│ document.addEventListener("keydown", onKe │
│ ydown); │
│ document.addEventListener("click", onDocC │
│ lick); │
│ + │
│ + // Tag badges link to /?q=<tag>; prefill │
│ the search and show results. │
│ + const q = new URLSearchParams(window.loca │
│ tion.search).get("q"); │
│ + if (q) { │
│ + const visible = Array.from(document.que │
│ rySelectorAll<HTMLInputElement>("[data-searc │
│ h-input]")) │
│ + .find((el) => el.offsetParent !== nul │
│ l); │
│ + if (visible) { │
│ + visible.value = q.slice(0, Number(vis │
│ ible.dataset.cells) || 20); │
│ + visible.dispatchEvent(new Event("inpu │
│ t", { bubbles: true })); │
│ + visible.focus(); │
│ + } │
│ + } │
│ </script> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/content/notes/n0-style-sheet.md ────────────────────────┐
│ diff --git a/ooknet-design/src/content/notes/n0-style-sheet.md b/ooknet-design/src │
│ /content/notes/n0-style-sheet.md │
│ new file mode 100644 │
│ index 0000000..e7f2cb0 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/content/notes/n0-style-sheet.md │
│ @@ -0,0 +1,72 @@ │
│ +--- │
│ +title: "STYLE SHEET" │
│ +note: "N0" │
│ +volume: "VOLUME 00 / NOTE 000" │
│ +date: "JUL 2026" │
│ +authors: ["OOKNET"] │
│ +published: "2026-07-16" │
│ +status: "REFERENCE" │
│ +meta: │
│ + source: "ooknet" │
│ + category: "design-system" │
│ + tags: ["design-system", "reference", "typography"] │
│ + version: "v0.1" │
│ +filed: "/META/STYLE/SHEET" │
│ +page: 0 │
│ +--- │
│ + │
│ +# Purpose │
│ + │
│ +This sheet exercises every markdown construct the ASCII pipeline │
│ +renders. If a change to the design system breaks layout, it should be │
│ +visible on this page first. │
│ + │
│ +# Tables │
│ + │
│ +| GLYPH SET | CELLS | STATUS | │
│ +| --------- | ----: | -------- | │
│ +| single | 86 | ACTIVE | │
│ +| double | 48 | RESERVED | │
│ +| heavy | 86 | DROPPED | │
│ + │
│ +A wider table that must shrink and wrap on the narrow frame: │
│ + │
│ +| COMPONENT | RESPONSIBILITY | NOTES │
│ | │
│ +| --------- | ----------------------------------------- | ----------------------- │
│ ----- | │
│ +| Frame | renders a build function at both widths | static content only │
│ | │
│ +| TextField | input seated on an underscore placeholder | occupies exact cells │
│ | │
│ +| Select | native dropdown inside bracket chrome | arrow glyph must be on- │
│ grid | │
│ + │
│ +# Callouts │
│ + │
│ +> [!NOTE] │
│ +> The frame font must ship every glyph the renderer emits. Unknown │
│ +> codepoints fall back to a wider face and break the cell grid. │
│ + │
│ +> [!WARNING] │
│ +> Interactive elements cannot dual-render: duplicating an input │
│ +> duplicates its name and splits radio state across variants. │
│ + │
│ +> A plain blockquote is unaffected by the callout transform and keeps │
│ +> its italic rail styling. │
│ + │
│ +# Figures │
│ + │
│ + │
│ + │
│ +# Code │
│ + │
│ +```ts │
│ +export function pad(s: unknown, n: number): string { │
│ + const str = String(s ?? ""); │
│ + return str + " ".repeat(Math.max(0, n - len(str))); │
│ +} │
│ +``` │
│ + │
│ +# Lists │
│ + │
│ +- frames are strings, components are chrome │
│ +- the grid is the contract │
│ + 1. measure in cells │
│ + 2. wrap at build time │
│ + 3. never trust `ch` │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...ntent/notes/n0-style-sheet.md ───┐
│ diff --git a/ooknet-design/src/content/notes │
│ /n0-style-sheet.md b/ooknet-design/src/conte │
│ nt/notes/n0-style-sheet.md │
│ new file mode 100644 │
│ index 0000000..e7f2cb0 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/content/notes/n0-sty │
│ le-sheet.md │
│ @@ -0,0 +1,72 @@ │
│ +--- │
│ +title: "STYLE SHEET" │
│ +note: "N0" │
│ +volume: "VOLUME 00 / NOTE 000" │
│ +date: "JUL 2026" │
│ +authors: ["OOKNET"] │
│ +published: "2026-07-16" │
│ +status: "REFERENCE" │
│ +meta: │
│ + source: "ooknet" │
│ + category: "design-system" │
│ + tags: ["design-system", "reference", "typ │
│ ography"] │
│ + version: "v0.1" │
│ +filed: "/META/STYLE/SHEET" │
│ +page: 0 │
│ +--- │
│ + │
│ +# Purpose │
│ + │
│ +This sheet exercises every markdown constru │
│ ct the ASCII pipeline │
│ +renders. If a change to the design system b │
│ reaks layout, it should be │
│ +visible on this page first. │
│ + │
│ +# Tables │
│ + │
│ +| GLYPH SET | CELLS | STATUS | │
│ +| --------- | ----: | -------- | │
│ +| single | 86 | ACTIVE | │
│ +| double | 48 | RESERVED | │
│ +| heavy | 86 | DROPPED | │
│ + │
│ +A wider table that must shrink and wrap on │
│ the narrow frame: │
│ + │
│ +| COMPONENT | RESPONSIBILITY │
│ | NOTES │
│ | │
│ +| --------- | ----------------------------- │
│ ------------ | ---------------------------- │
│ | │
│ +| Frame | renders a build function at b │
│ oth widths | static content only │
│ | │
│ +| TextField | input seated on an underscore │
│ placeholder | occupies exact cells │
│ | │
│ +| Select | native dropdown inside bracke │
│ t chrome | arrow glyph must be on-grid │
│ | │
│ + │
│ +# Callouts │
│ + │
│ +> [!NOTE] │
│ +> The frame font must ship every glyph the │
│ renderer emits. Unknown │
│ +> codepoints fall back to a wider face and │
│ break the cell grid. │
│ + │
│ +> [!WARNING] │
│ +> Interactive elements cannot dual-render: │
│ duplicating an input │
│ +> duplicates its name and splits radio stat │
│ e across variants. │
│ + │
│ +> A plain blockquote is unaffected by the c │
│ allout transform and keeps │
│ +> its italic rail styling. │
│ + │
│ +# Figures │
│ + │
│ + │
│ + │
│ +# Code │
│ + │
│ +```ts │
│ +export function pad(s: unknown, n: number): │
│ string { │
│ + const str = String(s ?? ""); │
│ + return str + " ".repeat(Math.max(0, n - l │
│ en(str))); │
│ +} │
│ +``` │
│ + │
│ +# Lists │
│ + │
│ +- frames are strings, components are chrome │
│ +- the grid is the contract │
│ + 1. measure in cells │
│ + 2. wrap at build time │
│ + 3. never trust `ch` │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/lib/ascii.ts ───────────────────────────────────────────┐
│ diff --git a/ooknet-design/src/lib/ascii.ts b/ooknet-design/src/lib/ascii.ts │
│ index 4c2efa8..466add5 100644 │
│ --- a/ooknet-design/src/lib/ascii.ts │
│ +++ b/ooknet-design/src/lib/ascii.ts │
│ @@ -3,8 +3,8 @@ │
│ export const G = { │
│ H: "─", V: "│", │
│ TL: "┌", TR: "┐", BL: "└", BR: "┘", │
│ - LJ: "├", RJ: "┤", │
│ - D: "═", │
│ + LJ: "├", RJ: "┤", TJ: "┬", BJ: "┴", X: "┼", │
│ + D: "═", HD: "╌", │
│ }; │
│ │
│ // Codepoint length, not UTF-16 units - frame math breaks on surrogate │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ooknet-design/src/lib/ascii.ts ─────┐
│ diff --git a/ooknet-design/src/lib/ascii.ts │
│ b/ooknet-design/src/lib/ascii.ts │
│ index 4c2efa8..466add5 100644 │
│ --- a/ooknet-design/src/lib/ascii.ts │
│ +++ b/ooknet-design/src/lib/ascii.ts │
│ @@ -3,8 +3,8 @@ │
│ export const G = { │
│ H: "─", V: "│", │
│ TL: "┌", TR: "┐", BL: "└", BR: "┘", │
│ - LJ: "├", RJ: "┤", │
│ - D: "═", │
│ + LJ: "├", RJ: "┤", TJ: "┬", BJ: "┴", X: "┼ │
│ ", │
│ + D: "═", HD: "╌", │
│ }; │
│ │
│ // Codepoint length, not UTF-16 units - fra │
│ me math breaks on surrogate │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/lib/rehype-ascii.ts ────────────────────────────────────┐
│ diff --git a/ooknet-design/src/lib/rehype-ascii.ts b/ooknet-design/src/lib/rehype- │
│ ascii.ts │
│ index bfc7b18..c9a4a9c 100644 │
│ --- a/ooknet-design/src/lib/rehype-ascii.ts │
│ +++ b/ooknet-design/src/lib/rehype-ascii.ts │
│ @@ -1,11 +1,14 @@ │
│ -// Frames fenced code blocks in an ASCII box and appends a head-rule │
│ -// span under each <h1>. Emits both the desktop and mobile variants in │
│ -// the same tree; CSS toggles visibility per viewport. │
│ +// Markdown ? ASCII chrome, applied at build time. Fenced code blocks │
│ +// and tables become framed boxes, [!NOTE]-style blockquotes become │
│ +// labeled callouts, lone images become numbered figures, and each <h1> │
│ +// gets a head-rule. Everything emits both the desktop and mobile │
│ +// variants in the same tree; CSS toggles visibility per viewport. │
│ import type { Root, Element, ElementContent, RootContent, Properties } from "hast │
│ "; │
│ -import { G, len } from "./ascii"; │
│ +import { G, len, pad, wrap, type PadSide } from "./ascii"; │
│ import { FRAME_W, MOBILE_FRAME_W } from "./config"; │
│ │
│ const FN_RE = /\b([a-zA-Z_]\w*)(?=\s*\()/g; │
│ +const CALLOUT_RE = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/; │
│ │
│ function visitElements(node: Root | RootContent, fn: (el: Element) => void): void │
│ { │
│ if ((node as Element).type === "element") fn(node as Element); │
│ @@ -13,6 +16,17 @@ function visitElements(node: Root | RootContent, fn: (el: Eleme │
│ nt) => void): voi │
│ if (children) for (const c of children) visitElements(c, fn); │
│ } │
│ │
│ +function hasClass(el: Element, cls: string): boolean { │
│ + const c = (el.properties as Properties | undefined)?.className; │
│ + return Array.isArray(c) && c.includes(cls); │
│ +} │
│ + │
│ +function textContent(node: RootContent): string { │
│ + if (node.type === "text") return node.value; │
│ + const kids = (node as { children?: RootContent[] }).children; │
│ + return kids ? kids.map(textContent).join("") : ""; │
│ +} │
│ + │
│ function frame(value: string): Element { │
│ return { │
│ type: "element", │
│ @@ -22,6 +36,15 @@ function frame(value: string): Element { │
│ }; │
│ } │
│ │
│ +function blockSpan(cls: string, variant: string, children: ElementContent[]): Ele │
│ ment { │
│ + return { │
│ + type: "element", │
│ + tagName: "span", │
│ + properties: { className: [cls, variant], "aria-hidden": "true" }, │
│ + children, │
│ + }; │
│ +} │
│ + │
│ const copyBtn: Element = { │
│ type: "element", │
│ tagName: "button", │
│ @@ -29,6 +52,8 @@ const copyBtn: Element = { │
│ children: [{ type: "text", value: "[ COPY ]" }], │
│ }; │
│ │
│ +// ---------------------------------------------------------------- code │
│ + │
│ function buildBoxChildren(text: string, lang: string, frameW: number): ElementCon │
│ tent[] { │
│ const inner = frameW - 4; │
│ const lines = text.replace(/\n+$/, "").split("\n").flatMap((line) => { │
│ @@ -93,23 +118,125 @@ function makeCodePre(text: string, lang: string, frameW: num │
│ ber, extraClass: str │
│ }; │
│ } │
│ │
│ -function ruleSpan(frameW: number, variant: "frame-wide" | "frame-narrow"): Elemen │
│ t { │
│ +// --------------------------------------------------------------- table │
│ + │
│ +type Cell = { text: string; align: PadSide }; │
│ + │
│ +function tableCells(tbl: Element): { rows: Cell[][]; headerRows: number } { │
│ + const rows: Cell[][] = []; │
│ + let headerRows = 0; │
│ + visitElements(tbl, (el) => { │
│ + if (el.tagName !== "tr") return; │
│ + const cells: Cell[] = []; │
│ + let isHeader = false; │
│ + for (const c of el.children) { │
│ + if (c.type !== "element" || (c.tagName !== "td" && c.tagName !== "th")) con │
│ tinue; │
│ + if (c.tagName === "th") isHeader = true; │
│ + const a = (c.properties as Properties | undefined)?.align; │
│ + const align: PadSide = a === "right" ? "start" : a === "center" ? "center" │
│ : "end"; │
│ + cells.push({ text: textContent(c).replace(/\s+/g, " ").trim(), align }); │
│ + } │
│ + if (cells.length) { │
│ + rows.push(cells); │
│ + if (isHeader) headerRows = rows.length; │
│ + } │
│ + }); │
│ + return { rows, headerRows }; │
│ +} │
│ + │
│ +function buildAsciiTable(rows: Cell[][], headerRows: number, width: number): stri │
│ ng { │
│ + const nCols = Math.max(...rows.map((r) => r.length)); │
│ + const norm = rows.map((r) => { │
│ + const out = [...r]; │
│ + while (out.length < nCols) out.push({ text: "", align: "end" }); │
│ + return out; │
│ + }); │
│ + │
│ + // Natural column widths, then shave the widest until the table fits. │
│ + // Cells wrap to their final column width. │
│ + const colW = Array.from({ length: nCols }, (_, i) => │
│ + Math.max(1, ...norm.map((r) => len(r[i].text))), │
│ + ); │
│ + const total = () => 1 + colW.reduce((s, w) => s + w + 3, 0); │
│ + const MIN_COL = 5; │
│ + while (total() > width && colW.some((w) => w > MIN_COL)) { │
│ + colW[colW.indexOf(Math.max(...colW))]--; │
│ + } │
│ + │
│ + const edge = (l: string, m: string, r: string, h = G.H) => │
│ + l + colW.map((w) => h.repeat(w + 2)).join(m) + r; │
│ + │
│ + // When any row wraps, dashed separators between body rows keep the │
│ + // logical rows readable; all-single-line tables stay compact. │
│ + const cellLines = norm.map((row) => row.map((c, i) => wrap(c.text, colW[i]).spl │
│ it("\n"))); │
│ + const anyWrapped = cellLines.some((row) => row.some((l) => l.length > 1)); │
│ + │
│ + const out: string[] = [edge(G.TL, G.TJ, G.TR)]; │
│ + norm.forEach((row, ri) => { │
│ + if (anyWrapped && ri > headerRows) out.push(edge(G.LJ, G.X, G.RJ, G.HD)); │
│ + const h = Math.max(...cellLines[ri].map((l) => l.length)); │
│ + for (let k = 0; k < h; k++) { │
│ + out.push( │
│ + G.V + │
│ + row.map((c, i) => ` ${pad(cellLines[ri][i][k] ?? "", colW[i], c.align)} │
│ `).join(G.V) + │
│ + G.V, │
│ + ); │
│ + } │
│ + if (ri === headerRows - 1) out.push(edge(G.LJ, G.X, G.RJ)); │
│ + }); │
│ + out.push(edge(G.BL, G.BJ, G.BR)); │
│ + return out.join("\n"); │
│ +} │
│ + │
│ +function makeTablePre(text: string, extraClass: string): Element { │
│ return { │
│ type: "element", │
│ - tagName: "span", │
│ - properties: { className: ["head-rule", variant] }, │
│ - children: [{ type: "text", value: G.H.repeat(frameW) }], │
│ + tagName: "pre", │
│ + properties: { className: ["ascii", "table-frame", extraClass], "aria-hidden": │
│ "true" }, │
│ + children: [{ type: "text", value: text }], │
│ }; │
│ } │
│ │
│ +// ------------------------------------------------------------ callouts │
│ + │
│ +function calloutTop(label: string, frameW: number, variant: string): Element { │
│ + return blockSpan("callout-rule", variant, [ │
│ + { type: "text", value: `${G.TL}${G.H} ` }, │
│ + { │
│ + type: "element", │
│ + tagName: "strong", │
│ + properties: {}, │
│ + children: [{ type: "text", value: label }], │
│ + }, │
│ + { type: "text", value: ` ${G.H.repeat(Math.max(0, frameW - 5 - len(label)))}$ │
│ {G.TR}` }, │
│ + ]); │
│ +} │
│ + │
│ +function calloutBottom(frameW: number, variant: string): Element { │
│ + return blockSpan("callout-rule", variant, [ │
│ + { type: "text", value: `${G.BL}${G.H.repeat(frameW - 2)}${G.BR}` }, │
│ + ]); │
│ +} │
│ + │
│ +// ------------------------------------------------------------- shared │
│ + │
│ +function ruleSpan(frameW: number, variant: "frame-wide" | "frame-narrow", cls = " │
│ head-rule"): Element { │
│ + return blockSpan(cls, variant, [{ type: "text", value: G.H.repeat(frameW) }]); │
│ +} │
│ + │
│ +function captionSpan(text: string, frameW: number, variant: string): Element { │
│ + return blockSpan("fig-caption", variant, [{ type: "text", value: wrap(text, fra │
│ meW) }]); │
│ +} │
│ + │
│ export function rehypeAscii() { │
│ return (tree: Root) => { │
│ + let fig = 0; │
│ + │
│ visitElements(tree, (node) => { │
│ if (node.tagName === "pre") { │
│ // Skip the dual-rendered pres we emit ourselves; otherwise the │
│ // visitor recurses into them and reprocesses indefinitely. │
│ - const cls = (node.properties as Properties | undefined)?.className; │
│ - if (Array.isArray(cls) && cls.includes("code-block")) return; │
│ + if (hasClass(node, "code-block") || hasClass(node, "table-frame")) return │
│ ; │
│ │
│ const codeEl = node.children.find( │
│ (c): c is Element => c.type === "element" && c.tagName === "code", │
│ @@ -134,11 +261,79 @@ export function rehypeAscii() { │
│ return; │
│ } │
│ │
│ + if (node.tagName === "table") { │
│ + if (hasClass(node, "sr-only")) return; │
│ + const { rows, headerRows } = tableCells(node); │
│ + if (!rows.length) return; │
│ + │
│ + // Keep the semantic table for screen readers; the ASCII pres │
│ + // are presentation only. │
│ + const original: Element = { │
│ + type: "element", │
│ + tagName: "table", │
│ + properties: { className: ["sr-only"] }, │
│ + children: node.children, │
│ + }; │
│ + node.tagName = "div"; │
│ + node.properties = { className: ["table-frames"] }; │
│ + node.children = [ │
│ + makeTablePre(buildAsciiTable(rows, headerRows, FRAME_W), "frame-wide"), │
│ + makeTablePre(buildAsciiTable(rows, headerRows, MOBILE_FRAME_W), "frame- │
│ narrow"), │
│ + original, │
│ + ]; │
│ + return; │
│ + } │
│ + │
│ + if (node.tagName === "blockquote" && !hasClass(node, "callout")) { │
│ + const firstP = node.children.find( │
│ + (c): c is Element => c.type === "element" && c.tagName === "p", │
│ + ); │
│ + const firstText = firstP?.children[0]; │
│ + if (!firstP || !firstText || firstText.type !== "text") return; │
│ + const m = firstText.value.match(CALLOUT_RE); │
│ + if (!m) return; │
│ + │
│ + const label = m[1]; │
│ + firstText.value = firstText.value.slice(m[0].length); │
│ + if (!firstText.value) firstP.children.shift(); │
│ + if (!firstP.children.length) node.children = node.children.filter((c) => │
│ c !== firstP); │
│ + │
│ + node.properties = { className: ["callout"] }; │
│ + node.children = [ │
│ + calloutTop(label, FRAME_W, "frame-wide"), │
│ + calloutTop(label, MOBILE_FRAME_W, "frame-narrow"), │
│ + ...node.children, │
│ + calloutBottom(FRAME_W, "frame-wide"), │
│ + calloutBottom(MOBILE_FRAME_W, "frame-narrow"), │
│ + ]; │
│ + return; │
│ + } │
│ + │
│ + if (node.tagName === "p") { │
│ + const kids = node.children.filter((c) => !(c.type === "text" && !c.value. │
│ trim())); │
│ + const img = kids[0]; │
│ + if (kids.length !== 1 || img.type !== "element" || img.tagName !== "img") │
│ return; │
│ + │
│ + fig++; │
│ + const alt = String((img.properties as Properties | undefined)?.alt ?? "") │
│ .trim(); │
│ + const caption = alt ? `FIG. ${fig} - ${alt.toUpperCase()}` : `FIG. ${fig} │
│ `; │
│ + node.tagName = "figure"; │
│ + node.properties = { className: ["figure-frame"] }; │
│ + node.children = [ │
│ + ruleSpan(FRAME_W, "frame-wide", "fig-rule"), │
│ + ruleSpan(MOBILE_FRAME_W, "frame-narrow", "fig-rule"), │
│ + img, │
│ + captionSpan(caption, FRAME_W, "frame-wide"), │
│ + captionSpan(caption, MOBILE_FRAME_W, "frame-narrow"), │
│ + ruleSpan(FRAME_W, "frame-wide", "fig-rule"), │
│ + ruleSpan(MOBILE_FRAME_W, "frame-narrow", "fig-rule"), │
│ + ]; │
│ + return; │
│ + } │
│ + │
│ if (node.tagName === "h1") { │
│ const already = node.children.some( │
│ - (c) => c.type === "element" && c.tagName === "span" │
│ - && Array.isArray((c.properties as Properties | undefined)?.className) │
│ - && ((c.properties as Properties).className as unknown[]).includes("he │
│ ad-rule"), │
│ + (c) => c.type === "element" && c.tagName === "span" && hasClass(c, "hea │
│ d-rule"), │
│ ); │
│ if (already) return; │
│ node.children = [ │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...esign/src/lib/rehype-ascii.ts ───┐
│ diff --git a/ooknet-design/src/lib/rehype-as │
│ cii.ts b/ooknet-design/src/lib/rehype-ascii. │
│ ts │
│ index bfc7b18..c9a4a9c 100644 │
│ --- a/ooknet-design/src/lib/rehype-ascii.ts │
│ +++ b/ooknet-design/src/lib/rehype-ascii.ts │
│ @@ -1,11 +1,14 @@ │
│ -// Frames fenced code blocks in an ASCII bo │
│ x and appends a head-rule │
│ -// span under each <h1>. Emits both the des │
│ ktop and mobile variants in │
│ -// the same tree; CSS toggles visibility pe │
│ r viewport. │
│ +// Markdown ? ASCII chrome, applied at buil │
│ d time. Fenced code blocks │
│ +// and tables become framed boxes, [!NOTE]- │
│ style blockquotes become │
│ +// labeled callouts, lone images become num │
│ bered figures, and each <h1> │
│ +// gets a head-rule. Everything emits both │
│ the desktop and mobile │
│ +// variants in the same tree; CSS toggles v │
│ isibility per viewport. │
│ import type { Root, Element, ElementContent │
│ , RootContent, Properties } from "hast"; │
│ -import { G, len } from "./ascii"; │
│ +import { G, len, pad, wrap, type PadSide } │
│ from "./ascii"; │
│ import { FRAME_W, MOBILE_FRAME_W } from "./ │
│ config"; │
│ │
│ const FN_RE = /\b([a-zA-Z_]\w*)(?=\s*\()/g; │
│ +const CALLOUT_RE = /^\[!(NOTE|TIP|IMPORTANT │
│ |WARNING|CAUTION)\]\s*/; │
│ │
│ function visitElements(node: Root | RootCon │
│ tent, fn: (el: Element) => void): void { │
│ if ((node as Element).type === "element") │
│ fn(node as Element); │
│ @@ -13,6 +16,17 @@ function visitElements(no │
│ de: Root | RootContent, fn: (el: Element) => │
│ void): voi │
│ if (children) for (const c of children) v │
│ isitElements(c, fn); │
│ } │
│ │
│ +function hasClass(el: Element, cls: string) │
│ : boolean { │
│ + const c = (el.properties as Properties | │
│ undefined)?.className; │
│ + return Array.isArray(c) && c.includes(cls │
│ ); │
│ +} │
│ + │
│ +function textContent(node: RootContent): st │
│ ring { │
│ + if (node.type === "text") return node.val │
│ ue; │
│ + const kids = (node as { children?: RootCo │
│ ntent[] }).children; │
│ + return kids ? kids.map(textContent).join( │
│ "") : ""; │
│ +} │
│ + │
│ function frame(value: string): Element { │
│ return { │
│ type: "element", │
│ @@ -22,6 +36,15 @@ function frame(value: str │
│ ing): Element { │
│ }; │
│ } │
│ │
│ +function blockSpan(cls: string, variant: st │
│ ring, children: ElementContent[]): Element { │
│ + return { │
│ + type: "element", │
│ + tagName: "span", │
│ + properties: { className: [cls, variant] │
│ , "aria-hidden": "true" }, │
│ + children, │
│ + }; │
│ +} │
│ + │
│ const copyBtn: Element = { │
│ type: "element", │
│ tagName: "button", │
│ @@ -29,6 +52,8 @@ const copyBtn: Element = { │
│ children: [{ type: "text", value: "[ COPY │
│ ]" }], │
│ }; │
│ │
│ +// ---------------------------------------- │
│ ------------------------ code │
│ + │
│ function buildBoxChildren(text: string, lan │
│ g: string, frameW: number): ElementContent[] │
│ { │
│ const inner = frameW - 4; │
│ const lines = text.replace(/\n+$/, "").sp │
│ lit("\n").flatMap((line) => { │
│ @@ -93,23 +118,125 @@ function makeCodePre(t │
│ ext: string, lang: string, frameW: number, e │
│ xtraClass: str │
│ }; │
│ } │
│ │
│ -function ruleSpan(frameW: number, variant: │
│ "frame-wide" | "frame-narrow"): Element { │
│ +// ---------------------------------------- │
│ ----------------------- table │
│ + │
│ +type Cell = { text: string; align: PadSide │
│ }; │
│ + │
│ +function tableCells(tbl: Element): { rows: │
│ Cell[][]; headerRows: number } { │
│ + const rows: Cell[][] = []; │
│ + let headerRows = 0; │
│ + visitElements(tbl, (el) => { │
│ + if (el.tagName !== "tr") return; │
│ + const cells: Cell[] = []; │
│ + let isHeader = false; │
│ + for (const c of el.children) { │
│ + if (c.type !== "element" || (c.tagNam │
│ e !== "td" && c.tagName !== "th")) continue; │
│ + if (c.tagName === "th") isHeader = tr │
│ ue; │
│ + const a = (c.properties as Properties │
│ | undefined)?.align; │
│ + const align: PadSide = a === "right" │
│ ? "start" : a === "center" ? "center" : "end │
│ "; │
│ + cells.push({ text: textContent(c).rep │
│ lace(/\s+/g, " ").trim(), align }); │
│ + } │
│ + if (cells.length) { │
│ + rows.push(cells); │
│ + if (isHeader) headerRows = rows.lengt │
│ h; │
│ + } │
│ + }); │
│ + return { rows, headerRows }; │
│ +} │
│ + │
│ +function buildAsciiTable(rows: Cell[][], he │
│ aderRows: number, width: number): string { │
│ + const nCols = Math.max(...rows.map((r) => │
│ r.length)); │
│ + const norm = rows.map((r) => { │
│ + const out = [...r]; │
│ + while (out.length < nCols) out.push({ t │
│ ext: "", align: "end" }); │
│ + return out; │
│ + }); │
│ + │
│ + // Natural column widths, then shave the │
│ widest until the table fits. │
│ + // Cells wrap to their final column width │
│ . │
│ + const colW = Array.from({ length: nCols } │
│ , (_, i) => │
│ + Math.max(1, ...norm.map((r) => len(r[i] │
│ .text))), │
│ + ); │
│ + const total = () => 1 + colW.reduce((s, w │
│ ) => s + w + 3, 0); │
│ + const MIN_COL = 5; │
│ + while (total() > width && colW.some((w) = │
│ > w > MIN_COL)) { │
│ + colW[colW.indexOf(Math.max(...colW))]-- │
│ ; │
│ + } │
│ + │
│ + const edge = (l: string, m: string, r: st │
│ ring, h = G.H) => │
│ + l + colW.map((w) => h.repeat(w + 2)).jo │
│ in(m) + r; │
│ + │
│ + // When any row wraps, dashed separators │
│ between body rows keep the │
│ + // logical rows readable; all-single-line │
│ tables stay compact. │
│ + const cellLines = norm.map((row) => row.m │
│ ap((c, i) => wrap(c.text, colW[i]).split("\n │
│ "))); │
│ + const anyWrapped = cellLines.some((row) = │
│ > row.some((l) => l.length > 1)); │
│ + │
│ + const out: string[] = [edge(G.TL, G.TJ, G │
│ .TR)]; │
│ + norm.forEach((row, ri) => { │
│ + if (anyWrapped && ri > headerRows) out. │
│ push(edge(G.LJ, G.X, G.RJ, G.HD)); │
│ + const h = Math.max(...cellLines[ri].map │
│ ((l) => l.length)); │
│ + for (let k = 0; k < h; k++) { │
│ + out.push( │
│ + G.V + │
│ + row.map((c, i) => ` ${pad(cellLin │
│ es[ri][i][k] ?? "", colW[i], c.align)} `).jo │
│ in(G.V) + │
│ + G.V, │
│ + ); │
│ + } │
│ + if (ri === headerRows - 1) out.push(edg │
│ e(G.LJ, G.X, G.RJ)); │
│ + }); │
│ + out.push(edge(G.BL, G.BJ, G.BR)); │
│ + return out.join("\n"); │
│ +} │
│ + │
│ +function makeTablePre(text: string, extraCl │
│ ass: string): Element { │
│ return { │
│ type: "element", │
│ - tagName: "span", │
│ - properties: { className: ["head-rule", │
│ variant] }, │
│ - children: [{ type: "text", value: G.H.r │
│ epeat(frameW) }], │
│ + tagName: "pre", │
│ + properties: { className: ["ascii", "tab │
│ le-frame", extraClass], "aria-hidden": "true │
│ " }, │
│ + children: [{ type: "text", value: text │
│ }], │
│ }; │
│ } │
│ │
│ +// ---------------------------------------- │
│ -------------------- callouts │
│ + │
│ +function calloutTop(label: string, frameW: │
│ number, variant: string): Element { │
│ + return blockSpan("callout-rule", variant, │
│ [ │
│ + { type: "text", value: `${G.TL}${G.H} ` │
│ }, │
│ + { │
│ + type: "element", │
│ + tagName: "strong", │
│ + properties: {}, │
│ + children: [{ type: "text", value: lab │
│ el }], │
│ + }, │
│ + { type: "text", value: ` ${G.H.repeat(M │
│ ath.max(0, frameW - 5 - len(label)))}${G.TR} │
│ ` }, │
│ + ]); │
│ +} │
│ + │
│ +function calloutBottom(frameW: number, vari │
│ ant: string): Element { │
│ + return blockSpan("callout-rule", variant, │
│ [ │
│ + { type: "text", value: `${G.BL}${G.H.re │
│ peat(frameW - 2)}${G.BR}` }, │
│ + ]); │
│ +} │
│ + │
│ +// ---------------------------------------- │
│ --------------------- shared │
│ + │
│ +function ruleSpan(frameW: number, variant: │
│ "frame-wide" | "frame-narrow", cls = "head-r │
│ ule"): Element { │
│ + return blockSpan(cls, variant, [{ type: " │
│ text", value: G.H.repeat(frameW) }]); │
│ +} │
│ + │
│ +function captionSpan(text: string, frameW: │
│ number, variant: string): Element { │
│ + return blockSpan("fig-caption", variant, │
│ [{ type: "text", value: wrap(text, frameW) } │
│ ]); │
│ +} │
│ + │
│ export function rehypeAscii() { │
│ return (tree: Root) => { │
│ + let fig = 0; │
│ + │
│ visitElements(tree, (node) => { │
│ if (node.tagName === "pre") { │
│ // Skip the dual-rendered pres we e │
│ mit ourselves; otherwise the │
│ // visitor recurses into them and r │
│ eprocesses indefinitely. │
│ - const cls = (node.properties as Pro │
│ perties | undefined)?.className; │
│ - if (Array.isArray(cls) && cls.inclu │
│ des("code-block")) return; │
│ + if (hasClass(node, "code-block") || │
│ hasClass(node, "table-frame")) return; │
│ │
│ const codeEl = node.children.find( │
│ (c): c is Element => c.type === " │
│ element" && c.tagName === "code", │
│ @@ -134,11 +261,79 @@ export function rehype │
│ Ascii() { │
│ return; │
│ } │
│ │
│ + if (node.tagName === "table") { │
│ + if (hasClass(node, "sr-only")) retu │
│ rn; │
│ + const { rows, headerRows } = tableC │
│ ells(node); │
│ + if (!rows.length) return; │
│ + │
│ + // Keep the semantic table for scre │
│ en readers; the ASCII pres │
│ + // are presentation only. │
│ + const original: Element = { │
│ + type: "element", │
│ + tagName: "table", │
│ + properties: { className: ["sr-onl │
│ y"] }, │
│ + children: node.children, │
│ + }; │
│ + node.tagName = "div"; │
│ + node.properties = { className: ["ta │
│ ble-frames"] }; │
│ + node.children = [ │
│ + makeTablePre(buildAsciiTable(rows │
│ , headerRows, FRAME_W), "frame-wide"), │
│ + makeTablePre(buildAsciiTable(rows │
│ , headerRows, MOBILE_FRAME_W), "frame-narrow │
│ "), │
│ + original, │
│ + ]; │
│ + return; │
│ + } │
│ + │
│ + if (node.tagName === "blockquote" && │
│ !hasClass(node, "callout")) { │
│ + const firstP = node.children.find( │
│ + (c): c is Element => c.type === " │
│ element" && c.tagName === "p", │
│ + ); │
│ + const firstText = firstP?.children[ │
│ 0]; │
│ + if (!firstP || !firstText || firstT │
│ ext.type !== "text") return; │
│ + const m = firstText.value.match(CAL │
│ LOUT_RE); │
│ + if (!m) return; │
│ + │
│ + const label = m[1]; │
│ + firstText.value = firstText.value.s │
│ lice(m[0].length); │
│ + if (!firstText.value) firstP.childr │
│ en.shift(); │
│ + if (!firstP.children.length) node.c │
│ hildren = node.children.filter((c) => c !== │
│ firstP); │
│ + │
│ + node.properties = { className: ["ca │
│ llout"] }; │
│ + node.children = [ │
│ + calloutTop(label, FRAME_W, "frame │
│ -wide"), │
│ + calloutTop(label, MOBILE_FRAME_W, │
│ "frame-narrow"), │
│ + ...node.children, │
│ + calloutBottom(FRAME_W, "frame-wid │
│ e"), │
│ + calloutBottom(MOBILE_FRAME_W, "fr │
│ ame-narrow"), │
│ + ]; │
│ + return; │
│ + } │
│ + │
│ + if (node.tagName === "p") { │
│ + const kids = node.children.filter(( │
│ c) => !(c.type === "text" && !c.value.trim() │
│ )); │
│ + const img = kids[0]; │
│ + if (kids.length !== 1 || img.type ! │
│ == "element" || img.tagName !== "img") retur │
│ n; │
│ + │
│ + fig++; │
│ + const alt = String((img.properties │
│ as Properties | undefined)?.alt ?? "").trim( │
│ ); │
│ + const caption = alt ? `FIG. ${fig} │
│ - ${alt.toUpperCase()}` : `FIG. ${fig}`; │
│ + node.tagName = "figure"; │
│ + node.properties = { className: ["fi │
│ gure-frame"] }; │
│ + node.children = [ │
│ + ruleSpan(FRAME_W, "frame-wide", " │
│ fig-rule"), │
│ + ruleSpan(MOBILE_FRAME_W, "frame-n │
│ arrow", "fig-rule"), │
│ + img, │
│ + captionSpan(caption, FRAME_W, "fr │
│ ame-wide"), │
│ + captionSpan(caption, MOBILE_FRAME │
│ _W, "frame-narrow"), │
│ + ruleSpan(FRAME_W, "frame-wide", " │
│ fig-rule"), │
│ + ruleSpan(MOBILE_FRAME_W, "frame-n │
│ arrow", "fig-rule"), │
│ + ]; │
│ + return; │
│ + } │
│ + │
│ if (node.tagName === "h1") { │
│ const already = node.children.some( │
│ - (c) => c.type === "element" && c. │
│ tagName === "span" │
│ - && Array.isArray((c.properties │
│ as Properties | undefined)?.className) │
│ - && ((c.properties as Properties │
│ ).className as unknown[]).includes("head-rul │
│ e"), │
│ + (c) => c.type === "element" && c. │
│ tagName === "span" && hasClass(c, "head-rule │
│ "), │
│ ); │
│ if (already) return; │
│ node.children = [ │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/pages/components.astro ─────────────────────────────────┐
│ diff --git a/ooknet-design/src/pages/components.astro b/ooknet-design/src/pages/co │
│ mponents.astro │
│ new file mode 100644 │
│ index 0000000..899ca73 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/pages/components.astro │
│ @@ -0,0 +1,90 @@ │
│ +--- │
│ +import ArticleLayout from "../layouts/ArticleLayout/ArticleLayout.astro"; │
│ +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"; │
│ + │
│ +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 FIELD_CELLS = 26; │
│ +const fieldUsed = LABEL_W + FIELD_CELLS; │
│ +const chanUsed = LABEL_W + selectWidth(20); │
│ +const submitUsed = buttonWidth("TRANSMIT") + 2 + buttonWidth("CLEAR"); │
│ +--- │
│ +<ArticleLayout title="OOKNET KB - Components"> │
│ + <TopHeader /> │
│ + <Frame build={(w) => [row2("COMPONENT INDEX", "DESIGN SYSTEM v0.1", w), ruleD(w │
│ )].join("\n")} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("BUTTONS", "Button.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre><Button label="SUBMIT" variant="primary" />{" "}<Button label="CANCEL" /> │
│ {" "}<Button label="DISABLED" disabled />{" "}<Button label="INDEX" href="/" />< │
│ /Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("TEXT FIELDS", "TextField.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{pad("CALLSIGN", LABEL_W)}<TextField name="demo-callsign" cells={20} /></P │
│ re> │
│ + <Pre>{pad("PASSPHRASE", LABEL_W)}<TextField name="demo-passphrase" cells={20} t │
│ ype="password" /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("TOGGLES", "Checkbox.astro / Radio.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre><Checkbox label="ENCRYPT TRANSMISSION" name="demo-encrypt" checked /></Pre │
│ > │
│ + <Pre><Checkbox label="NOTIFY ON REPLY" name="demo-notify" /></Pre> │
│ + <Pre><Checkbox label="DISABLED OPTION" name="demo-disabled" disabled /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + <Pre><Radio label="ROUTINE" name="demo-priority" value="routine" checked /></Pr │
│ e> │
│ + <Pre><Radio label="URGENT" name="demo-priority" value="urgent" /></Pre> │
│ + <Pre><Radio label="FLASH" name="demo-priority" value="flash" /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("SELECT", "Select.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <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> │
│ + <Pre>{" "}</Pre> │
│ + <Frame build={(w) => rule(w)} /> │
│ +</ArticleLayout> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...gn/src/pages/components.astro ───┐
│ diff --git a/ooknet-design/src/pages/compone │
│ nts.astro b/ooknet-design/src/pages/componen │
│ ts.astro │
│ new file mode 100644 │
│ index 0000000..899ca73 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/pages/components.ast │
│ ro │
│ @@ -0,0 +1,90 @@ │
│ +--- │
│ +import ArticleLayout from "../layouts/Artic │
│ leLayout/ArticleLayout.astro"; │
│ +import TopHeader from "../components/TopHea │
│ der/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"; │
│ + │
│ +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 FIELD_CELLS = 26; │
│ +const fieldUsed = LABEL_W + FIELD_CELLS; │
│ +const chanUsed = LABEL_W + selectWidth(20); │
│ +const submitUsed = buttonWidth("TRANSMIT") │
│ + 2 + buttonWidth("CLEAR"); │
│ +--- │
│ +<ArticleLayout title="OOKNET KB - Component │
│ s"> │
│ + <TopHeader /> │
│ + <Frame build={(w) => [row2("COMPONENT IND │
│ EX", "DESIGN SYSTEM v0.1", w), ruleD(w)].joi │
│ n("\n")} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("BUTTONS", "Button. │
│ astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre><Button label="SUBMIT" variant="prim │
│ ary" />{" "}<Button label="CANCEL" />{" "} │
│ <Button label="DISABLED" disabled />{" "}<B │
│ utton label="INDEX" href="/" /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("TEXT FIELDS", "Tex │
│ tField.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{pad("CALLSIGN", LABEL_W)}<TextField │
│ name="demo-callsign" cells={20} /></Pre> │
│ + <Pre>{pad("PASSPHRASE", LABEL_W)}<TextFie │
│ ld name="demo-passphrase" cells={20} type="p │
│ assword" /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("TOGGLES", "Checkbo │
│ x.astro / Radio.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre><Checkbox label="ENCRYPT TRANSMISSIO │
│ N" name="demo-encrypt" checked /></Pre> │
│ + <Pre><Checkbox label="NOTIFY ON REPLY" na │
│ me="demo-notify" /></Pre> │
│ + <Pre><Checkbox label="DISABLED OPTION" na │
│ me="demo-disabled" disabled /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + <Pre><Radio label="ROUTINE" name="demo-pr │
│ iority" value="routine" checked /></Pre> │
│ + <Pre><Radio label="URGENT" name="demo-pri │
│ ority" value="urgent" /></Pre> │
│ + <Pre><Radio label="FLASH" name="demo-prio │
│ rity" value="flash" /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("SELECT", "Select.a │
│ stro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <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> │
│ + <Pre>{" "}</Pre> │
│ + <Frame build={(w) => rule(w)} /> │
│ +</ArticleLayout> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/pages/notes/[slug].astro ───────────────────────────────┐
│ diff --git a/ooknet-design/src/pages/notes/[slug].astro b/ooknet-design/src/pages/ │
│ notes/[slug].astro │
│ index 429df24..343b055 100644 │
│ --- a/ooknet-design/src/pages/notes/[slug].astro │
│ +++ b/ooknet-design/src/pages/notes/[slug].astro │
│ @@ -6,16 +6,31 @@ import ArticleHeader from "../../components/ArticleHeader/Articl │
│ eHeader.astro"; │
│ import DocumentTitle from "../../components/DocumentTitle/DocumentTitle.astro"; │
│ import ArticleMeta from "../../components/ArticleMeta/ArticleMeta.astro"; │
│ import ArticleSeeAlso from "../../components/ArticleSeeAlso/ArticleSeeAlso.astro" │
│ ; │
│ +import ArticleTags from "../../components/ArticleTags/ArticleTags.astro"; │
│ +import ArticlePagination from "../../components/ArticlePagination/ArticlePaginati │
│ on.astro"; │
│ import ArticleFooter from "../../components/ArticleFooter/ArticleFooter.astro"; │
│ import Prose from "../../components/Prose/Prose.astro"; │
│ +import Pre from "../../components/Pre/Pre.astro"; │
│ +import type { PageLink } from "../../components/ArticlePagination/ArticlePaginati │
│ on"; │
│ │
│ export async function getStaticPaths() { │
│ const notes = await getCollection("notes", ({ data }) => !data.draft); │
│ - return notes.map((n) => ({ params: { slug: n.id }, props: { note: n } })); │
│ + const num = (s: string) => parseInt(s.replace(/\D/g, ""), 10) || 0; │
│ + notes.sort((a, b) => num(a.data.note) - num(b.data.note)); │
│ + const link = (n?: (typeof notes)[number]): PageLink | null => │
│ + n ? { id: n.id, note: n.data.note } : null; │
│ + return notes.map((n, i) => ({ │
│ + params: { slug: n.id }, │
│ + props: { note: n, prev: link(notes[i - 1]), next: link(notes[i + 1]) }, │
│ + })); │
│ } │
│ │
│ -interface Props { note: CollectionEntry<"notes">; } │
│ -const { note } = Astro.props as Props; │
│ +interface Props { │
│ + note: CollectionEntry<"notes">; │
│ + prev: PageLink | null; │
│ + next: PageLink | null; │
│ +} │
│ +const { note, prev, next } = Astro.props as Props; │
│ const { Content } = await render(note); │
│ --- │
│ <ArticleLayout title={note.data.title}> │
│ @@ -25,5 +40,8 @@ const { Content } = await render(note); │
│ <ArticleMeta fm={note.data} path={`notes/${note.id}`} /> │
│ <Prose><Content /></Prose> │
│ <ArticleSeeAlso fm={note.data} /> │
│ + <ArticleTags fm={note.data} /> │
│ + <Pre>{" "}</Pre> │
│ + <ArticlePagination prev={prev} next={next} /> │
│ <ArticleFooter page={note.data.page ?? 1} /> │
│ </ArticleLayout> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ .../src/pages/notes/[slug].astro ───┐
│ diff --git a/ooknet-design/src/pages/notes/[ │
│ slug].astro b/ooknet-design/src/pages/notes/ │
│ [slug].astro │
│ index 429df24..343b055 100644 │
│ --- a/ooknet-design/src/pages/notes/[slug].a │
│ stro │
│ +++ b/ooknet-design/src/pages/notes/[slug].a │
│ stro │
│ @@ -6,16 +6,31 @@ import ArticleHeader from │
│ "../../components/ArticleHeader/ArticleHeade │
│ r.astro"; │
│ import DocumentTitle from "../../components │
│ /DocumentTitle/DocumentTitle.astro"; │
│ import ArticleMeta from "../../components/A │
│ rticleMeta/ArticleMeta.astro"; │
│ import ArticleSeeAlso from "../../component │
│ s/ArticleSeeAlso/ArticleSeeAlso.astro"; │
│ +import ArticleTags from "../../components/A │
│ rticleTags/ArticleTags.astro"; │
│ +import ArticlePagination from "../../compon │
│ ents/ArticlePagination/ArticlePagination.ast │
│ ro"; │
│ import ArticleFooter from "../../components │
│ /ArticleFooter/ArticleFooter.astro"; │
│ import Prose from "../../components/Prose/P │
│ rose.astro"; │
│ +import Pre from "../../components/Pre/Pre.a │
│ stro"; │
│ +import type { PageLink } from "../../compon │
│ ents/ArticlePagination/ArticlePagination"; │
│ │
│ export async function getStaticPaths() { │
│ const notes = await getCollection("notes" │
│ , ({ data }) => !data.draft); │
│ - return notes.map((n) => ({ params: { slug │
│ : n.id }, props: { note: n } })); │
│ + const num = (s: string) => parseInt(s.rep │
│ lace(/\D/g, ""), 10) || 0; │
│ + notes.sort((a, b) => num(a.data.note) - n │
│ um(b.data.note)); │
│ + const link = (n?: (typeof notes)[number]) │
│ : PageLink | null => │
│ + n ? { id: n.id, note: n.data.note } : n │
│ ull; │
│ + return notes.map((n, i) => ({ │
│ + params: { slug: n.id }, │
│ + props: { note: n, prev: link(notes[i - │
│ 1]), next: link(notes[i + 1]) }, │
│ + })); │
│ } │
│ │
│ -interface Props { note: CollectionEntry<"no │
│ tes">; } │
│ -const { note } = Astro.props as Props; │
│ +interface Props { │
│ + note: CollectionEntry<"notes">; │
│ + prev: PageLink | null; │
│ + next: PageLink | null; │
│ +} │
│ +const { note, prev, next } = Astro.props as │
│ Props; │
│ const { Content } = await render(note); │
│ --- │
│ <ArticleLayout title={note.data.title}> │
│ @@ -25,5 +40,8 @@ const { Content } = await │
│ render(note); │
│ <ArticleMeta fm={note.data} path={`notes/ │
│ ${note.id}`} /> │
│ <Prose><Content /></Prose> │
│ <ArticleSeeAlso fm={note.data} /> │
│ + <ArticleTags fm={note.data} /> │
│ + <Pre>{" "}</Pre> │
│ + <ArticlePagination prev={prev} next={next │
│ } /> │
│ <ArticleFooter page={note.data.page ?? 1} │
│ /> │
│ </ArticleLayout> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/styles/global.scss ─────────────────────────────────────┐
│ diff --git a/ooknet-design/src/styles/global.scss b/ooknet-design/src/styles/globa │
│ l.scss │
│ index 2b1babc..f7d4677 100644 │
│ --- a/ooknet-design/src/styles/global.scss │
│ +++ b/ooknet-design/src/styles/global.scss │
│ @@ -84,6 +84,20 @@ │
│ box-sizing: border-box; │
│ } │
│ │
│ +// Kept in the DOM for screen readers (e.g. the semantic table behind an │
│ +// ASCII-rendered one) but visually removed. │
│ +.sr-only { │
│ + position: absolute; │
│ + width: 1px; │
│ + height: 1px; │
│ + padding: 0; │
│ + margin: -1px; │
│ + overflow: hidden; │
│ + clip: rect(0, 0, 0, 0); │
│ + white-space: nowrap; │
│ + border: 0; │
│ +} │
│ + │
│ body { │
│ margin: 0; │
│ font-family: var(--mono); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...design/src/styles/global.scss ───┐
│ diff --git a/ooknet-design/src/styles/global │
│ .scss b/ooknet-design/src/styles/global.scss │
│ index 2b1babc..f7d4677 100644 │
│ --- a/ooknet-design/src/styles/global.scss │
│ +++ b/ooknet-design/src/styles/global.scss │
│ @@ -84,6 +84,20 @@ │
│ box-sizing: border-box; │
│ } │
│ │
│ +// Kept in the DOM for screen readers (e.g. │
│ the semantic table behind an │
│ +// ASCII-rendered one) but visually removed │
│ . │
│ +.sr-only { │
│ + position: absolute; │
│ + width: 1px; │
│ + height: 1px; │
│ + padding: 0; │
│ + margin: -1px; │
│ + overflow: hidden; │
│ + clip: rect(0, 0, 0, 0); │
│ + white-space: nowrap; │
│ + border: 0; │
│ +} │
│ + │
│ body { │
│ margin: 0; │
│ font-family: var(--mono); │
└──────────────────────────────────────────────┘
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET