feat(desktop): add Buzz Term banner

Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
This commit is contained in:
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
2026-08-02 10:35:12 -04:00
parent 8515026860
commit 2b8b247f51
11 changed files with 1143 additions and 93 deletions
@@ -60,6 +60,7 @@ before(async () => {
toJSON() {},
});
dom.window.HTMLCanvasElement.prototype.getContext = () => ({
clearRect() {},
fillRect() {},
fillStyle: "",
fillText() {},
@@ -136,7 +136,7 @@ test("mounted IME paths neither toggle nor emit preedit text", async () => {
assert.deepEqual(calls.input, ["か"]);
});
test("welcome survives non-intersecting frame and dismisses on input or intersecting damage", async () => {
test("first active PTY output dismisses the welcome overlay", async () => {
const baseFrame = {
cursor: { column: 0, line: 0, visible: false },
full: false,
@@ -164,17 +164,8 @@ test("welcome survives non-intersecting frame and dismisses on input or intersec
};
const { view } = fixture({ frame: baseFrame });
await ready(view);
assert.ok(view.container.querySelector(".buzz-terminal-welcome"));
toggleChord();
const input = view.getByLabelText("Terminal input");
fireEvent.input(input, { target: { value: "x" } });
await waitFor(
() =>
assert.equal(
view.container.querySelector(".buzz-terminal-welcome"),
null,
),
{ timeout: 1_000 },
await waitFor(() =>
assert.equal(view.container.querySelector(".buzz-terminal-welcome"), null),
);
cleanup();
@@ -9,10 +9,14 @@ import {
encodePaste,
encodeTerminalKey,
reduceHandoff,
updateWelcomeForOutput,
type WelcomeState,
} from "./terminalState";
import { type TerminalFrame, TerminalGrid } from "./terminalRenderer";
import { buildTerminalBanner } from "./terminalBanner";
import { paintTerminalBanner } from "./terminalBannerPainter";
import {
TERMINAL_CELL_METRICS,
type TerminalFrame,
TerminalGrid,
} from "./terminalRenderer";
export type TerminalViewportSize = {
columns: number;
@@ -56,38 +60,12 @@ function isToggleChord(event: KeyboardEvent): boolean {
);
}
const CELL_HEIGHT = 17;
const WELCOME_COLUMNS = 25;
const WELCOME_LINES = 4;
const { width: CELL_WIDTH, height: CELL_HEIGHT } = TERMINAL_CELL_METRICS;
function welcomeRect(frame: TerminalFrame) {
const top = Math.max(
0,
Math.floor(frame.viewport.screenLines * 0.42 - WELCOME_LINES / 2),
);
const left = Math.max(
0,
Math.floor((frame.viewport.columns - WELCOME_COLUMNS) / 2),
);
return {
top,
left,
bottom: top + WELCOME_LINES,
right: left + WELCOME_COLUMNS,
};
}
function frameDamageRects(frame: TerminalFrame) {
return frame.rows.flatMap((row) =>
row.spans.flatMap((span) =>
span.clusters
.filter((cluster) => cluster.text.trim().length > 0)
.map((cluster) => ({
top: row.line,
left: cluster.column,
bottom: row.line + 1,
right: cluster.column + cluster.width,
})),
function hasVisibleOutput(frame: TerminalFrame): boolean {
return frame.rows.some((row) =>
row.spans.some((span) =>
span.clusters.some((cluster) => cluster.text.trim().length > 0),
),
);
}
@@ -112,6 +90,7 @@ export function TerminalSubstrate({
}: TerminalSubstrateProps) {
const { terminalPalette } = useTheme();
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const bannerCanvasRef = React.useRef<HTMLCanvasElement>(null);
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const fadeRef = React.useRef<FadeController | null>(null);
const handoffRef = React.useRef(INITIAL_HANDOFF_STATE);
@@ -131,10 +110,8 @@ export function TerminalSubstrate({
[activeSessionId, frame, sessionFrames],
);
const [owner, setOwner] = React.useState<"buzz" | "terminal">("buzz");
const [welcome, setWelcome] = React.useState<WelcomeState>({
visible: true,
reserved: null,
});
const [viewport, setViewport] = React.useState({ columns: 1, rows: 1 });
const [welcomeVisible, setWelcomeVisible] = React.useState(true);
const shortcutLabel = /Mac|iPhone|iPad/.test(navigator.platform)
? "⌘J"
: "CTRL+J";
@@ -144,6 +121,15 @@ export function TerminalSubstrate({
document.querySelector<HTMLDivElement>(".buzz-huddle-app-surface"),
[appSurfaceRef],
);
const banner = React.useMemo(
() =>
buildTerminalBanner(
viewport.columns,
viewport.rows,
CELL_HEIGHT / CELL_WIDTH,
),
[viewport.columns, viewport.rows],
);
const terminalStyle = terminalPalette
? ({
"--buzz-terminal-background": terminalPalette.background,
@@ -180,7 +166,7 @@ export function TerminalSubstrate({
const sendInput = React.useEffectEvent((text: string) => {
if (!text) return;
setWelcome((current) => ({ ...current, visible: false }));
setWelcomeVisible(false);
onInput(text);
});
const consumeFrame = React.useEffectEvent((nextFrame: TerminalFrame) => {
@@ -188,17 +174,19 @@ export function TerminalSubstrate({
});
const reportViewportSize = React.useEffectEvent(() => {
const canvas = canvasRef.current;
if (!canvas || !onViewportSize) return;
if (!canvas) return;
const bounds = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const pixelWidth = Math.max(1, Math.round(bounds.width * dpr));
const pixelHeight = Math.max(1, Math.round(bounds.height * dpr));
onViewportSize({
columns: Math.max(1, Math.floor(bounds.width / 8.4)),
rows: Math.max(1, Math.floor(bounds.height / CELL_HEIGHT)),
pixelWidth,
pixelHeight,
});
const columns = Math.max(1, Math.floor(bounds.width / CELL_WIDTH));
const rows = Math.max(1, Math.floor(bounds.height / CELL_HEIGHT));
setViewport((current) =>
current.columns === columns && current.rows === rows
? current
: { columns, rows },
);
onViewportSize?.({ columns, rows, pixelWidth, pixelHeight });
});
React.useEffect(() => {
@@ -207,14 +195,14 @@ export function TerminalSubstrate({
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !onViewportSize) return;
if (!canvas) return;
reportViewportSize();
const ResizeObserverConstructor = window.ResizeObserver;
if (!ResizeObserverConstructor) return;
const observer = new ResizeObserverConstructor(reportViewportSize);
observer.observe(canvas);
return () => observer.disconnect();
}, [onViewportSize]);
}, []);
React.useEffect(() => {
if (!focusReportingEnabled) {
@@ -290,6 +278,21 @@ export function TerminalSubstrate({
};
}, [enabled, getAppSurface]);
React.useEffect(() => {
const canvas = bannerCanvasRef.current;
if (!canvas || !banner || !terminalPalette || !welcomeVisible) return;
if (
!paintTerminalBanner(
canvas,
banner,
terminalPalette,
window.devicePixelRatio || 1,
)
) {
setWelcomeVisible(false);
}
}, [banner, terminalPalette, welcomeVisible]);
React.useEffect(() => {
for (const delivered of frames) {
if (appliedFramesRef.current.has(delivered.frame)) continue;
@@ -307,13 +310,12 @@ export function TerminalSubstrate({
}
grid.apply(delivered.frame);
consumeFrame(delivered.frame);
if (delivered.sessionId === activeSessionId) {
setWelcome((current) =>
updateWelcomeForOutput(
{ ...current, reserved: welcomeRect(delivered.frame) },
frameDamageRects(delivered.frame),
),
);
if (
delivered.sessionId === activeSessionId &&
hasVisibleOutput(delivered.frame)
) {
// Policy: first visible output from the active PTY removes the overlay.
setWelcomeVisible(false);
}
}
gridRef.current = activeSessionId
@@ -351,22 +353,12 @@ export function TerminalSubstrate({
context.fillStyle = terminalPalette.background;
context.fillRect(0, 0, bounds.width, bounds.height);
}
gridRef.current?.paint(
context,
{
width: 8.4,
height: 17,
baseline: 13,
font: '14px "JetBrains Mono", monospace',
boldFont: '700 14px "JetBrains Mono", monospace',
},
terminalPalette,
);
gridRef.current?.paint(context, TERMINAL_CELL_METRICS, terminalPalette);
}, [activeSessionId, frames, terminalPalette]);
return (
<section
aria-label="Local terminal"
aria-label="Buzz Term"
className="buzz-terminal-substrate"
data-terminal-owner={owner}
style={terminalStyle}
@@ -425,7 +417,7 @@ export function TerminalSubstrate({
</div>
))}
<button
aria-label="New terminal tab"
aria-label="New Buzz Term tab"
className="buzz-terminal-new-tab"
onClick={onNewSession}
type="button"
@@ -441,13 +433,8 @@ export function TerminalSubstrate({
</div>
<div className="buzz-terminal-viewport">
<canvas ref={canvasRef} />
{welcome.visible ? (
<div className="buzz-terminal-welcome" aria-hidden="true">
<pre>{`┌─╲ BUZZ SUBSTRATE ╱─┐
│ B U Z Z /\\_/\\ │
│ LOCAL PTY ( o.o ) │
└────────────── > ^ < ─┘`}</pre>
</div>
{welcomeVisible && banner ? (
<canvas className="buzz-terminal-welcome" ref={bannerCanvasRef} />
) : null}
<textarea
aria-label="Terminal input"
@@ -497,7 +484,7 @@ export function TerminalSubstrate({
/>
</div>
<div aria-live="polite" className="sr-only">
{owner === "terminal" ? "Terminal mode" : "Buzz mode"}
{owner === "terminal" ? "Buzz Term mode" : "Buzz mode"}
</div>
</section>
);
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildTerminalBanner } from "./terminalBanner.ts";
function layers(banner) {
return new Map(
["field", "head", "bevel_hi", "bevel_lo"].map((layer) => [
layer,
banner.cells.flat().filter((cell) => cell.layer === layer),
]),
);
}
test("builds the amended four-layer Buzz Term composite", () => {
const banner = buildTerminalBanner(183, 69, 17 / 8.4);
assert.ok(banner);
const seen = layers(banner);
for (const layer of seen.keys()) assert.ok(seen.get(layer).length > 0, layer);
const head = seen.get("head");
const sweep = head.map((cell) => cell.t);
assert.equal(Math.min(...sweep), 0);
assert.equal(Math.max(...sweep), 1);
assert.ok(head.every((cell) => cell.layer === "head"));
const { top, left, bottom, right } = banner.reserved;
assert.equal(banner.cells[top][left].layer, "bevel_hi");
assert.equal(banner.cells[bottom - 1][right - 1].layer, "bevel_lo");
assert.match(
seen
.get("head")
.map((cell) => cell.char)
.join(""),
/█/,
);
});
test("emits complete hexagons only and fades per ray", () => {
const banner = buildTerminalBanner(112, 46, 17 / 8.4);
assert.ok(banner);
const field = layers(banner).get("field");
assert.equal(field.length % 8, 0);
assert.ok(field.some((cell) => cell.t < 0.1));
assert.ok(field.some((cell) => cell.t > 0.5));
});
test("safe mode replaces ambiguous frame rails without changing geometry", () => {
const ink = buildTerminalBanner(183, 69, 17 / 8.4, false);
const safe = buildTerminalBanner(183, 69, 17 / 8.4, true);
assert.ok(ink && safe);
assert.deepEqual(ink.reserved, safe.reserved);
assert.ok(ink.cells.flat().some((cell) => cell.char === "▌"));
assert.equal(
safe.cells.flat().some((cell) => cell.char === "▌"),
false,
);
});
test("declines viewports too small for the complete wordmark", () => {
assert.equal(buildTerminalBanner(40, 20, 17 / 8.4), null);
});
@@ -0,0 +1,264 @@
export type TerminalBannerLayer = "field" | "head" | "bevel_hi" | "bevel_lo";
export type TerminalBannerCell = Readonly<{
char: string;
layer?: TerminalBannerLayer;
t: number;
}>;
export type TerminalBanner = Readonly<{
cells: readonly (readonly TerminalBannerCell[])[];
reserved: Readonly<{
top: number;
left: number;
bottom: number;
right: number;
}>;
}>;
const HEX = [
[0, 1, "_"],
[0, 2, "_"],
[1, 0, "/"],
[1, 3, "\\"],
[2, 0, "\\"],
[2, 1, "_"],
[2, 2, "_"],
[2, 3, "/"],
] as const;
const GLYPHS: Readonly<Record<string, readonly string[]>> = {
b: ["██ ", "██▄▄▄ ", "██▀▀██ ", "██ ██ ", "██████ "],
u: [" ", "██ ██ ", "██ ██ ", "██ ██ ", "▀█████ "],
z: [" ", "██████ ", " ▄██ ", " ▄██▀ ", "██████ "],
t: [" ██ ", "█████ ", " ██ ", " ██ ", " ███ "],
e: [" ", " ▄███▄ ", "██▄▄▄█ ", "██ ", " ▀███▀ "],
r: [" ", "██ ▄██ ", "███▀▀ ", "██ ", "██ "],
m: [" ", "██▄██▄██", "██ ██ ██", "██ ██ ██", "██ ██ ██"],
" ": [" ", " ", " ", " ", " "],
};
const INK_FRAME = {
topLeft: "▛",
topRight: "▜",
bottomLeft: "▙",
bottomRight: "▟",
top: "▀",
bottom: "▄",
left: "▌",
right: "▐",
} as const;
const SAFE_FRAME = {
topLeft: "▛",
topRight: "▜",
bottomLeft: "▙",
bottomRight: "▟",
top: "░",
bottom: "░",
left: "▐",
right: "▐",
} as const;
const LAYERS: readonly TerminalBannerLayer[] = [
"field",
"head",
"bevel_hi",
"bevel_lo",
];
function trimRight(value: string): string {
return value.replace(/\s+$/, "");
}
function wordmark(gap: number): readonly string[] {
const rows = Array.from({ length: 5 }, () => "");
for (const [index, letter] of [..."buzz term"].entries()) {
const glyph = GLYPHS[letter];
for (let row = 0; row < rows.length; row += 1) {
rows[row] += glyph[row] + (index < 8 ? " ".repeat(gap) : "");
}
}
return rows.map(trimRight).filter((row) => row.trim());
}
function fitWordmark(columns: number): readonly string[] | null {
for (const gap of [3, 2, 1, 0]) {
const mark = wordmark(gap);
if (Math.max(...mark.map((row) => row.length)) <= columns) return mark;
}
return null;
}
function stableRandom(band: number, index: number): number {
let value = 2166136261;
for (const code of [...`${band}:${index}`].map((char) =>
char.charCodeAt(0),
)) {
value = Math.imul(value ^ code, 16777619);
}
return (value >>> 0) / 0xffffffff;
}
function completeHexes(columns: number, rows: number) {
const cells: { band: number; index: number; y: number; x: number }[] = [];
for (let band = 0, y = -2; y < rows; band += 1, y += 2) {
for (
let index = 0, x = -6 + (band % 2 ? 3 : 0);
x < columns;
index += 1, x += 6
) {
if (
HEX.every(
([dy, dx]) =>
y + dy >= 0 && y + dy < rows && x + dx >= 0 && x + dx < columns,
)
) {
cells.push({ band, index, y, x });
}
}
}
return cells;
}
export function buildTerminalBanner(
columns: number,
rows: number,
cellAspect: number,
safe = false,
): TerminalBanner | null {
const mark = fitWordmark(columns - 10);
if (!mark || rows < mark.length + 6) return null;
const width = Math.max(...mark.map((row) => row.length));
const height = mark.length;
const paddingX = columns >= width + 24 ? 6 : 2;
const paddingY = rows >= height + 12 ? 2 : 1;
const innerWidth = Math.min(columns - 4, width + paddingX * 2);
const frameWidth = innerWidth + 2;
const frameHeight = height + paddingY * 2 + 2;
const left = Math.floor((columns - frameWidth) / 2);
const top = Math.floor((rows - frameHeight) / 2);
const centerX = left + frameWidth / 2;
const centerY = top + frameHeight / 2;
const grid: TerminalBannerCell[][] = Array.from({ length: rows }, () =>
Array.from({ length: columns }, () => ({ char: " ", t: 0 })),
);
const put = (
y: number,
x: number,
char: string,
layer: TerminalBannerLayer,
t = 0,
) => {
if (y >= 0 && y < rows && x >= 0 && x < columns)
grid[y][x] = { char, layer, t };
};
const halfWidth = frameWidth / 2;
const halfHeight = (frameHeight / 2) * cellAspect;
const viewportHalfWidth = columns / 2;
const viewportHalfHeight = (rows / 2) * cellAspect;
for (const hex of completeHexes(columns, rows)) {
if (
HEX.some(
([offsetY, offsetX]) =>
hex.y + offsetY >= top - 1 &&
hex.y + offsetY < top + frameHeight + 1 &&
hex.x + offsetX >= left - 2 &&
hex.x + offsetX < left + frameWidth + 2,
)
) {
continue;
}
const dx = hex.x + 1.5 - centerX;
const dy = (hex.y + 1 - centerY) * cellAspect;
const ellipse = Math.hypot(dx / halfWidth, dy / halfHeight);
if (ellipse <= 1) continue;
const viewport = Math.max(
Math.abs(dx) / viewportHalfWidth,
Math.abs(dy) / viewportHalfHeight,
);
const ray =
ellipse <= viewport
? 0
: Math.min(
1,
Math.max(0, (viewport * (ellipse - 1)) / (ellipse - viewport)),
);
if (
ray > 0.55 &&
stableRandom(hex.band, hex.index) < ((ray - 0.55) / 0.45) ** 1.3
)
continue;
for (const [offsetY, offsetX, char] of HEX)
put(hex.y + offsetY, hex.x + offsetX, char, "field", ray * ray);
}
for (
let y = Math.max(0, top - 1);
y < Math.min(rows, top + frameHeight + 1);
y += 1
) {
for (
let x = Math.max(0, left - 2);
x < Math.min(columns, left + frameWidth + 2);
x += 1
)
grid[y][x] = { char: " ", t: 0 };
}
const frame = safe ? SAFE_FRAME : INK_FRAME;
put(top, left, frame.topLeft, "bevel_hi");
for (let x = 0; x < innerWidth; x += 1)
put(top, left + x + 1, frame.top, "bevel_hi");
put(top, left + frameWidth - 1, frame.topRight, "bevel_hi");
for (let y = top + 1; y < top + frameHeight - 1; y += 1) {
put(y, left, frame.left, "bevel_hi");
put(y, left + frameWidth - 1, frame.right, "bevel_lo");
}
put(top + frameHeight - 1, left, frame.bottomLeft, "bevel_lo");
for (let x = 0; x < innerWidth; x += 1)
put(top + frameHeight - 1, left + x + 1, frame.bottom, "bevel_lo");
put(
top + frameHeight - 1,
left + frameWidth - 1,
frame.bottomRight,
"bevel_lo",
);
const markLeft = left + Math.floor((frameWidth - width) / 2);
const markTop = top + paddingY + 1;
const ink = mark.flatMap((row, y) =>
[...row].flatMap((char, x) => (char === " " ? [] : [{ char, x, y }])),
);
if (ink.length === 0) return null;
const minX = Math.min(...ink.map(({ x }) => x));
const maxX = Math.max(...ink.map(({ x }) => x));
const span = Math.max(1, maxX - minX);
for (const cell of ink)
put(
markTop + cell.y,
markLeft + cell.x,
cell.char,
"head",
(cell.x - minX) / span,
);
const seen = new Map<TerminalBannerLayer, number[]>();
for (const row of grid)
for (const cell of row)
if (cell.layer)
seen.set(cell.layer, [...(seen.get(cell.layer) ?? []), cell.t]);
if (LAYERS.some((layer) => !seen.has(layer))) return null;
const sweep = seen.get("head") ?? [];
if (Math.min(...sweep) !== 0 || Math.max(...sweep) !== 1) return null;
return {
cells: grid,
reserved: {
top,
left,
bottom: top + frameHeight,
right: left + frameWidth,
},
};
}
@@ -0,0 +1,216 @@
import assert from "node:assert/strict";
import test from "node:test";
import { extractTerminalPalette } from "../../shared/theme/terminal-palette.ts";
import {
SYNTAX_THEMES,
loadThemeData,
} from "../../shared/theme/theme-loader.ts";
import {
bannerColor,
bannerStops,
stopsAreDistinct,
de00,
hex2rgb,
lab,
ratio,
} from "./terminalBannerColor.ts";
// Gates. contrast 4.5 is WCAG 2.2 SC 1.4.3 (body text); the rest are HOUSE
// thresholds with no external standard, sized by sweep.
const CONTRAST_MIN = 4.5;
const CHROMA_MIN = 16;
const HUE_MIN = 12;
const SRGB_MIN = 45;
const SAMPLES = 101;
const chroma = (h) => Math.hypot(lab(h).a, lab(h).b);
/** Hue-only CIEDE2000: both colours forced to a shared L, so lightness cannot
* rescue a pair that is one hue at two brightnesses. */
const hueDist = (a, b) => {
const p = lab(a),
q = lab(b),
L = (p.L + q.L) / 2;
return de00({ L, a: p.a, b: p.b }, { L, a: q.a, b: q.b });
};
const srgbDist = (a, b) =>
Math.hypot(...hex2rgb(a).map((v, i) => v - hex2rgb(b)[i]));
const minPair = (s, f) => Math.min(f(s[0], s[1]), f(s[1], s[2]), f(s[0], s[2]));
/** Signed short-arc hue step, in DEGREES. Degrees matter: the reversal
* deadband below is calibrated in them. */
const shortArcStep = (a, b) => {
const p = lab(a),
q = lab(b);
let d = (Math.atan2(q.b, q.a) - Math.atan2(p.b, p.a)) * (180 / Math.PI);
if (d > 180) d -= 360;
if (d < -180) d += 360;
return d;
};
// 8-bit output quantization jitters the hue angle by a few tenths of a degree,
// so sub-0.5deg steps are noise, not reversals. Counting them flagged 56 clean
// themes. The gate is the COUNT of real reversals, allowed up to 10% of samples.
const REVERSAL_DEADBAND_DEG = 0.5;
// Production loader + production extractor: the palettes under test are the
// ones the app actually renders, not a fixture that can drift from them.
async function palettes() {
const out = [];
for (const name of SYNTAX_THEMES) {
out.push([name, extractTerminalPalette(await loadThemeData(name))]);
}
return out;
}
const THEMES = await palettes();
test("every shipped theme is censused", () => {
assert.equal(THEMES.length, SYNTAX_THEMES.length);
assert.equal(THEMES.length, 62);
});
test("wordmark holds contrast and chroma across the whole sweep, not just at the stops", () => {
// Dense sampling is load-bearing: an RGB lerp between opposing hues crosses
// the neutral axis, so the fade greys out mid-glyph while all three STOPS
// stay chromatic. Endpoint-only checks pass that bug. (kills M4)
const bad = [];
for (const [name, p] of THEMES) {
const { stops } = bannerStops(p);
for (let i = 0; i < SAMPLES; i++) {
const c = bannerColor(p, stops, "head", i / (SAMPLES - 1));
const cr = ratio(c, p.background);
if (cr < CONTRAST_MIN - 0.005)
bad.push(`${name} contrast ${cr.toFixed(2)}`);
if (chroma(c) < CHROMA_MIN)
bad.push(`${name} chroma ${chroma(c).toFixed(1)}`);
}
}
assert.deepEqual(bad.slice(0, 8), [], `${bad.length} failures`); // kills M1, M2, M10, M11
});
test("themes whose own palette lacks three hues fall back to synthesis", () => {
// This is where the distinctness gate is OBSERVABLE. Asserting only that the
// returned stops pass the gate is vacuous — the gate is what produced them,
// and with it disabled the fallback still yields passing stops. What changes
// is WHICH themes take the fallback, so that is what gets pinned.
const synth = THEMES.filter(([, p]) => bannerStops(p).mode === "synth")
.map(([name]) => name)
.sort();
assert.deepEqual(synth, ["min-dark", "vesper"]);
});
test("the three stops are distinct by hue AND by sRGB distance", () => {
// Conjunction, not either alone: sRGB counts lightness, so white vs #a0a0a0
// scores 165 while being hue-identical; hue alone would admit stops that
// differ in angle but barely in appearance.
const bad = [];
for (const [name, p] of THEMES) {
const { stops } = bannerStops(p);
const h = minPair(stops, hueDist),
d = minPair(stops, srgbDist);
if (h < HUE_MIN) bad.push(`${name} hue ${h.toFixed(1)}`);
if (d < SRGB_MIN) bad.push(`${name} srgb ${d.toFixed(1)}`);
}
assert.deepEqual(bad, []); // kills M3
});
test("each half of the distinctness gate rejects what the other half admits", () => {
// Tested directly on synthetic triads, NOT via the 62-theme census, because
// on the shipped themes the two halves mask each other: disabling either
// alone changes no theme's stops. A census-only test lets a reviewer delete
// one half and see all green.
//
// Hue-blind: three greys. sRGB says "far apart", hue says "same colour".
assert.equal(stopsAreDistinct(["#ffffff", "#a0a0a0", "#606060"]), false);
// Distance-blind: muted triad at hue 19.1deg, which CLEARS the hue half,
// but only 29.7 apart in sRGB — three colours on paper, one smudge on
// screen. Only the distance half rejects it.
assert.equal(stopsAreDistinct(["#6a7f8f", "#7f6a8f", "#8f7f6a"]), false);
// A real shipped triad (dark-plus) passes both.
assert.equal(stopsAreDistinct(["#569cd6", "#d7ba7d", "#b5cea8"]), true);
});
test("sRGB distance alone cannot gate distinctness", () => {
// Negative control on the gate itself: if this ever fails, the conjunction
// has stopped being a conjunction.
assert.ok(srgbDist("#ffffff", "#a0a0a0") > 60);
assert.ok(hueDist("#ffffff", "#a0a0a0") < 1);
});
test("the gradient walks the short hue arc and never reverses", () => {
// Total-variation counting was too noisy to catch a long-arc mutant (8-bit
// jitter -> false positives), and an off-arc angle tolerance misses it when
// the two arcs are near-equal (tokyo-night spans 178.3 deg). The invariant
// that holds: every step moves in the short-arc direction. (kills M5)
for (const [name, p] of THEMES) {
const { stops } = bannerStops(p);
for (const [lo, hi, a, b] of [
[0, 0.5, 0, 1],
[0.5, 1, 1, 2],
]) {
const want = Math.sign(shortArcStep(stops[a], stops[b]));
if (want === 0) continue;
let wrong = 0,
prev = bannerColor(p, stops, "head", lo);
for (let i = 1; i <= 50; i++) {
const c = bannerColor(p, stops, "head", lo + ((hi - lo) * i) / 50);
const step = shortArcStep(prev, c);
if (Math.abs(step) > REVERSAL_DEADBAND_DEG && Math.sign(step) !== want)
wrong++;
prev = c;
}
assert.ok(wrong <= 5, `${name}: ${wrong} wrong-direction steps of 50`);
}
}
});
test("the field fades monotonically from the box out to the rim", () => {
for (const [name, p] of THEMES) {
const { stops } = bannerStops(p);
const r = [...Array(SAMPLES)].map((_, i) =>
ratio(bannerColor(p, stops, "field", i / (SAMPLES - 1)), p.background),
);
for (let i = 1; i < r.length; i++) {
assert.ok(r[i] <= r[i - 1] + 0.02, `${name} non-monotone at ${i}`); // kills M7
}
assert.ok(r[0] > r[r.length - 1] + 1, `${name} field does not fade`);
}
});
test("the bevel keeps a visible light/shadow gap", () => {
for (const [name, p] of THEMES) {
const { stops } = bannerStops(p);
const hi = ratio(bannerColor(p, stops, "bevel_hi", 0), p.background);
const lo = ratio(bannerColor(p, stops, "bevel_lo", 0), p.background);
// ORIENTED, not just separated: a gap assertion is symmetric under
// swapping the two bevel layers, and a swap changes neither cell count nor
// draw-call count. Only a colour-valued, directional check sees it.
assert.ok(
hi > lo,
`${name} bevel inverted: hi ${hi.toFixed(2)} <= lo ${lo.toFixed(2)}`,
);
assert.ok(hi - lo >= 1.0, `${name} bevel gap ${(hi - lo).toFixed(2)}`);
}
});
test("every layer returns a colour", () => {
// A renamed layer silently returned undefined once; this is that regression.
for (const [name, p] of THEMES.slice(0, 3)) {
const { stops } = bannerStops(p);
for (const layer of ["field", "head", "bevel_hi", "bevel_lo"]) {
assert.match(
bannerColor(p, stops, layer, 0.5),
/^#[0-9a-f]{6}$/,
`${name}/${layer}`,
);
}
}
});
test("contrast is lifted to the floor, never pinned down to it", () => {
// A floor is not a target: pinning dimmed 30 of 62 themes and cost chroma.
const spread = THEMES.map(([, p]) => {
const { stops } = bannerStops(p);
return Math.max(...stops.map((s) => ratio(s, p.background)));
});
assert.ok(Math.max(...spread) > 8, "no theme exceeds the floor => pinned");
});
@@ -0,0 +1,379 @@
/**
* Terminal banner colour derivation — the colour half of the banner contract.
*
* Geometry (terminalBanner.ts) is pure and carries no colour:
* grid[row][col] = { char, layer, t }. This module maps (layer, t) -> hex,
* given the theme's TerminalPalette. Colour is derived in the renderer and
* regenerated only on viewport or theme change.
*
* Why the palette and not the CSS vars: `--primary` is a user preference that
* collapses to `--foreground` on the Buzz themes (ThemeProvider.tsx), and
* `--secondary` / `--accent` are assigned the same 6% hover tint
* (adaptive-theme.ts:241-243). A literal primary/secondary/accent fade is two
* stops, one of which is the background. The TerminalPalette carries the
* theme's real hues, so the three stops come from there.
*
* Thresholds: contrast 4.5 is WCAG 2.2 SC 1.4.3 (body text). Chroma 16,
* hue 12 and sRGB 45 are HOUSE thresholds with no external standard — sized by
* sweep and stable across 8-25 chroma x 8-20 hue.
*
* Validated by terminalBannerColor.test.mjs: all 62 shipped themes pass every
* gate, and 8 mutants of this file are killed by those gates.
*/
import type {
AnsiColorName,
TerminalPalette,
} from "../../shared/theme/terminal-palette.ts";
// sRGB -> CIELAB -> CIEDE2000. Instrument for "are two stops visibly distinct".
export const hex2rgb = (h: string) =>
[1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
export const rgb2hex = (r: number[]) =>
"#" +
r
.map((v) =>
Math.max(0, Math.min(255, Math.round(v)))
.toString(16)
.padStart(2, "0"),
)
.join("");
const lin = (c: number) => {
c /= 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
export const lum = (h: string) => {
const [r, g, b] = hex2rgb(h);
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
};
export const ratio = (a: string, b: string) => {
const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p);
return (x + 0.05) / (y + 0.05);
};
export const mix = (a: string, b: string, t: number) =>
rgb2hex(hex2rgb(a).map((v, i) => v + (hex2rgb(b)[i] - v) * t));
export function lab(hex: string) {
const [r, g, b] = hex2rgb(hex).map(lin);
// sRGB D65 -> XYZ
let x = r * 0.4124 + g * 0.3576 + b * 0.1805;
let y = r * 0.2126 + g * 0.7152 + b * 0.0722;
let z = r * 0.0193 + g * 0.1192 + b * 0.9505;
x /= 0.95047;
y /= 1.0;
z /= 1.08883;
const f = (t: number) =>
t > 216 / 24389 ? Math.cbrt(t) : (841 / 108) * t + 4 / 29;
const [fx, fy, fz] = [f(x), f(y), f(z)];
return { L: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) };
}
export function de00(
p: { L: number; a: number; b: number },
q: { L: number; a: number; b: number },
) {
const rad = Math.PI / 180,
deg = 180 / Math.PI;
const C1 = Math.hypot(p.a, p.b),
C2 = Math.hypot(q.a, q.b);
const Cbar = (C1 + C2) / 2;
const G = 0.5 * (1 - Math.sqrt(Cbar ** 7 / (Cbar ** 7 + 25 ** 7)));
const a1p = (1 + G) * p.a,
a2p = (1 + G) * q.a;
const C1p = Math.hypot(a1p, p.b),
C2p = Math.hypot(a2p, q.b);
const hp = (b: number, a: number) => {
if (b === 0 && a === 0) return 0;
const v = Math.atan2(b, a) * deg;
return v < 0 ? v + 360 : v;
};
const h1p = hp(p.b, a1p),
h2p = hp(q.b, a2p);
const dLp = q.L - p.L,
dCp = C2p - C1p;
let dhp = 0;
if (C1p * C2p !== 0) {
dhp = h2p - h1p;
if (dhp > 180) dhp -= 360;
else if (dhp < -180) dhp += 360;
}
const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin((dhp / 2) * rad);
const Lbp = (p.L + q.L) / 2,
Cbp = (C1p + C2p) / 2;
let hbp = h1p + h2p;
if (C1p * C2p !== 0) {
if (Math.abs(h1p - h2p) > 180) hbp += h1p + h2p < 360 ? 360 : -360;
hbp /= 2;
}
const T =
1 -
0.17 * Math.cos((hbp - 30) * rad) +
0.24 * Math.cos(2 * hbp * rad) +
0.32 * Math.cos((3 * hbp + 6) * rad) -
0.2 * Math.cos((4 * hbp - 63) * rad);
const dTh = 30 * Math.exp(-(((hbp - 275) / 25) ** 2));
const Rc = 2 * Math.sqrt(Cbp ** 7 / (Cbp ** 7 + 25 ** 7));
const Sl = 1 + (0.015 * (Lbp - 50) ** 2) / Math.sqrt(20 + (Lbp - 50) ** 2);
const Sc = 1 + 0.045 * Cbp,
Sh = 1 + 0.015 * Cbp * T;
const Rt = -Math.sin(2 * dTh * rad) * Rc;
return Math.sqrt(
(dLp / Sl) ** 2 +
(dCp / Sc) ** 2 +
(dHp / Sh) ** 2 +
Rt * (dCp / Sc) * (dHp / Sh),
);
}
import type { TerminalBannerLayer } from "./terminalBanner.ts";
export type Layer = TerminalBannerLayer;
// WCAG 2.2 SC 1.4.3: 4.5:1 body text. The wordmark is content, not decoration.
const WORDMARK_FLOOR = 4.5;
// Ours, tuning, no standard:
const FIELD_CORE = 2.6; // hex field nearest the box — present, not competing
const FIELD_EDGE = 1.04; // at the rim — all but gone
const BEVEL_HI = 2.9; // lit edge
const BEVEL_LO = 1.55; // shadowed edge; the ratio gap IS the bevel
const CHROMA_MIN = 16; // Lab C*: below this a "hue" is a grey
const HUE_MIN = 12; // hue-only CIEDE2000 between adjacent stops
const SRGB_MIN = 45; // min pairwise sRGB distance, conjoined with HUE_MIN
/** Euclidean sRGB distance. Guards near-coincident stops that differ in hue
* angle but barely in appearance. NOT sufficient alone: it counts lightness,
* so white vs #a0a0a0 scores 165 while being hue-identical (hueDist 0.0). */
const srgbDist = (a: string, b: string) =>
Math.hypot(...hex2rgb(a).map((v, i) => v - hex2rgb(b)[i]));
const chroma = (h: string) => {
const v = lab(h);
return Math.hypot(v.a, v.b);
};
/** CIEDE2000 with both colours forced to a shared L — lightness must not
* rescue a pair that is really one hue at two brightnesses. */
const hueDist = (a: string, b: string) => {
const p = lab(a),
q = lab(b),
L = (p.L + q.L) / 2;
return de00({ L, a: p.a, b: p.b }, { L, a: q.a, b: q.b });
};
function away(c: string, bg: string, floor: number) {
const dir = lum(bg) > 0.5 ? "#000000" : "#ffffff";
let x = c;
for (let i = 0; i < 200 && ratio(x, bg) < floor; i++) x = mix(x, dir, 0.02);
return x;
}
/** Solve for a CONTRAST RATIO, never a fixed mix fraction: one constant
* fraction gives a 2.6x appearance spread across themes. */
function atRatio(colour: string, bg: string, target: number) {
const have = ratio(colour, bg);
if (have < target) return away(colour, bg, target);
// Idempotence: re-pinning an already-compliant colour bisects it a hair
// closer to the ground each time, and every pass costs chroma. Measured on
// kanagawa-dragon: a second pin dropped a stop from C* 16.1 to 15.5.
if (have - target < 0.05) return colour;
let lo = 0,
hi = 1,
best = colour;
for (let i = 0; i < 28; i++) {
const m = (lo + hi) / 2,
c = mix(colour, bg, m);
if (ratio(c, bg) > target) {
best = c;
lo = m;
} else hi = m;
}
return best;
}
function lab2hex(L: number, A: number, B: number) {
const fy = (L + 16) / 116,
fx = fy + A / 500,
fz = fy - B / 200;
const fi = (t: number) =>
t ** 3 > 216 / 24389 ? t ** 3 : (116 * t - 16) / (24389 / 27);
const x = fi(fx) * 0.95047,
y = L > 8 ? ((L + 16) / 116) ** 3 : L / (24389 / 27),
z = fi(fz) * 1.08883;
const g = (c: number) => {
c = Math.max(0, Math.min(1, c));
return c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;
};
const to = (v: number) =>
Math.round(g(v) * 255)
.toString(16)
.padStart(2, "0");
return `#${to(x * 3.2406 + y * -1.5372 + z * -0.4986)}${to(x * -0.9689 + y * 1.8758 + z * 0.0415)}${to(x * 0.0557 + y * -0.204 + z * 1.057)}`;
}
/** Raise to the floor if short, otherwise leave alone. A floor is a floor,
* not a target: `atRatio` also pins DOWN, which dimmed every vivid theme to
* exactly 4.5 and cost chroma (tokyo-night's #f7768e sits at 6.46 natively).
* Use this wherever the requirement is "at least", and `atRatio` only where a
* specific ratio is the design (the field decay and the bevel gap). */
const lift = (c: string, bg: string, floor: number) =>
ratio(c, bg) >= floor ? c : away(c, bg, floor);
/**
* The distinctness gate. Conjunction by design, and each half is tested
* directly (terminalBannerColor.test.mjs) because on the 62 shipped themes the
* two halves MASK EACH OTHER: disabling either alone changes no theme's stops,
* only disabling both does. A census can therefore never justify keeping both.
*
* Neither half is sufficient. sRGB distance counts lightness, so white vs
* #a0a0a0 scores 165 while being one hue. Hue alone admits stops that differ
* in angle but barely in appearance.
*
* Thresholds: hue 12 and sRGB 45 are HOUSE thresholds with no external
* standard, sized by sweep. The contrast floor 4.5 is WCAG 2.2 SC 1.4.3.
*/
export function stopsAreDistinct(s: [string, string, string]): boolean {
const min = (f: (a: string, b: string) => number) =>
Math.min(f(s[0], s[1]), f(s[1], s[2]), f(s[0], s[2]));
return min(hueDist) >= HUE_MIN && min(srgbDist) >= SRGB_MIN;
}
const HUES = ["red", "green", "yellow", "blue", "magenta", "cyan"] as const;
/** The three wordmark stops: maximally hue-separated, each at the text floor. */
export function bannerStops(p: TerminalPalette): {
stops: [string, string, string];
mode: "native" | "synth";
} {
const bg = p.background;
const seen = new Set<string>();
const pool: string[] = [];
for (const n of HUES)
for (const k of [n, `bright${n[0].toUpperCase()}${n.slice(1)}`]) {
const c = lift(p.ansi[k as AnsiColorName], bg, WORDMARK_FLOOR);
if (!seen.has(c)) {
seen.add(c);
pool.push(c);
}
}
const chromatic = pool.filter((c) => chroma(c) >= CHROMA_MIN);
const pick = (cs: string[]) => {
let seed = [cs[0], cs[1]],
best = -1;
for (let i = 0; i < cs.length; i++)
for (let j = i + 1; j < cs.length; j++) {
const d = hueDist(cs[i], cs[j]);
if (d > best) {
best = d;
seed = [cs[i], cs[j]];
}
}
const ch = [...seed];
let third = cs[0],
td = -1;
for (const c of cs) {
if (ch.includes(c)) continue;
const d = Math.min(...ch.map((x) => hueDist(x, c)));
if (d > td) {
td = d;
third = c;
}
}
return [...ch, third];
};
let stops: string[] | null = null;
if (chromatic.length >= 3) {
const s = pick(chromatic);
// Conjunction. Neither alone is sufficient: sRGB distance counts
// lightness, so it scores white-vs-grey 165 while they are one hue; and
// hue alone admits stops that differ in angle but barely in appearance.
if (stopsAreDistinct(s as [string, string, string])) stops = s;
}
let mode: "native" | "synth" = "native";
if (!stops) {
// Near-monochrome theme (vesper, min-dark): rotate +/-40 deg in LCh around
// the theme's own most chromatic colour. It stays the theme's hue family —
// a restrained theme stays restrained, it just gains a fade.
mode = "synth";
const anchor = pool.reduce((a, b) => (chroma(b) > chroma(a) ? b : a));
const v = lab(anchor),
C = Math.max(Math.hypot(v.a, v.b), 22),
h0 = (Math.atan2(v.b, v.a) * 180) / Math.PI;
stops = [-40, 0, 40].map((d) => {
const h = ((h0 + d) * Math.PI) / 180;
return lift(
lab2hex(v.L, C * Math.cos(h), C * Math.sin(h)),
bg,
WORDMARK_FLOOR,
);
});
}
// order by hue angle so the sweep is monotone across the wordmark
stops.sort((a, b) => {
const x = lab(a),
y = lab(b);
return Math.atan2(x.b, x.a) - Math.atan2(y.b, y.a);
});
return { stops: stops as [string, string, string], mode };
}
/**
* 3-stop ramp interpolated in CIELCh (polar Lab) along the SHORT hue arc,
* then re-solved to the target ratio so every sampled point keeps its contrast
* instead of sagging between stops.
*
* Not RGB interpolation: a straight line in RGB between two opposing hues
* passes through the neutral axis, so the fade greys out mid-glyph. Measured
* on vitesse-dark, whose stops are all C* >= 24 yet whose RGB midpoint fell to
* C* 2.3 — three chromatic stops and a grey wordmark. Interpolating the hue
* ANGLE keeps chroma up across the whole sweep.
*/
function lerpLch(a: string, b: string, u: number) {
const p = lab(a),
q = lab(b);
const [C1, C2] = [Math.hypot(p.a, p.b), Math.hypot(q.a, q.b)];
const h1 = Math.atan2(p.b, p.a),
h2 = Math.atan2(q.b, q.a);
let dh = h2 - h1; // short arc
if (dh > Math.PI) dh -= 2 * Math.PI;
if (dh < -Math.PI) dh += 2 * Math.PI;
const L = p.L + (q.L - p.L) * u;
const C = C1 + (C2 - C1) * u;
const h = h1 + dh * u;
return lab2hex(L, C * Math.cos(h), C * Math.sin(h));
}
function ramp(
stops: [string, string, string],
t: number,
bg: string,
target: number,
pin = true,
) {
const u = Math.max(0, Math.min(1, t));
const c =
u < 0.5
? lerpLch(stops[0], stops[1], u * 2)
: lerpLch(stops[1], stops[2], (u - 0.5) * 2);
return pin ? atRatio(c, bg, target) : lift(c, bg, target);
}
export function bannerColor(
p: TerminalPalette,
stops: [string, string, string],
layer: Layer,
t: number,
): string {
const bg = p.background;
switch (layer) {
case "head":
// Tyler: "fill the box with buzz term in a three color fade".
return ramp(stops, t, bg, WORDMARK_FLOOR, false);
case "field":
// Tyler: "flow to the edges, fading out" + "colour shift centre to edge".
// Same three hues, so the field and the mark are one palette; the fade is
// in CONTRAST, from 2.60 at the box to 1.04 at the rim.
return ramp(
stops,
t,
bg,
FIELD_CORE + (FIELD_EDGE - FIELD_CORE) * Math.max(0, Math.min(1, t)),
);
case "bevel_hi":
return atRatio(p.foreground, bg, BEVEL_HI);
case "bevel_lo":
return atRatio(p.foreground, bg, BEVEL_LO);
}
}
@@ -0,0 +1,109 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildTerminalBanner } from "./terminalBanner.ts";
import { paintTerminalBanner } from "./terminalBannerPainter.ts";
const palette = {
background: "#10141c",
foreground: "#d0d0d0",
cursor: "#ffffff",
cursorText: "#000000",
ansi: {
black: "#10141c",
red: "#f07178",
green: "#aad94c",
yellow: "#ffb454",
blue: "#59c2ff",
magenta: "#d2a6ff",
cyan: "#95e6cb",
white: "#d0d0d0",
brightBlack: "#4d5566",
brightRed: "#f07178",
brightGreen: "#aad94c",
brightYellow: "#ffb454",
brightBlue: "#59c2ff",
brightMagenta: "#d2a6ff",
brightCyan: "#95e6cb",
brightWhite: "#ffffff",
},
};
function canvas() {
const draws = [];
const clears = [];
const colors = [];
const context = {
_fillStyle: "",
clearRect(...args) {
clears.push(args);
},
fillText(...args) {
draws.push(args);
colors.push(this._fillStyle);
},
font: "",
set fillStyle(value) {
this._fillStyle = value;
},
get fillStyle() {
return this._fillStyle;
},
setTransform() {},
textBaseline: "",
};
return {
canvas: {
height: 0,
width: 0,
getBoundingClientRect: () => ({ height: 600, width: 1000 }),
getContext: () => context,
},
clears,
colors,
draws,
};
}
test("paints every emitted banner glyph at terminal cell coordinates", () => {
const banner = buildTerminalBanner(112, 46, 17 / 8.4);
assert.ok(banner);
const emitted = banner.cells.flat().filter((cell) => cell.char !== " ");
const target = canvas();
assert.equal(paintTerminalBanner(target.canvas, banner, palette, 2), true);
assert.equal(target.draws.length, emitted.length);
assert.ok(target.draws.some(([char]) => char === "█"));
assert.ok(target.draws.some(([char]) => char === "/"));
assert.ok(target.draws.some(([, x, y]) => x > 0 && y > 0));
assert.equal(target.canvas.width, 2000);
assert.equal(target.canvas.height, 1200);
assert.deepEqual(target.clears, [[0, 0, 1000, 600]]);
});
test("uses visibly distinct theme-derived colors across banner layers", () => {
const banner = buildTerminalBanner(112, 46, 17 / 8.4);
assert.ok(banner);
const target = canvas();
paintTerminalBanner(target.canvas, banner, palette, 1);
assert.ok(new Set(target.colors).size >= 3);
assert.equal(target.colors.includes(""), false);
});
test("fails closed when the banner canvas has no 2d context", () => {
const banner = buildTerminalBanner(112, 46, 17 / 8.4);
assert.ok(banner);
assert.equal(
paintTerminalBanner(
{
height: 0,
width: 0,
getBoundingClientRect: () => ({ height: 600, width: 1000 }),
getContext: () => null,
},
banner,
palette,
1,
),
false,
);
});
@@ -0,0 +1,38 @@
import type { TerminalPalette } from "../../shared/theme/terminal-palette";
import type { TerminalBanner } from "./terminalBanner";
import { bannerColor, bannerStops } from "./terminalBannerColor";
import { TERMINAL_CELL_METRICS } from "./terminalRenderer";
export function paintTerminalBanner(
canvas: HTMLCanvasElement,
banner: TerminalBanner,
palette: TerminalPalette,
dpr: number,
): boolean {
const bounds = canvas.getBoundingClientRect();
const pixelWidth = Math.max(1, Math.round(bounds.width * dpr));
const pixelHeight = Math.max(1, Math.round(bounds.height * dpr));
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
const context = canvas.getContext("2d");
if (!context) return false;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, bounds.width, bounds.height);
context.font = TERMINAL_CELL_METRICS.font;
context.textBaseline = "alphabetic";
const { stops } = bannerStops(palette);
for (const [row, cells] of banner.cells.entries()) {
for (const [column, cell] of cells.entries()) {
if (cell.char === " " || !cell.layer) continue;
context.fillStyle = bannerColor(palette, stops, cell.layer, cell.t);
context.fillText(
cell.char,
column * TERMINAL_CELL_METRICS.width,
row * TERMINAL_CELL_METRICS.height + TERMINAL_CELL_METRICS.baseline,
);
}
}
return true;
}
@@ -44,6 +44,14 @@ export type CellMetrics = {
boldFont: string;
};
export const TERMINAL_CELL_METRICS = {
width: 8.4,
height: 17,
baseline: 13,
font: '14px "JetBrains Mono", monospace',
boldFont: '700 14px "JetBrains Mono", monospace',
} as const satisfies CellMetrics;
type RetainedRow = readonly TerminalSpan[];
export type PaintContext = Pick<
@@ -789,13 +789,10 @@
}
.buzz-terminal-welcome {
color: hsl(var(--buzz-selected-accent));
left: 50%;
line-height: 1.35;
inset: 0;
pointer-events: none;
position: absolute;
top: 42%;
transform: translate(-50%, -50%);
z-index: 1;
}
.buzz-terminal-input {