@1agh/maude 0.15.0 → 0.17.1
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/README.md +4 -2
- package/cli/commands/design.mjs +108 -2
- package/package.json +12 -18
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +8 -8
- package/plugins/design/dev-server/annotations-layer.tsx +8 -10
- package/plugins/design/dev-server/api.ts +227 -3
- package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +40 -0
- package/plugins/design/dev-server/bin/_html-playwright.mjs +129 -0
- package/plugins/design/dev-server/bin/_pdf-playwright.mjs +105 -0
- package/plugins/design/dev-server/bin/_png-playwright.mjs +143 -0
- package/plugins/design/dev-server/bin/_pptx-playwright.mjs +98 -0
- package/plugins/design/dev-server/bin/_svg-playwright.mjs +141 -0
- package/plugins/design/dev-server/canvas-lib.tsx +12 -13
- package/plugins/design/dev-server/canvas-shell.tsx +111 -9
- package/plugins/design/dev-server/client/app.jsx +71 -143
- package/plugins/design/dev-server/client/comments-overlay.css +381 -0
- package/plugins/design/dev-server/client/styles/3-shell.css +1 -10
- package/plugins/design/dev-server/client/styles/4-components.css +5 -161
- package/plugins/design/dev-server/client/styles.css +5 -160
- package/plugins/design/dev-server/comments-overlay.tsx +1156 -0
- package/plugins/design/dev-server/context-menu.tsx +36 -9
- package/plugins/design/dev-server/dist/client.bundle.js +52 -211
- package/plugins/design/dev-server/dist/styles.css +1 -218
- package/plugins/design/dev-server/export-dialog.tsx +401 -0
- package/plugins/design/dev-server/exporters/_browser-bundles.ts +89 -0
- package/plugins/design/dev-server/exporters/canva-handoff-prompt.ts +74 -0
- package/plugins/design/dev-server/exporters/canva.ts +126 -0
- package/plugins/design/dev-server/exporters/html.ts +103 -0
- package/plugins/design/dev-server/exporters/index.ts +135 -0
- package/plugins/design/dev-server/exporters/pdf.ts +109 -0
- package/plugins/design/dev-server/exporters/png.ts +136 -0
- package/plugins/design/dev-server/exporters/pptx.ts +263 -0
- package/plugins/design/dev-server/exporters/scope.ts +196 -0
- package/plugins/design/dev-server/exporters/svg.ts +122 -0
- package/plugins/design/dev-server/exporters/zip.ts +109 -0
- package/plugins/design/dev-server/http.ts +109 -0
- package/plugins/design/dev-server/input-router.tsx +21 -0
- package/plugins/design/dev-server/inspect.ts +1 -1
- package/plugins/design/dev-server/server.mjs +1 -1
- package/plugins/design/dev-server/test/canvas-meta-api.test.ts +0 -10
- package/plugins/design/dev-server/test/comments-api.test.ts +229 -0
- package/plugins/design/dev-server/test/exporters/canva.test.ts +64 -0
- package/plugins/design/dev-server/test/exporters/endpoint.test.ts +121 -0
- package/plugins/design/dev-server/test/exporters/history.test.ts +79 -0
- package/plugins/design/dev-server/test/exporters/html.test.ts +26 -0
- package/plugins/design/dev-server/test/exporters/pdf.test.ts +53 -0
- package/plugins/design/dev-server/test/exporters/png.test.ts +32 -0
- package/plugins/design/dev-server/test/exporters/pptx.test.ts +31 -0
- package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
- package/plugins/design/dev-server/test/exporters/svg.test.ts +29 -0
- package/plugins/design/dev-server/test/exporters/zip.test.ts +105 -0
- package/plugins/design/dev-server/tool-palette.tsx +34 -16
- package/plugins/design/templates/_shell.html +33 -0
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
# Maude
|
|
1
|
+
# Maude
|
|
2
2
|
|
|
3
3
|
A personal marketplace of Claude Code plugins. Two plugins today, plus a `maude` CLI for scaffolding and running the bundled dev tooling.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
<!-- Demo video lives at https://github.com/1aGh/maude/releases/download/v0.16.0/demo.mp4 once uploaded.
|
|
6
|
+
Until then the inline tag below stays a soft 404; landing page at https://maude.iagh.cz autoplays the same file. -->
|
|
7
|
+
<video src="https://github.com/1aGh/maude/releases/download/v0.16.0/demo.mp4" controls muted playsinline poster="https://maude.iagh.cz/demo-poster.jpg" width="800"></video>
|
|
6
8
|
|
|
7
9
|
> **📚 Full docs: https://maude.iagh.cz** (or browse the source under [`site/content/docs/`](./site/content/docs/) until the public URL lands).
|
|
8
10
|
> Contributing? See [CONTRIBUTING.md](./CONTRIBUTING.md). Security? See [SECURITY.md](./SECURITY.md).
|
package/cli/commands/design.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import { basename, join, resolve } from 'node:path';
|
|
4
4
|
import { parseArgs } from '../lib/argv.mjs';
|
|
5
5
|
|
|
6
|
-
const SUBCOMMANDS = new Set(['serve', 'init', 'help']);
|
|
6
|
+
const SUBCOMMANDS = new Set(['serve', 'init', 'export', 'help']);
|
|
7
7
|
|
|
8
8
|
export async function run({ args, pkgRoot }) {
|
|
9
9
|
const { positional } = parseArgs(args);
|
|
@@ -25,10 +25,13 @@ export async function run({ args, pkgRoot }) {
|
|
|
25
25
|
if (sub === 'init') {
|
|
26
26
|
return runInit({ args, pkgRoot });
|
|
27
27
|
}
|
|
28
|
+
if (sub === 'export') {
|
|
29
|
+
return runExport({ args });
|
|
30
|
+
}
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
function usage() {
|
|
31
|
-
return `maude design <serve|init> [options]
|
|
34
|
+
return `maude design <serve|init|export> [options]
|
|
32
35
|
|
|
33
36
|
serve [--port N] [--root PATH]
|
|
34
37
|
Start the design plugin's dev server in the current repo. Equivalent
|
|
@@ -45,6 +48,13 @@ function usage() {
|
|
|
45
48
|
--discovery-payload <path> reads a JSON file with answers + tokens and
|
|
46
49
|
scaffolds Core + the derived specimens deterministically (this is the
|
|
47
50
|
path skill 'design-system' uses when shelling out from Claude Code).
|
|
51
|
+
|
|
52
|
+
export <format> [--scope selection|artboard|canvas-as-separate|project-raw]
|
|
53
|
+
[--port N] [--out <path>] [--option key=value ...]
|
|
54
|
+
Drive the same POST /_api/export endpoint the UI uses. Auto-detects
|
|
55
|
+
port from .design/_server.json; requires a running dev server. The
|
|
56
|
+
response body is written to --out (default: current dir, server-
|
|
57
|
+
supplied filename). Formats: png pdf svg html pptx canva zip.
|
|
48
58
|
`;
|
|
49
59
|
}
|
|
50
60
|
|
|
@@ -98,6 +108,102 @@ async function runServe({ args, pkgRoot }) {
|
|
|
98
108
|
});
|
|
99
109
|
}
|
|
100
110
|
|
|
111
|
+
async function runExport({ args }) {
|
|
112
|
+
// `maude design export <format> [--scope ...] [--port N] [--out <path>] [--option key=value]`
|
|
113
|
+
const subArgs = args.slice(args.indexOf('export') + 1);
|
|
114
|
+
const { positional, flags } = parseArgs(subArgs, {
|
|
115
|
+
booleans: ['help'],
|
|
116
|
+
});
|
|
117
|
+
if (flags.help) {
|
|
118
|
+
process.stdout.write(usage());
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const format = positional[0];
|
|
123
|
+
const VALID_FORMATS = new Set(['png', 'pdf', 'svg', 'html', 'pptx', 'canva', 'zip']);
|
|
124
|
+
if (!format || !VALID_FORMATS.has(format)) {
|
|
125
|
+
process.stderr.write(
|
|
126
|
+
`maude design export: missing or unknown <format>. Try one of: ${Array.from(VALID_FORMATS).join(', ')}\n`
|
|
127
|
+
);
|
|
128
|
+
process.exit(2);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const scope = flags.scope ?? 'canvas-as-separate';
|
|
132
|
+
const VALID_SCOPES = new Set(['selection', 'artboard', 'canvas-as-separate', 'project-raw']);
|
|
133
|
+
if (!VALID_SCOPES.has(scope)) {
|
|
134
|
+
process.stderr.write(`maude design export: unknown --scope "${scope}"\n`);
|
|
135
|
+
process.exit(2);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Resolve port: --port > .design/_server.json
|
|
139
|
+
let port = flags.port ? Number(flags.port) : null;
|
|
140
|
+
if (!port) {
|
|
141
|
+
try {
|
|
142
|
+
const raw = await readFile(resolve(process.cwd(), '.design', '_server.json'), 'utf8');
|
|
143
|
+
port = JSON.parse(raw).port;
|
|
144
|
+
} catch {
|
|
145
|
+
/* no server.json — handled below */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!port) {
|
|
149
|
+
process.stderr.write(
|
|
150
|
+
'maude design export: no --port given and .design/_server.json not found. Start the dev server first (`maude design serve`).\n'
|
|
151
|
+
);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Collect `--option key=value` repeated flags into an object.
|
|
156
|
+
const options = {};
|
|
157
|
+
const repeated = collectRepeatedFlag(subArgs, '--option');
|
|
158
|
+
for (const item of repeated) {
|
|
159
|
+
const eq = item.indexOf('=');
|
|
160
|
+
if (eq < 0) {
|
|
161
|
+
process.stderr.write(
|
|
162
|
+
`maude design export: invalid --option "${item}" (expected key=value)\n`
|
|
163
|
+
);
|
|
164
|
+
process.exit(2);
|
|
165
|
+
}
|
|
166
|
+
const key = item.slice(0, eq);
|
|
167
|
+
const value = item.slice(eq + 1);
|
|
168
|
+
// Coerce common JSON-ish values: true/false, numbers, arrays via comma.
|
|
169
|
+
options[key] = value === 'true' ? true : value === 'false' ? false : value;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const url = `http://localhost:${port}/_api/export`;
|
|
173
|
+
const r = await fetch(url, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: { 'content-type': 'application/json' },
|
|
176
|
+
body: JSON.stringify({ format, scope, options }),
|
|
177
|
+
});
|
|
178
|
+
if (!r.ok) {
|
|
179
|
+
const text = await r.text();
|
|
180
|
+
process.stderr.write(`maude design export: server returned ${r.status}: ${text}\n`);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const disp = r.headers.get('content-disposition') ?? '';
|
|
185
|
+
const serverFilename = /filename="([^"]+)"/.exec(disp)?.[1] ?? `export.${format}`;
|
|
186
|
+
const outPath = flags.out
|
|
187
|
+
? resolve(process.cwd(), flags.out)
|
|
188
|
+
: resolve(process.cwd(), serverFilename);
|
|
189
|
+
const bytes = new Uint8Array(await r.arrayBuffer());
|
|
190
|
+
await writeFile(outPath, bytes);
|
|
191
|
+
process.stdout.write(`maude design export: wrote ${outPath} (${bytes.byteLength} bytes)\n`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function collectRepeatedFlag(argv, name) {
|
|
195
|
+
const out = [];
|
|
196
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
197
|
+
if (argv[i] === name && argv[i + 1] !== undefined) {
|
|
198
|
+
out.push(argv[i + 1]);
|
|
199
|
+
i += 1;
|
|
200
|
+
} else if (argv[i].startsWith(`${name}=`)) {
|
|
201
|
+
out.push(argv[i].slice(name.length + 1));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
101
207
|
async function runInit({ args, pkgRoot }) {
|
|
102
208
|
const subArgs = args.slice(args.indexOf('init') + 1);
|
|
103
209
|
const { flags } = parseArgs(subArgs, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1agh/maude",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -34,18 +34,20 @@
|
|
|
34
34
|
"video:smoke": "bash scripts/video/smoke/run.sh",
|
|
35
35
|
"video:smoke:terminal": "vhs scripts/video/smoke/terminal.tape",
|
|
36
36
|
"video:smoke:browser": "playwright test --config scripts/video/smoke/playwright.config.ts",
|
|
37
|
-
"video:smoke:card": "
|
|
37
|
+
"video:smoke:card": "cd scripts/video/final && pnpm run render SmokeCard ../.work/smoke/card.mp4 --mute",
|
|
38
|
+
"video:render": "cd scripts/video/final && pnpm run render",
|
|
39
|
+
"video:studio": "cd scripts/video/final && pnpm run studio",
|
|
38
40
|
"postinstall": "node cli/install.cjs",
|
|
39
41
|
"prepublishOnly": "bash scripts/check-version-parity.sh"
|
|
40
42
|
},
|
|
41
43
|
"optionalDependencies": {
|
|
42
|
-
"@1agh/maude-darwin-arm64": "0.
|
|
43
|
-
"@1agh/maude-darwin-x64": "0.
|
|
44
|
-
"@1agh/maude-linux-arm64": "0.
|
|
45
|
-
"@1agh/maude-linux-arm64-musl": "0.
|
|
46
|
-
"@1agh/maude-linux-x64": "0.
|
|
47
|
-
"@1agh/maude-linux-x64-musl": "0.
|
|
48
|
-
"@1agh/maude-win32-x64": "0.
|
|
44
|
+
"@1agh/maude-darwin-arm64": "0.17.1",
|
|
45
|
+
"@1agh/maude-darwin-x64": "0.17.1",
|
|
46
|
+
"@1agh/maude-linux-arm64": "0.17.1",
|
|
47
|
+
"@1agh/maude-linux-arm64-musl": "0.17.1",
|
|
48
|
+
"@1agh/maude-linux-x64": "0.17.1",
|
|
49
|
+
"@1agh/maude-linux-x64-musl": "0.17.1",
|
|
50
|
+
"@1agh/maude-win32-x64": "0.17.1"
|
|
49
51
|
},
|
|
50
52
|
"files": [
|
|
51
53
|
"cli",
|
|
@@ -81,14 +83,6 @@
|
|
|
81
83
|
"devDependencies": {
|
|
82
84
|
"@biomejs/biome": "^1.9.4",
|
|
83
85
|
"@changesets/cli": "^2.27.10",
|
|
84
|
-
"@playwright/test": "^1.60.0"
|
|
85
|
-
"@remotion/bundler": "^4.0.463",
|
|
86
|
-
"@remotion/cli": "^4.0.463",
|
|
87
|
-
"@remotion/renderer": "^4.0.463",
|
|
88
|
-
"@types/react": "^19.2.14",
|
|
89
|
-
"@types/react-dom": "^19.2.3",
|
|
90
|
-
"react": "^19.2.6",
|
|
91
|
-
"react-dom": "^19.2.6",
|
|
92
|
-
"remotion": "^4.0.463"
|
|
86
|
+
"@playwright/test": "^1.60.0"
|
|
93
87
|
}
|
|
94
88
|
}
|
|
@@ -36,14 +36,14 @@ const TOOLBAR_CSS = `
|
|
|
36
36
|
display: flex;
|
|
37
37
|
align-items: center;
|
|
38
38
|
gap: 6px;
|
|
39
|
-
background: var(--bg-1, rgba(255,255,255,0.98));
|
|
40
|
-
border: 1px solid var(--u-
|
|
41
|
-
border-radius:
|
|
39
|
+
background: var(--u-bg-2, var(--bg-1, rgba(255,255,255,0.98)));
|
|
40
|
+
border: 1px solid var(--u-fg-0, #1c1917);
|
|
41
|
+
border-radius: 0;
|
|
42
42
|
padding: 6px 8px;
|
|
43
|
-
box-shadow:
|
|
44
|
-
font-family:
|
|
43
|
+
box-shadow: 4px 4px 0 var(--u-fg-0, #1c1917);
|
|
44
|
+
font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
45
45
|
font-size: 12px;
|
|
46
|
-
color: var(--fg-0, #1a1a1a);
|
|
46
|
+
color: var(--u-fg-0, var(--fg-0, #1a1a1a));
|
|
47
47
|
user-select: none;
|
|
48
48
|
pointer-events: auto;
|
|
49
49
|
}
|
|
@@ -79,8 +79,8 @@ const TOOLBAR_CSS = `
|
|
|
79
79
|
.dc-annot-ctx-btn {
|
|
80
80
|
appearance: none;
|
|
81
81
|
background: transparent;
|
|
82
|
-
border: 1px solid rgba(0,0,0,0.
|
|
83
|
-
border-radius:
|
|
82
|
+
border: 1px solid var(--u-fg-0, rgba(0,0,0,0.6));
|
|
83
|
+
border-radius: 0;
|
|
84
84
|
padding: 3px 8px;
|
|
85
85
|
font: inherit;
|
|
86
86
|
color: inherit;
|
|
@@ -118,9 +118,7 @@ const STROKE_WIDTH_THIN = 2;
|
|
|
118
118
|
const STROKE_WIDTH_THICK = 6;
|
|
119
119
|
type Thickness = typeof STROKE_WIDTH_THIN | typeof STROKE_WIDTH_THICK;
|
|
120
120
|
|
|
121
|
-
const FONT_SIZE_SMALL = 12;
|
|
122
121
|
const FONT_SIZE_MEDIUM = 14;
|
|
123
|
-
const FONT_SIZE_LARGE = 20;
|
|
124
122
|
const DEFAULT_FONT_SIZE = FONT_SIZE_MEDIUM;
|
|
125
123
|
|
|
126
124
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -459,15 +457,15 @@ const ANNOT_CSS = `
|
|
|
459
457
|
display: flex;
|
|
460
458
|
align-items: center;
|
|
461
459
|
gap: 8px;
|
|
462
|
-
background: var(--bg-1, rgba(255,255,255,0.98));
|
|
463
|
-
border: 1px solid var(--u-
|
|
464
|
-
border-radius:
|
|
460
|
+
background: var(--u-bg-2, var(--bg-1, rgba(255,255,255,0.98)));
|
|
461
|
+
border: 1px solid var(--u-fg-0, #1c1917);
|
|
462
|
+
border-radius: 0;
|
|
465
463
|
padding: 6px 10px;
|
|
466
|
-
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
464
|
+
font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
467
465
|
font-size: 11px;
|
|
468
|
-
color: rgba(40,30,20,0.85);
|
|
466
|
+
color: var(--u-fg-0, rgba(40,30,20,0.85));
|
|
469
467
|
z-index: 6;
|
|
470
|
-
box-shadow:
|
|
468
|
+
box-shadow: 4px 4px 0 var(--u-fg-0, #1c1917);
|
|
471
469
|
user-select: none;
|
|
472
470
|
}
|
|
473
471
|
.dc-annot-chrome .dc-annot-swatches { display: flex; gap: 4px; }
|
|
@@ -1481,7 +1479,7 @@ function TextEditor({
|
|
|
1481
1479
|
textAlign: 'center',
|
|
1482
1480
|
color: existing?.color ?? '#1a1a1a',
|
|
1483
1481
|
fontSize: `${fontSize}px`,
|
|
1484
|
-
fontFamily: '
|
|
1482
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
1485
1483
|
lineHeight: 1.25,
|
|
1486
1484
|
outline: 'none',
|
|
1487
1485
|
background: 'transparent',
|
|
@@ -1568,7 +1566,7 @@ function StrokeNode({
|
|
|
1568
1566
|
fontSize={stroke.fontSize}
|
|
1569
1567
|
textAnchor="middle"
|
|
1570
1568
|
dominantBaseline="middle"
|
|
1571
|
-
style={{ fontFamily: '
|
|
1569
|
+
style={{ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)' }}
|
|
1572
1570
|
>
|
|
1573
1571
|
{stroke.text}
|
|
1574
1572
|
</text>
|
|
@@ -78,6 +78,19 @@ async function findFiles(absRoot: string, prefix: string, exts: string[]): Promi
|
|
|
78
78
|
|
|
79
79
|
// ---------- Comments ----------
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Phase 6 — single reply on a comment thread. `id` is `r_<hex>`; persists inside
|
|
83
|
+
* the parent `Comment.thread[]`. Bodies are bounded the same way as comment
|
|
84
|
+
* bodies (4000 chars), and `@handle` tokens in `body` flow into the parent's
|
|
85
|
+
* `mentions[]` union.
|
|
86
|
+
*/
|
|
87
|
+
export interface Reply {
|
|
88
|
+
id: string;
|
|
89
|
+
author: string;
|
|
90
|
+
body: string;
|
|
91
|
+
created: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
81
94
|
export interface Comment {
|
|
82
95
|
id: string;
|
|
83
96
|
file: string;
|
|
@@ -91,6 +104,29 @@ export interface Comment {
|
|
|
91
104
|
status: 'open' | 'resolved';
|
|
92
105
|
created: string;
|
|
93
106
|
resolved_at: string | null;
|
|
107
|
+
// Phase 6 — author + threading + mentions. Default-filled on read for legacy
|
|
108
|
+
// comments missing these fields (see `loadCommentsForFile`); persisted on next
|
|
109
|
+
// write. `author` defaults to the local `git config user.name` resolved at
|
|
110
|
+
// create time, `thread` to `[]`, `mentions` to `[]`.
|
|
111
|
+
author: string;
|
|
112
|
+
thread: Reply[];
|
|
113
|
+
mentions: string[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface GitCommitter {
|
|
117
|
+
name: string;
|
|
118
|
+
email: string;
|
|
119
|
+
commits: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Phase 6.5 T10 — export history. Five-deep ring buffer of recent exports
|
|
123
|
+
// surfaced by the dialog's "Recent" tab + ⌘⇧E re-run.
|
|
124
|
+
export interface ExportHistoryEntry {
|
|
125
|
+
format: string;
|
|
126
|
+
scope: string;
|
|
127
|
+
options?: Record<string, unknown>;
|
|
128
|
+
filename: string;
|
|
129
|
+
at: string;
|
|
94
130
|
}
|
|
95
131
|
|
|
96
132
|
export interface Api {
|
|
@@ -102,6 +138,9 @@ export interface Api {
|
|
|
102
138
|
commentsAdd(payload: Partial<Comment> & { file: string; text: string }): Promise<Comment | null>;
|
|
103
139
|
commentsPatch(id: string, patch: Partial<Comment>): Promise<Comment | null>;
|
|
104
140
|
commentsDelete(id: string): Promise<boolean>;
|
|
141
|
+
commentsAddReply(id: string, payload: { body: string; author?: string }): Promise<Comment | null>;
|
|
142
|
+
gitCommitters(): Promise<GitCommitter[]>;
|
|
143
|
+
parseMentions(text: string): string[];
|
|
105
144
|
// Canvas state
|
|
106
145
|
loadCanvasState(file: string): Promise<Record<string, unknown> | null>;
|
|
107
146
|
saveCanvasState(file: string, state: Record<string, unknown>): Promise<void>;
|
|
@@ -117,6 +156,9 @@ export interface Api {
|
|
|
117
156
|
// Aggregate data
|
|
118
157
|
buildIndexData(): Promise<unknown>;
|
|
119
158
|
buildSystemData(): Promise<unknown>;
|
|
159
|
+
// Export history (Phase 6.5 T10)
|
|
160
|
+
loadExportHistory(): Promise<ExportHistoryEntry[]>;
|
|
161
|
+
appendExportHistory(entry: ExportHistoryEntry): Promise<void>;
|
|
120
162
|
}
|
|
121
163
|
|
|
122
164
|
export function createApi(ctx: Context, onCommentsChanged: (file: string) => void): Api {
|
|
@@ -147,12 +189,26 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
|
|
|
147
189
|
try {
|
|
148
190
|
const raw = await Bun.file(commentsPath(file)).text();
|
|
149
191
|
const arr = JSON.parse(raw);
|
|
150
|
-
|
|
192
|
+
if (!Array.isArray(arr)) return [];
|
|
193
|
+
// Phase 6 — default-fill `author` / `thread` / `mentions` for legacy
|
|
194
|
+
// rows. No write-back here; the on-disk shape stays stable until the
|
|
195
|
+
// next mutation persists the upgraded record.
|
|
196
|
+
return arr.map(backfillComment);
|
|
151
197
|
} catch {
|
|
152
198
|
return [];
|
|
153
199
|
}
|
|
154
200
|
}
|
|
155
201
|
|
|
202
|
+
function backfillComment(raw: unknown): Comment {
|
|
203
|
+
const c = (raw ?? {}) as Partial<Comment>;
|
|
204
|
+
return {
|
|
205
|
+
...(c as Comment),
|
|
206
|
+
author: typeof c.author === 'string' ? c.author : '',
|
|
207
|
+
thread: Array.isArray(c.thread) ? c.thread : [],
|
|
208
|
+
mentions: Array.isArray(c.mentions) ? c.mentions : [],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
156
212
|
async function saveCommentsForFile(file: string, list: Comment[]) {
|
|
157
213
|
await Bun.write(commentsPath(file), JSON.stringify(list, null, 2));
|
|
158
214
|
}
|
|
@@ -172,7 +228,8 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
|
|
|
172
228
|
const arr = JSON.parse(raw);
|
|
173
229
|
if (!Array.isArray(arr) || arr.length === 0) continue;
|
|
174
230
|
const file = arr[0]?.file as string | undefined;
|
|
175
|
-
|
|
231
|
+
// Backfill legacy rows so callers see the v2 shape uniformly.
|
|
232
|
+
if (file) out[file] = arr.map(backfillComment);
|
|
176
233
|
} catch {
|
|
177
234
|
/* ignore */
|
|
178
235
|
}
|
|
@@ -184,10 +241,111 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
|
|
|
184
241
|
return `c_${crypto.randomBytes(6).toString('hex')}`;
|
|
185
242
|
}
|
|
186
243
|
|
|
244
|
+
function newReplyId(): string {
|
|
245
|
+
return `r_${crypto.randomBytes(6).toString('hex')}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------- Git author resolution ----------
|
|
249
|
+
//
|
|
250
|
+
// Author defaults flow from `git config user.name` resolved against the
|
|
251
|
+
// repo root. Cached for the lifetime of the process — the local git
|
|
252
|
+
// identity doesn't shift mid-session and `Bun.spawn` is cheap-but-not-free.
|
|
253
|
+
|
|
254
|
+
let cachedGitUser: string | null = null;
|
|
255
|
+
let cachedGitUserAttempted = false;
|
|
256
|
+
async function gitCurrentUser(): Promise<string> {
|
|
257
|
+
if (cachedGitUserAttempted) return cachedGitUser ?? '';
|
|
258
|
+
cachedGitUserAttempted = true;
|
|
259
|
+
try {
|
|
260
|
+
const proc = Bun.spawn(['git', 'config', 'user.name'], {
|
|
261
|
+
cwd: paths.repoRoot,
|
|
262
|
+
stdout: 'pipe',
|
|
263
|
+
stderr: 'pipe',
|
|
264
|
+
});
|
|
265
|
+
const out = await new Response(proc.stdout).text();
|
|
266
|
+
await proc.exited;
|
|
267
|
+
const name = out.trim();
|
|
268
|
+
cachedGitUser = name || null;
|
|
269
|
+
} catch {
|
|
270
|
+
cachedGitUser = null;
|
|
271
|
+
}
|
|
272
|
+
return cachedGitUser ?? '';
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// `git shortlog -sne` against the repo head — cached for 60 s so the
|
|
276
|
+
// @mention popup doesn't re-fork git on every keystroke.
|
|
277
|
+
let cachedCommitters: GitCommitter[] | null = null;
|
|
278
|
+
let cachedCommittersAt = 0;
|
|
279
|
+
async function gitCommitters(): Promise<GitCommitter[]> {
|
|
280
|
+
const now = Date.now();
|
|
281
|
+
if (cachedCommitters && now - cachedCommittersAt < 60_000) return cachedCommitters;
|
|
282
|
+
try {
|
|
283
|
+
const proc = Bun.spawn(['git', 'shortlog', '-sne', 'HEAD'], {
|
|
284
|
+
cwd: paths.repoRoot,
|
|
285
|
+
stdout: 'pipe',
|
|
286
|
+
stderr: 'pipe',
|
|
287
|
+
});
|
|
288
|
+
const text = await new Response(proc.stdout).text();
|
|
289
|
+
await proc.exited;
|
|
290
|
+
const lines = text
|
|
291
|
+
.split('\n')
|
|
292
|
+
.map((l) => l.trim())
|
|
293
|
+
.filter(Boolean)
|
|
294
|
+
.slice(0, 20);
|
|
295
|
+
const out: GitCommitter[] = [];
|
|
296
|
+
for (const line of lines) {
|
|
297
|
+
// Format: `<spaces><count>\t<name> <<email>>`
|
|
298
|
+
const m = line.match(/^(\d+)\s+(.+?)\s+<([^>]+)>$/);
|
|
299
|
+
if (!m) continue;
|
|
300
|
+
const commits = Number(m[1]);
|
|
301
|
+
const name = m[2]?.trim() ?? '';
|
|
302
|
+
const email = m[3]?.trim() ?? '';
|
|
303
|
+
if (!name) continue;
|
|
304
|
+
out.push({ name, email, commits });
|
|
305
|
+
}
|
|
306
|
+
cachedCommitters = out;
|
|
307
|
+
cachedCommittersAt = now;
|
|
308
|
+
return out;
|
|
309
|
+
} catch {
|
|
310
|
+
cachedCommitters = cachedCommitters ?? [];
|
|
311
|
+
cachedCommittersAt = now;
|
|
312
|
+
return cachedCommitters;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Extract `@handle` tokens from free text. Deduped, returns the literal
|
|
318
|
+
* `@name` form (matching what the autocomplete inserts), so a comment with
|
|
319
|
+
* `"@ada @lin @ada"` collapses to `["@ada","@lin"]`.
|
|
320
|
+
*/
|
|
321
|
+
function parseMentions(text: string): string[] {
|
|
322
|
+
const out: string[] = [];
|
|
323
|
+
const seen = new Set<string>();
|
|
324
|
+
if (typeof text !== 'string' || !text) return out;
|
|
325
|
+
const re = /@[\w][\w.-]*/g;
|
|
326
|
+
for (const m of text.matchAll(re)) {
|
|
327
|
+
const tok = m[0];
|
|
328
|
+
if (!tok || seen.has(tok)) continue;
|
|
329
|
+
seen.add(tok);
|
|
330
|
+
out.push(tok);
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function mentionsUnion(c: Comment): string[] {
|
|
336
|
+
const all = [c.text, ...c.thread.map((r) => r.body)].join('\n');
|
|
337
|
+
return parseMentions(all);
|
|
338
|
+
}
|
|
339
|
+
|
|
187
340
|
async function commentsAdd(payload: Partial<Comment> & { file: string; text: string }) {
|
|
188
341
|
if (!payload || typeof payload.file !== 'string' || !payload.file) return null;
|
|
189
342
|
if (typeof payload.text !== 'string' || !payload.text.trim()) return null;
|
|
190
343
|
const list = await loadCommentsForFile(payload.file);
|
|
344
|
+
const text = String(payload.text).trim().slice(0, 4000);
|
|
345
|
+
const author =
|
|
346
|
+
typeof payload.author === 'string' && payload.author.trim()
|
|
347
|
+
? payload.author.trim().slice(0, 120)
|
|
348
|
+
: await gitCurrentUser();
|
|
191
349
|
const c: Comment = {
|
|
192
350
|
id: newCommentId(),
|
|
193
351
|
file: payload.file,
|
|
@@ -197,10 +355,13 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
|
|
|
197
355
|
classes: String(payload.classes || ''),
|
|
198
356
|
bounds: payload.bounds ?? null,
|
|
199
357
|
html_excerpt: String(payload.html_excerpt || '').slice(0, 2000),
|
|
200
|
-
text
|
|
358
|
+
text,
|
|
201
359
|
status: 'open',
|
|
202
360
|
created: new Date().toISOString(),
|
|
203
361
|
resolved_at: null,
|
|
362
|
+
author,
|
|
363
|
+
thread: [],
|
|
364
|
+
mentions: parseMentions(text),
|
|
204
365
|
};
|
|
205
366
|
list.push(c);
|
|
206
367
|
await saveCommentsForFile(payload.file, list);
|
|
@@ -208,6 +369,37 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
|
|
|
208
369
|
return c;
|
|
209
370
|
}
|
|
210
371
|
|
|
372
|
+
async function commentsAddReply(
|
|
373
|
+
id: string,
|
|
374
|
+
payload: { body: string; author?: string }
|
|
375
|
+
): Promise<Comment | null> {
|
|
376
|
+
if (!payload || typeof payload.body !== 'string' || !payload.body.trim()) return null;
|
|
377
|
+
const all = await loadAllComments();
|
|
378
|
+
for (const [file, list] of Object.entries(all)) {
|
|
379
|
+
const i = list.findIndex((c) => c.id === id);
|
|
380
|
+
if (i < 0) continue;
|
|
381
|
+
const entry = list[i];
|
|
382
|
+
if (!entry) continue;
|
|
383
|
+
const body = payload.body.trim().slice(0, 4000);
|
|
384
|
+
const author =
|
|
385
|
+
typeof payload.author === 'string' && payload.author.trim()
|
|
386
|
+
? payload.author.trim().slice(0, 120)
|
|
387
|
+
: await gitCurrentUser();
|
|
388
|
+
const reply: Reply = {
|
|
389
|
+
id: newReplyId(),
|
|
390
|
+
author,
|
|
391
|
+
body,
|
|
392
|
+
created: new Date().toISOString(),
|
|
393
|
+
};
|
|
394
|
+
entry.thread = [...entry.thread, reply];
|
|
395
|
+
entry.mentions = mentionsUnion(entry);
|
|
396
|
+
await saveCommentsForFile(file, list);
|
|
397
|
+
onCommentsChanged(file);
|
|
398
|
+
return entry;
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
|
|
211
403
|
async function commentsPatch(id: string, patch: Partial<Comment>) {
|
|
212
404
|
const all = await loadAllComments();
|
|
213
405
|
for (const [file, list] of Object.entries(all)) {
|
|
@@ -221,6 +413,7 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
|
|
|
221
413
|
}
|
|
222
414
|
if (typeof patch.text === 'string' && patch.text.trim()) {
|
|
223
415
|
entry.text = patch.text.trim().slice(0, 4000);
|
|
416
|
+
entry.mentions = mentionsUnion(entry);
|
|
224
417
|
}
|
|
225
418
|
await saveCommentsForFile(file, list);
|
|
226
419
|
onCommentsChanged(file);
|
|
@@ -514,6 +707,32 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
|
|
|
514
707
|
};
|
|
515
708
|
}
|
|
516
709
|
|
|
710
|
+
// ---------- Export history (Phase 6.5 T10) ----------
|
|
711
|
+
//
|
|
712
|
+
// 5-deep ring buffer persisted at `<designRoot>/_export-history.json`.
|
|
713
|
+
// Reads tolerate missing / malformed files (returns []). Writes truncate
|
|
714
|
+
// to most-recent-first.
|
|
715
|
+
|
|
716
|
+
const HISTORY_PATH = path.join(paths.designRoot, '_export-history.json');
|
|
717
|
+
const HISTORY_DEPTH = 5;
|
|
718
|
+
|
|
719
|
+
async function loadExportHistory(): Promise<ExportHistoryEntry[]> {
|
|
720
|
+
try {
|
|
721
|
+
const raw = await Bun.file(HISTORY_PATH).text();
|
|
722
|
+
const arr = JSON.parse(raw);
|
|
723
|
+
if (!Array.isArray(arr)) return [];
|
|
724
|
+
return arr.slice(0, HISTORY_DEPTH);
|
|
725
|
+
} catch {
|
|
726
|
+
return [];
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function appendExportHistory(entry: ExportHistoryEntry): Promise<void> {
|
|
731
|
+
const prev = await loadExportHistory();
|
|
732
|
+
const next = [entry, ...prev].slice(0, HISTORY_DEPTH);
|
|
733
|
+
await Bun.write(HISTORY_PATH, JSON.stringify(next, null, 2));
|
|
734
|
+
}
|
|
735
|
+
|
|
517
736
|
function tokenKind(name: string, value: string): string {
|
|
518
737
|
const n = name.toLowerCase();
|
|
519
738
|
const v = String(value).trim();
|
|
@@ -662,6 +881,9 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
|
|
|
662
881
|
commentsAdd,
|
|
663
882
|
commentsPatch,
|
|
664
883
|
commentsDelete,
|
|
884
|
+
commentsAddReply,
|
|
885
|
+
gitCommitters,
|
|
886
|
+
parseMentions,
|
|
665
887
|
loadCanvasState,
|
|
666
888
|
saveCanvasState,
|
|
667
889
|
loadCanvasMeta,
|
|
@@ -670,5 +892,7 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
|
|
|
670
892
|
saveAnnotations,
|
|
671
893
|
buildIndexData,
|
|
672
894
|
buildSystemData,
|
|
895
|
+
loadExportHistory,
|
|
896
|
+
appendExportHistory,
|
|
673
897
|
};
|
|
674
898
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tiny playwright shim that lists `[data-dc-screen]` IDs on a canvas-shell URL,
|
|
3
|
+
// one per line on stdout. Spawned by `exporters/pptx.ts` for canvas-as-separate
|
|
4
|
+
// merge. Lives as a subprocess (not a direct import) so `bun build --compile`
|
|
5
|
+
// of the dev-server binary doesn't pull in playwright + chromium-bidi deep deps.
|
|
6
|
+
|
|
7
|
+
import { chromium } from 'playwright';
|
|
8
|
+
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
let url;
|
|
11
|
+
let timeoutSec = 20;
|
|
12
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
13
|
+
if (args[i] === '--url') {
|
|
14
|
+
i += 1;
|
|
15
|
+
url = args[i];
|
|
16
|
+
} else if (args[i] === '--timeout') {
|
|
17
|
+
i += 1;
|
|
18
|
+
timeoutSec = Number(args[i]);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (!url) {
|
|
22
|
+
console.error('usage: _enumerate-artboards-playwright.mjs --url <url> [--timeout <sec>]');
|
|
23
|
+
process.exit(2);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const timeoutMs = timeoutSec * 1000;
|
|
27
|
+
const browser = await chromium.launch();
|
|
28
|
+
try {
|
|
29
|
+
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
|
30
|
+
const page = await ctx.newPage();
|
|
31
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: timeoutMs });
|
|
32
|
+
const ids = await page.evaluate(() =>
|
|
33
|
+
Array.from(document.querySelectorAll('[data-dc-screen]')).map(
|
|
34
|
+
(el) => el.getAttribute('data-dc-screen') ?? ''
|
|
35
|
+
)
|
|
36
|
+
);
|
|
37
|
+
for (const id of ids.filter(Boolean)) console.log(id);
|
|
38
|
+
} finally {
|
|
39
|
+
await browser.close();
|
|
40
|
+
}
|