@1agh/maude 0.19.0 → 0.19.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.
Files changed (26) hide show
  1. package/package.json +8 -8
  2. package/plugins/design/dev-server/annotations-context-toolbar.tsx +14 -6
  3. package/plugins/design/dev-server/annotations-layer.tsx +144 -22
  4. package/plugins/design/dev-server/api.ts +70 -17
  5. package/plugins/design/dev-server/artboard-marquee.tsx +170 -0
  6. package/plugins/design/dev-server/canvas-lib.tsx +190 -94
  7. package/plugins/design/dev-server/canvas-shell.tsx +478 -34
  8. package/plugins/design/dev-server/client/app.jsx +173 -52
  9. package/plugins/design/dev-server/client/styles/1-tokens.css +15 -8
  10. package/plugins/design/dev-server/client/styles/4-components.css +32 -2
  11. package/plugins/design/dev-server/config.schema.json +31 -5
  12. package/plugins/design/dev-server/context-menu.tsx +3 -3
  13. package/plugins/design/dev-server/context.ts +37 -3
  14. package/plugins/design/dev-server/dist/client.bundle.js +212 -103
  15. package/plugins/design/dev-server/dist/styles.css +38 -2
  16. package/plugins/design/dev-server/export-dialog.tsx +3 -3
  17. package/plugins/design/dev-server/http.ts +14 -2
  18. package/plugins/design/dev-server/input-router.tsx +22 -8
  19. package/plugins/design/dev-server/server.mjs +84 -18
  20. package/plugins/design/dev-server/test/context-resolve-tokens.test.ts +97 -0
  21. package/plugins/design/dev-server/test/snap-distance-pill.test.ts +68 -0
  22. package/plugins/design/dev-server/test/system-endpoint.test.ts +144 -0
  23. package/plugins/design/dev-server/tool-palette.tsx +71 -15
  24. package/plugins/design/dev-server/use-annotation-resize.tsx +295 -0
  25. package/plugins/design/dev-server/use-snap-guides.tsx +25 -1
  26. package/plugins/design/dev-server/use-tool-mode.tsx +31 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
4
4
  "description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -41,13 +41,13 @@
41
41
  "prepublishOnly": "bash scripts/check-version-parity.sh"
42
42
  },
43
43
  "optionalDependencies": {
44
- "@1agh/maude-darwin-arm64": "0.19.0",
45
- "@1agh/maude-darwin-x64": "0.19.0",
46
- "@1agh/maude-linux-arm64": "0.19.0",
47
- "@1agh/maude-linux-arm64-musl": "0.19.0",
48
- "@1agh/maude-linux-x64": "0.19.0",
49
- "@1agh/maude-linux-x64-musl": "0.19.0",
50
- "@1agh/maude-win32-x64": "0.19.0"
44
+ "@1agh/maude-darwin-arm64": "0.19.1",
45
+ "@1agh/maude-darwin-x64": "0.19.1",
46
+ "@1agh/maude-linux-arm64": "0.19.1",
47
+ "@1agh/maude-linux-arm64-musl": "0.19.1",
48
+ "@1agh/maude-linux-x64": "0.19.1",
49
+ "@1agh/maude-linux-x64-musl": "0.19.1",
50
+ "@1agh/maude-win32-x64": "0.19.1"
51
51
  },
