mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
parent
8515026860
commit
2b8b247f51
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user