@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
@@ -1387,8 +1387,44 @@
1387
1387
  border-bottom: 2px solid var(--u-fg-0);
1388
1388
  padding-bottom: var(--u-s-3);
1389
1389
  margin-bottom: var(--u-s-5);
1390
- grid-template-columns: auto 1fr auto;
1391
- display: grid;
1390
+ flex-wrap: wrap;
1391
+ display: flex;
1392
+ }
1393
+
1394
+ .sv-header .sv-title {
1395
+ flex: auto;
1396
+ }
1397
+
1398
+ .sv-ds-picker {
1399
+ align-items: baseline;
1400
+ gap: var(--u-s-2);
1401
+ font-family: var(--u-font-mono);
1402
+ letter-spacing: var(--tracking-sku);
1403
+ text-transform: uppercase;
1404
+ color: var(--u-fg-2);
1405
+ font-size: 10px;
1406
+ display: inline-flex;
1407
+ }
1408
+
1409
+ .sv-ds-picker select {
1410
+ font-family: var(--u-font-mono);
1411
+ color: var(--u-fg-0);
1412
+ background: var(--u-bg-1);
1413
+ border: 1px solid var(--u-border);
1414
+ border-radius: var(--u-r-sm);
1415
+ text-transform: none;
1416
+ letter-spacing: 0;
1417
+ padding: 2px 6px;
1418
+ font-size: 11px;
1419
+ }
1420
+
1421
+ .sv-ds-description {
1422
+ font-family: var(--u-font-body);
1423
+ font-size: var(--type-sm);
1424
+ color: var(--u-fg-1);
1425
+ margin: 0 0 var(--u-s-5);
1426
+ max-width: 72ch;
1427
+ line-height: 1.5;
1392
1428
  }
1393
1429
 
