@1agh/maude 0.40.0 → 0.42.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/apps/studio/ai-banner.tsx +2 -2
- package/apps/studio/annotations-layer.tsx +53 -2
- package/apps/studio/api.ts +504 -44
- package/apps/studio/artboard-marquee.tsx +1 -1
- package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
- package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
- package/apps/studio/bin/_fetch-asset.mjs +556 -0
- package/apps/studio/bin/_html-playwright.mjs +26 -4
- package/apps/studio/bin/_pdf-playwright.mjs +13 -2
- package/apps/studio/bin/_png-playwright.mjs +15 -2
- package/apps/studio/bin/_pptx-playwright.mjs +17 -4
- package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
- package/apps/studio/bin/_svg-playwright.mjs +26 -4
- package/apps/studio/bin/annotate.mjs +576 -34
- package/apps/studio/bin/canvas-rects.sh +152 -0
- package/apps/studio/bin/fetch-asset.sh +34 -0
- package/apps/studio/bin/read-annotations.mjs +138 -7
- package/apps/studio/bin/screenshot.sh +53 -4
- package/apps/studio/bin/smoke.sh +42 -6
- package/apps/studio/build.ts +21 -0
- package/apps/studio/canvas-comment-mount.tsx +138 -4
- package/apps/studio/canvas-edit.ts +744 -11
- package/apps/studio/canvas-lib.tsx +219 -2
- package/apps/studio/canvas-shell.tsx +487 -20
- package/apps/studio/client/app.jsx +1476 -76
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/export-center.jsx +426 -0
- package/apps/studio/client/github.js +8 -0
- package/apps/studio/client/styles/3-shell-maude.css +65 -0
- package/apps/studio/client/styles/4-components.css +150 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/config.schema.json +2 -2
- package/apps/studio/context-menu.tsx +15 -5
- package/apps/studio/contextual-toolbar.tsx +262 -4
- package/apps/studio/cursors-overlay.tsx +4 -4
- package/apps/studio/dist/client.bundle.js +20 -20
- package/apps/studio/dist/comment-mount.js +59 -1
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +127 -1
- package/apps/studio/drag-state.ts +24 -0
- package/apps/studio/equal-spacing-detector.ts +205 -0
- package/apps/studio/export-dialog.tsx +19 -26
- package/apps/studio/exporters/_runtime.ts +104 -0
- package/apps/studio/exporters/html.ts +12 -20
- package/apps/studio/exporters/index.ts +14 -2
- package/apps/studio/exporters/jobs.ts +334 -0
- package/apps/studio/exporters/pdf.ts +16 -20
- package/apps/studio/exporters/png.ts +12 -20
- package/apps/studio/exporters/pptx.ts +22 -23
- package/apps/studio/exporters/scope.ts +1 -0
- package/apps/studio/exporters/svg.ts +14 -22
- package/apps/studio/exporters/video.ts +15 -17
- package/apps/studio/git/service.ts +3 -1
- package/apps/studio/history.ts +47 -1
- package/apps/studio/http.ts +368 -50
- package/apps/studio/input-router.tsx +12 -0
- package/apps/studio/marquee-overlay.tsx +1 -1
- package/apps/studio/measure-overlay.tsx +241 -0
- package/apps/studio/participants-chrome.tsx +3 -3
- package/apps/studio/server.ts +3 -1
- package/apps/studio/sizing-mode.ts +117 -0
- package/apps/studio/spacing-handles.ts +166 -0
- package/apps/studio/test/annotate-write.test.ts +890 -0
- package/apps/studio/test/camera-reveal.test.tsx +173 -0
- package/apps/studio/test/canvas-edit.test.ts +50 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +24 -0
- package/apps/studio/test/canvas-rects.test.ts +198 -0
- package/apps/studio/test/comments-overlay.test.ts +117 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
- package/apps/studio/test/dom-selection.test.ts +130 -0
- package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
- package/apps/studio/test/edit-scope-api.test.ts +115 -0
- package/apps/studio/test/element-resize.test.ts +136 -0
- package/apps/studio/test/element-structural-api.test.ts +360 -0
- package/apps/studio/test/element-structural-edit.test.ts +233 -0
- package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
- package/apps/studio/test/export-center.test.tsx +287 -0
- package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
- package/apps/studio/test/exporters/history.test.ts +32 -3
- package/apps/studio/test/exporters/jobs.test.ts +263 -0
- package/apps/studio/test/history-rollback.test.ts +26 -0
- package/apps/studio/test/knob-props-authored.test.ts +87 -0
- package/apps/studio/test/read-annotations.test.ts +154 -0
- package/apps/studio/test/sizing-mode.test.ts +102 -0
- package/apps/studio/test/spacing-handles.test.ts +138 -0
- package/apps/studio/test/specimen-select.test.ts +88 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
- package/apps/studio/tool-palette.tsx +122 -2
- package/apps/studio/undo-hud.tsx +2 -2
- package/apps/studio/use-element-resize.tsx +732 -0
- package/apps/studio/use-keyboard-discipline.tsx +205 -15
- package/apps/studio/use-selection-set.tsx +14 -0
- package/apps/studio/use-spacing-handles.tsx +388 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +6 -0
- package/cli/commands/design.mjs +6 -1
- package/cli/commands/design.test.mjs +49 -1
- package/cli/lib/fetch-asset.test.mjs +213 -0
- package/cli/lib/gitignore-block.mjs +1 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +10 -2
package/apps/studio/http.ts
CHANGED
|
@@ -16,7 +16,8 @@ import { canvasLibPath } from './canvas-lib-resolver.ts';
|
|
|
16
16
|
import { TranspileError } from './canvas-pipeline.ts';
|
|
17
17
|
import type { AiActivity } from './collab/ai-activity.ts';
|
|
18
18
|
import type { Context } from './context.ts';
|
|
19
|
-
import { isFormat, isScope,
|
|
19
|
+
import { type Format, isFormat, isScope, type Scope } from './exporters/index.ts';
|
|
20
|
+
import { type ExportJobQueue, ExportQueueFullError } from './exporters/jobs.ts';
|
|
20
21
|
import type { ActiveJsonShape } from './exporters/scope.ts';
|
|
21
22
|
import { createGitEndpoints } from './git/endpoints.ts';
|
|
22
23
|
import { gitShowFile } from './git/service.ts';
|
|
@@ -594,7 +595,13 @@ export interface Http {
|
|
|
594
595
|
isCanvasSafeRoute(pathname: string): boolean;
|
|
595
596
|
}
|
|
596
597
|
|
|
597
|
-
export function createHttp(
|
|
598
|
+
export function createHttp(
|
|
599
|
+
ctx: Context,
|
|
600
|
+
api: Api,
|
|
601
|
+
inspect: Inspect,
|
|
602
|
+
ai: AiActivity,
|
|
603
|
+
exportJobs: ExportJobQueue
|
|
604
|
+
): Http {
|
|
598
605
|
// Cache invalidation — when canvas-lib changes, every cached canvas bundle
|
|
599
606
|
// is stale because canvas-lib is inlined into each one via the resolver
|
|
600
607
|
// plugin. Drop the whole cache so the next request rebuilds with the fresh
|
|
@@ -722,6 +729,38 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
722
729
|
const gitJson = (r: { status: number; json: unknown }) =>
|
|
723
730
|
Response.json(r.json, { status: r.status, headers: { 'Cache-Control': 'no-store' } });
|
|
724
731
|
|
|
732
|
+
// Shared by /_api/export and /_api/export-jobs — build the exportJobs.enqueue()
|
|
733
|
+
// args from a validated request body. `inspect.state` is the live `_active.json`;
|
|
734
|
+
// readers narrow to the resolver's subset locally so the export pipeline doesn't
|
|
735
|
+
// pin the wider ActiveState interface.
|
|
736
|
+
function buildExportArgs(
|
|
737
|
+
req: Request,
|
|
738
|
+
body: { format: Format; scope: Scope; options?: Record<string, unknown> }
|
|
739
|
+
) {
|
|
740
|
+
const activeJson = inspect.state as unknown as ActiveJsonShape;
|
|
741
|
+
return {
|
|
742
|
+
format: body.format,
|
|
743
|
+
scope: body.scope,
|
|
744
|
+
options: body.options ?? {},
|
|
745
|
+
resolve: { activeJson, designRoot: ctx.paths.designRoot, repoRoot: ctx.paths.repoRoot },
|
|
746
|
+
ctx: {
|
|
747
|
+
designRoot: ctx.paths.designRoot,
|
|
748
|
+
repoRoot: ctx.paths.repoRoot,
|
|
749
|
+
// Adapters reach back into the server via this origin only when they
|
|
750
|
+
// need Playwright rendering (PNG / PDF / SVG / HTML). The host that
|
|
751
|
+
// received this request is, by definition, the one serving the canvas.
|
|
752
|
+
serverOrigin: new URL(req.url).origin,
|
|
753
|
+
// Mirror `client/app.jsx:85` — the per-DS tokensCssRel wins over the
|
|
754
|
+
// legacy top-level default (which still points at the pre-multi-DS
|
|
755
|
+
// layout `system/colors_and_type.css`). Without the per-DS path, the
|
|
756
|
+
// standalone `_canvas-shell.html` 404s on the tokens link and the
|
|
757
|
+
// rendered DOM uses `var(--bg-0)` unresolved → screenshots come out
|
|
758
|
+
// blank. See canvasShellUrl().
|
|
759
|
+
tokensCssRel: ctx.cfg.designSystems?.[0]?.tokensCssRel ?? ctx.cfg.tokensCssRel,
|
|
760
|
+
},
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
|
|
725
764
|
const routes = {
|
|
726
765
|
'/_health': () =>
|
|
727
766
|
Response.json({
|
|
@@ -1409,6 +1448,7 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1409
1448
|
property?: unknown;
|
|
1410
1449
|
value?: unknown;
|
|
1411
1450
|
reset?: unknown;
|
|
1451
|
+
idIndex?: unknown;
|
|
1412
1452
|
}>(req, 8 * 1024);
|
|
1413
1453
|
if (!body) return new Response('body required', { status: 400 });
|
|
1414
1454
|
const result = await api.editCss(body);
|
|
@@ -1780,6 +1820,228 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1780
1820
|
);
|
|
1781
1821
|
},
|
|
1782
1822
|
|
|
1823
|
+
'/_api/delete-element': async (req: Request) => {
|
|
1824
|
+
// Stage I (feature-element-editing-robustness) — delete an element by
|
|
1825
|
+
// data-cd-id. POST { canvas, id, idIndex? } → api.deleteElementOp (reparse
|
|
1826
|
+
// gate; reused-component instance via idIndex). Logs a whole-file undo seq
|
|
1827
|
+
// (Cmd+Z via /_api/reorder-revert — a structural edit churns positional
|
|
1828
|
+
// ids so an inverse descriptor goes stale). Same MAIN-ORIGIN-ONLY trust
|
|
1829
|
+
// boundary as /_api/reorder: absent from CANVAS_SAFE_API + startCanvasServer
|
|
1830
|
+
// routes (DDR-054); sameOriginWrite CSRF + loopback-Host (DNS-rebinding) gated.
|
|
1831
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1832
|
+
if (!sameOriginWrite(req))
|
|
1833
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1834
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1835
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1836
|
+
const body = await readJson<{ canvas?: unknown; id?: unknown; idIndex?: unknown }>(
|
|
1837
|
+
req,
|
|
1838
|
+
8 * 1024
|
|
1839
|
+
);
|
|
1840
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
1841
|
+
const result = await api.deleteElementOp(body);
|
|
1842
|
+
if (!result.ok) {
|
|
1843
|
+
return Response.json(
|
|
1844
|
+
{ ok: false, error: result.error },
|
|
1845
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1848
|
+
return Response.json(
|
|
1849
|
+
{ ok: true, deletedId: result.deletedId, seq: result.seq },
|
|
1850
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
1851
|
+
);
|
|
1852
|
+
},
|
|
1853
|
+
|
|
1854
|
+
'/_api/insert-element': async (req: Request) => {
|
|
1855
|
+
// Stage I — insert a synthesized div/text/image relative to `refId`. POST
|
|
1856
|
+
// { canvas, refId, position, kind, src?, refIndex? } → api.insertElementOp.
|
|
1857
|
+
// Returns the new element's post-transpile id so the shell can select it.
|
|
1858
|
+
// Whole-file undo seq. MAIN-ORIGIN ONLY (absent from both allowlists);
|
|
1859
|
+
// sameOriginWrite + loopback-Host gated. An `image` src is contained to
|
|
1860
|
+
// assets/ in the engine (no remote hotlink / ../ / scheme).
|
|
1861
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1862
|
+
if (!sameOriginWrite(req))
|
|
1863
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1864
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1865
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1866
|
+
const body = await readJson<{
|
|
1867
|
+
canvas?: unknown;
|
|
1868
|
+
refId?: unknown;
|
|
1869
|
+
position?: unknown;
|
|
1870
|
+
kind?: unknown;
|
|
1871
|
+
src?: unknown;
|
|
1872
|
+
refIndex?: unknown;
|
|
1873
|
+
}>(req, 8 * 1024);
|
|
1874
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
1875
|
+
const result = await api.insertElementOp(body);
|
|
1876
|
+
if (!result.ok) {
|
|
1877
|
+
return Response.json(
|
|
1878
|
+
{ ok: false, error: result.error },
|
|
1879
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
1880
|
+
);
|
|
1881
|
+
}
|
|
1882
|
+
return Response.json(
|
|
1883
|
+
{ ok: true, newId: result.newId, seq: result.seq },
|
|
1884
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
1885
|
+
);
|
|
1886
|
+
},
|
|
1887
|
+
|
|
1888
|
+
'/_api/duplicate-element': async (req: Request) => {
|
|
1889
|
+
// Cmd+D (Task L3) — duplicate an element (a copy as the next sibling). POST
|
|
1890
|
+
// { canvas, id, idIndex? } → api.duplicateElementOp. Whole-file undo seq.
|
|
1891
|
+
// MAIN-ORIGIN ONLY; sameOriginWrite + loopback-Host gated (dual-allowlist).
|
|
1892
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1893
|
+
if (!sameOriginWrite(req))
|
|
1894
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1895
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1896
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1897
|
+
const body = await readJson<{ canvas?: unknown; id?: unknown; idIndex?: unknown }>(
|
|
1898
|
+
req,
|
|
1899
|
+
8 * 1024
|
|
1900
|
+
);
|
|
1901
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
1902
|
+
const result = await api.duplicateElementOp(body);
|
|
1903
|
+
if (!result.ok) {
|
|
1904
|
+
return Response.json(
|
|
1905
|
+
{ ok: false, error: result.error },
|
|
1906
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
1907
|
+
);
|
|
1908
|
+
}
|
|
1909
|
+
return Response.json(
|
|
1910
|
+
{ ok: true, newId: result.newId, seq: result.seq },
|
|
1911
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
1912
|
+
);
|
|
1913
|
+
},
|
|
1914
|
+
|
|
1915
|
+
'/_api/insert-artboard': async (req: Request) => {
|
|
1916
|
+
// Stage I4 — insert a new EMPTY artboard from a screen-size preset. POST
|
|
1917
|
+
// { canvas, id, label, width, height } → api.insertArtboardOp (appends a
|
|
1918
|
+
// <DCArtboard id label width height></DCArtboard> after the last artboard;
|
|
1919
|
+
// size JSX-authoritative per DDR-027). Whole-file undo seq. MAIN-ORIGIN
|
|
1920
|
+
// ONLY (absent from both allowlists); sameOriginWrite + loopback-Host gated.
|
|
1921
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1922
|
+
if (!sameOriginWrite(req))
|
|
1923
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1924
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1925
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1926
|
+
const body = await readJson<{
|
|
1927
|
+
canvas?: unknown;
|
|
1928
|
+
id?: unknown;
|
|
1929
|
+
label?: unknown;
|
|
1930
|
+
width?: unknown;
|
|
1931
|
+
height?: unknown;
|
|
1932
|
+
}>(req, 8 * 1024);
|
|
1933
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
1934
|
+
const result = await api.insertArtboardOp(body);
|
|
1935
|
+
if (!result.ok) {
|
|
1936
|
+
return Response.json(
|
|
1937
|
+
{ ok: false, error: result.error },
|
|
1938
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
1939
|
+
);
|
|
1940
|
+
}
|
|
1941
|
+
return Response.json(
|
|
1942
|
+
{ ok: true, artboardId: result.artboardId, seq: result.seq },
|
|
1943
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
1944
|
+
);
|
|
1945
|
+
},
|
|
1946
|
+
|
|
1947
|
+
'/_api/assets': async (req: Request) => {
|
|
1948
|
+
// Stage F1 (feature-element-editing-robustness) — list content-addressed
|
|
1949
|
+
// image/video assets under <designRoot>/assets/ for the AssetPicker (Replace
|
|
1950
|
+
// image / insert image). GET → { assets:[{path,name,ext,kind,size,mtimeMs}] }.
|
|
1951
|
+
// MAIN-ORIGIN ONLY: the picker is a shell dialog; absent from CANVAS_SAFE_API
|
|
1952
|
+
// + startCanvasServer routes (dual-allowlist, DDR-054). Loopback-Host gated
|
|
1953
|
+
// (DNS-rebinding); read-only, so no sameOriginWrite (GET).
|
|
1954
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
1955
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1956
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1957
|
+
const r = await api.listAssets();
|
|
1958
|
+
return Response.json(
|
|
1959
|
+
{ ok: true, assets: r.assets },
|
|
1960
|
+
{ headers: { 'Cache-Control': 'no-store' } }
|
|
1961
|
+
);
|
|
1962
|
+
},
|
|
1963
|
+
|
|
1964
|
+
'/_api/edit-scope': async (req: Request) => {
|
|
1965
|
+
// Stage H (feature-element-editing-robustness) — the INV-3 predictability
|
|
1966
|
+
// verdict. GET ?canvas&id&rendered → { ok, scope:'local'|'shared',
|
|
1967
|
+
// componentName, affects, reason }. READ-only (a parse the shell runs on
|
|
1968
|
+
// selection to render the Local/Shared badge). MAIN-ORIGIN ONLY: absent
|
|
1969
|
+
// from CANVAS_SAFE_API + startCanvasServer routes (dual-allowlist, DDR-054);
|
|
1970
|
+
// loopback-Host gated (DNS-rebinding); no sameOriginWrite (GET).
|
|
1971
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
1972
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1973
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1974
|
+
const url = new URL(req.url);
|
|
1975
|
+
const result = await api.editScopeOp({
|
|
1976
|
+
canvas: url.searchParams.get('canvas') ?? undefined,
|
|
1977
|
+
id: url.searchParams.get('id') ?? undefined,
|
|
1978
|
+
rendered: url.searchParams.get('rendered') ?? undefined,
|
|
1979
|
+
});
|
|
1980
|
+
if (!result.ok) {
|
|
1981
|
+
return Response.json(
|
|
1982
|
+
{ ok: false, error: result.error },
|
|
1983
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
1984
|
+
);
|
|
1985
|
+
}
|
|
1986
|
+
return Response.json(result, { headers: { 'Cache-Control': 'no-store' } });
|
|
1987
|
+
},
|
|
1988
|
+
|
|
1989
|
+
'/_api/resize-artboard': async (req: Request) => {
|
|
1990
|
+
// Stage D4 — free-hand artboard resize. POST { canvas, artboardId, width?,
|
|
1991
|
+
// height? } → api.resizeArtboardOp (writes the NUMERIC width/height props on
|
|
1992
|
+
// the <DCArtboard id="…">, addressed by its `id` prop since the rendered
|
|
1993
|
+
// <article data-dc-screen> carries no data-cd-id; DDR-027). Whole-file undo
|
|
1994
|
+
// seq. MAIN-ORIGIN ONLY; sameOriginWrite + loopback-Host gated.
|
|
1995
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1996
|
+
if (!sameOriginWrite(req))
|
|
1997
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1998
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1999
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2000
|
+
const body = await readJson<{
|
|
2001
|
+
canvas?: unknown;
|
|
2002
|
+
artboardId?: unknown;
|
|
2003
|
+
width?: unknown;
|
|
2004
|
+
height?: unknown;
|
|
2005
|
+
}>(req, 8 * 1024);
|
|
2006
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
2007
|
+
const result = await api.resizeArtboardOp(body);
|
|
2008
|
+
if (!result.ok) {
|
|
2009
|
+
return Response.json(
|
|
2010
|
+
{ ok: false, error: result.error },
|
|
2011
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
2012
|
+
);
|
|
2013
|
+
}
|
|
2014
|
+
return Response.json(
|
|
2015
|
+
{ ok: true, seq: result.seq },
|
|
2016
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
2017
|
+
);
|
|
2018
|
+
},
|
|
2019
|
+
|
|
2020
|
+
'/_api/delete-artboard': async (req: Request) => {
|
|
2021
|
+
// Delete an artboard by its `id` prop (Backspace / context-menu on a frame).
|
|
2022
|
+
// POST { canvas, artboardId } → api.deleteArtboardOp (removes the
|
|
2023
|
+
// <DCArtboard id="…"> span; refuses the last one). Whole-file undo seq.
|
|
2024
|
+
// MAIN-ORIGIN ONLY; sameOriginWrite + loopback-Host gated (dual-allowlist).
|
|
2025
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2026
|
+
if (!sameOriginWrite(req))
|
|
2027
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
2028
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
2029
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2030
|
+
const body = await readJson<{ canvas?: unknown; artboardId?: unknown }>(req, 8 * 1024);
|
|
2031
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
2032
|
+
const result = await api.deleteArtboardOp(body);
|
|
2033
|
+
if (!result.ok) {
|
|
2034
|
+
return Response.json(
|
|
2035
|
+
{ ok: false, error: result.error },
|
|
2036
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
2037
|
+
);
|
|
2038
|
+
}
|
|
2039
|
+
return Response.json(
|
|
2040
|
+
{ ok: true, seq: result.seq },
|
|
2041
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
2042
|
+
);
|
|
2043
|
+
},
|
|
2044
|
+
|
|
1783
2045
|
'/_api/asset': async (req: Request) => {
|
|
1784
2046
|
// Phase 23 / DDR-148 — binary media upload from the canvas (drag-drop /
|
|
1785
2047
|
// paste). POST raw bytes → content-addressed write under
|
|
@@ -1818,17 +2080,28 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1818
2080
|
|
|
1819
2081
|
'/_api/export-history': async (req: Request) => {
|
|
1820
2082
|
// Phase 6.5 T10 — read-only recent-exports feed for the dialog's
|
|
1821
|
-
// Recent tab. Writes happen as a side-effect of
|
|
2083
|
+
// Recent tab. Writes happen as a side-effect of job completion
|
|
2084
|
+
// (exporters/jobs.ts persistAndEvict) rather than this handler.
|
|
1822
2085
|
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
1823
|
-
const history =
|
|
2086
|
+
const history = exportJobs.loadHistory();
|
|
1824
2087
|
return Response.json({ history }, { headers: { 'Cache-Control': 'no-store' } });
|
|
1825
2088
|
},
|
|
1826
2089
|
|
|
1827
2090
|
'/_api/export': async (req: Request) => {
|
|
1828
|
-
//
|
|
1829
|
-
//
|
|
1830
|
-
//
|
|
2091
|
+
// feature-background-export-notification-center — thin wrapper over the
|
|
2092
|
+
// job queue: enqueue() then await the SAME job's result. Byte-for-byte
|
|
2093
|
+
// identical external contract to the old synchronous handler, so
|
|
2094
|
+
// `/design:export` (CLI) and any other blocking caller need zero changes.
|
|
2095
|
+
// sameOriginWrite CSRF + loopback-Host (DNS-rebinding) gated, matching
|
|
2096
|
+
// the write-route convention elsewhere in this file (e.g.
|
|
2097
|
+
// /_api/delete-element) — closed as part of the /flow:done security
|
|
2098
|
+
// fan-out (defender finding: this POST now has a disk-write + queue-
|
|
2099
|
+
// growth consequence a bare cross-origin form-POST could trigger).
|
|
1831
2100
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2101
|
+
if (!sameOriginWrite(req))
|
|
2102
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
2103
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
2104
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1832
2105
|
const body = await readJson<{
|
|
1833
2106
|
format?: unknown;
|
|
1834
2107
|
scope?: unknown;
|
|
@@ -1837,63 +2110,108 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1837
2110
|
if (!body) return new Response('body required', { status: 400 });
|
|
1838
2111
|
if (!isFormat(body.format)) return new Response('unknown or missing format', { status: 400 });
|
|
1839
2112
|
if (!isScope(body.scope)) return new Response('unknown or missing scope', { status: 400 });
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
// wider ActiveState interface.
|
|
1843
|
-
const activeJson = inspect.state as unknown as ActiveJsonShape;
|
|
2113
|
+
const format = body.format;
|
|
2114
|
+
const scope = body.scope;
|
|
1844
2115
|
try {
|
|
1845
|
-
const result =
|
|
1846
|
-
format: body.
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
resolve: { activeJson, designRoot: ctx.paths.designRoot, repoRoot: ctx.paths.repoRoot },
|
|
1850
|
-
ctx: {
|
|
1851
|
-
designRoot: ctx.paths.designRoot,
|
|
1852
|
-
repoRoot: ctx.paths.repoRoot,
|
|
1853
|
-
// Adapters reach back into the server via this origin only when
|
|
1854
|
-
// they need Playwright rendering (PNG / PDF / SVG / HTML). The
|
|
1855
|
-
// host that received this request is, by definition, the one
|
|
1856
|
-
// serving the canvas.
|
|
1857
|
-
serverOrigin: new URL(req.url).origin,
|
|
1858
|
-
// Mirror `client/app.jsx:85` — the per-DS tokensCssRel wins over
|
|
1859
|
-
// the legacy top-level default (which still points at the pre-
|
|
1860
|
-
// multi-DS layout `system/colors_and_type.css`). Without the
|
|
1861
|
-
// per-DS path, the standalone `_canvas-shell.html` 404s on the
|
|
1862
|
-
// tokens link and the rendered DOM uses `var(--bg-0)` unresolved
|
|
1863
|
-
// → screenshots come out blank. See canvasShellUrl().
|
|
1864
|
-
tokensCssRel: ctx.cfg.designSystems?.[0]?.tokensCssRel ?? ctx.cfg.tokensCssRel,
|
|
1865
|
-
},
|
|
1866
|
-
});
|
|
1867
|
-
// Fire-and-forget history append — failure here doesn't block the
|
|
1868
|
-
// download. Synchronous await keeps the order: history reflects the
|
|
1869
|
-
// export the moment the client sees a 200.
|
|
1870
|
-
try {
|
|
1871
|
-
await api.appendExportHistory({
|
|
1872
|
-
format: body.format,
|
|
1873
|
-
scope: body.scope,
|
|
1874
|
-
options: body.options ?? {},
|
|
1875
|
-
filename: result.filename,
|
|
1876
|
-
at: new Date().toISOString(),
|
|
1877
|
-
});
|
|
1878
|
-
} catch {
|
|
1879
|
-
/* ignore — history is best-effort */
|
|
1880
|
-
}
|
|
2116
|
+
const { result } = exportJobs.enqueue(
|
|
2117
|
+
buildExportArgs(req, { format, scope, options: body.options })
|
|
2118
|
+
);
|
|
2119
|
+
const finished = await result;
|
|
1881
2120
|
// Bun.serve accepts Uint8Array directly; the cast satisfies the
|
|
1882
2121
|
// SharedArrayBuffer-strict BodyInit narrowing on @types/bun.
|
|
1883
|
-
return new Response(
|
|
2122
|
+
return new Response(finished.body as unknown as BodyInit, {
|
|
1884
2123
|
status: 200,
|
|
1885
2124
|
headers: {
|
|
1886
|
-
'Content-Type':
|
|
1887
|
-
'Content-Disposition': `attachment; filename="${
|
|
2125
|
+
'Content-Type': finished.contentType,
|
|
2126
|
+
'Content-Disposition': `attachment; filename="${finished.filename}"`,
|
|
1888
2127
|
'Cache-Control': 'no-store',
|
|
1889
2128
|
},
|
|
1890
2129
|
});
|
|
1891
2130
|
} catch (err) {
|
|
2131
|
+
if (err instanceof ExportQueueFullError) {
|
|
2132
|
+
return new Response(err.message, { status: 429 });
|
|
2133
|
+
}
|
|
1892
2134
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1893
2135
|
return new Response(`export failed: ${msg}`, { status: 500 });
|
|
1894
2136
|
}
|
|
1895
2137
|
},
|
|
1896
2138
|
|
|
2139
|
+
'/_api/export-jobs': async (req: Request) => {
|
|
2140
|
+
// feature-background-export-notification-center — the non-blocking
|
|
2141
|
+
// sibling of /_api/export: same body, returns 202 { jobId } immediately
|
|
2142
|
+
// without awaiting the render. MAIN-ORIGIN ONLY (absent from
|
|
2143
|
+
// CANVAS_SAFE_API + startCanvasServer routes, same trust boundary as
|
|
2144
|
+
// /_api/export today — DDR-060). loopback-Host gated on every method;
|
|
2145
|
+
// the mutating POST is additionally sameOriginWrite CSRF gated (same
|
|
2146
|
+
// /flow:done security fan-out fix as /_api/export above).
|
|
2147
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
2148
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2149
|
+
if (req.method === 'GET') {
|
|
2150
|
+
return Response.json(
|
|
2151
|
+
{ jobs: exportJobs.list() },
|
|
2152
|
+
{ headers: { 'Cache-Control': 'no-store' } }
|
|
2153
|
+
);
|
|
2154
|
+
}
|
|
2155
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2156
|
+
if (!sameOriginWrite(req))
|
|
2157
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
2158
|
+
const body = await readJson<{
|
|
2159
|
+
format?: unknown;
|
|
2160
|
+
scope?: unknown;
|
|
2161
|
+
options?: Record<string, unknown>;
|
|
2162
|
+
}>(req, 64 * 1024);
|
|
2163
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
2164
|
+
if (!isFormat(body.format)) return new Response('unknown or missing format', { status: 400 });
|
|
2165
|
+
if (!isScope(body.scope)) return new Response('unknown or missing scope', { status: 400 });
|
|
2166
|
+
try {
|
|
2167
|
+
const { id, result } = exportJobs.enqueue(
|
|
2168
|
+
buildExportArgs(req, { format: body.format, scope: body.scope, options: body.options })
|
|
2169
|
+
);
|
|
2170
|
+
// This route deliberately doesn't await the render — the job's own
|
|
2171
|
+
// status (surfaced via GET /_api/export-jobs + the export:job WS
|
|
2172
|
+
// push) is the completion signal. A render failure still needs a
|
|
2173
|
+
// handler here or it's an unhandled rejection on every failed/timed-
|
|
2174
|
+
// out background job (security fan-out finding, /flow:done) — the
|
|
2175
|
+
// failure is already recorded on the job record, so this is a no-op.
|
|
2176
|
+
result.catch(() => {});
|
|
2177
|
+
return Response.json(
|
|
2178
|
+
{ jobId: id },
|
|
2179
|
+
{ status: 202, headers: { 'Cache-Control': 'no-store' } }
|
|
2180
|
+
);
|
|
2181
|
+
} catch (err) {
|
|
2182
|
+
if (err instanceof ExportQueueFullError) {
|
|
2183
|
+
return new Response(err.message, { status: 429 });
|
|
2184
|
+
}
|
|
2185
|
+
throw err;
|
|
2186
|
+
}
|
|
2187
|
+
},
|
|
2188
|
+
|
|
2189
|
+
'/_api/export-jobs/download': async (req: Request) => {
|
|
2190
|
+
// MAIN-ORIGIN ONLY, same boundary as /_api/export-jobs above.
|
|
2191
|
+
// loopback-Host gated (read-only — no sameOriginWrite needed — but an
|
|
2192
|
+
// unguessable job-scoped UUID plus this guard closes the DNS-rebinding
|
|
2193
|
+
// angle the /flow:done security fan-out checked for).
|
|
2194
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
2195
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
2196
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2197
|
+
const id = new URL(req.url).searchParams.get('id');
|
|
2198
|
+
if (!id) return new Response('id query param required', { status: 400 });
|
|
2199
|
+
const dl = await exportJobs.getBytes(id);
|
|
2200
|
+
if (!dl.ok) {
|
|
2201
|
+
return new Response(dl.reason === 'not-done' ? 'job not finished' : 'Not found', {
|
|
2202
|
+
status: dl.reason === 'not-done' ? 409 : 404,
|
|
2203
|
+
});
|
|
2204
|
+
}
|
|
2205
|
+
return new Response(dl.bytes as unknown as BodyInit, {
|
|
2206
|
+
status: 200,
|
|
2207
|
+
headers: {
|
|
2208
|
+
'Content-Type': dl.contentType,
|
|
2209
|
+
'Content-Disposition': `attachment; filename="${dl.filename}"`,
|
|
2210
|
+
'Cache-Control': 'no-store',
|
|
2211
|
+
},
|
|
2212
|
+
});
|
|
2213
|
+
},
|
|
2214
|
+
|
|
1897
2215
|
'/_canvas-state': async (req: Request) => {
|
|
1898
2216
|
const url = new URL(req.url);
|
|
1899
2217
|
if (req.method === 'GET') {
|
|
@@ -475,6 +475,18 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
475
475
|
if (action.kind !== 'no-op') {
|
|
476
476
|
e.preventDefault();
|
|
477
477
|
e.stopImmediatePropagation();
|
|
478
|
+
// preventDefault above stops mousedown's default FOCUS, so a Cmd-click
|
|
479
|
+
// select would leave the iframe unfocused and every in-canvas keyboard
|
|
480
|
+
// shortcut (arrow-nudge, Cmd+D, copy/paste-style, Delete) dead until a
|
|
481
|
+
// drag happened to focus it (dogfood: "funguje jen když hnu myší").
|
|
482
|
+
// Restore canvas focus explicitly so the keydown listeners fire.
|
|
483
|
+
if (action.kind === 'select') {
|
|
484
|
+
try {
|
|
485
|
+
window.focus();
|
|
486
|
+
} catch {
|
|
487
|
+
/* focus may be rejected outside a user gesture */
|
|
488
|
+
}
|
|
489
|
+
}
|
|
478
490
|
}
|
|
479
491
|
};
|
|
480
492
|
|
|
@@ -54,7 +54,7 @@ function isChromeTarget(t: EventTarget | null): boolean {
|
|
|
54
54
|
const el = t as Element;
|
|
55
55
|
if (
|
|
56
56
|
el.closest(
|
|
57
|
-
'.dc-mm, .dc-zoom-tb, .dc-tool-palette, .dc-context-menu, .dc-cv-halo, .dc-cv-group-bbox, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-svg, .dc-annot-ctx, .dc-tp-popover, .dc-annot-resize-handle, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer'
|
|
57
|
+
'.dc-mm, .dc-zoom-tb, .dc-tool-palette, .dc-context-menu, .dc-cv-halo, .dc-cv-group-bbox, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-svg, .dc-annot-ctx, .dc-tp-popover, .dc-annot-resize-handle, .dc-el-resize-handle, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer'
|
|
58
58
|
)
|
|
59
59
|
) {
|
|
60
60
|
return true;
|