paqad-ai
Version:
Spec-driven development framework — AI agents that think before they type
832 lines (756 loc) • 31.5 kB
text/typescript
import { fitLine } from './shell';
import {
BREATH,
INK,
PX_PER_COLUMN,
SIGNAL,
STATUS_B,
SVG_FONT,
signalColor,
type Signal,
} from './palette';
export type StageState = 'done' | 'run' | 'paused' | 'marked' | 'fail' | 'skip' | 'todo';
export type SeatState = 'done' | 'writing' | 'seated' | 'late';
export type ChiefState = 'waiting' | 'ruling' | 'needs' | 'done';
export type ShipState = 'done' | 'run' | 'todo' | 'fail';
export type MeterTone = 'ink' | 'acc' | 'pass';
export interface Mark<S> {
label: string;
state: S;
}
export interface TableSeat extends Mark<SeatState> {
findings: number | null;
}
export type DeliveryTone = 'ink' | 'acc' | 'fail' | 'mut';
export interface DeliveryFigures {
commits: number;
additions: number;
deletions: number;
partial?: boolean;
}
export type SignoffTone = 'acct' | 'caut' | 'passt' | 'fail' | 'mutt';
export interface StripSignoff {
words: string;
tone: SignoffTone;
button: string | null;
ref?: string | null;
name?: string | null;
}
export interface StripInput {
columns: number;
stages: ReadonlyArray<Mark<StageState>>;
stageValue: string;
stageValueShort?: string;
seats: ReadonlyArray<Mark<SeatState>> | null;
hiddenSeats?: number;
chief: ChiefState;
tableValue: string;
deliveryValue: string;
deliveryTone: DeliveryTone;
deliveryFigures: DeliveryFigures | null;
signoff?: StripSignoff | null;
clockMs?: number;
settledAtMs?: number | null;
}
export const STRIP_HEIGHT = 52;
const PULSE_MS = 1600;
const SPIN_MS = 4000;
const SHEEN_MS = 2200;
const SETTLE_MS = 2000;
const TITLE_SHEEN_MS = 3600;
const BREATH_MS = 2400;
const TAG_BREATH_MS = 2400;
function phase(clockMs: number | undefined, periodMs: number): string | null {
if (clockMs === undefined || !Number.isFinite(clockMs)) return null;
const into = ((clockMs % periodMs) + periodMs) % periodMs;
return `-${(into / 1000).toFixed(3)}s`;
}
function begin(clockMs: number | undefined, periodMs: number): string {
const at = phase(clockMs, periodMs);
return at === null ? '' : ` begin="${at}"`;
}
const STRIP_MIN_WIDTH = 480;
const SECTION_PAD = 18;
const RIGHT_INSET = 7;
const NODE_RADIUS = 8.5;
const SEAT_SPACING = 22;
export const TABLE_PX = 150;
export const DELIVERY_PX = 190;
export const STAGE_MIN_PX = 340;
export const LABEL_MIN_PX = 40;
export const STAGE_NAME_X = 44;
const NEEDS_ANSWERS = 'Needs answers';
const NEEDS_ANSWERS_MIN_PX = 75;
const VALUE_CHAR_PX = 6;
const STATUS_CHAR_PX = 6.8;
const FIGURE_CHAR_PX = 6;
const Y_STATUS = 29.3;
const CHIEF_REACH = 31;
const Y_CAP = 9;
const Y_MARK = 25;
const Y_LBL = 47;
const XML: Readonly<Record<string, string>> = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
};
export function escapeXml(text: string): string {
return text.replace(/[&<>"']/g, (ch) => XML[ch] ?? ch);
}
function fixed(n: number): string {
return n.toFixed(1);
}
function svg(width: number, height: number, body: string, extra = '', x = 0): string {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${x} 0 ${width} ${height}" width="${width}" height="${height}"${extra}>${body}</svg>`;
}
function phaseRules(clockMs: number | undefined): string {
const pulse = phase(clockMs, PULSE_MS);
const spin = phase(clockMs, SPIN_MS);
return pulse === null || spin === null
? ''
: `.pulse{animation-delay:${pulse}}.spin{animation-delay:${spin}}`;
}
function css(clockMs?: number): string {
return [
'<style>',
`text{font-family:${SVG_FONT};font-variant-numeric:tabular-nums}`,
`.ink{fill:${INK.ink}}.inks{stroke:${INK.ink}}.mut{fill:${INK.muted}}`,
`.faint{fill:${INK.muted};fill-opacity:${INK.faintOpacity}}`,
`.faints{stroke:${INK.muted};stroke-opacity:${INK.faintStrokeOpacity}}`,
`.hair{stroke:${INK.muted};stroke-opacity:${INK.hairOpacity}}`,
`.acc{fill:${SIGNAL.accent}}.accs{stroke:${SIGNAL.accent}}`,
`.att{fill:${SIGNAL.attention}}.atts{stroke:${SIGNAL.attention}}`,
`.cau{fill:${SIGNAL.caution}}.caus{stroke:${SIGNAL.caution}}`,
`.pass{fill:${STATUS_B.done}}.passs{stroke:${STATUS_B.done}}`,
`.passt{fill:${SIGNAL.passText}}`,
`.fail{fill:${STATUS_B.failed}}.fails{stroke:${STATUS_B.failed}}`,
`.acct{fill:${SIGNAL.accentText}}.caut{fill:${SIGNAL.cautionText}}.mutt{fill:${INK.mutedText}}`,
'.cap{font-size:8.5px;font-weight:600;letter-spacing:1.1px}',
'.val{font-size:10.5px;font-weight:500}',
'.lbl{font-size:9px}.cur{font-weight:650}',
'@media (prefers-color-scheme: dark){',
`.ink{fill:${INK.inkDark}}.inks{stroke:${INK.inkDark}}.mut{fill:${INK.mutedDark}}`,
`.pass{fill:${STATUS_B.doneDark}}.passs{stroke:${STATUS_B.doneDark}}`,
`.passt{fill:${SIGNAL.passDark}}`,
`.fail{fill:${STATUS_B.failedDark}}.fails{stroke:${STATUS_B.failedDark}}`,
`.cau{fill:${SIGNAL.cautionDark}}.caus{stroke:${SIGNAL.cautionDark}}`,
`.acct{fill:${SIGNAL.accentTextDark}}.caut{fill:${SIGNAL.cautionDark}}.mutt{fill:${INK.mutedDark}}`,
'}',
'@keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}',
'@keyframes spin{to{transform:rotate(360deg)}}',
'.pulse{animation:pulse 1.6s ease-in-out infinite}',
'.spin{transform-box:fill-box;transform-origin:center;animation:spin 4s linear infinite}',
phaseRules(clockMs),
'.off{display:none}',
'@media (prefers-reduced-motion: reduce){.pulse,.spin{animation:none}.motion{display:none}}',
'</style>',
].join('');
}
function sheenStops(): string {
return [0, 0.5, 1]
.map(
(offset) =>
`<stop offset="${offset}" stop-color="${SIGNAL.onSignal}" stop-opacity="${offset === 0.5 ? 0.75 : 0}"/>`,
)
.join('');
}
function defs(glow: boolean, sheen: boolean): string {
if (!glow && !sheen) return '';
return [
'<defs>',
glow
? '<filter id="glow" x="-50%" y="-200%" width="200%" height="500%"><feGaussianBlur stdDeviation="2.2"/></filter>'
: '',
sheen ? `<linearGradient id="sheen" x1="0" x2="1">${sheenStops()}</linearGradient>` : '',
'</defs>',
].join('');
}
function arc(c: number, r: number, from: number, to: number): string {
const point = (a: number) =>
`${(c + r * Math.cos(a)).toFixed(2)} ${(c + r * Math.sin(a)).toFixed(2)}`;
return `M ${point(from)} A ${r} ${r} 0 ${to - from > Math.PI ? 1 : 0} 1 ${point(to)}`;
}
function polar(c: number, r: number, a: number): { x: string; y: string } {
return { x: (c + r * Math.cos(a)).toFixed(2), y: (c + r * Math.sin(a)).toFixed(2) };
}
const GIT: Readonly<Record<string, string>> = {
Edit: '<path d="M10.8 2.7l2.5 2.5-7.6 7.6-3.2.7.7-3.2z"/>',
Commit: '<circle cx="8" cy="8" r="2.6"/><path d="M1.5 8h3.9M10.6 8h3.9"/>',
Push: '<path d="M8 12.5V3.5M4.5 7 8 3.5 11.5 7"/>',
PR: '<circle cx="4.5" cy="3.5" r="1.7"/><circle cx="4.5" cy="12.5" r="1.7"/><circle cx="11.5" cy="12.5" r="1.7"/><path d="M4.5 5.2v5.6M11.5 10.8V7.2a3 3 0 0 0-3-3H7"/>',
CI: '<path d="m4.5 8.3 2.3 2.3 4.7-4.9"/>',
};
const DOT_ICON = '<circle cx="8" cy="8" r="2.4"/>';
interface Section {
x0: number;
x1: number;
inner: number;
last: boolean;
}
function section({ x0, x1, last }: Section, cap: string, cut = false): string {
let out = cap ? `<text class="cap mut" x="${fixed(x0)}" y="${Y_CAP}">${cap}</text>` : '';
if (!last) {
const at = fixed(cut ? x1 - 0.5 : x1);
out += `<line class="hair" x1="${at}" y1="1" x2="${at}" y2="${STRIP_HEIGHT - 2}" stroke-width="1"/>`;
}
return out;
}
const STAGE_BAR: Readonly<Record<StageState, string>> = {
done: 'pass',
run: 'acc pulse',
paused: 'att',
marked: 'cau',
fail: 'fail',
skip: 'faints',
todo: 'faint',
};
const STAGE_LABEL: Readonly<Record<StageState, string>> = {
done: 'passt',
run: 'acc cur',
paused: 'att cur',
marked: 'ink',
fail: 'fail cur',
skip: 'mut',
todo: 'mut',
};
const LIVE_BAR_STATES: ReadonlySet<StageState> = new Set(['run', 'paused', 'marked']);
function liveBar(
x: number,
w: number,
y: number,
index: number,
state: StageState,
clockMs: number | undefined,
): string {
const at = `x="${fixed(x)}" y="${y}" width="${fixed(w)}"`;
const running = state === 'run';
const glow = running ? 'acc motion' : `${STAGE_BAR[state]} off`;
const sheen = running ? 'motion' : 'off';
return [
`<rect class="${glow}" x="${fixed(x)}" y="${y - 1}" width="${fixed(w)}" height="5" rx="2.5" filter="url(#glow)" opacity=".55"><animate attributeName="opacity" values=".55;.15;.55" dur="1.6s" repeatCount="indefinite"${begin(clockMs, PULSE_MS)}/></rect>`,
`<clipPath id="run${index}"><rect ${at} height="3" rx="1.5"/></clipPath>`,
`<rect class="${STAGE_BAR[state]}" ${at} height="3" rx="1.5"/>`,
`<g class="${sheen}" clip-path="url(#run${index})"><rect x="${fixed(x - 28)}" y="${y}" width="28" height="3" fill="url(#sheen)"><animate attributeName="x" values="${fixed(x - 28)};${fixed(x + w)}" dur="2.2s" calcMode="spline" keyTimes="0;1" keySplines=".4 0 .2 1" repeatCount="indefinite"${begin(clockMs, SHEEN_MS)}/></rect></g>`,
].join('');
}
export function settlePhase(
clockMs: number | undefined,
settledAtMs: number | null | undefined,
): string | null {
if (clockMs === undefined || settledAtMs === null || settledAtMs === undefined) return null;
const into = clockMs - settledAtMs;
if (!Number.isFinite(into) || into < 0 || into >= SETTLE_MS) return null;
return `-${(into / 1000).toFixed(3)}s`;
}
function stageLabel(label: string, state: StageState): string {
const text = escapeXml(label);
return state === 'done' ? `<tspan class="pass">✓</tspan> ${text}` : text;
}
function settleGlow(bars: ReadonlyArray<{ x: number; w: number }>, y: number, at: string): string {
const rects = bars
.map(
({ x, w }) =>
`<rect class="pass" x="${fixed(x)}" y="${y - 1}" width="${fixed(w)}" height="5" rx="2.5" filter="url(#glow)" opacity="0"><animate attributeName="opacity" values=".5;0" dur="2s" repeatCount="1" fill="freeze" begin="${at}"/></rect>`,
)
.join('');
return `<g class="motion settle">${rects}</g>`;
}
const STAGE_GAP = 4;
export function stageBarPx(room: number, bars: number): number {
const n = Math.max(1, bars);
return (room - STAGE_GAP * (n - 1)) / n;
}
function stageMarks(
x0: number,
x1: number,
stages: ReadonlyArray<Mark<StageState>>,
clockMs?: number,
settle: string | null = null,
): string {
const gap = STAGE_GAP;
const n = Math.max(1, stages.length);
const w = stageBarPx(x1 - x0, n);
const labels = w >= LABEL_MIN_PX;
const y = Y_MARK - 1.5;
const done = stages.flatMap(({ state }, i) =>
state === 'done' ? [{ x: x0 + i * (w + gap), w }] : [],
);
const glow = settle === null || done.length === 0 ? '' : settleGlow(done, y, settle);
return (
glow +
stages
.map(({ label, state }, i) => {
const x = x0 + i * (w + gap);
const bar =
state === 'skip'
? `<line class="faints" x1="${fixed(x + 1)}" y1="${Y_MARK}" x2="${fixed(x + w - 1)}" y2="${Y_MARK}" stroke-width="1" stroke-dasharray="2 3"/>`
: LIVE_BAR_STATES.has(state)
? liveBar(x, w, y, i, state, clockMs)
: `<rect class="${STAGE_BAR[state]}" x="${fixed(x)}" y="${y}" width="${fixed(w)}" height="3" rx="1.5"/>`;
if (!labels) return bar;
return `${bar}<text class="lbl ${STAGE_LABEL[state]}" x="${fixed(x + w / 2)}" y="${Y_LBL}" text-anchor="middle">${stageLabel(label, state)}</text>`;
})
.join('')
);
}
function seatMark(state: SeatState, cx: string): string {
if (state === 'done') return `<circle class="ink" cx="${cx}" cy="${Y_MARK}" r="4"/>`;
if (state === 'writing') {
return `<circle class="acc pulse" cx="${cx}" cy="${Y_MARK}" r="4" fill-opacity=".22"/><circle class="accs" cx="${cx}" cy="${Y_MARK}" r="4" fill="none" stroke-width="1.4"/>`;
}
if (state === 'late') {
return `<circle class="caus" cx="${cx}" cy="${Y_MARK}" r="4" fill="none" stroke-width="1.4"/>`;
}
return `<circle class="faints" cx="${cx}" cy="${Y_MARK}" r="3.6" fill="none" stroke-width="1.2"/>`;
}
function chiefMark(chief: ChiefState, cx: string): string {
if (chief === 'ruling') {
return `<circle class="accs spin" cx="${cx}" cy="${Y_MARK}" r="5" fill="none" stroke-width="1.6" stroke-dasharray="3.5 2.6"/>`;
}
if (chief === 'needs') {
return `<circle class="att pulse" cx="${cx}" cy="${Y_MARK}" r="5" fill-opacity=".25"/><circle class="atts" cx="${cx}" cy="${Y_MARK}" r="5" fill="none" stroke-width="1.6"/>`;
}
if (chief === 'done') return `<circle class="ink" cx="${cx}" cy="${Y_MARK}" r="5"/>`;
return `<circle class="faints" cx="${cx}" cy="${Y_MARK}" r="5" fill="none" stroke-width="1.2" stroke-dasharray="2 2"/>`;
}
function tableMarks(
x0: number,
x1: number,
seats: ReadonlyArray<Mark<SeatState>>,
chief: ChiefState,
hidden: number,
): string {
const slots = seats.length + (hidden > 0 ? 1 : 0);
const step = Math.min(SEAT_SPACING, (x1 - x0 - CHIEF_REACH) / (Math.max(1, slots) - 0.5));
let out = '';
seats.forEach(({ label, state }, i) => {
const cx = fixed(x0 + 5 + i * step);
out += seatMark(state, cx);
out += `<text class="lbl mut" x="${cx}" y="${Y_LBL}" text-anchor="middle">${escapeXml(label)}</text>`;
});
if (hidden > 0) {
out += `<text class="lbl mut" x="${fixed(x0 + 5 + seats.length * step)}" y="${Y_MARK + 3}" text-anchor="middle" style="font-weight:500">+${hidden}</text>`;
}
const sx = x0 + 5 + slots * step - step / 2 + 6;
const ccx = fixed(sx + 14);
out +=
seats.length === 1 && hidden === 0
? `<line class="faints" x1="${fixed(x0 + 10)}" y1="${Y_MARK}" x2="${fixed(sx + 14 - 6)}" y2="${Y_MARK}" stroke-width="1.2" stroke-linecap="round"/>`
: `<line class="hair" x1="${fixed(sx)}" y1="${Y_MARK - 6}" x2="${fixed(sx)}" y2="${Y_MARK + 6}" stroke-width="1"/>`;
out += chiefMark(chief, ccx);
out += `<text class="lbl mut" x="${ccx}" y="${Y_LBL}" text-anchor="middle">CA</text>`;
return out;
}
const STILL_SHARE = 0.3;
function shareOf(label: string, ci: number | null): number | null {
if (label !== 'CI') return null;
return ci === null ? STILL_SHARE : Math.max(0.05, Math.min(1, ci));
}
function nodeArt(
state: ShipState,
x: string,
y: string,
r: number,
share: number | null,
outer: number,
): { ring: string; stroke: string } {
if (state === 'done') {
return {
ring: `<circle class="ink" cx="${x}" cy="${y}" r="${r}" fill-opacity=".1"/><circle class="inks" cx="${x}" cy="${y}" r="${r}" fill="none" stroke-width="1.2"/>`,
stroke: 'class="inks"',
};
}
if (state === 'run') {
const rr = r + outer;
const len = 2 * Math.PI * rr;
const arcLength = len * (share ?? STILL_SHARE);
return {
ring: [
`<circle class="faints" cx="${x}" cy="${y}" r="${r}" fill="none" stroke-width="1.2"/>`,
`<circle class="faints" cx="${x}" cy="${y}" r="${rr}" fill="none" stroke-width="1.4" style="stroke-opacity:.18"/>`,
`<g${share === null ? ' class="spin"' : ''}><circle class="accs" cx="${x}" cy="${y}" r="${rr}" fill="none" stroke-width="1.6" stroke-linecap="round" stroke-dasharray="${fixed(arcLength)} ${fixed(len)}" transform="rotate(-90 ${x} ${y})"/></g>`,
].join(''),
stroke: 'class="accs"',
};
}
if (state === 'fail') {
return {
ring: `<circle class="fails" cx="${x}" cy="${y}" r="${r}" fill="none" stroke-width="1.4"/>`,
stroke: 'class="fails"',
};
}
return {
ring: `<circle class="faints" cx="${x}" cy="${y}" r="${r}" fill="none" stroke-width="1.2"/>`,
stroke: 'class="faints"',
};
}
function icon(label: string, x: number, y: number, stroke: string): string {
return `<g transform="translate(${fixed(x - 5)} ${fixed(y - 5)}) scale(.625)" fill="none" ${stroke} stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">${GIT[label] ?? DOT_ICON}</g>`;
}
export function stripWidth(columns: number): number {
return Math.max(STRIP_MIN_WIDTH, Math.round(columns * PX_PER_COLUMN));
}
export type StripSectionId = 'stage' | 'table' | 'delivery';
export interface StripSection {
id: StripSectionId;
px: number;
grow: boolean;
source: string;
}
export interface StripLayout {
ids: StripSectionId[];
px: number[];
}
export function stripLayout(width: number, table: boolean): StripLayout {
if (width < STAGE_MIN_PX + DELIVERY_PX) return { ids: ['stage'], px: [width] };
if (table && width >= STAGE_MIN_PX + DELIVERY_PX + TABLE_PX) {
return {
ids: ['stage', 'table', 'delivery'],
px: [width - TABLE_PX - DELIVERY_PX, TABLE_PX, DELIVERY_PX],
};
}
return { ids: ['stage', 'delivery'], px: [width - DELIVERY_PX, DELIVERY_PX] };
}
const WORDS_GAP_PX = 10;
const BUTTON_PAD_COLUMNS = 3;
export function buttonPx(label: string): number {
return ([...label].length + BUTTON_PAD_COLUMNS) * PX_PER_COLUMN + WORDS_GAP_PX;
}
function fitPx(text: string, px: number, charPx: number): string {
if (px < charPx) return '';
return fitLine(text, px / charPx);
}
function thousands(n: number): string {
return n.toLocaleString('en-US');
}
export function figureWords(figures: DeliveryFigures | null): {
commits: string;
additions: string;
deletions: string;
} | null {
if (!figures || figures.additions + figures.deletions <= 0) return null;
const more = figures.partial ? '+' : '';
return {
commits:
figures.commits > 0
? `${figures.commits} ${figures.commits === 1 ? 'commit' : 'commits'}`
: '',
additions: `+${thousands(figures.additions)}${more}`,
deletions: `−${thousands(figures.deletions)}`,
};
}
function deliveryLines(x0: number, inner: number, input: StripInput): string {
const room = inner - x0;
const status = fitPx(input.deliveryValue, room, STATUS_CHAR_PX);
let out = status
? `<text class="${input.deliveryTone}" x="${fixed(x0)}" y="${Y_STATUS}" style="font-size:12px;font-weight:500">${escapeXml(status)}</text>`
: '';
const words = figureWords(input.deliveryFigures);
if (!words) return out;
const figures = `${words.additions} ${words.deletions}`;
const commits =
words.commits && (words.commits.length + 2 + figures.length) * FIGURE_CHAR_PX <= room
? words.commits
: '';
const at = `x="${fixed(x0)}" y="${Y_LBL}" style="font-size:10.5px"`;
if (figures.length * FIGURE_CHAR_PX > room) {
const cut = fitPx(figures, room, FIGURE_CHAR_PX);
return `${out}<text class="mut" ${at}>${escapeXml(cut)}</text>`;
}
const lead = commits ? `<tspan class="mut">${escapeXml(commits)}</tspan><tspan> </tspan>` : '';
out += `<text ${at} xml:space="preserve">${lead}<tspan class="passt">${escapeXml(words.additions)}</tspan><tspan> </tspan><tspan class="fail">${escapeXml(words.deletions)}</tspan></text>`;
return out;
}
function stripBody(
input: StripInput,
ids: readonly StripSectionId[],
sections: readonly Section[],
cut: boolean,
): Record<StripSectionId, string> {
const stage = sections[0]!;
const settle = settlePhase(input.clockMs, input.settledAtMs);
const body = { stage: '', table: '', delivery: '' };
body.stage = section(stage, 'STAGE', cut);
const signoff = input.signoff ?? null;
const wordsEnd = stage.inner - (signoff?.button ? buttonPx(signoff.button) : 0);
const wordsPx = signoff ? [...signoff.words].length * VALUE_CHAR_PX : 0;
if (signoff) {
body.stage += `<text class="val ${signoff.tone}" x="${fixed(wordsEnd)}" y="${Y_CAP}" text-anchor="end">${escapeXml(signoff.words)}</text>`;
}
const nameRoom =
(signoff ? wordsEnd - wordsPx - WORDS_GAP_PX : stage.inner) - (stage.x0 + STAGE_NAME_X);
const value =
input.stageValueShort && [...input.stageValue].length * VALUE_CHAR_PX > nameRoom
? input.stageValueShort
: input.stageValue;
const name = value ? fitPx(value, nameRoom, VALUE_CHAR_PX) : '';
if (name) {
body.stage += `<text class="val ink" x="${fixed(stage.x0 + STAGE_NAME_X)}" y="${Y_CAP}">${escapeXml(name)}</text>`;
}
body.stage += stageMarks(stage.x0, stage.inner, input.stages, input.clockMs, settle);
const tableAt = ids.indexOf('table');
if (input.seats && tableAt > 0) {
const table = sections[tableAt]!;
const needs =
input.tableValue === NEEDS_ANSWERS && table.inner - table.x0 >= NEEDS_ANSWERS_MIN_PX;
body.table = section(table, needs ? '' : 'HIGH TABLE', cut);
if (needs) {
body.table += `<text class="val att" x="${fixed(table.x0)}" y="${Y_CAP}">${NEEDS_ANSWERS}</text>`;
}
body.table += tableMarks(
table.x0,
table.inner,
input.seats,
input.chief,
input.hiddenSeats ?? 0,
);
}
const deliveryAt = ids.indexOf('delivery');
if (deliveryAt > 0) {
const delivery = sections[deliveryAt]!;
body.delivery = section(delivery, 'DELIVERY', cut);
body.delivery += deliveryLines(delivery.x0, delivery.inner, input);
}
return body;
}
function trim(n: number): number {
return Number(n.toFixed(2));
}
function layout(px: readonly number[]): { edges: number[]; sections: Section[] } {
const edges = [0];
px.forEach((width, i) => edges.push(edges[i]! + width));
const sections: Section[] = px.map((_, i) => {
const last = i === px.length - 1;
const x1 = edges[i + 1]!;
return {
x0: edges[i]! + (i === 0 ? 0 : SECTION_PAD),
x1,
inner: last ? x1 - RIGHT_INSET : x1 - SECTION_PAD,
last,
};
});
return { edges, sections };
}
export function seatsThatFit(seats: number, cap: number): number {
const room = TABLE_PX - 2 * SECTION_PAD - CHIEF_REACH;
for (let shown = Math.min(cap, seats); shown > 1; shown -= 1) {
const slots = shown + (seats > shown ? 1 : 0);
if ((slots - 0.5) * SEAT_SPACING <= room) return shown;
}
return Math.min(1, seats);
}
export function strip(input: StripInput): {
source: string;
width: number;
height: number;
sections: StripSection[];
} {
const width = stripWidth(input.columns);
const { ids, px } = stripLayout(width, input.seats !== null);
const { edges, sections } = layout(px);
const settle = settlePhase(input.clockMs, input.settledAtMs);
const live = input.stages.some((mark) => LIVE_BAR_STATES.has(mark.state));
const style = css(input.clockMs);
const art = defs(live || settle !== null, live);
const whole = stripBody(input, ids, sections, false);
const cuts = stripBody(input, ids, sections, true);
return {
source: svg(width, STRIP_HEIGHT, style + art + whole.stage + whole.table + whole.delivery),
width,
height: STRIP_HEIGHT,
sections: ids.map((id, i) => ({
id,
px: trim(px[i]!),
grow: id === 'stage',
source: svg(
trim(edges[i + 1]! - edges[i]!),
STRIP_HEIGHT,
style + (id === 'stage' ? art : '') + cuts[id],
'',
trim(edges[i]!),
),
})),
};
}
export interface LiveHeadlineInput {
columns: number;
signal: Signal;
text: string;
note: string | null;
clock: string | null;
clockMs?: number;
tag?: string | null;
}
export const HEADLINE_HEIGHT = 24;
function headlineCss(): string {
const title = INK.headline;
const titleDark = INK.headlineDark;
return [
'<style>',
`text{font-family:${SVG_FONT};font-variant-numeric:tabular-nums}`,
'.ttl{fill:url(#title-sheen);font-size:17px;font-weight:600}',
'.aside{font-size:12px;font-weight:400}',
`.hs{stop-color:${title}}.mut{fill:${INK.muted}}`,
'.still{display:none}',
'@media (prefers-color-scheme: dark){',
`.hs{stop-color:${titleDark}}.mut{fill:${INK.mutedDark}}`,
'}',
`@media (prefers-reduced-motion: reduce){.ttl{fill:${title}}.motion{display:none}.still{display:inline}}`,
`@media (prefers-color-scheme: dark) and (prefers-reduced-motion: reduce){.ttl{fill:${titleDark}}}`,
'</style>',
].join('');
}
function breathValues(): string {
return BREATH.map((alpha) => String(Math.round((parseInt(alpha, 16) / 255) * 100) / 100)).join(
';',
);
}
function tagPill(tag: string, clockMs: number | undefined): string {
const shape = `x=".6" y="3.6" width="70" height="14.8" rx="7.4" fill="none" stroke="${SIGNAL.accent}" stroke-width="1.1"`;
return [
`<rect class="motion" ${shape}><animate attributeName="stroke-opacity" values=".25;1;.25" dur="2.4s" repeatCount="indefinite" calcMode="spline" keySplines=".4 0 .6 1;.4 0 .6 1"${begin(clockMs, TAG_BREATH_MS)}/></rect>`,
`<rect class="still" ${shape} stroke-opacity=".55"/>`,
`<text x="35.6" y="14" text-anchor="middle" style="font-size:8.5px;font-weight:600;letter-spacing:1px" fill="${SIGNAL.accent}">${escapeXml(tag)}</text>`,
].join('');
}
export function liveHeadline(input: LiveHeadlineInput): {
source: string;
width: number;
height: number;
} {
const width = stripWidth(input.columns);
const colour = signalColor(input.signal);
const sheen = [
'<defs><linearGradient id="title-sheen" gradientUnits="objectBoundingBox" x1="0" x2=".18">',
'<stop class="hs" offset="0"/>',
`<stop offset=".5" stop-color="${SIGNAL.accent}" stop-opacity=".9"/>`,
'<stop class="hs" offset="1"/>',
`<animateTransform attributeName="gradientTransform" type="translate" values="-.3 0;1.3 0;1.3 0" keyTimes="0;.61;1" calcMode="spline" keySplines=".4 0 .2 1;0 0 1 1" dur="3.6s" repeatCount="indefinite"${begin(input.clockMs, TITLE_SHEEN_MS)}/>`,
'</linearGradient></defs>',
].join('');
const dot = `cx="6" cy="11" r="5.5" fill="${colour}"`;
const dots = [
`<circle class="motion" ${dot}><animate attributeName="opacity" values="${breathValues()}" dur="2.4s" calcMode="discrete" repeatCount="indefinite"${begin(input.clockMs, BREATH_MS)}/></circle>`,
`<circle class="still" ${dot}/>`,
].join('');
const aside = (words: string | null) =>
words ? `<tspan class="aside mut" dx="16">${escapeXml(words)}</tspan>` : '';
const tag = input.tag ? tagPill(input.tag, input.clockMs) : '';
const line = `<text x="${tag ? 81 : 20}" y="17"><tspan class="ttl">${escapeXml(input.text)}</tspan>${aside(input.note)}${aside(input.clock)}</text>`;
return {
source: svg(width, HEADLINE_HEIGHT, headlineCss() + sheen + (tag || dots) + line),
width,
height: HEADLINE_HEIGHT,
};
}
export function miniStages(stages: readonly StageState[]): string {
const w = 64;
const gap = 2;
const n = Math.max(1, stages.length);
const bw = (w - gap * (n - 1)) / n;
const body = stages
.map((state, i) => {
const x = i * (bw + gap);
return state === 'skip'
? `<line class="faints" x1="${fixed(x)}" y1="2" x2="${fixed(x + bw)}" y2="2" stroke-width="1" stroke-dasharray="1.5 2"/>`
: `<rect class="${STAGE_BAR[state]}" x="${fixed(x)}" y=".5" width="${fixed(bw)}" height="3" rx="1.5"/>`;
})
.join('');
return svg(w, 4, css() + body);
}
export function tabRule(count: number, selected: number): string {
const w = 2000;
const seg = w / Math.max(1, count);
return svg(
w,
2,
`<rect x="0" y="1" width="${w}" height="1" fill="${INK.muted}" fill-opacity="${INK.hairOpacity}"/><rect x="${fixed(selected * seg + seg * 0.2)}" y="0" width="${fixed(seg * 0.6)}" height="2" rx="1" fill="${SIGNAL.accent}"/>`,
' preserveAspectRatio="none"',
);
}
export function hairline(): string {
return svg(
2000,
1,
`<rect x="0" y="0" width="2000" height="1" fill="${INK.muted}" fill-opacity="${INK.hairOpacity}"/>`,
' preserveAspectRatio="none"',
);
}
const SEAT_ARC: Readonly<Record<SeatState, string>> = {
done: 'inks',
writing: 'accs pulse',
seated: 'faints',
late: 'caus',
};
const CHIEF_RING: Readonly<Record<ChiefState, string>> = {
waiting: 'faints',
ruling: 'accs spin',
needs: 'atts pulse',
done: 'inks',
};
const CHIEF_WORD: Readonly<Record<ChiefState, { word: string; tone: string }>> = {
waiting: { word: 'listening', tone: 'mut' },
ruling: { word: 'ruling', tone: 'acc' },
needs: { word: 'needs you', tone: 'att' },
done: { word: 'approved', tone: 'pass' },
};
export function tableRing(seats: readonly TableSeat[], chief: ChiefState): string {
const size = 150;
const c = size / 2;
const r = 52;
const n = Math.max(1, seats.length);
let body = '';
seats.forEach(({ label, state, findings }, i) => {
const from = (i / n) * Math.PI * 2 - Math.PI / 2 + 0.07;
const to = ((i + 1) / n) * Math.PI * 2 - Math.PI / 2 - 0.07;
const mid = (from + to) / 2;
const faded = state === 'seated' ? ' style="stroke-opacity:.3"' : '';
body += `<path class="${SEAT_ARC[state]}" d="${arc(c, r, from, to)}" fill="none" stroke-width="7" stroke-linecap="round"${faded}/>`;
if (state !== 'seated') {
const a = polar(c, r - 8, mid);
const b = polar(c, 30, mid);
const writing = state === 'writing';
body += `<line class="${writing ? 'accs' : 'hair'}" x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke-width="1"${writing ? ' stroke-dasharray="2 3" style="stroke-opacity:.7"' : ''}/>`;
}
const at = polar(c, r + 15, mid);
body += `<text class="mut" x="${at.x}" y="${fixed(Number(at.y) + 3)}" text-anchor="middle" style="font-size:9px;font-weight:600">${escapeXml(label)}</text>`;
if ((state === 'done' || state === 'late') && typeof findings === 'number') {
const badge = polar(c, r, mid);
body += `<circle class="ink" cx="${badge.x}" cy="${badge.y}" r="6.5"/><text fill="${SIGNAL.onSignal}" x="${badge.x}" y="${fixed(Number(badge.y) + 2.8)}" text-anchor="middle" style="font-size:7.5px;font-weight:600">${findings}</text>`;
}
});
const dash = chief === 'ruling' || chief === 'waiting' ? ' stroke-dasharray="4 3"' : '';
body += `<circle class="${CHIEF_RING[chief]}" cx="${c}" cy="${c}" r="24" fill="none" stroke-width="1.6"${dash}/>`;
body += `<text class="ink" x="${c}" y="${c - 1}" text-anchor="middle" style="font-size:11px;font-weight:600">Chief</text>`;
body += `<text class="${CHIEF_WORD[chief].tone}" x="${c}" y="${c + 11}" text-anchor="middle" style="font-size:8.5px">${CHIEF_WORD[chief].word}</text>`;
return svg(size, size, css() + body);
}
export function nodeIcon(label: string, state: ShipState, ci: number | null = null): string {
const size = 24;
const x = size / 2;
const art = nodeArt(state, String(x), String(x), NODE_RADIUS, shareOf(label, ci), 2.6);
return svg(size, size, css() + art.ring + icon(label, x, x, art.stroke));
}
export function meter(share: number | null, tone: MeterTone = 'ink'): string {
const w = 2000;
const track = `<rect class="faint" x="0" y="0" width="${w}" height="4" rx="2" style="fill-opacity:.18"/>`;
if (share === null) {
const run = w * 0.3;
const sweep = `<linearGradient id="meterSheen" x1="0" x2="1"><stop offset="0" stop-color="${SIGNAL.accent}" stop-opacity="0"/><stop offset=".5" stop-color="${SIGNAL.accent}" stop-opacity="1"/><stop offset="1" stop-color="${SIGNAL.accent}" stop-opacity="0"/></linearGradient>`;
return svg(
w,
4,
`${css()}<defs>${sweep}</defs>${track}<rect x="${-run}" y="0" width="${run}" height="4" rx="2" fill="url(#meterSheen)"><animate attributeName="x" values="${-run};${w}" dur="2.2s" calcMode="spline" keyTimes="0;1" keySplines=".4 0 .2 1" repeatCount="indefinite"/></rect>`,
' preserveAspectRatio="none"',
);
}
const fill = Math.max(0, Math.min(1, share)) * w;
return svg(
w,
4,
`${css()}${track}<rect class="${tone}" x="0" y="0" width="${fixed(fill)}" height="4" rx="2"/>`,
' preserveAspectRatio="none"',
);
}