/**
* 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 ``;
}
/**
* 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 ? `${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 `