1394
1430
  .sv-sku {
@@ -115,9 +115,9 @@ const DIALOG_CSS = `
115
115
  .dc-export-dialog {
116
116
  border: 1px solid var(--u-fg-0, #1c1917);
117
117
  padding: 0;
118
- border-radius: 0;
119
- background: var(--u-bg-2, var(--bg-1, #fff));
120
- box-shadow: 4px 4px 0 var(--u-fg-0, #1c1917);
118
+ border-radius: 8px;
119
+ background: var(--u-bg-0, var(--bg-0, #fff));
120
+ box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
121
121
  font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
122
122
  color: var(--u-fg-0, var(--fg-0, #1a1a1a));
123
123
  width: min(640px, 100vw - 48px);
@@ -218,8 +218,20 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect): Http {
218
218
  '/_index-data': async () =>
219
219
  Response.json(await api.buildIndexData(), { headers: { 'Cache-Control': 'no-store' } }),
220
220
 
221
- '/_system-data': async () =>
222
- Response.json(await api.buildSystemData(), { headers: { 'Cache-Control': 'no-store' } }),
221
+ '/_system-data': async (req: Request) => {
222
+ // DDR-048 `?ds=<name>` scopes to one design system (per-DS tokens,
223
+ // per-DS preview gallery). Omitted = legacy top-level scan for
224
+ // backwards compat with single-DS projects.
225
+ const dsName = new URL(req.url).searchParams.get('ds');
226
+ const data = await api.buildSystemData(dsName);
227
+ if (data === null) {
228
+ return Response.json(
229
+ { error: 'unknown design system', ds: dsName },
230
+ { status: 404, headers: { 'Cache-Control': 'no-store' } }
231
+ );
232
+ }
233
+ return Response.json(data, { headers: { 'Cache-Control': 'no-store' } });
234
+ },
223
235
 
224
236
  '/_comments-all': async () =>
225
237
  Response.json(await api.loadAllComments(), { headers: { 'Cache-Control': 'no-store' } }),
@@ -469,16 +469,30 @@ export function resolveHoverTarget(
469
469
  const artboardEl = hit.closest?.('[data-dc-screen]') ?? null;
470
470
  const artboardId = artboardEl?.getAttribute('data-dc-screen') ?? null;
471
471
 
472
- // Hover-target hard ceiling = `.dc-artboard-body`. The artboard chrome
473
- // (article root + label button + body wrapper) is never a selection
474
- // candidate — those carry `data-cd-id` from the pipeline pass but visually
475
- // selecting them makes the WHOLE artboard outline, which looks like the
476
- // canvas viewport is selected. If the user landed on chrome (the label
477
- // button or empty body padding), bail.
472
+ // Hover-target hard ceiling = `.dc-artboard-body`. Inner DOM content lives
473
+ // there; chrome lives outside (label, header, article root). The two paths
474
+ // diverge from here:
475
+ // * hit body resolve to the deepest stamped element (existing
476
+ // deep/top logic below).
477
+ // * hit chrome (label/header/article-root) → the user wants to select
478
+ // the WHOLE artboard. Return the article element itself with no cdId;
479
+ // consumers (hoverTargetToSelection) fall back to a
480
+ // `[data-dc-screen="…"]` selector that wraps the whole frame. This is
481
+ // what enables Cmd+Shift+Click multi-select of artboards (T24 / G8).
478
482
  const bodyEl = hit.closest?.('.dc-artboard-body') ?? null;
479
- if (!bodyEl) return null;
483
+ if (!bodyEl) {
484
+ if (artboardEl && artboardId) {
485
+ return { el: artboardEl, cdId: null, artboardId };
486
+ }
487
+ return null;
488
+ }
480
489
  if (hit === bodyEl) {
481
- // Clicked exactly on the body wrapper, no inner element under cursor.
490
+ // Clicked the body wrapper itself (empty padding inside an artboard, no
491
+ // user content under the cursor). Promote to "select whole artboard" so
492
+ // the gesture stays consistent with chrome clicks above.
493
+ if (artboardEl && artboardId) {
494
+ return { el: artboardEl, cdId: null, artboardId };
495
+ }
482
496
  return null;
483
497
  }
484
498
 
@@ -78,7 +78,28 @@ function loadConfig() {
78
78
  console.error(` warn: ${CONFIG_PATH} is not valid JSON: ${e.message}. Using defaults.`);
79
79
  return { ...DEFAULT_CONFIG, _source: 'defaults (config invalid)' };
80
80
  }
81
- return { ...DEFAULT_CONFIG, ...parsed, _source: '.design/config.json' };
81
+ return normalizeDesignSystems({ ...DEFAULT_CONFIG, ...parsed, _source: '.design/config.json' });
82
+ }
83
+
84
+ /**
85
+ * Mirror of context.ts `normalizeDesignSystems` for the legacy Node entry.
86
+ * Auto-derives per-DS `tokensCssRel = <entry.path>/colors_and_type.css` when
87
+ * missing so the System view can find tokens regardless of multi-DS layout.
88
+ * DDR-048. Keep in lock-step with the .ts version until DDR-009 retires this.
89
+ */
90
+ function normalizeDesignSystems(cfg) {
91
+ if (!cfg.designSystems?.length) return cfg;
92
+ const designSystems = cfg.designSystems.map((entry) => {
93
+ const p = String(entry.path).replace(/^\/+|\/+$/g, '');
94
+ return {
95
+ ...entry,
96
+ path: p,
97
+ tokensCssRel:
98
+ (entry.tokensCssRel ?? '').toString().replace(/^\/+/, '') ||
99
+ path.posix.join(p, 'colors_and_type.css'),
100
+ };
101
+ });
102
+ return { ...cfg, designSystems };
82
103
  }
83
104
 
84
105
  const CFG = loadConfig();
@@ -962,9 +983,22 @@ function parseTokens(css) {
962
983
  return tokens;
963
984
  }
964
985
 
965
- async function buildSystemData() {
966
- const sysAbs = path.join(DESIGN_ROOT, SYSTEM_DIR_REL);
967
- const sysRel = path.posix.join(DESIGN_REL, SYSTEM_DIR_REL);
986
+ /**
987
+ * Build the System view payload. When `dsName` is set, scope to that DS entry
988
+ * (per-DS tokens + per-DS preview/ui_kits gallery). Returns null when the
989
+ * name is set but not registered in `CFG.designSystems` so the caller can
990
+ * 404 instead of silently falling back to shell defaults. DDR-048.
991
+ */
992
+ async function buildSystemData(dsName) {
993
+ let dsEntry = null;
994
+ if (dsName) {
995
+ dsEntry = (CFG.designSystems || []).find((d) => d.name === dsName) || null;
996
+ if (!dsEntry) return null;
997
+ }
998
+
999
+ const scopedSystemRel = dsEntry ? dsEntry.path : SYSTEM_DIR_REL;
1000
+ const sysAbs = path.join(DESIGN_ROOT, scopedSystemRel);
1001
+ const sysRel = path.posix.join(DESIGN_REL, scopedSystemRel);
968
1002
 
969
1003
  // README — try canonical locations (designRoot/README.md, system/README.md, system/<project>/README.md)
970
1004
  let readme = null, readmePath = null;
@@ -986,11 +1020,13 @@ async function buildSystemData() {
986
1020
  } catch {}
987
1021
  }
988
1022
 
989
- // Tokens
1023
+ // Tokens — per-DS path wins; top-level CFG.tokensCssRel is the fallback
1024
+ // for legacy single-DS configs that don't declare `designSystems[]`.
1025
+ const tokensCssRel = (dsEntry && dsEntry.tokensCssRel) || CFG.tokensCssRel;
990
1026
  let tokens = [];
991
1027
  let tokensPath = null;
992
1028
  try {
993
- const tokensAbs = path.join(DESIGN_ROOT, CFG.tokensCssRel);
1029
+ const tokensAbs = path.join(DESIGN_ROOT, tokensCssRel);
994
1030
  const css = await fs.readFile(tokensAbs, 'utf8');
995
1031
  tokens = parseTokens(css);
996
1032
  tokensPath = path.relative(REPO_ROOT, tokensAbs);
@@ -1005,14 +1041,19 @@ async function buildSystemData() {
1005
1041
  // Look one level deep — system/<project>/<folderName>/...
1006
1042
  const matches = [];
1007
1043
  try {
1008
- const subs = await fs.readdir(sysAbs, { withFileTypes: true });
1009
- for (const s of subs) {
1010
- if (!s.isDirectory()) continue;
1011
- const candidate = path.join(sysAbs, s.name, folderName);
1012
- try {
1013
- const stat = await fs.stat(candidate);
1014
- if (stat.isDirectory()) matches.push({ abs: candidate, rel: path.posix.join(sysRel, s.name, folderName) });
1015
- } catch {}
1044
+ // When scoped to one DS (sysAbs IS the DS folder), only check the
1045
+ // DS-relative `<folderName>` subdir. Scanning sub-dirs here would
1046
+ // misread the DS's own `preview/` as a sibling DS root.
1047
+ if (!dsEntry) {
1048
+ const subs = await fs.readdir(sysAbs, { withFileTypes: true });
1049
+ for (const s of subs) {
1050
+ if (!s.isDirectory()) continue;
1051
+ const candidate = path.join(sysAbs, s.name, folderName);
1052
+ try {
1053
+ const stat = await fs.stat(candidate);
1054
+ if (stat.isDirectory()) matches.push({ abs: candidate, rel: path.posix.join(sysRel, s.name, folderName) });
1055
+ } catch {}
1056
+ }
1016
1057
  }
1017
1058
  // Also accept top-level system/<folderName>/
1018
1059
  try {
@@ -1037,10 +1078,27 @@ async function buildSystemData() {
1037
1078
  const previewGallery = await galleryFor('preview');
1038
1079
  const uiKitsGallery = await galleryFor('ui_kits');
1039
1080
 
1081
+ const availableDesignSystems = (CFG.designSystems || []).map((d) => ({
1082
+ name: d.name,
1083
+ path: d.path,
1084
+ description: d.description || null,
1085
+ }));
1086
+
1040
1087
  return {
1041
1088
  project: CFG.name,
1042
1089
  designRoot: DESIGN_REL,
1043
1090
  systemDir: sysRel,
1091
+ ds: dsEntry
1092
+ ? {
1093
+ name: dsEntry.name,
1094
+ path: dsEntry.path,
1095
+ description: dsEntry.description || null,
1096
+ rootClass: dsEntry.rootClass || null,
1097
+ themeDefault: dsEntry.themeDefault || null,
1098
+ }
1099
+ : null,
1100
+ availableDesignSystems,
1101
+ defaultDesignSystem: CFG.defaultDesignSystem || availableDesignSystems[0]?.name || null,
1044
1102
  readme,
1045
1103
  readmePath,
1046
1104
  tokens,
@@ -1048,8 +1106,8 @@ async function buildSystemData() {
1048
1106
  tokensPath,
1049
1107
  previewGallery,
1050
1108
  uiKitsGallery,
1051
- rootClass: CFG.rootClass,
1052
- themeDefault: CFG.themeDefault,
1109
+ rootClass: (dsEntry && dsEntry.rootClass) || CFG.rootClass,
1110
+ themeDefault: (dsEntry && dsEntry.themeDefault) || CFG.themeDefault,
1053
1111
  teamAccentDefault: CFG.teamAccentDefault,
1054
1112
  };
1055
1113
  }
@@ -1105,8 +1163,16 @@ const server = http.createServer(async (req, res) => {
1105
1163
  res.end(JSON.stringify(data));
1106
1164
  return;
1107
1165
  }
1108
- if (reqPath === '/_system-data') {
1109
- const data = await buildSystemData();
1166
+ if (reqPath === '/_system-data' || reqPath.startsWith('/_system-data?')) {
1167
+ // DDR-048 — `?ds=<name>` scopes to one DS; omitted = legacy unscoped.
1168
+ const sysUrl = new URL(reqPath, 'http://x');
1169
+ const dsName = sysUrl.searchParams.get('ds');
1170
+ const data = await buildSystemData(dsName);
1171
+ if (data === null) {
1172
+ res.writeHead(404, { 'Content-Type': MIME['.json'], 'Cache-Control': 'no-store' });
1173
+ res.end(JSON.stringify({ error: 'unknown design system', ds: dsName }));
1174
+ return;
1175
+ }
1110
1176
  res.writeHead(200, { 'Content-Type': MIME['.json'], 'Cache-Control': 'no-store' });
1111
1177
  res.end(JSON.stringify(data));
1112
1178
  return;
@@ -0,0 +1,97 @@
1
+ // Per-DS tokensCssRel auto-resolution — load-bearing for DDR-048. When a
2
+ // config entry doesn't spell out `tokensCssRel`, the dev-server must derive
3
+ // it from `<entry.path>/colors_and_type.css` so the System view can read the
4
+ // scaffolded layout without forcing every config author to repeat the path.
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { normalizeDesignSystems } from '../context';
8
+
9
+ const baseCfg = {
10
+ name: 'fixture',
11
+ projectLabel: null,
12
+ designRoot: '.design',
13
+ canvasGroups: [],
14
+ rootClass: 'app',
15
+ themeDefault: 'dark' as const,
16
+ tokensCssRel: 'system/colors_and_type.css',
17
+ teamAccentDefault: null,
18
+ handoffTargets: [],
19
+ newCanvasDir: 'ui',
20
+ newComponentDir: 'ui/components',
21
+ _source: '.design/config.json' as const,
22
+ };
23
+
24
+ describe('normalizeDesignSystems', () => {
25
+ test('no designSystems → passthrough (top-level tokensCssRel is the only source)', () => {
26
+ const out = normalizeDesignSystems({ ...baseCfg, designSystems: undefined });
27
+ expect(out.designSystems).toBeUndefined();
28
+ expect(out.tokensCssRel).toBe('system/colors_and_type.css');
29
+ });
30
+
31
+ test('empty designSystems array → passthrough', () => {
32
+ const out = normalizeDesignSystems({ ...baseCfg, designSystems: [] });
33
+ expect(out.designSystems).toEqual([]);
34
+ });
35
+
36
+ test('entry without tokensCssRel → derived from <path>/colors_and_type.css', () => {
37
+ const out = normalizeDesignSystems({
38
+ ...baseCfg,
39
+ designSystems: [{ name: 'studyfi', path: 'system/studyfi' }],
40
+ });
41
+ expect(out.designSystems?.[0].tokensCssRel).toBe('system/studyfi/colors_and_type.css');
42
+ });
43
+
44
+ test('entry with explicit tokensCssRel → preserved (leading slash trimmed)', () => {
45
+ const out = normalizeDesignSystems({
46
+ ...baseCfg,
47
+ designSystems: [
48
+ { name: 'studyfi', path: 'system/studyfi', tokensCssRel: '/system/studyfi/tokens.css' },
49
+ ],
50
+ });
51
+ expect(out.designSystems?.[0].tokensCssRel).toBe('system/studyfi/tokens.css');
52
+ });
53
+
54
+ test('path with leading + trailing slashes → normalized', () => {
55
+ const out = normalizeDesignSystems({
56
+ ...baseCfg,
57
+ designSystems: [{ name: 'alpha', path: '/system/alpha/' }],
58
+ });
59
+ expect(out.designSystems?.[0].path).toBe('system/alpha');
60
+ expect(out.designSystems?.[0].tokensCssRel).toBe('system/alpha/colors_and_type.css');
61
+ });
62
+
63
+ test('multiple entries → each resolved independently', () => {
64
+ const out = normalizeDesignSystems({
65
+ ...baseCfg,
66
+ designSystems: [
67
+ { name: 'alpha', path: 'system/alpha' },
68
+ { name: 'beta', path: 'system/beta', tokensCssRel: 'system/beta/custom.css' },
69
+ ],
70
+ });
71
+ expect(out.designSystems?.[0].tokensCssRel).toBe('system/alpha/colors_and_type.css');
72
+ expect(out.designSystems?.[1].tokensCssRel).toBe('system/beta/custom.css');
73
+ });
74
+
75
+ test('preserves unrelated entry fields (description, rootClass, themes)', () => {
76
+ const out = normalizeDesignSystems({
77
+ ...baseCfg,
78
+ designSystems: [
79
+ {
80
+ name: 'studyfi',
81
+ path: 'system/studyfi',
82
+ description: 'StudyFi production mirror',
83
+ rootClass: 'studyfi',
84
+ themeDefault: 'light',
85
+ themes: ['light', 'dark'],
86
+ newCanvasDir: 'ui/studyfi',
87
+ },
88
+ ],
89
+ });
90
+ const entry = out.designSystems?.[0];
91
+ expect(entry?.description).toBe('StudyFi production mirror');
92
+ expect(entry?.rootClass).toBe('studyfi');
93
+ expect(entry?.themeDefault).toBe('light');
94
+ expect(entry?.themes).toEqual(['light', 'dark']);
95
+ expect(entry?.newCanvasDir).toBe('ui/studyfi');
96
+ });
97
+ });
@@ -0,0 +1,68 @@
1
+ // snap-distance-pill — DDR-046. Verifies SnapGuide.delta + .kind population
2
+ // in `computeSnap`. The visual `Δ{Math.round(delta)}` pill rendering lives in
3
+ // `SnapGuideOverlay` (`canvas-lib.tsx`); this file covers only the pure-function
4
+ // contract that the overlay reads.
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { type Rect, type SnapOptions, computeSnap } from '../use-snap-guides.tsx';
9
+
10
+ const DEFAULTS: SnapOptions = { gridSize: 40, tolerance: 8, disabled: false };
11
+ const rect = (x: number, y: number, w = 100, h = 80): Rect => ({ x, y, w, h });
12
+
13
+ describe('SnapGuide.delta + .kind (DDR-046)', () => {
14
+ test('grid snap emits delta + kind=grid', () => {
15
+ const r = computeSnap(rect(33, 200), [], DEFAULTS);
16
+ const xg = r.guides.find((g) => g.axis === 'x');
17
+ expect(xg?.delta).toBe(7); // 40 − 33
18
+ expect(xg?.kind).toBe('grid');
19
+ });
20
+
21
+ test('grid snap with zero delta (already on grid line) still emits kind=grid', () => {
22
+ const r = computeSnap(rect(40, 200), [], DEFAULTS);
23
+ const xg = r.guides.find((g) => g.axis === 'x');
24
+ expect(xg?.delta).toBe(0);
25
+ expect(xg?.kind).toBe('grid');
26
+ });
27
+
28
+ test('sibling snap beats grid when both fire (closest |delta| wins)', () => {
29
+ // proposed x=37: grid candidate at 40 (delta +3), sibling at 35 (delta −2).
30
+ // |−2| < |3| → sibling wins.
31
+ const r = computeSnap(rect(37, 200), [rect(35, 0, 50, 50)], DEFAULTS);
32
+ const xg = r.guides.find((g) => g.axis === 'x');
33
+ expect(xg?.kind).toBe('sibling');
34
+ expect(xg?.delta).toBe(-2);
35
+ expect(r.x).toBe(35);
36
+ });
37
+
38
+ test('sibling snap emits delta + kind=sibling', () => {
39
+ // proposed x=503, sibling at x=500. Snap left↔left.
40
+ const r = computeSnap(rect(503, 200), [rect(500, 50, 100, 80)], DEFAULTS);
41
+ const xg = r.guides.find((g) => g.axis === 'x');
42
+ expect(xg?.delta).toBe(-3);
43
+ expect(xg?.kind).toBe('sibling');
44
+ expect(r.x).toBe(500);
45
+ });
46
+
47
+ test('merged-at-pos guide keeps the larger |delta|', () => {
48
+ // Two siblings both at x=500 — same edge, different y so merged guide
49
+ // unions their from/to. Both candidates have identical delta here, but
50
+ // we still expect the merge code path to preserve a single delta value.
51
+ const r = computeSnap(
52
+ rect(503, 200),
53
+ [rect(500, 50, 100, 50), rect(500, 400, 100, 50)],
54
+ DEFAULTS
55
+ );
56
+ const xg = r.guides.find((g) => g.axis === 'x');
57
+ expect(xg?.delta).toBe(-3);
58
+ expect(xg?.kind).toBe('sibling');
59
+ // Merged span covers both siblings' Y extents.
60
+ expect(xg?.from).toBe(50);
61
+ expect(xg?.to).toBe(450);
62
+ });
63
+
64
+ test('disabled mode emits no guides (delta + kind absent)', () => {
65
+ const r = computeSnap(rect(33, 200), [rect(35, 0)], { ...DEFAULTS, disabled: true });
66
+ expect(r.guides).toEqual([]);
67
+ });
68
+ });
@@ -0,0 +1,144 @@
1
+ // /_system-data — bias-free token rendering depends on per-DS scoping.
2
+ // Covers DDR-048: ?ds=<name> scopes payload to one designSystem entry;
3
+ // unknown ds → 404; omitted ds → legacy unscoped behavior for single-DS
4
+ // projects that don't declare `designSystems[]`.
5
+
6
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
7
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import type { Subprocess } from 'bun';
11
+ import { bootServer, killProc, nextPort } from './_helpers';
12
+
13
+ interface MultiDsSandbox {
14
+ root: string;
15
+ designRoot: string;
16
+ }
17
+
18
+ function makeMultiDsSandbox(): MultiDsSandbox {
19
+ const root = mkdtempSync(join(tmpdir(), 'mdcc-multi-ds-'));
20
+ const designRoot = join(root, '.design');
21
+ mkdirSync(designRoot, { recursive: true });
22
+
23
+ writeFileSync(
24
+ join(designRoot, 'config.json'),
25
+ JSON.stringify(
26
+ {
27
+ name: 'multi-ds',
28
+ designRoot: '.design',
29
+ canvasGroups: [
30
+ { label: 'Design system', path: 'system' },
31
+ { label: 'UI', path: 'ui' },
32
+ ],
33
+ designSystems: [
34
+ { name: 'alpha', path: 'system/alpha', description: 'Alpha brand' },
35
+ { name: 'beta', path: 'system/beta', description: 'Beta brand' },
36
+ ],
37
+ defaultDesignSystem: 'alpha',
38
+ },
39
+ null,
40
+ 2
41
+ )
42
+ );
43
+
44
+ // Alpha — periwinkle accent, cream surfaces (mimics StudyFi case).
45
+ mkdirSync(join(designRoot, 'system', 'alpha', 'preview'), { recursive: true });
46
+ writeFileSync(
47
+ join(designRoot, 'system', 'alpha', 'colors_and_type.css'),
48
+ ":root {\n --color-bg: #fbfaf7;\n --accent: #9CACFF;\n --color-text-primary: #000000;\n --fs-base: 1rem;\n --font-body: 'Inter', sans-serif;\n}\n"
49
+ );
50
+ writeFileSync(
51
+ join(designRoot, 'system', 'alpha', 'preview', 'colors-accent.tsx'),
52
+ 'export default function ColorsAccent() { return <div>alpha accent</div>; }\n'
53
+ );
54
+
55
+ // Beta — different palette + names.
56
+ mkdirSync(join(designRoot, 'system', 'beta', 'preview'), { recursive: true });
57
+ writeFileSync(
58
+ join(designRoot, 'system', 'beta', 'colors_and_type.css'),
59
+ ':root {\n --bg-0: oklch(13% 0.012 60);\n --accent: oklch(72% 0.16 55);\n --type-base: 14px;\n}\n'
60
+ );
61
+ writeFileSync(
62
+ join(designRoot, 'system', 'beta', 'preview', 'colors-accent.tsx'),
63
+ 'export default function ColorsAccent() { return <div>beta accent</div>; }\n'
64
+ );
65
+
66
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
67
+ return { root, designRoot };
68
+ }
69
+
70
+ describe('/_system-data — per-DS scope (DDR-048)', () => {
71
+ let proc: Subprocess | null = null;
72
+ let sb: MultiDsSandbox;
73
+ let port: number;
74
+
75
+ beforeEach(async () => {
76
+ sb = makeMultiDsSandbox();
77
+ port = nextPort();
78
+ proc = await bootServer(sb.root, port);
79
+ });
80
+
81
+ afterEach(async () => {
82
+ if (proc) await killProc(proc);
83
+ proc = null;
84
+ });
85
+
86
+ test('?ds=alpha → tokens parsed from alpha colors_and_type.css', async () => {
87
+ const r = await fetch(`http://localhost:${port}/_system-data?ds=alpha`);
88
+ expect(r.status).toBe(200);
89
+ const data = (await r.json()) as {
90
+ tokens: { name: string; value: string }[];
91
+ ds: { name: string; description: string };
92
+ tokensPath: string;
93
+ previewGallery: { label: string }[];
94
+ };
95
+ expect(data.ds.name).toBe('alpha');
96
+ expect(data.ds.description).toBe('Alpha brand');
97
+ expect(data.tokensPath).toContain('system/alpha/colors_and_type.css');
98
+ const accent = data.tokens.find((t) => t.name === '--accent');
99
+ expect(accent?.value).toBe('#9CACFF');
100
+ const colorBg = data.tokens.find((t) => t.name === '--color-bg');
101
+ expect(colorBg?.value).toBe('#fbfaf7');
102
+ // Alpha previews only — no beta leakage.
103
+ expect(data.previewGallery.some((p) => p.label === 'colors-accent')).toBe(true);
104
+ expect(data.previewGallery.every((p) => !p.label.includes('beta'))).toBe(true);
105
+ });
106
+
107
+ test('?ds=beta → distinct tokens, distinct previews', async () => {
108
+ const r = await fetch(`http://localhost:${port}/_system-data?ds=beta`);
109
+ expect(r.status).toBe(200);
110
+ const data = (await r.json()) as {
111
+ tokens: { name: string; value: string }[];
112
+ ds: { name: string };
113
+ tokensPath: string;
114
+ };
115
+ expect(data.ds.name).toBe('beta');
116
+ expect(data.tokensPath).toContain('system/beta/colors_and_type.css');
117
+ const accent = data.tokens.find((t) => t.name === '--accent');
118
+ expect(accent?.value).toBe('oklch(72% 0.16 55)');
119
+ const bg0 = data.tokens.find((t) => t.name === '--bg-0');
120
+ expect(bg0?.value).toBe('oklch(13% 0.012 60)');
121
+ });
122
+
123
+ test('?ds=ghost → 404 + error payload (no silent shell fallback)', async () => {
124
+ const r = await fetch(`http://localhost:${port}/_system-data?ds=ghost`);
125
+ expect(r.status).toBe(404);
126
+ const body = (await r.json()) as { error: string; ds: string };
127
+ expect(body.error).toBe('unknown design system');
128
+ expect(body.ds).toBe('ghost');
129
+ });
130
+
131
+ test('unscoped (no ?ds) → availableDesignSystems list + defaultDesignSystem', async () => {
132
+ const r = await fetch(`http://localhost:${port}/_system-data`);
133
+ expect(r.status).toBe(200);
134
+ const data = (await r.json()) as {
135
+ availableDesignSystems: { name: string; description: string | null }[];
136
+ defaultDesignSystem: string;
137
+ ds: unknown;
138
+ };
139
+ expect(data.availableDesignSystems).toHaveLength(2);
140
+ expect(data.availableDesignSystems.map((d) => d.name)).toEqual(['alpha', 'beta']);
141
+ expect(data.defaultDesignSystem).toBe('alpha');
142
+ expect(data.ds).toBeNull();
143
+ });
144
+ });