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/init.js CHANGED
@@ -1,9 +1,10 @@
1
1
  // `flowfig init`: write flowfig instructions for the coding agents of a repo. Pure functions where possible, so the tests need no TTY.
2
2
  import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
- import { dirname, join, resolve } from 'node:path';
4
+ import { dirname, join, relative, resolve } from 'node:path';
5
5
  import { createInterface } from 'node:readline';
6
6
  import { AGENT_TEXT, SKILL_DESCRIPTION } from './guide.js';
7
+ import { runPicker } from './picker.js';
7
8
  const START = '<!-- flowfig:start -->';
8
9
  const END = '<!-- flowfig:end -->';
9
10
  export const MCP_ENTRY = { command: 'npx', args: ['flowfig', 'mcp'] };
@@ -15,6 +16,10 @@ export const AGENTS = [
15
16
  file: '.claude/skills/figure/SKILL.md',
16
17
  kind: 'whole',
17
18
  mcp: { file: '.mcp.json', key: 'mcpServers', type: true, global: '.claude.json' },
19
+ also: {
20
+ file: 'CLAUDE.md',
21
+ text: '## Diagrams\nDraw every diagram with the `figure` skill (flowfig). The user can run `/figure <question>`. Do not answer a diagram request with a Mermaid block or ASCII art, unless the user asks for that tool.',
22
+ },
18
23
  head: `---\nname: figure\ndescription: ${JSON.stringify(SKILL_DESCRIPTION)}\nuser_invocable: true\n---\n\n`,
19
24
  },
20
25
  { id: 'agents', name: 'AGENTS.md', marks: ['AGENTS.md'], file: 'AGENTS.md', kind: 'section', note: 'Codex, Amp, Jules, ...' },
@@ -87,12 +92,16 @@ export function renderList(selected, detected, dir) {
87
92
  'Type numbers to toggle (for example "1 3"), Enter to write, q to quit.',
88
93
  ].join('\n');
89
94
  }
90
- const block = () => `${START}\n${AGENT_TEXT.trimEnd()}\n${END}\n`;
95
+ const block = (text = AGENT_TEXT) => `${START}\n${text.trimEnd()}\n${END}\n`;
91
96
  /** The new file text, or `undefined` if the file belongs to the user and init must leave it. `old` is `undefined` for a missing file. */
