/** * Pure HTML atoms shared by the Bytefly browser pages. * * Each helper returns a string with every dynamic *value* escaped through * `lib/escape.ts`. Fields documented as trusted (`css`, an `icon` glyph, an * attribute key) are code-authored constants, never model or file text. */ import { escapeHtml } from "../escape.ts"; import { THEME_JS_PATH } from "./runtime.ts"; import { WEB_THEME_TOKENS } from "./tokens.ts"; /** Options for {@link docHead}. */ export interface DocHeadOptions { /** Page title, escaped. */ title: string; /** Trusted CSS assembled by the page. */ css: string; /** * The mount prefix this page answers under, e.g. `/files`; `""` for the origin's * root. A page's scripts and links are emitted under it, because a session serves * several pages on one origin. */ base?: string; } /** * A page's browser-tab title: ` · · `. * * One convention across the served pages, so a strip of tabs reads as one product. The * page's name comes first, lowercase and the same word its header's `brandLockup` * carries; then what the page is showing; then the count a reader opens the tab for. A * part the page does not have is left out rather than drawn empty, so a board with * nothing open reads `board · my-pi-agent` and a plan reads `plan review · `. */ export function tabTitle(product: string, subject?: string, count?: string): string { const kept = [product, subject, count]; return kept.filter((part) => part !== undefined && part !== "").join(" · "); } /** * The same body, as the browser receives it. * * The board's count is live, so its tab is repainted on every frame and this rule has to * cross into the bundle. It is written as concatenation with no backtick and no * interpolation, because the bundle is a template the source is spliced into. */ export const TAB_TITLE_JS = String(tabTitle); /** * The `data-base` attribute a page's `` carries, which its own bundle reads * (`base()` in `client-runtime.ts`) to build every URL it emits. * * `""` — the origin's root — writes nothing at all, so a page served alone is markup * without a mount to name. */ export function baseAttribute(base: string): string { return base === "" ? "" : ` data-base="${escapeHtml(base)}"`; } /** * The shared document head: charset, viewport, colour scheme, title, the page * CSS, and the pre-paint theme bootstrap. The body and its scripts stay with * each page. */ export function docHead(options: DocHeadOptions): string { return ` ${escapeHtml(options.title)} ${markIcon()} `; } /** * The mark's blocks: `█▀█` over `█▀ █`, as `[x, height]` per row in a 4x2 grid. * * One shape, three places — the header lockup, the tab icon, and the TUI header's * half-blocks — so it is written once. The rows differ: the top fills x=2, the bottom * leaves it empty and fills x=3, which is what makes the mark read as two glyphs. */ const MARK_ROWS: ReadonlyArray<{ group: "top" | "bottom"; rects: ReadonlyArray }> = [ { group: "top", rects: [ [0, 1], [1, 0.5], [2, 1], ], }, { group: "bottom", rects: [ [0, 1], [1, 0.5], [3, 1], ], }, ]; /** One row of the mark, as ``s starting at `y`. */ function markRowRects(row: (typeof MARK_ROWS)[number], y: number): string { return row.rects.map(([x, height]) => ``).join(""); } /** * The mark as a standalone SVG, in one appearance's colours. * * A favicon is its own document: it cannot read the page's tokens, so the fills are * resolved here from the same palette the page paints with, and each appearance gets * its own icon. */ function markSvg(fills: { top: string; bottom: string }): string { const rows = MARK_ROWS.map((row, index) => `${markRowRects(row, index)}`); return `${rows.join("")}`; } /** * The tab icon: the mark, in the appearance the reader's system is in. * * The dark icon is the plain `rel="icon"` — the page paints dark at `:root`, and a * browser that reads no `media` query on an icon keeps it. The light one is the * override, so a light tab does not carry a mark tuned for a dark one. */ export function markIcon(): string { const dark = encodeURIComponent( markSvg({ top: WEB_THEME_TOKENS.dark["--accent"], bottom: WEB_THEME_TOKENS.dark["--warn"] }), ); const light = encodeURIComponent( markSvg({ top: WEB_THEME_TOKENS.light["--accent"], bottom: WEB_THEME_TOKENS.light["--warn"] }), ); return ` `; } /** * The Bytefly nameplate: the two-tone half-block mark, the `bytefly` wordmark, * and the product name. The mark mirrors the TUI header (`█▀█` / `█▀ █`), drawn * as a 4x2 grid so the accent and warning rows stay crisp at any zoom. */ export function brandLockup(product: string): string { const rows = MARK_ROWS.map((row, index) => `${markRowRects(row, index)}`); return `bytefly${escapeHtml(product)}`; } /** * The theme toggle button. Keeps the `#theme-toggle` id and `.theme-glyph` * span the shared `THEME_TOGGLE_JS` scripts find. */ export function themeToggle(): string { return ``; } /** A chip's tone, mapping to a `bf-chip--*` modifier. */ export type ChipTone = "accent" | "success" | "warn" | "danger"; /** Options for {@link chip}. */ export interface ChipOptions { /** Visible text, escaped. */ label: string; /** Optional tone modifier. */ tone?: ChipTone; /** Trusted leading glyph or SVG. */ icon?: string; } /** A compact status chip. */ export function chip(options: ChipOptions): string { const tone = options.tone ? ` bf-chip--${options.tone}` : ""; const icon = options.icon ? `` : ""; return `${icon}${escapeHtml(options.label)}`; } /** A button variant, mapping to a `bf-btn--*` modifier. */ export type ButtonVariant = "primary" | "ghost" | "icon"; /** Options for {@link button}. */ export interface ButtonOptions { /** Visible text, escaped. */ label: string; /** Optional variant modifier. */ variant?: ButtonVariant; /** Element id. */ id?: string; /** Tooltip and accessible name. */ title?: string; /** Sets `aria-pressed` when defined. */ pressed?: boolean; /** Extra code-authored attributes; keys and values are escaped. */ attrs?: Record; } /** A `type="button"` control wired to the kit classes. */ export function button(options: ButtonOptions): string { const classes = options.variant ? `bf-btn bf-btn--${options.variant}` : "bf-btn"; const attrs: Array<[string, string]> = []; if (options.id) attrs.push(["id", options.id]); if (options.title) attrs.push(["title", options.title]); if (options.pressed !== undefined) attrs.push(["aria-pressed", String(options.pressed)]); for (const [key, value] of Object.entries(options.attrs ?? {})) attrs.push([key, value]); const rendered = attrs.map(([key, value]) => ` ${key}="${escapeHtml(value)}"`).join(""); return ``; } /** One row of the keyboard sheet: the key or chord, and what it does. */ export interface KeyHint { /** The key or chord, one `` per entry; a single string is one key, chord, or alias run. */ keys: string | readonly string[]; /** What the key does. */ label: string; } /** * The `?` keyboard sheet: the dialog of key/label rows both pages share, hidden * until the client opens it. * * The ids are the contract with `keysDialogScript` in `runtime.ts` — `#keys-dialog` * for the sheet, `#keys-close` for its own close button — and the trigger button * is the page's own concern, since the two pages name it differently. */ export function keysSheet(rows: readonly KeyHint[]): string { const kbd = (key: string): string => `${escapeHtml(key)}`; const body = rows .map((row) => { const keys = typeof row.keys === "string" ? kbd(row.keys) : row.keys.map(kbd).join(" "); return `
${keys}
${escapeHtml(row.label)}
`; }) .join("\n"); return ``; }