@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.
Files changed (102) hide show
  1. package/apps/studio/ai-banner.tsx +2 -2
  2. package/apps/studio/annotations-layer.tsx +53 -2
  3. package/apps/studio/api.ts +504 -44
  4. package/apps/studio/artboard-marquee.tsx +1 -1
  5. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  6. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  7. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  8. package/apps/studio/bin/_html-playwright.mjs +26 -4
  9. package/apps/studio/bin/_pdf-playwright.mjs +13 -2
  10. package/apps/studio/bin/_png-playwright.mjs +15 -2
  11. package/apps/studio/bin/_pptx-playwright.mjs +17 -4
  12. package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
  13. package/apps/studio/bin/_svg-playwright.mjs +26 -4
  14. package/apps/studio/bin/annotate.mjs +576 -34
  15. package/apps/studio/bin/canvas-rects.sh +152 -0
  16. package/apps/studio/bin/fetch-asset.sh +34 -0
  17. package/apps/studio/bin/read-annotations.mjs +138 -7
  18. package/apps/studio/bin/screenshot.sh +53 -4
  19. package/apps/studio/bin/smoke.sh +42 -6
  20. package/apps/studio/build.ts +21 -0
  21. package/apps/studio/canvas-comment-mount.tsx +138 -4
  22. package/apps/studio/canvas-edit.ts +744 -11
  23. package/apps/studio/canvas-lib.tsx +219 -2
  24. package/apps/studio/canvas-shell.tsx +487 -20
  25. package/apps/studio/client/app.jsx +1476 -76
  26. package/apps/studio/client/comments-overlay.css +130 -126
  27. package/apps/studio/client/export-center.jsx +426 -0
  28. package/apps/studio/client/github.js +8 -0
  29. package/apps/studio/client/styles/3-shell-maude.css +65 -0
  30. package/apps/studio/client/styles/4-components.css +150 -0
  31. package/apps/studio/comments-overlay.tsx +148 -41
  32. package/apps/studio/config.schema.json +2 -2
  33. package/apps/studio/context-menu.tsx +15 -5
  34. package/apps/studio/contextual-toolbar.tsx +262 -4
  35. package/apps/studio/cursors-overlay.tsx +4 -4
  36. package/apps/studio/dist/client.bundle.js +20 -20
  37. package/apps/studio/dist/comment-mount.js +59 -1
  38. package/apps/studio/dist/styles.css +1 -1
  39. package/apps/studio/dom-selection.ts +127 -1
  40. package/apps/studio/drag-state.ts +24 -0
  41. package/apps/studio/equal-spacing-detector.ts +205 -0
  42. package/apps/studio/export-dialog.tsx +19 -26
  43. package/apps/studio/exporters/_runtime.ts +104 -0
  44. package/apps/studio/exporters/html.ts +12 -20
  45. package/apps/studio/exporters/index.ts +14 -2
  46. package/apps/studio/exporters/jobs.ts +334 -0
  47. package/apps/studio/exporters/pdf.ts +16 -20
  48. package/apps/studio/exporters/png.ts +12 -20
  49. package/apps/studio/exporters/pptx.ts +22 -23
  50. package/apps/studio/exporters/scope.ts +1 -0
  51. package/apps/studio/exporters/svg.ts +14 -22
  52. package/apps/studio/exporters/video.ts +15 -17
  53. package/apps/studio/git/service.ts +3 -1
  54. package/apps/studio/history.ts +47 -1
  55. package/apps/studio/http.ts +368 -50
  56. package/apps/studio/input-router.tsx +12 -0
  57. package/apps/studio/marquee-overlay.tsx +1 -1
  58. package/apps/studio/measure-overlay.tsx +241 -0
  59. package/apps/studio/participants-chrome.tsx +3 -3
  60. package/apps/studio/server.ts +3 -1
  61. package/apps/studio/sizing-mode.ts +117 -0
  62. package/apps/studio/spacing-handles.ts +166 -0
  63. package/apps/studio/test/annotate-write.test.ts +890 -0
  64. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  65. package/apps/studio/test/canvas-edit.test.ts +50 -0
  66. package/apps/studio/test/canvas-origin-gate.test.ts +24 -0
  67. package/apps/studio/test/canvas-rects.test.ts +198 -0
  68. package/apps/studio/test/comments-overlay.test.ts +117 -0
  69. package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
  70. package/apps/studio/test/dom-selection.test.ts +130 -0
  71. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  72. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  73. package/apps/studio/test/element-resize.test.ts +136 -0
  74. package/apps/studio/test/element-structural-api.test.ts +360 -0
  75. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  76. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  77. package/apps/studio/test/export-center.test.tsx +287 -0
  78. package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
  79. package/apps/studio/test/exporters/history.test.ts +32 -3
  80. package/apps/studio/test/exporters/jobs.test.ts +263 -0
  81. package/apps/studio/test/history-rollback.test.ts +26 -0
  82. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  83. package/apps/studio/test/read-annotations.test.ts +154 -0
  84. package/apps/studio/test/sizing-mode.test.ts +102 -0
  85. package/apps/studio/test/spacing-handles.test.ts +138 -0
  86. package/apps/studio/test/specimen-select.test.ts +88 -0
  87. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  88. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  89. package/apps/studio/tool-palette.tsx +122 -2
  90. package/apps/studio/undo-hud.tsx +2 -2
  91. package/apps/studio/use-element-resize.tsx +732 -0
  92. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  93. package/apps/studio/use-selection-set.tsx +14 -0
  94. package/apps/studio/use-spacing-handles.tsx +388 -0
  95. package/apps/studio/whats-new.json +45 -0
  96. package/apps/studio/ws.ts +6 -0
  97. package/cli/commands/design.mjs +6 -1
  98. package/cli/commands/design.test.mjs +49 -1
  99. package/cli/lib/fetch-asset.test.mjs +213 -0
  100. package/cli/lib/gitignore-block.mjs +1 -0
  101. package/package.json +8 -8
  102. package/plugins/design/dependencies.json +10 -2
@@ -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, runExport } from './exporters/index.ts';
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(ctx: Context, api: Api, inspect: Inspect, ai: AiActivity): Http {
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 `/_api/export`.
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 = await api.loadExportHistory();
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
- // Phase 6.5 single dispatch endpoint for the export pipeline.
1829
- // POST body { format, scope, options? } binary stream with
1830
- // Content-Disposition + Content-Type set by the adapter.
2091
+ // feature-background-export-notification-centerthin 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
- // `inspect.state` is the live `_active.json` — readers narrow to the
1841
- // resolver's subset locally so the export pipeline doesn't pin the
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 = await runExport({
1846
- format: body.format,
1847
- scope: body.scope,
1848
- options: body.options ?? {},
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(result.body as unknown as BodyInit, {
2122
+ return new Response(finished.body as unknown as BodyInit, {
1884
2123
  status: 200,
1885
2124
  headers: {
1886
- 'Content-Type': result.contentType,
1887
- 'Content-Disposition': `attachment; filename="${result.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;