/** * 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 ``; } /** * The page's subject: what this page is of — a repository's name, a served root. * * It is the toolbar's splitter as well as its label, so the only thing after it is the page's * own facts and the app's chrome. The whole value is on the hover, because the line truncates * its head: two clones of one repository, or two served folders, are told apart by the path. */ export function pageSubject(options: { id?: string; label: string; title: string }): string { const id = options.id === undefined ? "" : ` id="${escapeHtml(options.id)}"`; return `${escapeHtml(options.label)}`; } /** * The live-state chip: the state's colour, its word, and its glyph. * * The tone is a `bf-chip--*` modifier and the glyph is decoration, so a client repaints the * word and the class and never the shape ({@link paintLive} does exactly that). `attrs` * carries a page's own hooks — the browse pages put their polling interval and the revision * of what they drew there. The chip is not a live region: the header is where a reader looks * for the state, and a page that announced every repaint would talk over its own content. */ export function liveChip(options: { id: string; label: string; title?: string; hidden?: boolean; attrs?: Record; }): string { const title = options.title === undefined ? "" : ` title="${escapeHtml(options.title)}"`; const attrs = Object.entries(options.attrs ?? {}) .map(([key, value]) => ` ${key}="${escapeHtml(value)}"`) .join(""); return `${escapeHtml(options.label)}`; } /** * The live chip's one action: reload the page. * * It starts hidden and a client reveals it when the page stops being true — the folder it * drew changed on disk, or the stream it reads has gone. Reloading is not a write: a page * that cannot change a card can still ask the server for the page again. */ export function reloadChip(options: { title: string }): string { return ``; } /** The `?` button that opens {@link keysSheet}, which is the one trigger both pages use. */ export function keysButton(): string { return button({ label: "?", variant: "icon", id: "keys-open", title: "Keyboard shortcuts", attrs: { "aria-haspopup": "dialog" }, }); } /** The app's chrome, in the toolbar's last zone: the key sheet, then the theme toggle. */ export function toolbarChrome(): string { return `
${keysButton()}${themeToggle()}
`; } /** * The footer: one line of the page's own facts. * * A page says where its content came from — `store …`, `served from …` — and * the line truncates its head rather than its tail, so the end that names the folder survives. * `factsId` is for a page whose client keeps the line true after a frame; the facts ride the * document so a slow frame never leaves the footer blank. */ export function pageFooter(options: { facts: string; factsId?: string }): string { const id = options.factsId === undefined ? "" : ` id="${escapeHtml(options.factsId)}"`; return `
${escapeHtml(options.facts)}
`; } /** 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 its trigger is * {@link keysButton}. */ export function keysSheet(rows: readonly KeyHint[], note?: string): 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 ``; }