92
97
  export function place(a, old) {
93
98
  const mine = block();
94
99
  if (a.kind === 'whole')
95
100
  return old === undefined || old.includes(START) ? `${a.head ?? ''}${mine}` : undefined;
101
+ return section(old, mine);
102
+ }
103
+ /** `old` with the marked block `mine` added or replaced. `undefined` if the block is open. */
104
+ function section(old, mine) {
96
105
  if (old === undefined)
97
106
  return mine;
98
107
  const s = old.indexOf(START), e = old.indexOf(END, s);
@@ -124,12 +133,17 @@ export function registerMcp(a, old) {
124
133
  json[a.mcp.key] = { ...servers, flowfig: entry };
125
134
  return JSON.stringify(json, null, 2) + '\n';
126
135
  }
136
+ /** `✓ ` before a finished write. */
137
+ const mark = (status) => (/^(created|updated|unchanged)$/.test(status) ? '✓ ' : '');
138
+ /** The path to show: relative to the repo folder, or `~` for the home folder. */
139
+ const shown = (path, base, global) => (global ? `~/${relative(base, path)}` : relative(base, path));
127
140
  const HINT = 'run with -y or --agents <ids>';
128
141
  /** Run `flowfig init`. Returns the exit code. */
129
142
  export async function runInit(argv) {
130
143
  const args = [...argv];
131
144
  const flag = (n, ...alias) => [n, ...alias].some((x) => args.includes(x) && args.splice(args.indexOf(x), 1).length > 0);
132
- const listAgents = flag('--list-agents'), all = flag('--all-agents'), yes = flag('-y', '--yes'), global = flag('--global'), dry = flag('--dry-run'), noMcp = flag('--no-mcp');
145
+ const listAgents = flag('--list-agents'), all = flag('--all-agents'), yes = flag('-y', '--yes'), global = flag('--global'), dry = flag('--dry-run');
146
+ let noMcp = flag('--no-mcp');
133
147
  let ids;
134
148
  const at = args.indexOf('--agents');
135
149
  if (at !== -1)
@@ -165,30 +179,50 @@ export async function runInit(argv) {
165
179
  return fail(HINT);
166
180
  }
167
181
  else {
168
- const picked = await pick(auto, detected, dir);
169
- if (!picked)
182
+ const rows = AGENTS.map((a) => ({
183
+ id: a.id,
184
+ name: a.name,
185
+ detected: detected.includes(a.id),
186
+ files: [...new Set([a.file, a.also?.file, a.mcp?.file].filter((f) => !!f))],
187
+ }));
188
+ const got = await runPicker(rows, auto, !noMcp);
189
+ if (got === 'fallback') {
190
+ const picked = await pick(auto, detected, dir);
191
+ if (!picked)
192
+ return 0;
193
+ chosen = picked;
194
+ }
195
+ else if (!got)
170
196
  return 0;
171
- chosen = picked;
197
+ else {
198
+ chosen = got.ids;
199
+ noMcp = !got.mcp;
200
+ }
172
201
  }
173
202
  for (const a of AGENTS.filter((x) => chosen.has(x.id))) {
174
- const path = global ? join(homedir(), a.file) : join(dir, a.file);
175
- const old = existsSync(path) ? readFileSync(path, 'utf8') : undefined;
176
- const next = place(a, old);
177
- let status;
178
- if (next === undefined)
179
- status = old?.includes(START) ? 'skipped (flowfig:start without flowfig:end)' : 'skipped (the file exists and is not from flowfig)';
180
- else if (next === old)
181
- status = 'unchanged';
182
- else {
183
- status = old === undefined ? 'created' : 'updated';
184
- if (dry)
185
- status = `would ${status === 'created' ? 'create' : 'update'}`;
203
+ const files = [{ file: a.file, place: (o) => place(a, o) }];
204
+ if (a.also && !global)
205
+ files.push({ file: a.also.file, place: (o) => section(o, block(a.also.text)) });
206
+ for (const f of files) {
207
+ const path = global ? join(homedir(), f.file) : join(dir, f.file);
208
+ const old = existsSync(path) ? readFileSync(path, 'utf8') : undefined;
209
+ const next = f.place(old);
210
+ let status;
211
+ if (next === undefined)
212
+ status = old?.includes(START) ? 'skipped (flowfig:start without flowfig:end)' : 'skipped (the file exists and is not from flowfig)';
213
+ else if (next === old)
214
+ status = 'unchanged';
186
215
  else {
187
- mkdirSync(dirname(path), { recursive: true });
188
- writeFileSync(path, next);
216
+ status = old === undefined ? 'created' : 'updated';
217
+ if (dry)
218
+ status = `would ${status === 'created' ? 'create' : 'update'}`;
219
+ else {
220
+ mkdirSync(dirname(path), { recursive: true });
221
+ writeFileSync(path, next);
222
+ }
189
223
  }
224
+ console.log(`${mark(status)}${status.padEnd(9)} ${shown(path, global ? homedir() : dir, global)}`);
190
225
  }
191
- console.log(`${status.padEnd(9)} ${path}`);
192
226
  if (noMcp)
193
227
  continue;
194
228
  if (a.mcpNote) {
@@ -227,8 +261,10 @@ export async function runInit(argv) {
227
261
  }
228
262
  }
229
263
  }
230
- console.log(`${mcpStatus.padEnd(9)} ${mcpPath}`);
264
+ console.log(`${mark(mcpStatus)}${mcpStatus.padEnd(9)} ${shown(mcpPath, global ? homedir() : dir, global)}`);
231
265
  }
266
+ if (!dry)
267
+ console.log(`Next: start a new agent session.${chosen.has('claude') ? ' In Claude Code, run /figure how does login work.' : ''}`);
232
268
  return 0;
233
269
  }
234
270
  function fail(message) {
package/dist/model.d.ts CHANGED
@@ -13,8 +13,16 @@ export type FigNode = {
13
13
  lines?: number;
14
14
  /** Width in px. Overrides the width the layout picks. */
15
15
  width?: number;
16
+ /** In a `lanes` figure: the time column of the box, 0-based. Default: the box's first appearance in the steps. */
17
+ at?: number;
18
+ /** In a `timeline` figure: the start of the item, or the date of a milestone, as YYYY-MM-DD. */
19
+ from?: string;
20
+ /** In a `timeline` figure: the last day of the item, as YYYY-MM-DD. The day counts. Without it the box is a milestone. */
21
+ to?: string;
16
22
  /** A permanent state color for the box, such as a failing part. It gives a 1 px border and a light tint. An arrival with no hop tone uses it too. */
17
23
  tone?: FigTone;
24
+ /** A lifecycle mark: `start` draws a filled dot before the box, `end` a ringed dot after it. No layout change. */
25
+ mark?: 'start' | 'end';
18
26
  /** The code this draws, `path` or `path#symbol`, relative to the repo root. `flowfig verify` checks it. */
19
27
  source?: string;
20
28
  };
@@ -75,6 +83,8 @@ export type FigBeat = {
75
83
  show?: Record<string, FigContent>;
76
84
  /** Ids of boxes to highlight for this beat, without a packet or a content card. */
77
85
  light?: string[];
86
+ /** Ids of boxes that take the active look for this beat: a 2 px border, a tint and a glow. The boxes in `light` keep the trail look. */
87
+ focus?: string[];
78
88
  /** How long the beat lasts in ms. The packet crosses in `speed`; the rest is a hold. An explicit `ms` wins as written. Default: `speed` plus the time to read `say`. */
79
89
  ms?: number;
80
90
  };
@@ -128,6 +138,12 @@ export type FlowProps = {
128
138
  check?: boolean;
129
139
  /** Draw the steps as a lifeline rail under the map: one row for each message, with its payload. `'only'` draws the rail without the map. Default: `false`. */
130
140
  rail?: boolean | 'only';
141
+ /** Draw the layout as swimlanes: a `column` group of labeled groups, one per role, with the boxes in time order left to right. Default: off. */
142
+ lanes?: true;
143
+ /** Draw the layout as a timeline: one labeled group per track, with the boxes placed by their `from` and `to` dates. Default: off. */
144
+ timeline?: true;
145
+ /** In a `timeline` figure: the date of the fixed today marker, as YYYY-MM-DD. With no `today`, the figure has no marker. */
146
+ today?: string;
131
147
  };
132
148
  /** A figure file's default export: a title, a source note and the props. */
133
149
  export type Figure = {
@@ -195,9 +211,82 @@ export declare const str: (n: unknown) => string;
195
211
  export declare const readMs: (say: unknown) => number;
196
212
  /** How long a beat lasts. An explicit `ms` wins as written. Else the beat lasts `speed` plus the time to read `say`. */
197
213
  export declare const beatMs: (b: Beat, speed: number) => number;
214
+ /** The item the playhead rests on at beat `i`: the first dated id in the latest focus up to `i`, or undefined for home. */
215
+ export declare const playheadItem: <T extends {
216
+ id: string;
217
+ }>(items: T[], beats: {
218
+ focus?: string[];
219
+ }[], i: number) => T | undefined;
220
+ /** The box of a timeline label that sits beside its bar, in the bar's coordinates. */
221
+ export declare const outsideLabelRect: (bar: {
222
+ x: number;
223
+ y: number;
224
+ w: number;
225
+ h: number;
226
+ }, label: string) => {
227
+ x: number;
228
+ y: number;
229
+ w: number;
230
+ h: number;
231
+ };
232
+ /**
233
+ * The time span in which the playhead label shows its date. The label waits for the line to arrive: it stays hidden for
234
+ * the ramp (the move), so it never shows a date at a place that is not that date, and it never meets "today" on the way.
235
+ */
236
+ export declare const labelSpan: (t0: number, end: number, ramp: number) => [number, number];
237
+ /** True if a playhead date label at x would overlap the "today" label, so it moves to the top row. Widths are in px. */
238
+ export declare const dateLabelRaised: (x: number, today: number | null | undefined, w: number, todayW: number) => boolean;
198
239
  /** The hold at the end of each step, before the next step starts. */
199
240
  export declare const STEP_HOLD_MS = 2000;
200
241
  export declare const nodes: (g: FigGroup) => FigNode[];
242
+ /** Lane layout constants, shared by both renderers: the gap between time columns, the padding inside a lane, and the gap between lanes. */
243
+ export declare const LANE_GAP = 56, LANE_PAD = 24, LANE_ROW_GAP = 20;
244
+ /** True if the layout can draw as lanes: a `column` group whose every child is a labeled group with boxes only. */
245
+ export declare const isLanesLayout: (g: FigGroup) => boolean;
246
+ /** True if `at` is a usable time column: an integer of 0 or more. */
247
+ export declare const validAt: (at: unknown) => at is number;
248
+ /** The time column of each box in a lanes figure: its first appearance in the steps (from, then to; a back hop to, then from),
249
+ * then the boxes no step touches in layout order. `at` overrides. Two boxes may share a column. */
250
+ export declare function laneColumns(fig: FlowProps): Map<string, number>;
251
+ /** Timeline constants, shared by both renderers: the bar height, the gap between rows, the least bar width, the milestone width,
252
+ * the axis strip height and the axis width of the SVG. */
253
+ export declare const TL_BAR_H = 28, TL_ROW_GAP = 8, TL_MIN_BAR = 24, TL_DIAMOND = 14, TL_AXIS_H = 28, TL_AXIS_W = 640;
254
+ /** The days since 1970-01-01 (UTC) of a YYYY-MM-DD date. It is null for any other text and for a date that does not exist. */
255
+ export declare const dayOf: (iso: string) => number | null;
256
+ export type TimelineItem = {
257
+ id: string;
258
+ track: number;
259
+ row: number;
260
+ x: number;
261
+ w: number;
262
+ milestone: boolean;
263
+ labelInside: boolean;
264
+ /** The `from` date in the d MMM form, for the playhead label. */
265
+ date: string;
266
+ };
267
+ export type TimelineLayout = {
268
+ /** The range in days, rounded out to whole weeks: the Monday of the first week and the Sunday of the last week. */
269
+ start: number;
270
+ end: number;
271
+ /** The week ticks (`W41`) or the month ticks (`Oct`). */
272
+ ticks: {
273
+ x: number;
274
+ label: string;
275
+ }[];
276
+ /** Bars and milestones. `x` and `w` are px inside the axis; `row` is the stack row inside the track. */
277
+ items: TimelineItem[];
278
+ /** The row count of each track. */
279
+ rows: number[];
280
+ /** The x of the today marker. It is null when the figure has no valid `today`. */
281
+ today: number | null;
282
+ /** The x and the d MMM date of the last day: the playhead rests there in the step hold. The x is null when the figure has no dated item. */
283
+ last: number | null;
284
+ lastDate: string;
285
+ };
286
+ /** Where each dated item, tick and the today line sit on the axis, `axisWidth` px wide. Both renderers call it. */
287
+ export declare function timelineLayout(fig: FlowProps, axisWidth: number): TimelineLayout;
288
+ /** One step, "timeline", with one beat for each dated item in date order. The beat lights the item and says "label, d MMM to d MMM" (a milestone: "label, d MMM"), then " · sub". */
289
+ export declare function timelineBeats(fig: FlowProps): FigStep[];
201
290
  /** The part counts of a spec, for the line that `flowfig check` prints. An agent copies the line into its reply. */
202
291
  export declare const counts: (p: FlowProps) => {
203
292
  boxes: number;
package/dist/model.js CHANGED
@@ -53,18 +53,182 @@ export const readMs = (say) => {
53
53
  };
54
54
  /** How long a beat lasts. An explicit `ms` wins as written. Else the beat lasts `speed` plus the time to read `say`. */
55
55
  export const beatMs = (b, speed) => b.ms ?? speed + readMs(b.say);
56
+ /** The item the playhead rests on at beat `i`: the first dated id in the latest focus up to `i`, or undefined for home. */
57
+ export const playheadItem = (items, beats, i) => {
58
+ for (let k = Math.min(i, beats.length - 1); k >= 0; k--) {
59
+ const id = (beats[k].focus ?? []).find((f) => items.some((x) => x.id === f));
60
+ if (id != null)
61
+ return items.find((x) => x.id === id);
62
+ }
63
+ return undefined;
64
+ };
65
+ /** The box of a timeline label that sits beside its bar, in the bar's coordinates. */
66
+ export const outsideLabelRect = (bar, label) => ({
67
+ x: bar.x + bar.w + 6,
68
+ y: bar.y,
69
+ w: textWidth(label, 13),
70
+ h: bar.h,
71
+ });
72
+ /**
73
+ * The time span in which the playhead label shows its date. The label waits for the line to arrive: it stays hidden for
74
+ * the ramp (the move), so it never shows a date at a place that is not that date, and it never meets "today" on the way.
75
+ */
76
+ export const labelSpan = (t0, end, ramp) => [Math.min(t0 + ramp, end), end];
77
+ /** True if a playhead date label at x would overlap the "today" label, so it moves to the top row. Widths are in px. */
78
+ export const dateLabelRaised = (x, today, w, todayW) => today != null && x + 3 < today - 3 && x + 3 + w > today - 3 - todayW;
56
79
  /** The hold at the end of each step, before the next step starts. */
57
80
  export const STEP_HOLD_MS = 2000;
58
81
  export const nodes = (g) => g.children.flatMap((c) => (isGroup(c) ? nodes(c) : [c]));
82
+ /** Lane layout constants, shared by both renderers: the gap between time columns, the padding inside a lane, and the gap between lanes. */
83
+ export const LANE_GAP = 56, LANE_PAD = 24, LANE_ROW_GAP = 20;
84
+ /** True if the layout can draw as lanes: a `column` group whose every child is a labeled group with boxes only. */
85
+ export const isLanesLayout = (g) => g.direction === 'column' && g.children.every((c) => isGroup(c) && c.label != null && c.children.every((k) => !isGroup(k)));
86
+ /** True if `at` is a usable time column: an integer of 0 or more. */
87
+ export const validAt = (at) => typeof at === 'number' && Number.isInteger(at) && at >= 0;
88
+ /** The time column of each box in a lanes figure: its first appearance in the steps (from, then to; a back hop to, then from),
89
+ * then the boxes no step touches in layout order. `at` overrides. Two boxes may share a column. */
90
+ export function laneColumns(fig) {
91
+ const byId = new Map(fig.edges.map((e) => [edgeId(e), e]));
92
+ const cols = new Map();
93
+ const seen = (id) => {
94
+ if (!cols.has(id))
95
+ cols.set(id, cols.size);
96
+ };
97
+ for (const s of fig.steps ?? [])
98
+ for (const b of s.flow.map(toBeat))
99
+ for (const h of b.hops) {
100
+ const e = byId.get(h.edge);
101
+ if (!e)
102
+ continue;
103
+ for (const id of h.back ? [e.to, e.from] : [e.from, e.to])
104
+ seen(id);
105
+ }
106
+ for (const n of nodes(fig.layout))
107
+ seen(n.id);
108
+ for (const n of nodes(fig.layout))
109
+ if (validAt(n.at))
110
+ cols.set(n.id, n.at);
111
+ return cols;
112
+ }
113
+ /** Timeline constants, shared by both renderers: the bar height, the gap between rows, the least bar width, the milestone width,
114
+ * the axis strip height and the axis width of the SVG. */
115
+ export const TL_BAR_H = 28, TL_ROW_GAP = 8, TL_MIN_BAR = 24, TL_DIAMOND = 14, TL_AXIS_H = 28, TL_AXIS_W = 640;
116
+ /** The days since 1970-01-01 (UTC) of a YYYY-MM-DD date. It is null for any other text and for a date that does not exist. */
117
+ export const dayOf = (iso) => {
118
+ const m = typeof iso === 'string' ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso) : null;
119
+ if (!m)
120
+ return null;
121
+ const t = Date.UTC(+m[1], +m[2] - 1, +m[3]);
122
+ return new Date(t).toISOString().slice(0, 10) === iso ? t / 86400000 : null;
123
+ };
124
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
125
+ const dateOf = (day) => new Date(day * 86400000);
126
+ // Day 0 is a Thursday, so this is 0 for Monday and 6 for Sunday.
127
+ const weekday = (day) => (((day + 3) % 7) + 7) % 7;
128
+ /** The ISO 8601 week number of a day: the week of the year's first Thursday is week 1. */
129
+ const isoWeek = (day) => {
130
+ const thu = day - weekday(day) + 3;
131
+ const jan1 = Date.UTC(dateOf(thu).getUTCFullYear(), 0, 1) / 86400000;
132
+ return Math.floor((thu - jan1) / 7) + 1;
133
+ };
134
+ /** The boxes with a real `from`, in layout order, with their track (the index of the top-level child) and their days.
135
+ * A `to` that is not a date, or is before `from`, makes the box a milestone; `checkSpec` reports it. */
136
+ const datedItems = (fig) => {
137
+ const out = [];
138
+ fig.layout.children.forEach((c, track) => nodes(isGroup(c) ? c : { children: [c] }).forEach((n) => {
139
+ const from = n.from == null ? null : dayOf(n.from);
140
+ if (from == null)
141
+ return;
142
+ const to = n.to == null ? null : dayOf(n.to);
143
+ out.push({ id: n.id, track, from, to: to != null && to >= from ? to : null, label: n });
144
+ }));
145
+ return out;
146
+ };
147
+ /** Where each dated item, tick and the today line sit on the axis, `axisWidth` px wide. Both renderers call it. */
148
+ export function timelineLayout(fig, axisWidth) {
149
+ const items = datedItems(fig);
150
+ const today = fig.today == null ? null : dayOf(fig.today);
151
+ const days = [...items.flatMap((i) => [i.from, i.to ?? i.from]), ...(today == null ? [] : [today])];
152
+ const first = days.length ? Math.min(...days) : 0;
153
+ const last = days.length ? Math.max(...days) : 0;
154
+ const start = first - weekday(first);
155
+ let end = last + (6 - weekday(last));
156
+ const px = (day) => ((day - start) / (end - start + 1)) * axisWidth;
157
+ // A label right of the last items may pass the axis end. Add whole weeks and lay out again; after 4 passes the label stays and `check` reports it.
158
+ for (let pass = 0; pass < 4; pass++) {
159
+ const over = Math.max(0, ...items.map((i) => {
160
+ const x = i.to != null ? px(i.from) : px(i.from) - TL_DIAMOND / 2;
161
+ const w = i.to != null ? Math.max(TL_MIN_BAR, px(i.to + 1) - px(i.from)) : TL_DIAMOND;
162
+ const need = textWidth(str(i.label.label), 13);
163
+ return i.to != null && need + 16 <= w ? 0 : x + w + 6 + need - axisWidth;
164
+ }));
165
+ if (over <= 0)
166
+ break;
167
+ end += Math.ceil(over / (axisWidth / (end - start + 1)) / 7) * 7;
168
+ }
169
+ const ticks = [];
170
+ if ((end - start + 1) / 7 <= 16)
171
+ for (let d = start; d <= end; d += 7)
172
+ ticks.push({ x: px(d), label: `W${isoWeek(d)}` });
173
+ else
174
+ for (let d = start; d <= end; d++)
175
+ if (dateOf(d).getUTCDate() === 1)
176
+ ticks.push({ x: px(d), label: MONTHS[dateOf(d).getUTCMonth()] });
177
+ const rows = fig.layout.children.map(() => 1);
178
+ const placed = [];
179
+ const busy = fig.layout.children.map(() => []); // per track: the px end of the latest item in each row, with its label
180
+ const GAP = 8; // the least px between two items in a row
181
+ for (const i of [...items].sort((a, b) => a.from - b.from)) {
182
+ const bar = i.to != null;
183
+ const x = bar ? px(i.from) : px(i.from) - TL_DIAMOND / 2;
184
+ const w = bar ? Math.max(TL_MIN_BAR, px(i.to + 1) - px(i.from)) : TL_DIAMOND;
185
+ const need = textWidth(str(i.label.label), 13);
186
+ const labelInside = bar && need + 16 <= w;
187
+ let row = busy[i.track].findIndex((until) => until + GAP <= x);
188
+ if (row < 0)
189
+ row = busy[i.track].length;
190
+ busy[i.track][row] = labelInside ? x + w : x + w + 6 + need;
191
+ rows[i.track] = Math.max(rows[i.track], row + 1);
192
+ placed.push({ id: i.id, track: i.track, row, x, w, milestone: !bar, labelInside, date: fmt(i.from) });
193
+ }
194
+ // Keep the layout order in the result.
195
+ const order = new Map(items.map((i, k) => [i.id, k]));
196
+ placed.sort((a, b) => order.get(a.id) - order.get(b.id));
197
+ return {
198
+ start,
199
+ end,
200
+ ticks,
201
+ items: placed,
202
+ rows,
203
+ today: items.length && today != null ? px(today) : null,
204
+ last: items.length ? px(last) : null,
205
+ lastDate: fmt(last),
206
+ };
207
+ }
208
+ const fmt = (d) => `${dateOf(d).getUTCDate()} ${MONTHS[dateOf(d).getUTCMonth()]}`;
209
+ /** One step, "timeline", with one beat for each dated item in date order. The beat lights the item and says "label, d MMM to d MMM" (a milestone: "label, d MMM"), then " · sub". */
210
+ export function timelineBeats(fig) {
211
+ const items = datedItems(fig).sort((a, b) => a.from - b.from);
212
+ const flow = items.map((i, k) => ({
213
+ focus: [i.id],
214
+ light: items.slice(0, k + 1).map((p) => p.id),
215
+ say: `${str(i.label.label)}, ${fmt(i.from)}${i.to != null ? ` to ${fmt(i.to)}` : ''}${i.label.sub != null ? ` · ${str(i.label.sub)}` : ''}`,
216
+ }));
217
+ return [{ label: 'timeline', flow }];
218
+ }
59
219
  const labeledGroups = (g) => (g.label ? 1 : 0) + g.children.reduce((n, c) => n + (isGroup(c) ? labeledGroups(c) : 0), 0);
60
220
  /** The part counts of a spec, for the line that `flowfig check` prints. An agent copies the line into its reply. */
61
- export const counts = (p) => ({
62
- boxes: nodes(p.layout).length,
63
- groups: labeledGroups(p.layout),
64
- edges: p.edges.length,
65
- steps: (p.steps ?? []).length,
66
- messages: (p.steps ?? []).reduce((n, s) => n + s.flow.reduce((m, b) => m + toBeat(b).hops.length, 0), 0),
67
- });
221
+ export const counts = (p) => {
222
+ // A timeline with no steps plays one synthetic step.
223
+ const st = p.timeline && isLanesLayout(p.layout) && !p.steps?.length ? timelineBeats(p) : (p.steps ?? []);
224
+ return {
225
+ boxes: nodes(p.layout).length,
226
+ groups: labeledGroups(p.layout),
227
+ edges: p.edges.length,
228
+ steps: st.length,
229
+ messages: st.reduce((n, s) => n + s.flow.reduce((m, b) => m + toBeat(b).hops.length, 0), 0),
230
+ };
231
+ };
68
232
  /** Every id inside a child: its own id, and the ids of all boxes and groups below it. */
69
233
  const idsIn = (c) => (isGroup(c) ? [...(c.id ? [c.id] : []), ...c.children.flatMap(idsIn)] : [c.id]);
70
234
  /**
package/dist/open.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ export type Opener = {
2
+ cmd: string;
3
+ args: string[];
4
+ verbatim: boolean;
5
+ };
6
+ /** The command that opens a file in the default program of the system. Pure. */
7
+ export declare function openerFor(platform: NodeJS.Platform, file: string): Opener;
8
+ /** The name of the temp page: the SVG base name, each character other than a letter, a digit, - or _ as -. */
9
+ export declare function pageName(svgPath: string): string;
10
+ /** The line that `open`, a render with `--open` and `draw --open` print. */
11
+ export declare const openedLine: (page: string) => string;
12
+ /** Write the page to a new temp folder (and to `html`, if given), then start the opener. Returns the written paths. */
13
+ export declare function openSvg(svgPath: string, html?: string): Promise<string[]>;
14
+ /** `flowfig open`: parse argv, call openSvg, print. Returns the exit code. */
15
+ export declare function runOpen(argv: string[]): Promise<number>;
package/dist/open.js ADDED
@@ -0,0 +1,82 @@
1
+ // `flowfig open`: show a figure in the default browser. An .svg file often opens in an editor; an .html page opens in a browser.
2
+ import { spawn } from 'node:child_process';
3
+ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { basename, join } from 'node:path';
6
+ import { pageHtml } from './page.js';
7
+ const USAGE = 'usage: flowfig open <figure.svg> [--html <path>]';
8
+ /** The command that opens a file in the default program of the system. Pure. */
9
+ export function openerFor(platform, file) {
10
+ if (platform === 'darwin')
11
+ return { cmd: 'open', args: [file], verbatim: false };
12
+ // `start` reads its first quoted argument as a window title, so an empty title goes first.
13
+ if (platform === 'win32')
14
+ return { cmd: 'cmd', args: ['/c', 'start', '""', `"${file}"`], verbatim: true };
15
+ return { cmd: 'xdg-open', args: [file], verbatim: false };
16
+ }
17
+ /** The name of the temp page: the SVG base name, each character other than a letter, a digit, - or _ as -. */
18
+ export function pageName(svgPath) {
19
+ // The safe name also keeps cmd metacharacters such as & out of the Windows command line.
20
+ return (basename(svgPath)
21
+ .replace(/\.svg$/, '')
22
+ .replace(/[^\w-]/g, '-') + '.html');
23
+ }
24
+ /** The line that `open`, a render with `--open` and `draw --open` print. */
25
+ export const openedLine = (page) => `${page} — opened in the default browser`;
26
+ /** Write the page to a new temp folder (and to `html`, if given), then start the opener. Returns the written paths. */
27
+ export async function openSvg(svgPath, html) {
28
+ const page = pageHtml(readFileSync(svgPath, 'utf8'), basename(svgPath));
29
+ // A fresh private folder (mode 0700): a fixed shared path lets another user plant a symlink there.
30
+ const dir = mkdtempSync(join(tmpdir(), 'flowfig-open-'));
31
+ // The page stays after the exit: the browser reads it after the CLI stops.
32
+ const temp = join(dir, pageName(svgPath));
33
+ writeFileSync(temp, page);
34
+ if (html)
35
+ writeFileSync(html, page);
36
+ // FLOWFIG_OPENER replaces the system opener, so the tests start no browser.
37
+ const o = process.env.FLOWFIG_OPENER
38
+ ? { cmd: process.env.FLOWFIG_OPENER, args: [temp], verbatim: false }
39
+ : openerFor(process.platform, temp);
40
+ await new Promise((resolve, reject) => {
41
+ const child = spawn(o.cmd, o.args, { detached: true, windowsHide: true, stdio: 'ignore', windowsVerbatimArguments: o.verbatim });
42
+ child.once('spawn', () => (child.unref(), resolve()));
43
+ child.once('error', (e) => reject(new Error(`${e.message}. Open ${temp} in a browser.`)));
44
+ });
45
+ return html ? [temp, html] : [temp];
46
+ }
47
+ /** `flowfig open`: parse argv, call openSvg, print. Returns the exit code. */
48
+ export async function runOpen(argv) {
49
+ const fail = (message, code) => (console.error(message), code);
50
+ const args = [...argv];
51
+ let html;
52
+ const at = args.indexOf('--html');
53
+ if (at !== -1) {
54
+ html = args.splice(at, 2)[1];
55
+ if (!html || html.startsWith('-'))
56
+ return fail('--html needs a path', 2);
57
+ }
58
+ const bad = args.find((a) => a.startsWith('-'));
59
+ if (bad)
60
+ return fail(`unknown flag ${bad}`, 2);
61
+ const [path] = args;
62
+ if (!path)
63
+ return fail(USAGE, 2);
64
+ if (!path.endsWith('.svg'))
65
+ return fail(`${path}: expected a .svg path`, 2);
66
+ try {
67
+ readFileSync(path);
68
+ }
69
+ catch (e) {
70
+ return fail(`${path}: ${e.message}`, 2);
71
+ }
72
+ try {
73
+ const [page, copy] = await openSvg(path, html);
74
+ console.log(openedLine(page));
75
+ if (copy)
76
+ console.log(copy);
77
+ return 0;
78
+ }
79
+ catch (e) {
80
+ return fail(`open: ${e.message}`, 1);
81
+ }
82
+ }
package/dist/page.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** The page: the figure SVG inline, centered, scaled down to the window width. The title is escaped. */
2
+ export declare function pageHtml(svg: string, title: string): string;
package/dist/page.js ADDED
@@ -0,0 +1,16 @@
1
+ // The page that holds a figure SVG: `flowfig open` shows it in the default browser, and `flowfig gif` captures it. Pure.
2
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
3
+ /** The page: the figure SVG inline, centered, scaled down to the window width. The title is escaped. */
4
+ export function pageHtml(svg, title) {
5
+ // Element selectors only: the figure styles use class names, so the two sets of rules do not meet.
6
+ return `<!doctype html>
7
+ <meta charset="utf-8">
8
+ <title>${esc(title)}</title>
9
+ <style>
10
+ :root { color-scheme: light dark }
11
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: Canvas }
12
+ svg { display: block; max-width: 100%; height: auto }
13
+ </style>
14
+ ${svg}
15
+ `;
16
+ }
@@ -0,0 +1,33 @@
1
+ export type PickRow = {
2
+ id: string;
3
+ name: string;
4
+ detected: boolean;
5
+ files: string[];
6
+ };
7
+ export type PickState = {
8
+ rows: PickRow[];
9
+ sel: Set<string>;
10
+ mcp: boolean;
11
+ cursor: number;
12
+ };
13
+ export type Key = {
14
+ name?: string;
15
+ ctrl?: boolean;
16
+ sequence?: string;
17
+ };
18
+ export type Screen = {
19
+ color: boolean;
20
+ width: number;
21
+ };
22
+ export type Picked = {
23
+ ids: Set<string>;
24
+ mcp: boolean;
25
+ };
26
+ /** The banner lines. A terminal narrower than the banner gets the plain version line. */
27
+ export declare function banner({ color, width }: Screen): string[];
28
+ /** The picker screen, one string per line. */
29
+ export declare function screen(s: PickState, o: Screen): string[];
30
+ /** One key press. Returns the next state, or `confirm` or `cancel`. */
31
+ export declare function keyStep(s: PickState, k: Key): PickState | 'confirm' | 'cancel';
32
+ /** Run the picker on the terminal. Returns `undefined` on cancel, or `'fallback'` if the terminal has no raw mode. */
33
+ export declare function runPicker(rows: PickRow[], start: Set<string>, mcp: boolean): Promise<Picked | undefined | 'fallback'>;