flowfig 0.3.0 → 0.5.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,20 +13,29 @@ 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. */
17
+ /** `pts` are the corners of an edge drawn as straight runs (a cross-block lanes edge): `check` tests them in place of `curve`. */
16
18
  export type SceneEdge = {
17
19
  id: string;
18
20
  from: string;
19
21
  to: string;
20
22
  curve: [Pt, Pt, Pt, Pt];
23
+ pts?: Pt[];
21
24
  label?: Rect;
22
25
  step?: number;
26
+ behind?: true;
23
27
  };
24
28
  /** `minFont` is the smallest reading text in px, before any scale. Group frames are not boxes. */
29
+ /** `lanes` are the lane bands of a swimlanes figure, one per lane and block, named by the lane label. A stub pill stays inside one. */
25
30
  export type Scene = {
26
31
  width: number;
27
32
  boxes: SceneBox[];
28
33
  edges: SceneEdge[];
29
34
  minFont: number;
35
+ lanes?: {
36
+ id: string;
37
+ rect: Rect;
38
+ }[];
30
39
  };
31
40
  /** One fault that `flowfig check` found. `rule` names the check, for example `unknown-id`, `text-overflow`, `edge-crosses-box`, `label-overlap`, `small-text`, `low-contrast`. `ids` are the boxes or edges it names. */
32
41
  export type Finding = {
package/dist/svg.d.ts CHANGED
@@ -15,6 +15,9 @@ export type SvgOptions = {
15
15
  padding?: number;
16
16
  /** Colors, in place of `FlowProps.theme`. Default: `FlowProps.theme`. */
17
17
  theme?: FigTheme;
18
+ /** Lanes: the page width and the smallest text that decide when the time columns wrap into blocks. Default: 830 and 10, as in `check`. */
19
+ width?: number;
20
+ minText?: number;
18
21
  };
19
22
  /**
20
23
  * The SVG string, and the scene it drew. The scene lets `flowfig check` test the same layout the SVG uses.