@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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Phase 6.5 T4 — SVG adapter.
|
|
2
|
+
//
|
|
3
|
+
// Walks the rendered DOM via Playwright (mirrors the screenshot path) and
|
|
4
|
+
// emits an SVG with <foreignObject>-wrapped HTML + concatenated stylesheets.
|
|
5
|
+
// Web fonts are NOT inlined in v1 — see plan T4 caveat + DDR (Safari +
|
|
6
|
+
// Illustrator render foreignObject inconsistently; vector text from the
|
|
7
|
+
// HTML payload remains editable in Illustrator/Inkscape on the happy path).
|
|
8
|
+
|
|
9
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
import JSZip from 'jszip';
|
|
14
|
+
|
|
15
|
+
import { getBrowserBundle } from './_browser-bundles.ts';
|
|
16
|
+
import {
|
|
17
|
+
type ExportContext,
|
|
18
|
+
type ExportOptions,
|
|
19
|
+
type ExportResult,
|
|
20
|
+
canvasShellUrl,
|
|
21
|
+
} from './index.ts';
|
|
22
|
+
import type { Target } from './scope.ts';
|
|
23
|
+
|
|
24
|
+
const SVG_PLAYWRIGHT = path.join(import.meta.dir, '..', 'bin', '_svg-playwright.mjs');
|
|
25
|
+
|
|
26
|
+
async function captureSvg(
|
|
27
|
+
target: Extract<Target, { kind: 'element' }>,
|
|
28
|
+
ctx: ExportContext,
|
|
29
|
+
outDir: string,
|
|
30
|
+
timeoutSec: number,
|
|
31
|
+
bundlePath: string
|
|
32
|
+
): Promise<string[]> {
|
|
33
|
+
const args = [
|
|
34
|
+
SVG_PLAYWRIGHT,
|
|
35
|
+
'--url',
|
|
36
|
+
canvasShellUrl(ctx, target.file),
|
|
37
|
+
'--selector',
|
|
38
|
+
target.cssPath,
|
|
39
|
+
'--bundle-path',
|
|
40
|
+
bundlePath,
|
|
41
|
+
'--timeout',
|
|
42
|
+
String(timeoutSec),
|
|
43
|
+
];
|
|
44
|
+
if (target.multi) {
|
|
45
|
+
args.push('--multi', '1', '--out-dir', outDir);
|
|
46
|
+
} else {
|
|
47
|
+
args.push('--widen-to-artboard', '1', '--out', path.join(outDir, `${target.canvasSlug}.svg`));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Run via `node` so the shim's `import 'playwright'` resolves against
|
|
51
|
+
// dev-server/node_modules (playwright is a devDep). `npm exec` doesn't
|
|
52
|
+
// bridge the module path for ESM imports — confirmed against npm 10.x.
|
|
53
|
+
const proc = Bun.spawn(['node', ...args], {
|
|
54
|
+
cwd: path.dirname(SVG_PLAYWRIGHT),
|
|
55
|
+
stdout: 'pipe',
|
|
56
|
+
stderr: 'pipe',
|
|
57
|
+
});
|
|
58
|
+
const [stdout, stderr] = await Promise.all([
|
|
59
|
+
new Response(proc.stdout).text(),
|
|
60
|
+
new Response(proc.stderr).text(),
|
|
61
|
+
]);
|
|
62
|
+
const code = await proc.exited;
|
|
63
|
+
if (code !== 0) {
|
|
64
|
+
throw new Error(`_svg-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`);
|
|
65
|
+
}
|
|
66
|
+
return stdout
|
|
67
|
+
.split('\n')
|
|
68
|
+
.map((s) => s.trim())
|
|
69
|
+
.filter(Boolean);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function run(
|
|
73
|
+
targets: Target[],
|
|
74
|
+
options: ExportOptions,
|
|
75
|
+
ctx: ExportContext
|
|
76
|
+
): Promise<ExportResult> {
|
|
77
|
+
if (!targets.length) {
|
|
78
|
+
return { filename: 'export.svg', contentType: 'image/svg+xml', body: new Uint8Array(0) };
|
|
79
|
+
}
|
|
80
|
+
const elementTargets = targets.filter(
|
|
81
|
+
(t): t is Extract<Target, { kind: 'element' }> => t.kind === 'element'
|
|
82
|
+
);
|
|
83
|
+
if (!elementTargets.length) {
|
|
84
|
+
throw new Error('svg adapter requires element targets (got file-tree)');
|
|
85
|
+
}
|
|
86
|
+
const timeoutSec = (options.timeoutSec as number | undefined) ?? 12;
|
|
87
|
+
const bundlePath = await getBrowserBundle('dom-to-svg', 'domToSvg');
|
|
88
|
+
const tmp = mkdtempSync(path.join(tmpdir(), 'maude-svg-'));
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const written: string[] = [];
|
|
92
|
+
for (const t of elementTargets) {
|
|
93
|
+
const paths = await captureSvg(t, ctx, tmp, timeoutSec, bundlePath);
|
|
94
|
+
written.push(...paths);
|
|
95
|
+
}
|
|
96
|
+
if (!written.length) {
|
|
97
|
+
return { filename: 'export.svg', contentType: 'image/svg+xml', body: new Uint8Array(0) };
|
|
98
|
+
}
|
|
99
|
+
const entries = written.map((p) => ({
|
|
100
|
+
name: path.basename(p),
|
|
101
|
+
bytes: new Uint8Array(readFileSync(p)),
|
|
102
|
+
}));
|
|
103
|
+
if (entries.length === 1) {
|
|
104
|
+
return {
|
|
105
|
+
filename: entries[0].name,
|
|
106
|
+
contentType: 'image/svg+xml',
|
|
107
|
+
body: entries[0].bytes,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const zip = new JSZip();
|
|
111
|
+
for (const e of entries) zip.file(e.name, e.bytes);
|
|
112
|
+
const zipBytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
|
|
113
|
+
const baseSlug = elementTargets[0]?.canvasSlug ?? 'export';
|
|
114
|
+
return {
|
|
115
|
+
filename: `${baseSlug}.svg.zip`,
|
|
116
|
+
contentType: 'application/zip',
|
|
117
|
+
body: zipBytes,
|
|
118
|
+
};
|
|
119
|
+
} finally {
|
|
120
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Phase 6.5 T7 — project-raw ZIP adapter.
|
|
2
|
+
//
|
|
3
|
+
// Consumes one or more file-tree targets (typically just the single one the
|
|
4
|
+
// scope resolver returns for `project-raw`) and streams them into a ZIP.
|
|
5
|
+
// Excludes runtime + node_modules + dist by default; user can override via
|
|
6
|
+
// `options.exclude` (gitignore-style globs) or `options.include` (filter to
|
|
7
|
+
// `'system' | 'canvases' | 'assets' | 'meta'` subtrees).
|
|
8
|
+
//
|
|
9
|
+
// Memory profile: JSZip's `generateAsync({ type: 'uint8array' })` builds the
|
|
10
|
+
// archive in memory. Plan T7 calls for streaming via Bun's Response stream
|
|
11
|
+
// to bound RSS at large designRoots — that's a refinement once a real user
|
|
12
|
+
// hits the buffer ceiling. Until then, the buffered path keeps the contract
|
|
13
|
+
// simple (Uint8Array body matches every other adapter).
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
import JSZip from 'jszip';
|
|
19
|
+
|
|
20
|
+
import type { ExportContext, ExportOptions, ExportResult } from './index.ts';
|
|
21
|
+
import type { Target } from './scope.ts';
|
|
22
|
+
|
|
23
|
+
type IncludeTag = 'system' | 'canvases' | 'assets' | 'meta';
|
|
24
|
+
|
|
25
|
+
function matchesGlob(p: string, glob: string): boolean {
|
|
26
|
+
// Tiny gitignore-style matcher. Supports `*`, `**`, and a trailing slash to
|
|
27
|
+
// mean "directory". Anything fancier (negation, brace expansion) is left to
|
|
28
|
+
// the user — surface in T13 docs.
|
|
29
|
+
const norm = glob.replace(/^\.\//, '').replace(/\/+$/, '');
|
|
30
|
+
if (norm.includes('**')) {
|
|
31
|
+
const re = new RegExp(
|
|
32
|
+
`^${norm
|
|
33
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
34
|
+
.replace(/\*\*/g, '.*')
|
|
35
|
+
.replace(/\*/g, '[^/]*')}(?:/.*)?$`
|
|
36
|
+
);
|
|
37
|
+
return re.test(p);
|
|
38
|
+
}
|
|
39
|
+
if (norm.includes('*')) {
|
|
40
|
+
const re = new RegExp(`^${norm.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')}$`);
|
|
41
|
+
return re.test(p);
|
|
42
|
+
}
|
|
43
|
+
return p === norm || p.startsWith(`${norm}/`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isUnder(p: string, prefix: string): boolean {
|
|
47
|
+
return p === prefix || p.startsWith(`${prefix}/`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function filterPaths(paths: string[], options: ExportOptions): string[] {
|
|
51
|
+
const exclude = Array.isArray(options.exclude) ? (options.exclude as string[]) : [];
|
|
52
|
+
const include = Array.isArray(options.include) ? (options.include as IncludeTag[]) : null;
|
|
53
|
+
const tagPrefix: Record<IncludeTag, string> = {
|
|
54
|
+
system: 'system',
|
|
55
|
+
canvases: 'ui',
|
|
56
|
+
assets: 'assets',
|
|
57
|
+
meta: '',
|
|
58
|
+
};
|
|
59
|
+
return paths.filter((p) => {
|
|
60
|
+
if (exclude.some((g) => matchesGlob(p, g))) return false;
|
|
61
|
+
if (include) {
|
|
62
|
+
const hit = include.some((tag) => {
|
|
63
|
+
if (tag === 'meta') {
|
|
64
|
+
return /\.(json|md|css)$/i.test(p) && !p.includes('/');
|
|
65
|
+
}
|
|
66
|
+
return isUnder(p, tagPrefix[tag]);
|
|
67
|
+
});
|
|
68
|
+
if (!hit) return false;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function run(
|
|
75
|
+
targets: Target[],
|
|
76
|
+
options: ExportOptions,
|
|
77
|
+
ctx: ExportContext
|
|
78
|
+
): Promise<ExportResult> {
|
|
79
|
+
if (!targets.length) {
|
|
80
|
+
return { filename: 'project.zip', contentType: 'application/zip', body: new Uint8Array(0) };
|
|
81
|
+
}
|
|
82
|
+
const fileTreeTargets = targets.filter(
|
|
83
|
+
(t): t is Extract<Target, { kind: 'file-tree' }> => t.kind === 'file-tree'
|
|
84
|
+
);
|
|
85
|
+
if (!fileTreeTargets.length) {
|
|
86
|
+
throw new Error('zip adapter requires file-tree targets (got element)');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const allPaths = fileTreeTargets.flatMap((t) => t.paths);
|
|
90
|
+
const kept = filterPaths(allPaths, options);
|
|
91
|
+
|
|
92
|
+
const zip = new JSZip();
|
|
93
|
+
for (const rel of kept) {
|
|
94
|
+
const abs = path.join(ctx.designRoot, rel);
|
|
95
|
+
try {
|
|
96
|
+
const bytes = readFileSync(abs);
|
|
97
|
+
zip.file(rel, new Uint8Array(bytes));
|
|
98
|
+
} catch {
|
|
99
|
+
// File vanished between scope walk and read — skip.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
|
|
103
|
+
const projectName = path.basename(ctx.designRoot).replace(/^\./, '') || 'project';
|
|
104
|
+
return {
|
|
105
|
+
filename: `${projectName}.zip`,
|
|
106
|
+
contentType: 'application/zip',
|
|
107
|
+
body: bytes,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -13,6 +13,8 @@ import { buildCanvasModule } from './canvas-build.ts';
|
|
|
13
13
|
import { canvasLibPath } from './canvas-lib-resolver.ts';
|
|
14
14
|
import { TranspileError } from './canvas-pipeline.ts';
|
|
15
15
|
import type { Context } from './context.ts';
|
|
16
|
+
import { isFormat, isScope, runExport } from './exporters/index.ts';
|
|
17
|
+
import type { ActiveJsonShape } from './exporters/scope.ts';
|
|
16
18
|
import type { Inspect } from './inspect.ts';
|
|
17
19
|
import { canvasSlug, writeLocator } from './locator.ts';
|
|
18
20
|
import { RUNTIME_PACKAGES, getRuntimeBundle, packageForSlug, slugFor } from './runtime-bundle.ts';
|
|
@@ -254,6 +256,15 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect): Http {
|
|
|
254
256
|
return new Response('Method not allowed', { status: 405 });
|
|
255
257
|
},
|
|
256
258
|
|
|
259
|
+
'/_api/git-committers': async (req: Request) => {
|
|
260
|
+
// Phase 6 — feed for the @mention autocomplete in composer + reply box.
|
|
261
|
+
// GET → top-20 committers on HEAD (`git shortlog -sne | head -20`)
|
|
262
|
+
// already cached server-side for 60 s.
|
|
263
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
264
|
+
const committers = await api.gitCommitters();
|
|
265
|
+
return Response.json({ committers }, { headers: { 'Cache-Control': 'no-store' } });
|
|
266
|
+
},
|
|
267
|
+
|
|
257
268
|
'/_api/annotations': async (req: Request) => {
|
|
258
269
|
// Phase 5 — `<designRoot>/<slug>.annotations.svg` read / overwrite.
|
|
259
270
|
// GET ?file=<repo-relative-canvas-path> → SVG text (empty if absent)
|
|
@@ -286,6 +297,84 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect): Http {
|
|
|
286
297
|
return new Response('Method not allowed', { status: 405 });
|
|
287
298
|
},
|
|
288
299
|
|
|
300
|
+
'/_api/export-history': async (req: Request) => {
|
|
301
|
+
// Phase 6.5 T10 — read-only recent-exports feed for the dialog's
|
|
302
|
+
// Recent tab. Writes happen as a side-effect of `/_api/export`.
|
|
303
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
304
|
+
const history = await api.loadExportHistory();
|
|
305
|
+
return Response.json({ history }, { headers: { 'Cache-Control': 'no-store' } });
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
'/_api/export': async (req: Request) => {
|
|
309
|
+
// Phase 6.5 — single dispatch endpoint for the export pipeline.
|
|
310
|
+
// POST body { format, scope, options? } → binary stream with
|
|
311
|
+
// Content-Disposition + Content-Type set by the adapter.
|
|
312
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
313
|
+
const body = await readJson<{
|
|
314
|
+
format?: unknown;
|
|
315
|
+
scope?: unknown;
|
|
316
|
+
options?: Record<string, unknown>;
|
|
317
|
+
}>(req, 64 * 1024);
|
|
318
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
319
|
+
if (!isFormat(body.format)) return new Response('unknown or missing format', { status: 400 });
|
|
320
|
+
if (!isScope(body.scope)) return new Response('unknown or missing scope', { status: 400 });
|
|
321
|
+
// `inspect.state` is the live `_active.json` — readers narrow to the
|
|
322
|
+
// resolver's subset locally so the export pipeline doesn't pin the
|
|
323
|
+
// wider ActiveState interface.
|
|
324
|
+
const activeJson = inspect.state as unknown as ActiveJsonShape;
|
|
325
|
+
try {
|
|
326
|
+
const result = await runExport({
|
|
327
|
+
format: body.format,
|
|
328
|
+
scope: body.scope,
|
|
329
|
+
options: body.options ?? {},
|
|
330
|
+
resolve: { activeJson, designRoot: ctx.paths.designRoot, repoRoot: ctx.paths.repoRoot },
|
|
331
|
+
ctx: {
|
|
332
|
+
designRoot: ctx.paths.designRoot,
|
|
333
|
+
repoRoot: ctx.paths.repoRoot,
|
|
334
|
+
// Adapters reach back into the server via this origin only when
|
|
335
|
+
// they need Playwright rendering (PNG / PDF / SVG / HTML). The
|
|
336
|
+
// host that received this request is, by definition, the one
|
|
337
|
+
// serving the canvas.
|
|
338
|
+
serverOrigin: new URL(req.url).origin,
|
|
339
|
+
// Mirror `client/app.jsx:85` — the per-DS tokensCssRel wins over
|
|
340
|
+
// the legacy top-level default (which still points at the pre-
|
|
341
|
+
// multi-DS layout `system/colors_and_type.css`). Without the
|
|
342
|
+
// per-DS path, the standalone `_canvas-shell.html` 404s on the
|
|
343
|
+
// tokens link and the rendered DOM uses `var(--bg-0)` unresolved
|
|
344
|
+
// → screenshots come out blank. See canvasShellUrl().
|
|
345
|
+
tokensCssRel: ctx.cfg.designSystems?.[0]?.tokensCssRel ?? ctx.cfg.tokensCssRel,
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
// Fire-and-forget history append — failure here doesn't block the
|
|
349
|
+
// download. Synchronous await keeps the order: history reflects the
|
|
350
|
+
// export the moment the client sees a 200.
|
|
351
|
+
try {
|
|
352
|
+
await api.appendExportHistory({
|
|
353
|
+
format: body.format,
|
|
354
|
+
scope: body.scope,
|
|
355
|
+
options: body.options ?? {},
|
|
356
|
+
filename: result.filename,
|
|
357
|
+
at: new Date().toISOString(),
|
|
358
|
+
});
|
|
359
|
+
} catch {
|
|
360
|
+
/* ignore — history is best-effort */
|
|
361
|
+
}
|
|
362
|
+
// Bun.serve accepts Uint8Array directly; the cast satisfies the
|
|
363
|
+
// SharedArrayBuffer-strict BodyInit narrowing on @types/bun.
|
|
364
|
+
return new Response(result.body as unknown as BodyInit, {
|
|
365
|
+
status: 200,
|
|
366
|
+
headers: {
|
|
367
|
+
'Content-Type': result.contentType,
|
|
368
|
+
'Content-Disposition': `attachment; filename="${result.filename}"`,
|
|
369
|
+
'Cache-Control': 'no-store',
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
} catch (err) {
|
|
373
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
374
|
+
return new Response(`export failed: ${msg}`, { status: 500 });
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
|
|
289
378
|
'/_canvas-state': async (req: Request) => {
|
|
290
379
|
const url = new URL(req.url);
|
|
291
380
|
if (req.method === 'GET') {
|
|
@@ -325,6 +414,26 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect): Http {
|
|
|
325
414
|
const url = new URL(req.url);
|
|
326
415
|
const pathname = url.pathname;
|
|
327
416
|
|
|
417
|
+
// Phase 6 — POST /_api/comments/<id>/reply. Dynamic path, so it lives in
|
|
418
|
+
// the fall-through instead of the static `routes` map. `<id>` is the
|
|
419
|
+
// c_<hex> id of the parent comment; body is `{ body, author? }`. Bodies
|
|
420
|
+
// share the same 4000-char cap as a top-level comment.
|
|
421
|
+
const replyMatch = pathname.match(/^\/_api\/comments\/([A-Za-z0-9_]+)\/reply$/);
|
|
422
|
+
if (replyMatch) {
|
|
423
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
424
|
+
const id = replyMatch[1] ?? '';
|
|
425
|
+
const body = await readJson<{ body?: string; author?: string }>(req);
|
|
426
|
+
if (!body || typeof body.body !== 'string' || !body.body.trim()) {
|
|
427
|
+
return new Response('body.body required', { status: 400 });
|
|
428
|
+
}
|
|
429
|
+
const next = await api.commentsAddReply(id, {
|
|
430
|
+
body: body.body,
|
|
431
|
+
author: typeof body.author === 'string' ? body.author : undefined,
|
|
432
|
+
});
|
|
433
|
+
if (!next) return new Response('Not found', { status: 404 });
|
|
434
|
+
return Response.json(next, { headers: { 'Cache-Control': 'no-store' } });
|
|
435
|
+
}
|
|
436
|
+
|
|
328
437
|
// Bundled client assets (preferred path — bundle from dist/).
|
|
329
438
|
if (pathname.startsWith('/_client/')) {
|
|
330
439
|
const rel = decodeURIComponent(pathname.slice('/_client/'.length));
|
|
@@ -240,6 +240,21 @@ export function isEditableTarget(t: EventTarget | null): boolean {
|
|
|
240
240
|
return false;
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Phase 6 — the comments overlay (pins / composer / thread popover / mention
|
|
245
|
+
* popup) lives INSIDE the canvas world, which means its DOM nodes are inside
|
|
246
|
+
* the input-router's capture host. Without an explicit bail-out the router
|
|
247
|
+
* would `preventDefault + stopImmediatePropagation` every click on a
|
|
248
|
+
* composer button while comment mode is active, blocking Save / Cancel.
|
|
249
|
+
*
|
|
250
|
+
* We treat the overlay nodes like editable form widgets — the router yields,
|
|
251
|
+
* the React event handler runs.
|
|
252
|
+
*/
|
|
253
|
+
export function isOverlayTarget(t: EventTarget | null): boolean {
|
|
254
|
+
if (!t || !(t as Element).closest) return false;
|
|
255
|
+
return !!(t as Element).closest('.cm-composer, .cm-thread, .cm-mention-popup, .cm-pin');
|
|
256
|
+
}
|
|
257
|
+
|
|
243
258
|
export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
244
259
|
const { hostRef, getActiveTool, isSpaceHeld, callbacks, enabled = true } = opts;
|
|
245
260
|
|
|
@@ -290,6 +305,10 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
290
305
|
};
|
|
291
306
|
|
|
292
307
|
const onPointerDown = (e: PointerEvent): void => {
|
|
308
|
+
// Phase 6 — overlay surfaces (composer / thread / mention popup) own
|
|
309
|
+
// their own clicks. The router is in capture phase, so we have to
|
|
310
|
+
// bail HERE before classify can claim the event.
|
|
311
|
+
if (isOverlayTarget(e.target)) return;
|
|
293
312
|
const action = classify({
|
|
294
313
|
type: 'pointerdown',
|
|
295
314
|
button: e.button,
|
|
@@ -321,6 +340,7 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
321
340
|
* stop their twin mousedown.
|
|
322
341
|
*/
|
|
323
342
|
const onMouseDown = (e: MouseEvent): void => {
|
|
343
|
+
if (isOverlayTarget(e.target)) return;
|
|
324
344
|
const action = classify({
|
|
325
345
|
type: 'pointerdown',
|
|
326
346
|
button: e.button,
|
|
@@ -346,6 +366,7 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
346
366
|
* matching pointerdown (re-classify with the same modifiers).
|
|
347
367
|
*/
|
|
348
368
|
const onClick = (e: MouseEvent): void => {
|
|
369
|
+
if (isOverlayTarget(e.target)) return;
|
|
349
370
|
const tool = getActiveTool();
|
|
350
371
|
const mod = e.metaKey || e.ctrlKey;
|
|
351
372
|
const wouldRoute =
|
|
@@ -227,7 +227,7 @@ const INSPECTOR_SCRIPT = `
|
|
|
227
227
|
|
|
228
228
|
var styleEl = document.createElement('style');
|
|
229
229
|
styleEl.textContent = [
|
|
230
|
-
'.dgn-pin { position: absolute; top: 0; left: 0; z-index: 2147483646; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 999px 999px 999px 4px; background: #facc15; color: #1c1917; font: 600 11px/22px ui-
|
|
230
|
+
'.dgn-pin { position: absolute; top: 0; left: 0; z-index: 2147483646; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 999px 999px 999px 4px; background: #facc15; color: #1c1917; font: 600 11px/22px var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); text-align: center; cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,0.5), 0 0 0 1px rgba(0,0,0,0.4); transition: filter 120ms; transform-origin: bottom left; will-change: transform; }',
|
|
231
231
|
'.dgn-pin:hover { filter: brightness(1.1); outline: 2px solid rgba(0,0,0,0.3); }',
|
|
232
232
|
'.dgn-pin.resolved { background: #22c55e; color: #052e16; }',
|
|
233
233
|
'.dgn-pin.focused { box-shadow: 0 4px 12px rgba(0,0,0,0.6), 0 0 0 2px #fff; outline: 2px solid #fff; }'
|
|
@@ -573,7 +573,7 @@ const INSPECTOR_SCRIPT = `
|
|
|
573
573
|
'.dgn-insp-selected { outline: 2px solid #00D4E4 !important; outline-offset: 1px !important; box-shadow: 0 0 0 4px rgba(0,212,228,0.18) !important; }',
|
|
574
574
|
'.dgn-insp-label { position: fixed; z-index: 2147483647; font: 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace; background: #00D4E4; color: #000; padding: 4px 8px; border-radius: 4px; pointer-events: none; box-shadow: 0 2px 8px rgba(0,0,0,0.4); transform: translate(0, -110%); white-space: nowrap; max-width: 320px; overflow: hidden; text-overflow: ellipsis; }',
|
|
575
575
|
'.dgn-insp-label.warn { background: #ef4444; color: #fff; }',
|
|
576
|
-
'.dgn-pin { position: absolute; top: 0; left: 0; z-index: 2147483646; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 999px 999px 999px 4px; background: #facc15; color: #1c1917; font: 600 11px/22px ui-
|
|
576
|
+
'.dgn-pin { position: absolute; top: 0; left: 0; z-index: 2147483646; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 999px 999px 999px 4px; background: #facc15; color: #1c1917; font: 600 11px/22px var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); text-align: center; cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,0.5), 0 0 0 1px rgba(0,0,0,0.4); transition: filter 120ms; transform-origin: bottom left; will-change: transform; }',
|
|
577
577
|
'.dgn-pin:hover { filter: brightness(1.1); outline: 2px solid rgba(0,0,0,0.3); }',
|
|
578
578
|
'.dgn-pin.resolved { background: #22c55e; color: #052e16; }',
|
|
579
579
|
'.dgn-pin.focused { box-shadow: 0 4px 12px rgba(0,0,0,0.6), 0 0 0 2px #fff; outline: 2px solid #fff; }'
|
|
@@ -24,16 +24,6 @@ interface MetaShape {
|
|
|
24
24
|
[k: string]: unknown;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
function seedCanvas(designRoot: string, name = 'Phase4.tsx', meta?: MetaShape): string {
|
|
28
|
-
const ui = join(designRoot, 'ui');
|
|
29
|
-
mkdirSync(ui, { recursive: true });
|
|
30
|
-
const tsxPath = join(ui, name);
|
|
31
|
-
writeFileSync(tsxPath, 'export default function P(){return <main/>}\n');
|
|
32
|
-
const metaPath = tsxPath.replace(/\.tsx$/, '.meta.json');
|
|
33
|
-
if (meta) writeFileSync(metaPath, JSON.stringify(meta, null, 2));
|
|
34
|
-
return tsxPath.replace(`${designRoot.replace(/\.design$/, '')}`, '').replace(/^\/+/, '');
|
|
35
|
-
}
|
|
36
|
-
|
|
37
27
|
function repoRel(designRoot: string, abs: string): string {
|
|
38
28
|
// designRoot ends in `.design`. repoRoot is its parent.
|
|
39
29
|
const repoRoot = designRoot.replace(/\.design$/, '').replace(/\/+$/, '');
|