flowfig 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/picker.js ADDED
@@ -0,0 +1,148 @@
1
+ // The arrow-key picker for `flowfig init`. The screen and the keys are pure, so the tests need no TTY.
2
+ import { emitKeypressEvents } from 'node:readline';
3
+ import { VERSION } from './version.js';
4
+ const BANNER = [
5
+ '███████╗██╗ ██████╗ ██╗ ██╗███████╗██╗ ██████╗ ',
6
+ '██╔════╝██║ ██╔═══██╗██║ ██║██╔════╝██║██╔════╝ ',
7
+ '█████╗ ██║ ██║ ██║██║ █╗ ██║█████╗ ██║██║ ███╗',
8
+ '██╔══╝ ██║ ██║ ██║██║███╗██║██╔══╝ ██║██║ ██║',
9
+ '██║ ███████╗╚██████╔╝╚███╔███╔╝██║ ██║╚██████╔╝',
10
+ '╚═╝ ╚══════╝ ╚═════╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ╚═════╝ ',
11
+ ];
12
+ const BANNER_WIDTH = Math.max(...BANNER.map((l) => l.length));
13
+ const NOT = '(not detected)'.length; // the width of the detected column
14
+ const HELP = '↑↓ move · space toggle · a all · enter confirm · esc cancel';
15
+ const SHADOW = '38;2;43;93;143';
16
+ const ansi = (on, code, text) => (on ? `\x1b[${code}m${text}\x1b[0m` : text);
17
+ /** Row `i` of `n`: the blue gradient from #4da3ff to #0074d9. */
18
+ const blue = (i, n) => {
19
+ const mix = (a, b) => Math.round(a + ((b - a) * i) / (n - 1));
20
+ return `38;2;${mix(0x4d, 0x00)};${mix(0xa3, 0x74)};${mix(0xff, 0xd9)}`;
21
+ };
22
+ /** The banner lines. A terminal narrower than the banner gets the plain version line. */
23
+ export function banner({ color, width }) {
24
+ const ver = `flowfig ${VERSION}`;
25
+ if (width < BANNER_WIDTH)
26
+ return [ansi(color, '1;38;2;77;163;255', ver)];
27
+ const art = BANNER.map((l, i) =>
28
+ // each run of one character type gets its own colour: the block letters take the gradient, the shadow takes dim blue
29
+ l.replace(/█+|[╗║╚═╔╝]+/g, (run) => ansi(color, run[0] === '█' ? blue(i, BANNER.length - 1) : SHADOW, run)));
30
+ return [...art, ansi(color, '90', ver)];
31
+ }
32
+ /** Cut the pieces `[text, colour code]` to `width` visible characters, then colour them. */
33
+ function fit(pieces, width, color) {
34
+ let room = width;
35
+ let out = '';
36
+ for (const [text, code] of pieces) {
37
+ const cut = text.length > room ? `${text.slice(0, Math.max(room - 1, 0))}…` : text;
38
+ out += code ? ansi(color, code, cut) : cut;
39
+ room -= cut.length;
40
+ if (room <= 0)
41
+ break;
42
+ }
43
+ return out;
44
+ }
45
+ /** The picker screen, one string per line. */
46
+ export function screen(s, o) {
47
+ const w = Math.max(o.width - 1, 1); // the last column stays free, so no row wraps
48
+ const pad = Math.max(...s.rows.map((r) => r.name.length), 10);
49
+ const mark = (i) => (i === s.cursor ? '›' : ' ');
50
+ const rows = s.rows.map((r, i) => fit([
51
+ [`${mark(i)} [${s.sel.has(r.id) ? 'x' : ' '}] `, ''],
52
+ [r.name.padEnd(pad), ''],
53
+ [` ${r.detected ? ''.padEnd(NOT) : '(not detected)'}`, '90'],
54
+ [` ${r.files.join(', ')}`, '36'],
55
+ ], w, o.color));
56
+ const mcp = fit([
57
+ [`${mark(s.rows.length)} [${s.mcp ? 'x' : ' '}] `, ''],
58
+ [`${'MCP server'.padEnd(pad)} ${''.padEnd(NOT)}`, ''],
59
+ [' register flowfig in the MCP file of each agent', '36'],
60
+ ], w, o.color);
61
+ return [
62
+ ...banner(o),
63
+ '',
64
+ fit([['Pick the coding agents that should draw diagrams in this repo.', '']], w, o.color),
65
+ '',
66
+ ...rows,
67
+ ansi(o.color, '90', '─'.repeat(Math.min(w, 40))),
68
+ mcp,
69
+ '',
70
+ fit([[HELP, '90']], w, o.color),
71
+ ];
72
+ }
73
+ /** One key press. Returns the next state, or `confirm` or `cancel`. */
74
+ export function keyStep(s, k) {
75
+ const last = s.rows.length; // the MCP row
76
+ const n = last + 1;
77
+ if (k.name === 'escape' || k.name === 'q' || (k.ctrl && k.name === 'c'))
78
+ return 'cancel';
79
+ if (k.name === 'return')
80
+ return 'confirm';
81
+ if (k.name === 'up' || k.name === 'k')
82
+ return { ...s, cursor: (s.cursor + n - 1) % n };
83
+ if (k.name === 'down' || k.name === 'j')
84
+ return { ...s, cursor: (s.cursor + 1) % n };
85
+ if (k.name === 'space') {
86
+ if (s.cursor === last)
87
+ return { ...s, mcp: !s.mcp };
88
+ const id = s.rows[s.cursor].id;
89
+ const sel = new Set(s.sel);
90
+ sel.has(id) ? sel.delete(id) : sel.add(id);
91
+ return { ...s, sel };
92
+ }
93
+ if (k.name === 'a')
94
+ return { ...s, sel: s.sel.size === last ? new Set() : new Set(s.rows.map((r) => r.id)) };
95
+ return s;
96
+ }
97
+ /** Run the picker on the terminal. Returns `undefined` on cancel, or `'fallback'` if the terminal has no raw mode. */
98
+ export function runPicker(rows, start, mcp) {
99
+ const { stdin, stdout } = process;
100
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function')
101
+ return Promise.resolve('fallback');
102
+ try {
103
+ stdin.setRawMode(true);
104
+ }
105
+ catch {
106
+ return Promise.resolve('fallback');
107
+ }
108
+ const color = process.env.NO_COLOR === undefined;
109
+ let state = { rows, sel: start, mcp, cursor: 0 };
110
+ let drawn = 0;
111
+ const draw = () => {
112
+ const lines = screen(state, { color, width: stdout.columns || 80 });
113
+ stdout.write(`${drawn ? `\x1b[${drawn}A\r\x1b[J` : ''}${lines.join('\n')}\n`);
114
+ drawn = lines.length;
115
+ };
116
+ return new Promise((resolve, reject) => {
117
+ const end = (r) => {
118
+ stdin.off('keypress', onKey);
119
+ stdin.setRawMode(false);
120
+ stdin.pause();
121
+ stdout.write(`\x1b[${drawn}A\r\x1b[J\x1b[?25h`);
122
+ resolve(r);
123
+ };
124
+ const onKey = (_, key = {}) => {
125
+ try {
126
+ const next = keyStep(state, key);
127
+ if (next === 'cancel')
128
+ return end();
129
+ if (next === 'confirm')
130
+ return end({ ids: state.sel, mcp: state.mcp });
131
+ state = next;
132
+ draw();
133
+ }
134
+ catch (e) {
135
+ stdin.off('keypress', onKey);
136
+ stdin.setRawMode(false);
137
+ stdin.pause();
138
+ stdout.write('\x1b[?25h');
139
+ reject(e);
140
+ }
141
+ };
142
+ emitKeypressEvents(stdin);
143
+ stdin.on('keypress', onKey);
144
+ stdin.resume();
145
+ stdout.write('\x1b[?25l');
146
+ draw();
147
+ });
148
+ }
package/dist/png.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /** RGBA pixels, 4 bytes per pixel, rows top to bottom. */
2
+ export type Image = {
3
+ width: number;
4
+ height: number;
5
+ data: Uint8Array;
6
+ };
7
+ export declare function decodePng(png: Uint8Array): Image;
package/dist/png.js ADDED
@@ -0,0 +1,70 @@
1
+ // Read the PNG screenshots of the capture browser. Only what Chrome writes: 8-bit RGB or RGBA, no interlace.
2
+ import { inflateSync } from 'node:zlib';
3
+ const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
4
+ export function decodePng(png) {
5
+ if (png.length < 8 || SIGNATURE.some((b, i) => png[i] !== b))
6
+ throw new Error('not a PNG: bad signature');
7
+ const view = new DataView(png.buffer, png.byteOffset, png.byteLength);
8
+ let width = 0, height = 0, bpp = 0;
9
+ const idat = [];
10
+ // The CRC of each chunk is not checked: the PNG comes from the browser over a local pipe.
11
+ for (let at = 8; at + 8 <= png.length;) {
12
+ const len = view.getUint32(at);
13
+ const kind = String.fromCharCode(...png.subarray(at + 4, at + 8));
14
+ const body = png.subarray(at + 8, at + 8 + len);
15
+ if (kind === 'IHDR') {
16
+ width = view.getUint32(at + 8);
17
+ height = view.getUint32(at + 12);
18
+ if (body[8] !== 8)
19
+ throw new Error(`unsupported PNG: bit depth ${body[8]}`);
20
+ if (body[9] !== 2 && body[9] !== 6)
21
+ throw new Error(`unsupported PNG: color type ${body[9]}`);
22
+ if (body[12] !== 0)
23
+ throw new Error(`unsupported PNG: interlace ${body[12]}`);
24
+ bpp = body[9] === 6 ? 4 : 3;
25
+ }
26
+ else if (kind === 'IDAT')
27
+ idat.push(body);
28
+ else if (kind === 'IEND')
29
+ break;
30
+ at += 12 + len;
31
+ }
32
+ if (!bpp)
33
+ throw new Error('not a PNG: no IHDR chunk');
34
+ const raw = inflateSync(Buffer.concat(idat));
35
+ const stride = width * bpp;
36
+ if (raw.length < height * (stride + 1))
37
+ throw new Error('not a PNG: the image data is too short');
38
+ const data = new Uint8Array(width * height * 4);
39
+ let prev = new Uint8Array(stride);
40
+ for (let y = 0; y < height; y++) {
41
+ const filter = raw[y * (stride + 1)];
42
+ const row = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
43
+ // Undo the row filter in place. A Uint8Array write keeps the low 8 bits, as the PNG standard needs.
44
+ if (filter === 1)
45
+ for (let x = bpp; x < stride; x++)
46
+ row[x] += row[x - bpp];
47
+ else if (filter === 2)
48
+ for (let x = 0; x < stride; x++)
49
+ row[x] += prev[x];
50
+ else if (filter === 3)
51
+ for (let x = 0; x < stride; x++)
52
+ row[x] += ((x >= bpp ? row[x - bpp] : 0) + prev[x]) >> 1;
53
+ else if (filter === 4)
54
+ for (let x = 0; x < stride; x++) {
55
+ const a = x >= bpp ? row[x - bpp] : 0, b = prev[x], c = x >= bpp ? prev[x - bpp] : 0;
56
+ const pa = Math.abs(b - c), pb = Math.abs(a - c), pc = Math.abs(a + b - 2 * c);
57
+ row[x] += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
58
+ }
59
+ else if (filter !== 0)
60
+ throw new Error(`unsupported PNG: filter ${filter}`);
61
+ for (let x = 0, o = y * width * 4; x < stride; x += bpp, o += 4) {
62
+ data[o] = row[x];
63
+ data[o + 1] = row[x + 1];
64
+ data[o + 2] = row[x + 2];
65
+ data[o + 3] = bpp === 4 ? row[x + 3] : 255;
66
+ }
67
+ prev = row;
68
+ }
69
+ return { width, height, data };
70
+ }
package/dist/scene.d.ts CHANGED
@@ -13,6 +13,7 @@ export type SceneBox = {
13
13
  texts: TextRun[];
14
14
  };
15
15
  /** `step` marks a rail row: rows of two steps never show at the same time, so their labels cannot overlap. */
16
+ /** `behind` marks an edge drawn under the boxes (a timeline dependency): `check` skips its crossing test. */
16
17
  export type SceneEdge = {
17
18
  id: string;
18
19
  from: string;
@@ -20,6 +21,7 @@ export type SceneEdge = {
20
21
  curve: [Pt, Pt, Pt, Pt];
21
22
  label?: Rect;
22
23
  step?: number;
24
+ behind?: true;
23
25
  };
24
26
  /** `minFont` is the smallest reading text in px, before any scale. Group frames are not boxes. */
25
27
  export type Scene = {
package/dist/svg.js CHANGED
@@ -12,7 +12,7 @@ import { textWidth } from './text.js';
12
12
  import { checkScene, checkSpec, checkTheme } from './check.js';
13
13
  export { parseSource, links } from './source.js';
14
14
  export { diff, formatDiff } from './diff.js';
15
- import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, DARK, EDGE_OFF, EDGE_ON, LIGHT, TONES, toneFill, toneTint, labelPillW, ON_ACCENT, str, edgeId, groupGap, isGroup, isRows, toBeat, beatMs, STEP_HOLD_MS, } from './model.js';
15
+ import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, DARK, EDGE_OFF, EDGE_ON, LIGHT, TONES, toneFill, toneTint, labelPillW, ON_ACCENT, str, edgeId, groupGap, isGroup, isRows, laneColumns, LANE_GAP, LANE_PAD, LANE_ROW_GAP, isLanesLayout, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, beatMs, STEP_HOLD_MS, playheadItem, dateLabelRaised, labelSpan, outsideLabelRect, } from './model.js';
16
16
  export { LIGHT, DARK } from './model.js';
17
17
  const LINE = CARD_LINE, CARD_SIDE = 8, ROW_GAP = 4;
18
18
  const LABEL_LINE = 18, SUB_LINE = 15, NODE_MIN_W = 100, NODE_MAX_W = 190;
@@ -94,7 +94,70 @@ function size(item, s) {
94
94
  const inner = item.direction === 'column' ? { w: across, h: along } : { w: along, h: across };
95
95
  return item.label != null ? { w: inner.w + FRAME_SIDE * 2, h: inner.h + FRAME_TOP + FRAME_BOTTOM } : inner;
96
96
  }
97
+ /** Swimlanes: the bands span the width, the label sits in a left gutter, and a box sits at its time column. */
98
+ function placeLanes(fig, x, y, s, out) {
99
+ const lanes = fig.layout.children;
100
+ const cols = laneColumns(fig);
101
+ const colW = Array.from({ length: Math.max(-1, ...cols.values()) + 1 }, () => 0);
102
+ for (const lane of lanes)
103
+ for (const b of lane.children)
104
+ colW[cols.get(b.id)] = Math.max(colW[cols.get(b.id)], size(b, s).w);
105
+ const gutter = gutterOf(lanes);
106
+ const colX = colW.map((_, c) => gutter + colW.slice(0, c).reduce((a, w) => a + w + LANE_GAP, 0));
107
+ const width = gutter + colW.reduce((a, w) => a + w, 0) + LANE_GAP * Math.max(0, colW.length - 1) + FRAME_SIDE;
108
+ const topAt = out.length;
109
+ out.push({ x, y, w: width, h: 0, item: fig.layout });
110
+ let ly = y;
111
+ for (const lane of lanes) {
112
+ const kids = lane.children.map((b) => ({ b, ...size(b, s) }));
113
+ const inner = Math.max(LABEL_LINE + 20, ...kids.map((k) => k.h));
114
+ const h = inner + LANE_PAD * 2;
115
+ out.push({ x, y: ly, w: width, h, item: lane, lane: true });
116
+ for (const k of kids)
117
+ out.push({ x: x + colX[cols.get(k.b.id)], y: ly + LANE_PAD + (inner - k.h) / 2, w: k.w, h: k.h, item: k.b });
118
+ ly += h + LANE_ROW_GAP;
119
+ }
120
+ out[topAt].h = ly - LANE_ROW_GAP - y;
121
+ }
122
+ /** The width of the label gutter of lanes and of a timeline: the widest track label plus the frame sides. */
123
+ const gutterOf = (lanes) => Math.max(0, ...lanes.map((l) => textWidth(str(l.label).toUpperCase(), 12))) + FRAME_SIDE * 2;
124
+ /** A timeline: the axis strip on top, one band per track, and the bars and milestones at the shared layout x. */
125
+ function placeTimeline(fig, x, y, out) {
126
+ const lanes = fig.layout.children;
127
+ const gutter = gutterOf(lanes);
128
+ const lay = timelineLayout(fig, TL_AXIS_W);
129
+ const width = gutter + TL_AXIS_W + FRAME_SIDE;
130
+ const topAt = out.length;
131
+ out.push({ x, y, w: width, h: 0, item: fig.layout });
132
+ let ly = y + TL_AXIS_H;
133
+ lanes.forEach((lane, track) => {
134
+ const rows = lay.rows[track];
135
+ const h = rows * TL_BAR_H + (rows - 1) * TL_ROW_GAP + LANE_PAD * 2;
136
+ out.push({ x, y: ly, w: width, h, item: lane, lane: true });
137
+ for (const it of lay.items) {
138
+ if (it.track !== track)
139
+ continue;
140
+ const node = lane.children.find((k) => !isGroup(k) && k.id === it.id);
141
+ const top = ly + LANE_PAD + it.row * (TL_BAR_H + TL_ROW_GAP);
142
+ const bh = it.milestone ? it.w : TL_BAR_H;
143
+ out.push({
144
+ x: x + gutter + it.x,
145
+ y: top + (TL_BAR_H - bh) / 2,
146
+ w: it.w,
147
+ h: bh,
148
+ item: node,
149
+ tl: { milestone: it.milestone, labelInside: it.labelInside },
150
+ });
151
+ }
152
+ ly += h + LANE_ROW_GAP;
153
+ });
154
+ out[topAt].h = ly - LANE_ROW_GAP - y;
155
+ }
97
156
  function place(item, x, y, s, out) {
157
+ if (item === s.fig.layout && s.fig.timeline && isLanesLayout(s.fig.layout))
158
+ return placeTimeline(s.fig, x, y, out);
159
+ if (item === s.fig.layout && s.fig.lanes && isLanesLayout(s.fig.layout))
160
+ return placeLanes(s.fig, x, y, s, out);
98
161
  const { w, h } = size(item, s);
99
162
  out.push({ x, y, w, h, item });
100
163
  if (!isGroup(item))
@@ -126,7 +189,10 @@ const SYSTEM_FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helv
126
189
  export function render(fig, opts = {}) {
127
190
  const speed = (opts.speed ?? fig.speed ?? 900) / 1000 / BASE_RATE;
128
191
  const pad = opts.padding ?? 24;
129
- const steps = fig.steps ?? [];
192
+ const tl = fig.timeline && isLanesLayout(fig.layout) ? timelineLayout(fig, TL_AXIS_W) : null;
193
+ // A timeline with no steps of its own walks its dated items in date order.
194
+ const synthetic = tl != null && !fig.steps?.length;
195
+ const steps = synthetic ? timelineBeats(fig) : (fig.steps ?? []);
130
196
  const beats = steps.map((s) => s.flow.map(toBeat));
131
197
  // Every content a box will ever show, so its card can be sized to the biggest one up front.
132
198
  const cards = new Map();
@@ -144,7 +210,7 @@ export function render(fig, opts = {}) {
144
210
  return n > 2 ? n * 30 : 0;
145
211
  };
146
212
  const cardH = new Map();
147
- const sizes = { cards, cardH, minH, gap: (g) => groupGap(g, fig.edges) };
213
+ const sizes = { cards, cardH, minH, gap: (g) => groupGap(g, fig.edges), fig };
148
214
  for (const [id, contents] of cards) {
149
215
  const width = CARD_WIDTH; // refined below once the node's own width is known
150
216
  const widths = [width];
@@ -166,13 +232,29 @@ export function render(fig, opts = {}) {
166
232
  for (const p of placed)
167
233
  if (p.item.id)
168
234
  rects[p.item.id] = p;
169
- const tips = new Set(placed.filter((p) => !isGroup(p.item) && p.item.shape === 'decision').map((p) => p.item.id));
235
+ const tips = new Set(placed.filter((p) => !isGroup(p.item) && (p.item.shape === 'decision' || p.tl?.milestone)).map((p) => p.item.id));
170
236
  const ids = fig.edges.map(edgeId);
171
- const routed = route(fig.edges.map((e, i) => ({ id: ids[i], from: e.from, to: e.to, around: e.around })), rects, tips);
237
+ const routed = route(fig.edges.map((e, i) => ({
238
+ id: ids[i],
239
+ from: e.from,
240
+ to: e.to,
241
+ around: e.around,
242
+ ...(tl && { sides: ['r', 'l'], elbow: true }),
243
+ })), rects, tips, placed.filter((p) => p.tl && !p.tl.labelInside).map((p) => outsideLabelRect(p, str(p.item.label))));
172
244
  const byId = Object.fromEntries(routed.map((r) => [r.id, r]));
173
245
  // What this layout drew, in scene form. Tags are left out of minFont: short bold capitals, not reading text.
174
246
  const fonts = [14, ...(steps.length ? [13.5] : [])];
175
247
  const sceneBoxes = nodes.map((p) => {
248
+ if (p.tl) {
249
+ // A label inside the bar has the bar as room; a label beside the bar has the space to the next item of its row, or to the band edge.
250
+ const label = str(p.item.label);
251
+ const mine = tl.items.find((i) => i.id === p.item.id);
252
+ const next = tl.items.filter((i) => i.track === mine.track && i.row === mine.row && i.x > mine.x).map((i) => p.x - mine.x + i.x - 8);
253
+ const limit = Math.min(placed[0].x + placed[0].w - FRAME_SIDE, ...next);
254
+ const room = p.tl.labelInside ? p.w - 16 : limit - (p.x + p.w) - 6;
255
+ fonts.push(13);
256
+ return { id: p.item.id, rect: { x: p.x, y: p.y, w: p.w, h: p.h }, texts: [{ text: label, fontSize: 13, room }] };
257
+ }
176
258
  const room = (p.item.shape === 'decision' ? p.w - 70 : p.w) - 16;
177
259
  const texts = [{ text: str(p.item.label), fontSize: 14, room }];
178
260
  if (p.item.sub) {
@@ -225,7 +307,12 @@ export function render(fig, opts = {}) {
225
307
  const shownAt = segs.map(({ si, bi }) => Object.assign({}, ...beats[si].slice(0, bi + 1).map((b) => b.show)));
226
308
  const litEdges = segs.map(({ si, bi }) => new Set(beats[si].slice(0, bi + 1).flatMap((b) => b.hops.map((h) => h.edge))));
227
309
  const litNodes = segs.map((seg, i) => {
228
- const on = new Set([...(steps[seg.si].nodes ?? []), ...Object.keys(shownAt[i]), ...(beats[seg.si][seg.bi].light ?? [])]);
310
+ const on = new Set([
311
+ ...(steps[seg.si].nodes ?? []),
312
+ ...Object.keys(shownAt[i]),
313
+ ...(beats[seg.si][seg.bi].light ?? []),
314
+ ...(beats[seg.si][seg.bi].focus ?? []),
315
+ ]);
229
316
  fig.edges.forEach((e, ei) => {
230
317
  if (litEdges[i].has(ids[ei]))
231
318
  on.add(e.from).add(e.to);
@@ -277,6 +364,7 @@ export function render(fig, opts = {}) {
277
364
  * FADE is in timeline seconds, which are wall seconds.
278
365
  */
279
366
  const FADE = 0.4;
367
+ const RAMP = 0.4; // the today line moves in 400 ms, in wall seconds like FADE
280
368
  // A box tone is a permanent state: a 1 px border and a light tint in the off and trail looks. The active look uses the hop tone, else the box tone.
281
369
  const looks = (look, boxTone, hopTone) => {
282
370
  const tint = boxTone ? toneTint(boxTone, 'var(--bg)') : 'var(--bg)';
@@ -295,7 +383,9 @@ export function render(fig, opts = {}) {
295
383
  if (!litNodes[i].has(id))
296
384
  return void pieces.push({ a: s.t0, b: s.t1, look: 'off' });
297
385
  const here = hops.filter((h) => h.dest === id && h.si === s.si && h.bi === s.bi);
298
- const arrive = Math.min(...here.map((h) => h.t1), s.t1);
386
+ // A focused box turns active at the beat start; in a timeline, when the today line arrives.
387
+ const focused = beats[s.si][s.bi].focus?.includes(id);
388
+ const arrive = Math.min(...here.map((h) => h.t1), focused ? Math.min(s.t0 + (tl ? RAMP : 0), s.t1) : s.t1, s.t1);
299
389
  if (arrive > s.t0)
300
390
  pieces.push({ a: s.t0, b: arrive, look: 'trail' });
301
391
  if (arrive < s.t1)
@@ -330,11 +420,23 @@ export function render(fig, opts = {}) {
330
420
  return '';
331
421
  const on = item.id ? segs.map((_, i) => litNodes[i].has(item.id)) : [];
332
422
  return (`<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="14" fill="var(--surface)" stroke="var(--border)"` +
333
- cls(anim(on, 'stroke: var(--accent)', 'stroke: var(--border)', 'n')) +
334
- `/><text x="${n2(p.x + FRAME_SIDE)}" y="${n2(p.y + 20)}" class="frame">${esc(str(item.label).toUpperCase())}</text>`);
423
+ cls(p.lane && 'lane', anim(on, 'stroke: var(--accent)', 'stroke: var(--border)', 'n')) +
424
+ `/><text x="${n2(p.x + FRAME_SIDE)}" y="${n2(p.lane ? p.y + p.h / 2 + 4 : p.y + 20)}" class="frame">${esc(str(item.label).toUpperCase())}</text>`);
335
425
  }
336
426
  const bt = item.tone && TONES[item.tone];
337
427
  const stroke = cls(boxAnim(item.id, true, bt));
428
+ if (p.tl) {
429
+ const fill0 = bt ? toneTint(bt, 'var(--bg)') : 'var(--bg)';
430
+ const stroke0 = bt ?? 'var(--border)';
431
+ const label = str(item.label);
432
+ const inside = p.tl.labelInside;
433
+ const cx = p.x + p.w / 2, cy = p.y + p.h / 2;
434
+ const shape = p.tl.milestone
435
+ ? `<polygon points="${n2(cx)},${n2(p.y)} ${n2(p.x + p.w)},${n2(cy)} ${n2(cx)},${n2(p.y + p.h)} ${n2(p.x)},${n2(cy)}" fill="${fill0}" stroke="${stroke0}"${stroke}/>`
436
+ : `<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="6" fill="${fill0}" stroke="${stroke0}"${stroke}/>`;
437
+ const tx = inside ? p.x + 8 : p.x + p.w + 6;
438
+ return shape + `<text x="${n2(tx)}" y="${n2(cy + 4.5)}" class="bar">${esc(label)}</text>`;
439
+ }
338
440
  const rim = item.shape === 'store' ? cls(boxAnim(item.id, false, bt)) : '';
339
441
  const fill0 = bt ? toneTint(bt, 'var(--bg)') : 'var(--bg)';
340
442
  const stroke0 = bt ?? 'var(--border)';
@@ -350,7 +452,15 @@ export function render(fig, opts = {}) {
350
452
  : `<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="10" fill="${fill0}" stroke="${stroke0}"${stroke}/>`;
351
453
  const label = `<text x="${n2(cx)}" y="${n2(labelY)}" class="label">${esc(str(item.label))}</text>`;
352
454
  const sub = item.sub ? `<text x="${n2(cx)}" y="${n2(labelY + SUB_LINE)}" class="sub">${esc(str(item.sub))}</text>` : '';
353
- return shape + label + sub + (contents ? card(p, cardTop, contents) : '');
455
+ // The mark sits in the gap beside the box, so it changes no size and adds nothing to the scene.
456
+ const dot = bt ?? 'var(--accent)';
457
+ const my = n2(p.y + p.h / 2);
458
+ const mark = item.mark === 'start'
459
+ ? `<circle cx="${n2(p.x - 12)}" cy="${my}" r="5" fill="${dot}"/>`
460
+ : item.mark === 'end'
461
+ ? `<circle cx="${n2(p.x + p.w + 12)}" cy="${my}" r="6.25" fill="none" stroke="${dot}" stroke-width="1.5"/><circle cx="${n2(p.x + p.w + 12)}" cy="${my}" r="4" fill="${dot}"/>`
462
+ : '';
463
+ return shape + label + sub + mark + (contents ? card(p, cardTop, contents) : '');
354
464
  });
355
465
  /** The dashed content card: one group per content it will ever hold, each visible on its own beats. */
356
466
  function card(p, top, contents) {
@@ -423,7 +533,7 @@ export function render(fig, opts = {}) {
423
533
  : anim(on, `stroke: var(--accent); stroke-width: ${EDGE_ON}`, off, 'e'));
424
534
  const path = `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="var(--muted)" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`;
425
535
  // A quiet edge is only drawn while a step uses it, so wrap the whole thing rather than the stroke.
426
- const label = e.label == null
536
+ const label = e.label == null || tl
427
537
  ? ''
428
538
  : (() => {
429
539
  const lw = labelPillW(str(e.label));
@@ -459,6 +569,79 @@ export function render(fig, opts = {}) {
459
569
  `<animateMotion dur="${n2(total)}s" repeatCount="indefinite" keyTimes="0;${n4(t0)};${n4(t1)};1" keyPoints="${h.back ? '1;1;0;0' : '0;0;1;1'}" calcMode="linear">` +
460
570
  `<mpath href="#p-${esc(h.edge)}" xlink:href="#p-${esc(h.edge)}"/></animateMotion></g>`);
461
571
  });
572
+ // The timeline axis under the boxes, the fixed today marker and the moving playhead over them.
573
+ let axisSvg = '', todaySvg = '';
574
+ if (tl) {
575
+ const top = placed[0];
576
+ const x0 = top.x + gutterOf(fig.layout.children);
577
+ const ay = top.y + TL_AXIS_H;
578
+ fonts.push(11);
579
+ axisSvg =
580
+ `<path d="M ${n2(x0)} ${n2(ay - 1)} H ${n2(x0 + TL_AXIS_W)}" stroke="var(--border)"/>` +
581
+ tl.ticks
582
+ .map((k) => `<path d="M ${n2(x0 + k.x)} ${n2(ay - 5)} V ${n2(ay - 1)}" stroke="var(--border)"/>` +
583
+ `<text x="${n2(x0 + k.x + 3)}" y="${n2(top.y + 10)}" class="tick">${esc(k.label)}</text>`)
584
+ .join('');
585
+ const bottom = n2(top.y + top.h);
586
+ // The today marker is fixed at the figure's `today`.
587
+ if (tl.today != null) {
588
+ const tx = x0 + tl.today;
589
+ todaySvg =
590
+ `<path d="M ${n2(tx)} ${n2(top.y + 12)} V ${bottom}" stroke="var(--accent)" stroke-opacity="0.6" stroke-width="1" stroke-dasharray="3 3"/>` +
591
+ `<text x="${n2(tx - 3)}" y="${n2(top.y + 22)}" text-anchor="end" class="today">today</text>`;
592
+ }
593
+ // The playhead moves to the start of each beat's item, then to the last date in the step hold.
594
+ if (tl.last != null && segs.length) {
595
+ const home = tl.last;
596
+ const startOf = new Map(tl.items.map((i) => [i.id, i.x + (i.milestone ? i.w / 2 : 0)]));
597
+ const dateOf = new Map(tl.items.map((i) => [i.id, i.date]));
598
+ const hold = STEP_HOLD_MS / 1000 / BASE_RATE;
599
+ const pts = [[0, home]];
600
+ const dates = []; // the label date in each time span
601
+ let at = home;
602
+ segs.forEach((s, i) => {
603
+ const item = playheadItem(tl.items, beats[s.si], s.bi);
604
+ const to = item ? startOf.get(item.id) : home;
605
+ const date = item ? dateOf.get(item.id) : tl.lastDate;
606
+ const last = i === segs.length - 1 || segs[i + 1].si !== s.si;
607
+ const end = last ? s.t1 - hold : s.t1; // the step hold starts here
608
+ pts.push([s.t0, at], [Math.min(s.t0 + RAMP, end), to], [end, to]);
609
+ const [la, lb] = labelSpan(s.t0, end, RAMP);
610
+ if (la < lb)
611
+ dates.push({ a: la, b: lb, d: date, x: to });
612
+ at = to;
613
+ if (last) {
614
+ pts.push([Math.min(end + RAMP, s.t1), home], [s.t1, home]);
615
+ const [ha, hb] = labelSpan(end, s.t1, RAMP);
616
+ if (ha < hb)
617
+ dates.push({ a: ha, b: hb, d: tl.lastDate, x: home });
618
+ at = home;
619
+ }
620
+ });
621
+ const name = `a${seen.size}`;
622
+ seen.set('playhead', name);
623
+ const kf = pts.map(([t, x]) => `${pct(t / total)} { transform: translateX(${n2(x - home)}px) }`).join(' ');
624
+ css.push(`@keyframes ${name} { ${kf} }\n.${name} { animation: ${name} ${n2(total)}s infinite linear; }`);
625
+ const lx = x0 + home;
626
+ const labels = [...new Set(dates.map((x) => x.d))].map((d) => {
627
+ const on = dates.map((x) => x.d === d);
628
+ const key = `d${d}${on.map((x) => (x ? 1 : 0)).join('')}`;
629
+ if (!seen.has(key)) {
630
+ const nm = `a${seen.size}`;
631
+ seen.set(key, nm);
632
+ // Each span ends with a hidden frame: the label stays off while the line moves.
633
+ const fr = dates
634
+ .map((x) => `${pct(x.a / total)},${pct(x.b / total - 0.0001)} { opacity: ${x.d === d ? 1 : 0} } ${pct(x.b / total)} { opacity: 0 }`)
635
+ .join(' ');
636
+ css.push(`@keyframes ${nm} { ${fr} }\n.${nm} { animation: ${nm} ${n2(total)}s infinite step-end; }`);
637
+ }
638
+ // A label that would meet "today" moves to the tick row.
639
+ const raised = dateLabelRaised(dates.find((x) => x.d === d).x, tl.today, textWidth(d, 11), textWidth('today', 11));
640
+ return `<text x="${n2(lx + 3)}" y="${n2(top.y + (raised ? 10 : 22))}" opacity="0"${cls('today', seen.get(key))}>${esc(d)}</text>`;
641
+ });
642
+ todaySvg += `<g${cls(name)}><path d="M ${n2(lx)} ${n2(top.y + 12)} V ${bottom}" stroke="var(--accent)" stroke-width="1.5"/>${labels.join('')}</g>`;
643
+ }
644
+ }
462
645
  const bounds = placed[0];
463
646
  const arcs = fig.edges.some((e) => e.around);
464
647
  const capLines = [...new Set(captions)].flatMap((c) => wrap(c, Math.max(560, bounds.w), 13.5).length);
@@ -476,7 +659,7 @@ export function render(fig, opts = {}) {
476
659
  const shift = (W - (bounds.w + pad * 2)) / 2;
477
660
  const railX = rail ? (W - rail.width) / 2 : 0;
478
661
  // The step label and its narration, both switching with the beats.
479
- const labels = rail
662
+ const labels = rail || synthetic
480
663
  ? []
481
664
  : steps.map((_, si) => {
482
665
  const on = segs.map((s) => s.si === si);
@@ -579,6 +762,9 @@ svg { ${vars(t0, '#eef5fd')} }
579
762
  .row { fill: var(--fg); font-size: 11px; }
580
763
  .row.mono { font-size: 10.5px; font-family: ui-monospace, Menlo, monospace; }
581
764
  .muted { fill: var(--muted); }
765
+ .bar { fill: var(--fg); font-size: 13px; font-weight: 500; }
766
+ .tick { fill: var(--muted); font-size: 11px; }
767
+ .today { fill: var(--accent); font-size: 11px; font-weight: 600; }
582
768
  .tag { font-size: 9px; font-weight: 600; letter-spacing: .03em; text-anchor: middle; }
583
769
  .mark { fill: var(--accent); font-size: 11px; font-weight: 600; text-anchor: end; }
584
770
  .chip { fill: ${ON_ACCENT}; font-size: 11.5px; text-anchor: middle; }
@@ -595,9 +781,13 @@ ${css.join('\n')}
595
781
  ${only
596
782
  ? ''
597
783
  : `<g transform="translate(${n2(shift)} ${arcs ? 44 : 0})">
598
- ${boxes.filter(Boolean).join('\n')}
599
- ${edgeSvg.join('\n')}
784
+ ${axisSvg}
785
+ ${tl ? boxes.filter((b, i) => b && isGroup(placed[i].item)).join('\n') : ''}
786
+ ${tl ? edgeSvg.join('\n') : ''}
787
+ ${boxes.filter((b, i) => b && !(tl && isGroup(placed[i].item))).join('\n')}
788
+ ${tl ? '' : edgeSvg.join('\n')}
600
789
  ${packets.join('\n')}
790
+ ${todaySvg}
601
791
  </g>
602
792
  `}${railSvg}
603
793
  ${labels.join('\n')}
@@ -612,7 +802,7 @@ ${said.join('\n')}
612
802
  edges: [
613
803
  ...(only ? [] : routed).map((r) => {
614
804
  const e = fig.edges[ids.indexOf(r.id)];
615
- return { id: r.id, from: e.from, to: e.to, curve: r.curve, label: labelRects[r.id] };
805
+ return { id: r.id, from: e.from, to: e.to, curve: r.curve, label: labelRects[r.id], ...(tl && { behind: true }) };
616
806
  }),
617
807
  // Each rail payload is a label too, in its own open-phase position, so label-overlap covers the rail.
618
808
  ...(rail?.rows ?? []).flatMap((row, i) => {
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** The package version, for the MCP `serverInfo`. The release list bumps it with package.json. */
2
- export declare const VERSION = "0.3.0";
2
+ export declare const VERSION = "0.4.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** The package version, for the MCP `serverInfo`. The release list bumps it with package.json. */
2
- export const VERSION = '0.3.0';
2
+ export const VERSION = '0.4.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowfig",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Your coding agent draws animated diagrams from your code, and flowfig checks them. One animated SVG for a GitHub README, PR or issue, and a React player. Set up Claude Code, Cursor, GitHub Copilot, Codex, Gemini CLI, Windsurf and Kiro with npx flowfig init.",
5
5
  "license": "MIT",
6
6
  "repository": {