52
52
  "files": [
53
53
  "cli",
@@ -36,11 +36,11 @@ const TOOLBAR_CSS = `
36
36
  display: flex;
37
37
  align-items: center;
38
38
  gap: 6px;
39
- background: var(--u-bg-2, var(--bg-1, rgba(255,255,255,0.98)));
39
+ background: var(--u-bg-0, var(--bg-0, rgba(255,255,255,0.98)));
40
40
  border: 1px solid var(--u-fg-0, #1c1917);
41
- border-radius: 0;
41
+ border-radius: 8px;
42
42
  padding: 6px 8px;
43
- box-shadow: 4px 4px 0 var(--u-fg-0, #1c1917);
43
+ box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
44
44
  font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
45
45
  font-size: 12px;
46
46
  color: var(--u-fg-0, var(--fg-0, #1a1a1a));
@@ -147,7 +147,10 @@ export function AnnotationContextToolbar() {
147
147
  return { color: false, fill: false, thickness: false, fontSize: false };
148
148
  }
149
149
  const allFillable = selectedStrokes.every((s) => s.tool === 'rect' || s.tool === 'ellipse');
150
- const allThickness = selectedStrokes.every((s) => s.tool === 'pen' || s.tool === 'arrow');
150
+ // T20 rect + ellipse now carry stroke weight too.
151
+ const allThickness = selectedStrokes.every(
152
+ (s) => s.tool === 'pen' || s.tool === 'arrow' || s.tool === 'rect' || s.tool === 'ellipse'
153
+ );
151
154
  const anyText = selectedStrokes.some((s) => s.tool === 'text');
152
155
  return {
153
156
  color: true,
@@ -226,7 +229,8 @@ export function AnnotationContextToolbar() {
226
229
  (w: number) => {
227
230
  if (!store) return;
228
231
  for (const s of selectedStrokes) {
229
- if (s.tool === 'pen' || s.tool === 'arrow') {
232
+ // T20 rect + ellipse now carry stroke weight.
233
+ if (s.tool === 'pen' || s.tool === 'arrow' || s.tool === 'rect' || s.tool === 'ellipse') {
230
234
  store.updateStroke(s.id, { width: w } as Partial<Stroke>);
231
235
  }
232
236
  }
@@ -263,7 +267,11 @@ export function AnnotationContextToolbar() {
263
267
  : undefined;
264
268
  const uniqThickness = caps.thickness
265
269
  ? uniformValue(
266
- selectedStrokes.map((s) => (s.tool === 'pen' || s.tool === 'arrow' ? s.width : undefined))
270
+ selectedStrokes.map((s) =>
271
+ s.tool === 'pen' || s.tool === 'arrow' || s.tool === 'rect' || s.tool === 'ellipse'
272
+ ? s.width
273
+ : undefined
274
+ )
267
275
  )
268
276
  : undefined;
269
277
  const uniqFontSize = caps.fontSize
@@ -35,6 +35,7 @@ import { createPortal } from 'react-dom';
35
35
 
36
36
  import { AnnotationContextToolbar } from './annotations-context-toolbar.tsx';
37
37
  import { useViewportControllerContext, useWorldRefContext } from './canvas-lib.tsx';
38
+ import { AnnotationResizeOverlay } from './use-annotation-resize.tsx';
38
39
  import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
39
40
  import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
40
41
  import { useSelectionSetOptional } from './use-selection-set.tsx';
@@ -457,15 +458,15 @@ const ANNOT_CSS = `
457
458
  display: flex;
458
459
  align-items: center;
459
460
  gap: 8px;
460
- background: var(--u-bg-2, var(--bg-1, rgba(255,255,255,0.98)));
461
+ background: var(--u-bg-0, var(--bg-0, rgba(255,255,255,0.98)));
461
462
  border: 1px solid var(--u-fg-0, #1c1917);
462
- border-radius: 0;
463
+ border-radius: 8px;
463
464
  padding: 6px 10px;
464
465
  font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
465
466
  font-size: 11px;
466
467
  color: var(--u-fg-0, rgba(40,30,20,0.85));
467
468
  z-index: 6;
468
- box-shadow: 4px 4px 0 var(--u-fg-0, #1c1917);
469
+ box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
469
470
  user-select: none;
470
471
  }
471
472
  .dc-annot-chrome .dc-annot-swatches { display: flex; gap: 4px; }
@@ -618,7 +619,7 @@ export { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
618
619
 
619
620
  export function AnnotationsLayer() {
620
621
  ensureAnnotStyles();
621
- const { tool } = useToolMode();
622
+ const { tool, setTool, sticky } = useToolMode();
622
623
  const controller = useViewportControllerContext();
623
624
  const vp = controller?.viewport ?? null;
624
625
  const worldRef = useWorldRefContext();
@@ -653,7 +654,11 @@ export function AnnotationsLayer() {
653
654
  const isDraw = tool === 'pen' || tool === 'rect' || tool === 'arrow' || tool === 'ellipse';
654
655
  const isErase = tool === 'eraser';
655
656
  const isActive = isDraw || isErase;
656
- const supportsThickness = tool === 'pen' || tool === 'arrow';
657
+ // T20 rect + ellipse expose stroke weight too (FigJam ships thickness on
658
+ // every shape). The annotation toolbar reads supportsThickness to decide
659
+ // whether to render the Thin / Thick chips.
660
+ const supportsThickness =
661
+ tool === 'pen' || tool === 'arrow' || tool === 'rect' || tool === 'ellipse';
657
662
  const supportsFill = tool === 'rect' || tool === 'ellipse';
658
663
 
659
664
  // Load existing annotations on mount.
@@ -839,7 +844,7 @@ export function AnnotationsLayer() {
839
844
  id,
840
845
  tool: 'rect',
841
846
  color,
842
- width: STROKE_WIDTH_THIN,
847
+ width,
843
848
  x: wx,
844
849
  y: wy,
845
850
  w: 0,
@@ -851,7 +856,7 @@ export function AnnotationsLayer() {
851
856
  id,
852
857
  tool: 'ellipse',
853
858
  color,
854
- width: STROKE_WIDTH_THIN,
859
+ width,
855
860
  cx: wx,
856
861
  cy: wy,
857
862
  rx: 0,
@@ -935,9 +940,38 @@ export function AnnotationsLayer() {
935
940
  scheduleSave(next);
936
941
  return next;
937
942
  });
943
+ // T18 — auto-select the freshly drawn shape so the user can immediately
944
+ // see + adjust it. annotSel is optional (some test harnesses mount
945
+ // AnnotationsLayer without the provider), so guard the call.
946
+ if (annotSel) annotSel.replace(committed.id);
938
947
  }
948
+ // T18 / T19 — flip the tool back to Move after every commit UNLESS sticky
949
+ // mode is locked on this tool. Sticky lets the user draw many shapes in a
950
+ // row (canonical pattern: tldraw double-click to lock). Eraser stays
951
+ // armed by default — that tool is destructive, not constructive.
952
+ const toolJustUsed = cur.tool;
953
+ if (toolJustUsed !== 'eraser') {
954
+ const stickyOnThis = sticky.locked && sticky.tool === toolJustUsed;
955
+ if (!stickyOnThis) setTool('move');
956
+ }
957
+ setDrawing(null);
958
+ }, [isActive, isErase, visible, scheduleSave, annotSel, setTool, sticky]);
959
+
960
+ // T21 — abort a mid-stroke draw without committing. Dispatched by the
961
+ // canvas-shell Esc handler (`maude:cancel-stroke`). Safe to call when
962
+ // nothing is being drawn — the early-return on drawingRef keeps it
963
+ // a no-op.
964
+ const cancelStroke = useCallback(() => {
965
+ if (!drawingRef.current) return;
939
966
  setDrawing(null);
940
- }, [isActive, isErase, visible, scheduleSave]);
967
+ }, []);
968
+
969
+ useEffect(() => {
970
+ if (typeof document === 'undefined') return;
971
+ const onCancel = () => cancelStroke();
972
+ document.addEventListener('maude:cancel-stroke', onCancel);
973
+ return () => document.removeEventListener('maude:cancel-stroke', onCancel);
974
+ }, [cancelStroke]);
941
975
 
942
976
  const renderStrokes = useMemo(
943
977
  () => (drawing ? [...strokes, drawing] : strokes),
@@ -1064,8 +1098,9 @@ export function AnnotationsLayer() {
1064
1098
  return;
1065
1099
  }
1066
1100
 
1067
- // Empty world — start a drag-select gesture. A small movement falls
1068
- // back to a "click on empty world → clear selection" (Figma-style).
1101
+ // Empty world — start a drag-select gesture. A bare click without
1102
+ // moving is a no-op (post-Wave-2 feedback: click-on-empty-space
1103
+ // does NOT clear selection; Esc is the canonical deselect).
1069
1104
  const addToSelection = e.shiftKey;
1070
1105
  let moved = false;
1071
1106
  const onMove = (mv: PointerEvent) => {
@@ -1080,8 +1115,8 @@ export function AnnotationsLayer() {
1080
1115
  document.removeEventListener('pointerup', onUp, true);
1081
1116
  document.removeEventListener('pointercancel', onUp, true);
1082
1117
  if (!moved) {
1083
- // True click on empty world → clear (unless modifier add-mode).
1084
- if (!addToSelection && annotSel.selectedIds.length > 0) annotSel.clear();
1118
+ // Click without movement on empty world → no-op. Selection
1119
+ // survives accidental misses; Esc is how the user deselects.
1085
1120
  return;
1086
1121
  }
1087
1122
  const final = marqueeRef.current;
@@ -1100,13 +1135,10 @@ export function AnnotationsLayer() {
1100
1135
  hits.push(s.id);
1101
1136
  }
1102
1137
  }
1103
- if (addToSelection) {
1104
- annotSel.add(hits);
1105
- } else if (hits.length === 0) {
1106
- annotSel.clear();
1107
- } else {
1108
- annotSel.replace(hits);
1109
- }
1138
+ // Marquee that captured no strokes — preserve existing selection.
1139
+ if (hits.length === 0) return;
1140
+ if (addToSelection) annotSel.add(hits);
1141
+ else annotSel.replace(hits);
1110
1142
  };
1111
1143
  document.addEventListener('pointermove', onMove, true);
1112
1144
  document.addEventListener('pointerup', onUp, true);
@@ -1257,6 +1289,7 @@ export function AnnotationsLayer() {
1257
1289
  />
1258
1290
  ) : null}
1259
1291
  <AnnotationContextToolbar />
1292
+ {visible && tool === 'move' ? <AnnotationResizeOverlay store={strokesStore} /> : null}
1260
1293
  {isActive ? (
1261
1294
  <AnnotationsChrome
1262
1295
  color={color}
@@ -1382,8 +1415,14 @@ function AnnotationsSvg({
1382
1415
  <StrokeNode key={s.id} stroke={s} anchorsById={anchorsById} interactive={selectMode} />
1383
1416
  ))}
1384
1417
  {selectedStrokes.map((s) => (
1385
- <SelectionHalo key={`halo-${s.id}`} stroke={s} anchorsById={anchorsById} />
1418
+ <SelectionHalo
1419
+ key={`halo-${s.id}`}
1420
+ stroke={s}
1421
+ anchorsById={anchorsById}
1422
+ multi={selectedStrokes.length > 1}
1423
+ />
1386
1424
  ))}
1425
+ <AnnotGroupBbox selectedStrokes={selectedStrokes} anchorsById={anchorsById} />
1387
1426
  {marquee ? (
1388
1427
  <rect
1389
1428
  className="dc-annot-marquee"
@@ -1507,12 +1546,26 @@ function TextEditor({
1507
1546
  function SelectionHalo({
1508
1547
  stroke,
1509
1548
  anchorsById,
1549
+ multi,
1510
1550
  }: {
1511
1551
  stroke: Stroke;
1512
1552
  anchorsById: Map<string, RectStroke | EllipseStroke>;
1553
+ multi: boolean;
1513
1554
  }) {
1514
1555
  const bbox = strokeBBox(stroke, anchorsById);
1515
1556
  if (!bbox) return null;
1557
+ // T17 + post-Wave-2 fix — annotation halo idioms:
1558
+ // * Single select → 2 px solid border, NO ring, NO corner ticks.
1559
+ // The resize overlay (T23) renders the corner handles in screen-space,
1560
+ // so painting SVG ticks here too would duplicate them. The element
1561
+ // halo uses CSS box-shadow for the 18% ring; the SVG equivalent (a
1562
+ // second outline rect) reads as "double frame" rather than a halo —
1563
+ // user feedback flagged this immediately. Solid 2 px is enough signal
1564
+ // once the resize handles claim the corners.
1565
+ // * Multi member → 1.5 px solid full accent, no ring, no ticks (group
1566
+ // bbox above carries the container affordance).
1567
+ // Marquee STAYS dashed (drawn elsewhere) — dashed is reserved for the
1568
+ // ambient group-container + active-gesture idioms per DDR-046 rev 2.
1516
1569
  const pad = 4;
1517
1570
  return (
1518
1571
  <rect
@@ -1522,8 +1575,7 @@ function SelectionHalo({
1522
1575
  height={bbox.h + pad * 2}
1523
1576
  fill="none"
1524
1577
  stroke="var(--accent, #d63b1f)"
1525
- strokeWidth={1.5}
1526
- strokeDasharray="4 3"
1578
+ strokeWidth={multi ? 1.5 : 2}
1527
1579
  vectorEffect="non-scaling-stroke"
1528
1580
  pointerEvents="none"
1529
1581
  rx={2}
@@ -1531,6 +1583,76 @@ function SelectionHalo({
1531
1583
  );
1532
1584
  }
1533
1585
 
1586
+ // T17 — group bbox dashed rect for multi-stroke annotation selection. Mirrors
1587
+ // the element-side GroupBbox idiom (1 px dashed accent + 6 × 6 corner handles).
1588
+ function AnnotGroupBbox({
1589
+ selectedStrokes,
1590
+ anchorsById,
1591
+ }: {
1592
+ selectedStrokes: readonly Stroke[];
1593
+ anchorsById: Map<string, RectStroke | EllipseStroke>;
1594
+ }) {
1595
+ if (selectedStrokes.length < 2) return null;
1596
+ let xMin = Number.POSITIVE_INFINITY;
1597
+ let yMin = Number.POSITIVE_INFINITY;
1598
+ let xMax = Number.NEGATIVE_INFINITY;
1599
+ let yMax = Number.NEGATIVE_INFINITY;
1600
+ let any = false;
1601
+ for (const s of selectedStrokes) {
1602
+ const b = strokeBBox(s, anchorsById);
1603
+ if (!b) continue;
1604
+ any = true;
1605
+ if (b.x < xMin) xMin = b.x;
1606
+ if (b.y < yMin) yMin = b.y;
1607
+ if (b.x + b.w > xMax) xMax = b.x + b.w;
1608
+ if (b.y + b.h > yMax) yMax = b.y + b.h;
1609
+ }
1610
+ if (!any) return null;
1611
+ const pad = 6;
1612
+ const x = xMin - pad;
1613
+ const y = yMin - pad;
1614
+ const w = xMax - xMin + pad * 2;
1615
+ const h = yMax - yMin + pad * 2;
1616
+ const handle = 6;
1617
+ const inset = 3;
1618
+ const handles = [
1619
+ { corner: 'nw', x: x - inset, y: y - inset },
1620
+ { corner: 'ne', x: x + w - handle + inset, y: y - inset },
1621
+ { corner: 'sw', x: x - inset, y: y + h - handle + inset },
1622
+ { corner: 'se', x: x + w - handle + inset, y: y + h - handle + inset },
1623
+ ];
1624
+ return (
1625
+ <g pointerEvents="none">
1626
+ <rect
1627
+ x={x}
1628
+ y={y}
1629
+ width={w}
1630
+ height={h}
1631
+ fill="none"
1632
+ stroke="var(--accent, #d63b1f)"
1633
+ strokeWidth={1}
1634
+ strokeDasharray="4 3"
1635
+ vectorEffect="non-scaling-stroke"
1636
+ rx={2}
1637
+ />
1638
+ {handles.map((c) => (
1639
+ <rect
1640
+ key={c.corner}
1641
+ x={c.x}
1642
+ y={c.y}
1643
+ width={handle}
1644
+ height={handle}
1645
+ fill="var(--accent, #d63b1f)"
1646
+ stroke="var(--bg-0, #ffffff)"
1647
+ strokeWidth={1}
1648
+ vectorEffect="non-scaling-stroke"
1649
+ rx={1}
1650
+ />
1651
+ ))}
1652
+ </g>
1653
+ );
1654
+ }
1655
+
1534
1656
  // ─────────────────────────────────────────────────────────────────────────────
1535
1657
  // Stroke renderer
1536
1658
 
@@ -155,7 +155,7 @@ export interface Api {
155
155
  saveAnnotations(file: string, svg: string): Promise<boolean>;
156
156
  // Aggregate data
157
157
  buildIndexData(): Promise<unknown>;
158
- buildSystemData(): Promise<unknown>;
158
+ buildSystemData(dsName?: string | null): Promise<unknown>;
159
159
  // Export history (Phase 6.5 T10)
160
160
  loadExportHistory(): Promise<ExportHistoryEntry[]>;
161
161
  appendExportHistory(entry: ExportHistoryEntry): Promise<void>;
@@ -765,9 +765,34 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
765
765
  return tokens;
766
766
  }
767
767
 
768
- async function buildSystemData() {
769
- const sysAbs = path.join(paths.designRoot, paths.systemDirRel);
770
- const sysRel = path.posix.join(paths.designRel, paths.systemDirRel);
768
+ /**
769
+ * Build the System view payload.
770
+ *
771
+ * - When `dsName` is null/undefined, scope to the top-level system dir
772
+ * (`paths.systemDirRel`) and read tokens from `cfg.tokensCssRel`. This is
773
+ * the legacy single-DS shape every pre-DDR-048 caller sees.
774
+ * - When `dsName` is provided, scope to the matching `designSystems[]` entry:
775
+ * `sysAbs`/`sysRel` point at the DS folder, `tokensAbs` reads from the
776
+ * per-DS `tokensCssRel` (auto-resolved by `normalizeDesignSystems`), and
777
+ * the previews/ui_kits galleries are restricted to that DS subtree.
778
+ *
779
+ * Returns `null` when `dsName` is set but not found in `cfg.designSystems` so
780
+ * the HTTP handler can 404 instead of silently falling back.
781
+ *
782
+ * DDR-048 — system view renders user tokens only; the per-DS scope is what
783
+ * keeps multi-DS projects from blending the wrong tokens into the wrong
784
+ * preview gallery.
785
+ */
786
+ async function buildSystemData(dsName?: string | null) {
787
+ let dsEntry: NonNullable<typeof cfg.designSystems>[number] | null = null;
788
+ if (dsName) {
789
+ dsEntry = cfg.designSystems?.find((d) => d.name === dsName) ?? null;
790
+ if (!dsEntry) return null;
791
+ }
792
+
793
+ const scopedSystemRel = dsEntry ? dsEntry.path : paths.systemDirRel;
794
+ const sysAbs = path.join(paths.designRoot, scopedSystemRel);
795
+ const sysRel = path.posix.join(paths.designRel, scopedSystemRel);
771
796
 
772
797
  let readme: string | null = null;
773
798
  let readmePath: string | null = null;
@@ -794,8 +819,12 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
794
819
 
795
820
  let tokens: ReturnType<typeof parseTokens> = [];
796
821
  let tokensPath: string | null = null;
822
+ // Per-DS tokens path (auto-resolved by normalizeDesignSystems) wins; the
823
+ // top-level cfg.tokensCssRel is a project-wide fallback for legacy
824
+ // single-DS configs that don't declare `designSystems[]`. DDR-048.
825
+ const tokensCssRel = dsEntry?.tokensCssRel ?? cfg.tokensCssRel;
797
826
  try {
798
- const tokensAbs = path.join(paths.designRoot, cfg.tokensCssRel);
827
+ const tokensAbs = path.join(paths.designRoot, tokensCssRel);
799
828
  const css = await readFile(tokensAbs, 'utf8');
800
829
  tokens = parseTokens(css);
801
830
  tokensPath = path.relative(paths.repoRoot, tokensAbs);
@@ -812,16 +841,21 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
812
841
  async function galleryFor(folderName: string) {
813
842
  const matches: { abs: string; rel: string }[] = [];
814
843
  try {
815
- const subs = await readdir(sysAbs, { withFileTypes: true });
816
- for (const s of subs) {
817
- if (!s.isDirectory()) continue;
818
- const candidate = path.join(sysAbs, s.name, folderName);
819
- try {
820
- const st = await statp(candidate);
821
- if (st.isDirectory())
822
- matches.push({ abs: candidate, rel: path.posix.join(sysRel, s.name, folderName) });
823
- } catch {
824
- /* ignore */
844
+ // When scoped to a single DS (sysAbs IS the DS folder), only check
845
+ // the DS-relative `<folderName>` subdir — scanning sub-dirs here
846
+ // would treat the DS's own `preview/` as a sibling DS root.
847
+ if (!dsEntry) {
848
+ const subs = await readdir(sysAbs, { withFileTypes: true });
849
+ for (const s of subs) {
850
+ if (!s.isDirectory()) continue;
851
+ const candidate = path.join(sysAbs, s.name, folderName);
852
+ try {
853
+ const st = await statp(candidate);
854
+ if (st.isDirectory())
855
+ matches.push({ abs: candidate, rel: path.posix.join(sysRel, s.name, folderName) });
856
+ } catch {
857
+ /* ignore */
858
+ }
825
859
  }
826
860
  }
827
861
  try {
@@ -856,10 +890,29 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
856
890
  const previewGallery = await galleryFor('preview');
857
891
  const uiKitsGallery = await galleryFor('ui_kits');
858
892
 
893
+ // Always advertise the available DSes so the client can render the picker
894
+ // even when the initial fetch was unscoped — avoids a second roundtrip.
895
+ const availableDesignSystems = (cfg.designSystems ?? []).map((d) => ({
896
+ name: d.name,
897
+ path: d.path,
898
+ description: d.description ?? null,
899
+ }));
900
+
859
901
  return {
860
902
  project: cfg.name,
861
903
  designRoot: paths.designRel,
862
904
  systemDir: sysRel,
905
+ ds: dsEntry
906
+ ? {
907
+ name: dsEntry.name,
908
+ path: dsEntry.path,
909
+ description: dsEntry.description ?? null,
910
+ rootClass: dsEntry.rootClass ?? null,
911
+ themeDefault: dsEntry.themeDefault ?? null,
912
+ }
913
+ : null,
914
+ availableDesignSystems,
915
+ defaultDesignSystem: cfg.defaultDesignSystem ?? availableDesignSystems[0]?.name ?? null,
863
916
  readme,
864
917
  readmePath,
865
918
  tokens,
@@ -867,8 +920,8 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
867
920
  tokensPath,
868
921
  previewGallery,
869
922
  uiKitsGallery,
870
- rootClass: cfg.rootClass,
871
- themeDefault: cfg.themeDefault,
923
+ rootClass: dsEntry?.rootClass ?? cfg.rootClass,
924
+ themeDefault: dsEntry?.themeDefault ?? cfg.themeDefault,
872
925
  teamAccentDefault: cfg.teamAccentDefault,
873
926
  };
874
927
  }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * @file artboard-marquee.tsx — Task 24 sub-item (Wave 2 follow-up, G8)
3
+ * @scope plugins/design/dev-server/artboard-marquee.tsx
4
+ * @purpose Drag-to-lasso multi-select for artboards. Bare-button pointerdown
5
+ * on empty world (NOT inside any artboard, NOT on floating chrome)
6
+ * starts a marquee. 4 px drag threshold suppresses click jitter.
7
+ * pointerup intersects the marquee AABB with every artboard's
8
+ * screen-coord bbox; hits become Selection entries with
9
+ * `artboardId` set. Modifier semantics — bare = replace, Shift
10
+ * = add (Alt-subtract / Shift+Alt-intersect are deferred).
11
+ *
12
+ * The marquee rect itself follows DDR-046 rev 2: 1 px solid
13
+ * accent border + 8 % accent fill. SOLID, not dashed — solid +
14
+ * tinted fill = "active gesture" idiom, dashed = persistent
15
+ * group bbox.
16
+ */
17
+
18
+ import { useEffect, useRef } from 'react';
19
+
20
+ import { useArtboardsContext } from './canvas-lib.tsx';
21
+ import { type Selection, useSelectionSet } from './use-selection-set.tsx';
22
+ import { useToolMode } from './use-tool-mode.tsx';
23
+
24
+ const DRAG_THRESHOLD_PX = 4;
25
+
26
+ const MARQUEE_CSS = `
27
+ .dc-cv-marquee {
28
+ position: fixed;
29
+ pointer-events: none;
30
+ z-index: 5;
31
+ border: 1px solid var(--accent, #0d99ff);
32
+ background: color-mix(in oklab, var(--accent, #0d99ff) 8%, transparent);
33
+ display: none;
34
+ }
35
+ `.trim();
36
+
37
+ function ensureMarqueeStyles(): void {
38
+ if (typeof document === 'undefined') return;
39
+ if (document.getElementById('dc-cv-marquee-css')) return;
40
+ const s = document.createElement('style');
41
+ s.id = 'dc-cv-marquee-css';
42
+ s.textContent = MARQUEE_CSS;
43
+ document.head.appendChild(s);
44
+ }
45
+
46
+ function shouldIgnoreTarget(t: EventTarget | null): boolean {
47
+ if (!t || !(t as Element).closest) return true;
48
+ const el = t as Element;
49
+ // Skip if the pointerdown landed inside any artboard — that's a select-
50
+ // or-drag gesture, not a marquee. Same hard ceiling as resolveHoverTarget.
51
+ if (el.closest('[data-dc-screen]')) return true;
52
+ // Skip floating chrome and existing overlays (their click handlers run).
53
+ if (
54
+ el.closest(
55
+ '.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'
56
+ )
57
+ ) {
58
+ return true;
59
+ }
60
+ return false;
61
+ }
62
+
63
+ export function ArtboardMarqueeOverlay() {
64
+ ensureMarqueeStyles();
65
+ const selSet = useSelectionSet();
66
+ const artboardsCtx = useArtboardsContext();
67
+ const { tool } = useToolMode();
68
+ const overlayRef = useRef<HTMLDivElement | null>(null);
69
+ const stateRef = useRef<{
70
+ pointerId: number;
71
+ startX: number;
72
+ startY: number;
73
+ crossed: boolean;
74
+ shift: boolean;
75
+ } | null>(null);
76
+
77
+ useEffect(() => {
78
+ if (tool !== 'move') return;
79
+ if (typeof document === 'undefined') return;
80
+
81
+ const onDown = (e: PointerEvent) => {
82
+ if (e.button !== 0) return;
83
+ // Bare or Shift only — Cmd/Ctrl/Alt clicks belong to other gestures
84
+ // (Cmd-select, Alt-copy-drag) and shouldn't start a marquee.
85
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
86
+ if (shouldIgnoreTarget(e.target)) return;
87
+ stateRef.current = {
88
+ pointerId: e.pointerId,
89
+ startX: e.clientX,
90
+ startY: e.clientY,
91
+ crossed: false,
92
+ shift: e.shiftKey,
93
+ };
94
+ };
95
+
96
+ const onMove = (e: PointerEvent) => {
97
+ const s = stateRef.current;
98
+ if (!s || e.pointerId !== s.pointerId) return;
99
+ const dx = e.clientX - s.startX;
100
+ const dy = e.clientY - s.startY;
101
+ if (!s.crossed) {
102
+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
103
+ s.crossed = true;
104
+ // Per post-Wave-2 feedback, the marquee never pre-emptively clears
105
+ // the existing selection. The set is only updated on pointerup if
106
+ // the marquee actually captured something — empty-marquee + bare-
107
+ // click preserve the previous selection. Esc is the only deselect.
108
+ }
109
+ const div = overlayRef.current;
110
+ if (!div) return;
111
+ const left = Math.min(s.startX, e.clientX);
112
+ const top = Math.min(s.startY, e.clientY);
113
+ const w = Math.abs(dx);
114
+ const h = Math.abs(dy);
115
+ div.style.display = 'block';
116
+ div.style.left = `${left}px`;
117
+ div.style.top = `${top}px`;
118
+ div.style.width = `${w}px`;
119
+ div.style.height = `${h}px`;
120
+ };
121
+
122
+ const finish = (e: PointerEvent) => {
123
+ const s = stateRef.current;
124
+ if (!s || e.pointerId !== s.pointerId) return;
125
+ stateRef.current = null;
126
+ const div = overlayRef.current;
127
+ if (div) div.style.display = 'none';
128
+ if (!s.crossed) return;
129
+ if (!artboardsCtx) return;
130
+
131
+ // Intersect the marquee AABB with each artboard's screen-coord bbox.
132
+ // getBoundingClientRect on the article reads the post-zoom, post-pan
133
+ // screen position — exactly what we want, no manual world↔screen math.
134
+ const left = Math.min(s.startX, e.clientX);
135
+ const top = Math.min(s.startY, e.clientY);
136
+ const right = Math.max(s.startX, e.clientX);
137
+ const bottom = Math.max(s.startY, e.clientY);
138
+ const hits: Selection[] = [];
139
+ for (const r of artboardsCtx.artboards) {
140
+ const el = document.querySelector(`[data-dc-screen="${r.id}"]`);
141
+ if (!el) continue;
142
+ const b = (el as HTMLElement).getBoundingClientRect();
143
+ if (b.width === 0 && b.height === 0) continue;
144
+ // AABB intersection test — any overlap counts as a hit.
145
+ if (b.right < left || b.left > right) continue;
146
+ if (b.bottom < top || b.top > bottom) continue;
147
+ hits.push({
148
+ selector: `[data-dc-screen="${r.id}"]`,
149
+ artboardId: r.id,
150
+ });
151
+ }
152
+ if (hits.length === 0) return;
153
+ if (s.shift) selSet.add(hits);
154
+ else selSet.replace(hits);
155
+ };
156
+
157
+ document.addEventListener('pointerdown', onDown, true);
158
+ document.addEventListener('pointermove', onMove);
159
+ document.addEventListener('pointerup', finish);
160
+ document.addEventListener('pointercancel', finish);
161
+ return () => {
162
+ document.removeEventListener('pointerdown', onDown, true);
163
+ document.removeEventListener('pointermove', onMove);
164
+ document.removeEventListener('pointerup', finish);
165
+ document.removeEventListener('pointercancel', finish);
166
+ };
167
+ }, [tool, selSet, artboardsCtx]);
168
+
169
+ return <div ref={overlayRef} className="dc-cv-marquee" aria-hidden="true" />;
170
+ }