@1agh/maude 0.19.0 → 0.20.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 (66) hide show
  1. package/package.json +9 -9
  2. package/plugins/design/dev-server/annotations-context-toolbar.tsx +126 -44
  3. package/plugins/design/dev-server/annotations-layer.tsx +343 -112
  4. package/plugins/design/dev-server/api.ts +70 -17
  5. package/plugins/design/dev-server/artboard-marquee.tsx +174 -0
  6. package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
  7. package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
  8. package/plugins/design/dev-server/canvas-lib.tsx +691 -119
  9. package/plugins/design/dev-server/canvas-shell.tsx +818 -42
  10. package/plugins/design/dev-server/client/app.jsx +173 -52
  11. package/plugins/design/dev-server/client/hmr.mjs +28 -0
  12. package/plugins/design/dev-server/client/styles/1-tokens.css +15 -8
  13. package/plugins/design/dev-server/client/styles/4-components.css +32 -2
  14. package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
  15. package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
  16. package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
  17. package/plugins/design/dev-server/comments-overlay.tsx +12 -4
  18. package/plugins/design/dev-server/config.schema.json +31 -5
  19. package/plugins/design/dev-server/context-menu.tsx +3 -3
  20. package/plugins/design/dev-server/context.ts +37 -3
  21. package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
  22. package/plugins/design/dev-server/dist/client.bundle.js +212 -103
  23. package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
  24. package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
  25. package/plugins/design/dev-server/dist/styles.css +38 -2
  26. package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
  27. package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
  28. package/plugins/design/dev-server/export-dialog.tsx +3 -3
  29. package/plugins/design/dev-server/handoff.ts +24 -0
  30. package/plugins/design/dev-server/http.ts +41 -2
  31. package/plugins/design/dev-server/input-router.tsx +74 -10
  32. package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
  33. package/plugins/design/dev-server/runtime-bundle.ts +7 -0
  34. package/plugins/design/dev-server/server.mjs +84 -18
  35. package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
  36. package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
  37. package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
  38. package/plugins/design/dev-server/test/context-resolve-tokens.test.ts +97 -0
  39. package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
  40. package/plugins/design/dev-server/test/input-router.test.ts +87 -1
  41. package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
  42. package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
  43. package/plugins/design/dev-server/test/snap-distance-pill.test.ts +68 -0
  44. package/plugins/design/dev-server/test/system-endpoint.test.ts +144 -0
  45. package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
  46. package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
  47. package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
  48. package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
  49. package/plugins/design/dev-server/tool-palette.tsx +71 -15
  50. package/plugins/design/dev-server/undo-hud.tsx +95 -0
  51. package/plugins/design/dev-server/undo-stack.ts +240 -0
  52. package/plugins/design/dev-server/use-annotation-resize.tsx +295 -0
  53. package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
  54. package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
  55. package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
  56. package/plugins/design/dev-server/use-snap-guides.tsx +25 -1
  57. package/plugins/design/dev-server/use-tool-mode.tsx +31 -2
  58. package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
  59. package/plugins/design/templates/_shell.html +17 -6
  60. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
  61. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
  62. package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
  63. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
  64. package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
  65. package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
  66. /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
@@ -1049,7 +1049,7 @@ function Wordmark({ project, port, version }) {
1049
1049
  );
1050
1050
  }
1051
1051
 
1052
- function Viewport({ tabs, activePath, registerIframe, systemData, onOpenFromSystem, project, cfg }) {
1052
+ function Viewport({ tabs, activePath, registerIframe, systemData, onOpenFromSystem, onSelectDs, project, cfg }) {
1053
1053
  return (
1054
1054
  <div className="viewport">
1055
1055
  {tabs.length === 0 && (
@@ -1073,7 +1073,7 @@ function Viewport({ tabs, activePath, registerIframe, systemData, onOpenFromSyst
1073
1073
  if (t.path === SYSTEM_TAB) {
1074
1074
  return (
1075
1075
  <div key={t.path} className={'system-view' + (t.path === activePath ? ' active' : '')}>
1076
- <SystemView data={systemData} onOpen={onOpenFromSystem} cfg={cfg} />
1076
+ <SystemView data={systemData} onOpen={onOpenFromSystem} cfg={cfg} onSelectDs={onSelectDs} />
1077
1077
  </div>
1078
1078
  );
1079
1079
  }
@@ -1092,83 +1092,184 @@ function Viewport({ tabs, activePath, registerIframe, systemData, onOpenFromSyst
1092
1092
  }
1093
1093
 
1094
1094
  // ---------- SystemView ----------
1095
-
1096
- const TOKEN_NAMES = [
1097
- '--bg-0', '--bg-1', '--bg-2', '--bg-3', '--bg-4',
1098
- '--fg-0', '--fg-1', '--fg-2', '--fg-3',
1099
- '--accent', '--accent-hover', '--accent-active', '--accent-fg', '--accent-tint',
1100
- '--status-success', '--status-warn', '--status-error', '--status-info',
1101
- '--border-subtle', '--border-default', '--border-strong',
1095
+ //
1096
+ // DDR-048 the System view renders the USER's design-system tokens. It does
1097
+ // NOT read from `document.documentElement` (that would surface the dev-server
1098
+ // shell's amber-rust chrome theme from styles/1-tokens.css, which is NOT a
1099
+ // user template) and it does NOT assume any canonical token-name contract.
1100
+ // Whatever the user's `colors_and_type.css` declared — names, values, theme
1101
+ // blocks — is what shows up here.
1102
+
1103
+ // Order kinds match the typical reading flow of a tokens file. Unknown kinds
1104
+ // fall through to `other` so a custom token group still renders, just last.
1105
+ const TOKEN_GROUP_ORDER = [
1106
+ 'color', 'space', 'radius', 'shadow', 'leading', 'weight', 'motion', 'font', 'other',
1102
1107
  ];
1103
- const TYPE_STEPS = ['xs', 'sm', 'base', 'md', 'lg', 'xl', '2xl', '3xl'];
1108
+ const TOKEN_GROUP_LABELS = {
1109
+ color: 'colors',
1110
+ space: 'spacing',
1111
+ radius: 'radii',
1112
+ shadow: 'shadows',
1113
+ leading: 'leading',
1114
+ weight: 'weights',
1115
+ motion: 'motion',
1116
+ font: 'font stacks',
1117
+ other: 'other',
1118
+ };
1104
1119
 
1105
- function readTokens(names) {
1106
- if (typeof window === 'undefined') return names.map(name => ({ name, value: '' }));
1107
- const cs = getComputedStyle(document.documentElement);
1108
- return names.map(name => ({ name, value: cs.getPropertyValue(name).trim() }));
1109
- }
1120
+ function isSwatchKind(kind) { return kind === 'color'; }
1121
+
1122
+ function TokenLadder({ tokens, tokenGroups, tokensPath }) {
1123
+ if (!tokens || tokens.length === 0) {
1124
+ return (
1125
+ <section className="sv-section sv-section-tokens">
1126
+ <h2>tokens<span className="sv-h-num">0</span></h2>
1127
+ <div className="sv-empty">
1128
+ <p>
1129
+ No tokens parsed from {tokensPath ? <code>{tokensPath}</code> : 'the configured tokens file'}.
1130
+ Does the file exist and contain CSS custom properties (<code>--name: value;</code>)?
1131
+ </p>
1132
+ </div>
1133
+ </section>
1134
+ );
1135
+ }
1136
+
1137
+ const groups = tokenGroups || {};
1138
+ const kinds = Array.from(
1139
+ new Set([...TOKEN_GROUP_ORDER, ...Object.keys(groups)])
1140
+ ).filter((k) => groups[k]?.length);
1110
1141
 
1111
- function TokenLadder() {
1112
- const [tokens, setTokens] = useState(() => readTokens(TOKEN_NAMES));
1113
- useEffect(() => {
1114
- setTokens(readTokens(TOKEN_NAMES));
1115
- const obs = new MutationObserver(() => setTokens(readTokens(TOKEN_NAMES)));
1116
- obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
1117
- return () => obs.disconnect();
1118
- }, []);
1119
1142
  return (
1120
- <section className="sv-section sv-section-tokens">
1121
- <h2>tokens · surfaces & ink<span className="sv-h-num">{tokens.length}</span></h2>
1122
- <div className="sv-tokens-ladder">
1123
- {tokens.map(t => (
1124
- <div className="sv-tok-cell" key={t.name}>
1125
- <div className="sv-tok-swatch" style={{ background: `var(${t.name})` }} />
1126
- <div className="sv-tok-meta">
1127
- <code className="sv-tok-name">{t.name}</code>
1128
- <span className="sv-tok-value">{t.value || '—'}</span>
1143
+ <>
1144
+ {kinds.map((kind) => {
1145
+ const list = groups[kind];
1146
+ const swatch = isSwatchKind(kind);
1147
+ return (
1148
+ <section className={'sv-section sv-section-tokens sv-section-' + kind} key={kind}>
1149
+ <h2>
1150
+ tokens · {TOKEN_GROUP_LABELS[kind] || kind}
1151
+ <span className="sv-h-num">{list.length}</span>
1152
+ </h2>
1153
+ <div className="sv-tokens-ladder">
1154
+ {list.map((t) => (
1155
+ <div className="sv-tok-cell" key={t.name + '|' + t.value}>
1156
+ {swatch ? (
1157
+ <div className="sv-tok-swatch" style={{ background: t.value }} />
1158
+ ) : null}
1159
+ <div className="sv-tok-meta">
1160
+ <code className="sv-tok-name">{t.name}</code>
1161
+ <span className="sv-tok-value">{t.value || '—'}</span>
1162
+ </div>
1163
+ </div>
1164
+ ))}
1129
1165
  </div>
1130
- </div>
1131
- ))}
1132
- </div>
1133
- </section>
1166
+ </section>
1167
+ );
1168
+ })}
1169
+ </>
1134
1170
  );
1135
1171
  }
1136
1172
 
1137
- function TypeLadder() {
1173
+ // Find a leading partner by name suffix: --fs-base → --lh-base, --type-xl → --lh-xl.
1174
+ // Returns null when no convention match exists; caller omits the lineHeight style.
1175
+ function findLeadingFor(typeToken, leadingTokens) {
1176
+ const m = typeToken.name.match(/^--(?:type|fs|text)-(.+)$/);
1177
+ if (!m) return null;
1178
+ const suffix = m[1];
1179
+ return (leadingTokens || []).find((t) =>
1180
+ /^--(?:lh|leading|line-height)-/.test(t.name) && t.name.endsWith('-' + suffix)
1181
+ )?.value ?? null;
1182
+ }
1183
+
1184
+ // Best-effort sample font: prefer body / sans / display tokens, fall back to
1185
+ // the first font-kind token, fall back to system-ui. Avoids the shell's
1186
+ // Berkeley Mono leaking into user-facing previews.
1187
+ function sampleFontFamily(fontTokens) {
1188
+ if (!fontTokens?.length) return undefined;
1189
+ const prefer = ['body', 'sans', 'display', 'text', 'family'];
1190
+ for (const tag of prefer) {
1191
+ const hit = fontTokens.find((t) => t.name.includes(tag));
1192
+ if (hit) return hit.value;
1193
+ }
1194
+ return fontTokens[0].value;
1195
+ }
1196
+
1197
+ function TypeLadder({ tokenGroups }) {
1198
+ const typeTokens = tokenGroups?.fontsize || [];
1199
+ if (typeTokens.length === 0) return null;
1200
+ const leadingTokens = tokenGroups?.leading || [];
1201
+ const sampleFont = sampleFontFamily(tokenGroups?.font);
1202
+
1138
1203
  return (
1139
1204
  <section className="sv-section sv-section-type">
1140
- <h2>type · 8-step ladder<span className="sv-h-num">{TYPE_STEPS.length}</span></h2>
1205
+ <h2>
1206
+ type · ladder<span className="sv-h-num">{typeTokens.length}</span>
1207
+ </h2>
1141
1208
  <div className="sv-type-list">
1142
- {TYPE_STEPS.map(s => (
1143
- <div className="sv-type-row" key={s}>
1144
- <code className="sv-type-tok">--type-{s}</code>
1145
- <span className="sv-type-sample" style={{ fontSize: `var(--type-${s})`, lineHeight: `var(--lh-${s})` }}>
1146
- The catalog is the system.
1147
- </span>
1148
- </div>
1149
- ))}
1209
+ {typeTokens.map((t) => {
1210
+ const lh = findLeadingFor(t, leadingTokens);
1211
+ const style = { fontSize: t.value };
1212
+ if (lh) style.lineHeight = lh;
1213
+ if (sampleFont) style.fontFamily = sampleFont;
1214
+ return (
1215
+ <div className="sv-type-row" key={t.name}>
1216
+ <code className="sv-type-tok">{t.name}</code>
1217
+ <span className="sv-type-sample" style={style}>
1218
+ The catalog is the system.
1219
+ </span>
1220
+ </div>
1221
+ );
1222
+ })}
1150
1223
  </div>
1151
1224
  </section>
1152
1225
  );
1153
1226
  }
1154
1227
 
1155
- function SystemView({ data, onOpen, cfg }) {
1228
+ function SystemView({ data, onOpen, cfg, onSelectDs }) {
1156
1229
  if (!data) {
1157
1230
  return <div className="sv-empty"><p>Loading design system…</p></div>;
1158
1231
  }
1159
- const { previewGallery, uiKitsGallery, systemDir } = data;
1232
+ const {
1233
+ previewGallery,
1234
+ uiKitsGallery,
1235
+ systemDir,
1236
+ tokens,
1237
+ tokenGroups,
1238
+ tokensPath,
1239
+ ds,
1240
+ availableDesignSystems,
1241
+ } = data;
1160
1242
  const empty = (!previewGallery || !previewGallery.length) && (!uiKitsGallery || !uiKitsGallery.length);
1243
+ const hasPicker = Array.isArray(availableDesignSystems) && availableDesignSystems.length > 1;
1244
+ const selectedName = ds?.name ?? availableDesignSystems?.[0]?.name ?? '';
1161
1245
 
1162
1246
  return (
1163
1247
  <div className="sv">
1164
1248
  <header className="sv-header">
1165
1249
  <span className="sv-sku">MDCC-DSN/01</span>
1166
1250
  <span className="sv-title">design system view</span>
1251
+ {hasPicker ? (
1252
+ <label className="sv-ds-picker">
1253
+ <span className="sv-ds-picker-label">DS</span>
1254
+ <select
1255
+ value={selectedName}
1256
+ onChange={(e) => onSelectDs && onSelectDs(e.target.value)}
1257
+ >
1258
+ {availableDesignSystems.map((d) => (
1259
+ <option key={d.name} value={d.name}>{d.name}</option>
1260
+ ))}
1261
+ </select>
1262
+ </label>
1263
+ ) : null}
1167
1264
  <span className="sv-loc"><code>{systemDir}</code></span>
1168
1265
  </header>
1169
1266
 
1170
- <TokenLadder />
1171
- <TypeLadder />
1267
+ {ds?.description ? (
1268
+ <p className="sv-ds-description">{ds.description}</p>
1269
+ ) : null}
1270
+
1271
+ <TokenLadder tokens={tokens} tokenGroups={tokenGroups} tokensPath={tokensPath} />
1272
+ <TypeLadder tokenGroups={tokenGroups} />
1172
1273
 
1173
1274
  {empty ? (
1174
1275
  <div className="sv-empty">
@@ -1494,10 +1595,29 @@ function App() {
1494
1595
  useEffect(() => { loadTree(); }, [loadTree]);
1495
1596
 
1496
1597
  // ----- System data (lazy) -----
1497
- const loadSystemData = useCallback(async () => {
1598
+ // `dsName` scopes to a single design-system entry (DDR-048). The initial
1599
+ // call is unscoped — server returns `availableDesignSystems[]` + a default
1600
+ // — so the picker can render without a probe round-trip. Subsequent calls
1601
+ // (e.g. picker change) pass the chosen DS name and we replace systemData
1602
+ // wholesale (tokens + previews + ds metadata all shift together).
1603
+ const loadSystemData = useCallback(async (dsName) => {
1498
1604
  try {
1499
- const r = await fetch('/_system-data');
1605
+ const url = dsName ? `/_system-data?ds=${encodeURIComponent(dsName)}` : '/_system-data';
1606
+ const r = await fetch(url);
1607
+ if (!r.ok) {
1608
+ console.error('failed to load system-data', r.status);
1609
+ return;
1610
+ }
1500
1611
  const data = await r.json();
1612
+ // If the initial unscoped fetch has a defaultDesignSystem but no `ds`
1613
+ // attached (multi-DS project), kick off a scoped fetch so the visible
1614
+ // tokens + previews match the default DS, not the union root scan.
1615
+ if (!dsName && data?.defaultDesignSystem && !data.ds) {
1616
+ setSystemData(data);
1617
+ const r2 = await fetch(`/_system-data?ds=${encodeURIComponent(data.defaultDesignSystem)}`);
1618
+ if (r2.ok) setSystemData(await r2.json());
1619
+ return;
1620
+ }
1501
1621
  setSystemData(data);
1502
1622
  } catch (e) {
1503
1623
  console.error('failed to load system-data', e);
@@ -1918,6 +2038,7 @@ function App() {
1918
2038
  registerIframe={registerIframe}
1919
2039
  systemData={systemData}
1920
2040
  onOpenFromSystem={openTab}
2041
+ onSelectDs={loadSystemData}
1921
2042
  project={project}
1922
2043
  cfg={cfg}
1923
2044
  />
@@ -65,6 +65,34 @@ function handle(evt) {
65
65
  }
66
66
  }
67
67
  }
68
+ return;
69
+ }
70
+ if (evt.type === 'fs:json' && evt.file) {
71
+ // Phase 20 (DDR-049) — external edits to canvas `.meta.json` or
72
+ // `.annotations.svg` invalidate the iframe's in-memory undo stack
73
+ // (stale before/after snapshots would otherwise restore wrong state).
74
+ // The iframe's UndoStackProvider self-echo-dampens our own PATCH-es
75
+ // via window.__maude_last_meta_self_write_at, so we forward
76
+ // indiscriminately here.
77
+ const file = evt.file;
78
+ const isMeta = file.endsWith('.meta.json');
79
+ if (!isMeta) return;
80
+ const iframes = document.querySelectorAll('iframe[data-canvas-path]');
81
+ for (const f of iframes) {
82
+ if (!f.dataset.canvasPath) continue;
83
+ // Match by the canvas filename root (foo.tsx ↔ foo.meta.json).
84
+ const root = f.dataset.canvasPath.replace(/\.(tsx|jsx|ts|js|html)$/i, '');
85
+ if (!file.includes(`${root}.meta.json`)) continue;
86
+ try {
87
+ f.contentWindow?.dispatchEvent(
88
+ new CustomEvent('maude:invalidate-undo', {
89
+ detail: { reason: 'Edit history reset (external change)' },
90
+ })
91
+ );
92
+ } catch {
93
+ /* cross-origin / detached — ignore */
94
+ }
95
+ }
68
96
  }
69
97
  }
70
98
 
@@ -1,13 +1,20 @@
1
- /* @layer tokens — project DS bridge for the dev-server shell.
1
+ /* @layer tokens — SHELL theme for the dev-server UI itself.
2
2
  *
3
- * Source of truth: .design/system/project/colors_and_type.css (MDCC-DSN/01).
4
- * The OKLCH ladders below are pasted in so the dev-server bundle stays
5
- * self-contained (a relative `@import` would have to cross from
6
- * plugins/design/dev-server/ into the repo's .design/ folder fragile
7
- * once installed centrally). If the DS evolves, re-sync this file
8
- * manually and keep the values verbatim.
3
+ * This is NOT a template for user design systems and NOT a fallback for
4
+ * the System view's token ladder. It themes the dev-server chrome — sidebar,
5
+ * status bar, header, menubar, comments panel, System tab outer shell. The
6
+ * amber-rust "catalog stamp" aesthetic below belongs to Maude as a tool, not
7
+ * to any project that runs against it.
9
8
  *
10
- * Two themes both first-class, both equal:
9
+ * Per DDR-048, the System view (MDCC-DSN/01) reads user tokens from the
10
+ * project's own `colors_and_type.css` (parsed by the server, returned via
11
+ * /_system-data) and renders them by raw value — never by `var(--name)`
12
+ * resolving against this document. That isolation is load-bearing: prior
13
+ * to DDR-048 the System view leaked these shell tokens into the user's
14
+ * "design system overview" because both happened to share the canonical
15
+ * `--bg-0..4 / --fg-0..3 / --accent*` naming.
16
+ *
17
+ * Two shell themes — both first-class, both equal:
11
18
  * :root + [data-theme="light"] — paper-light (cream + ink)
12
19
  * [data-theme="dark"] — phosphor-dark (default for dev-server)
13
20
  *
@@ -36,14 +36,44 @@
36
36
 
37
37
  /* CV-09 header — SKU framing, hairline divider underneath. */
38
38
  .sv-header {
39
- display: grid;
40
- grid-template-columns: auto 1fr auto;
39
+ display: flex;
40
+ flex-wrap: wrap;
41
41
  align-items: baseline;
42
42
  gap: var(--space-5);
43
43
  border-bottom: 2px solid var(--u-fg-0);
44
44
  padding-bottom: var(--u-s-3);
45
45
  margin-bottom: var(--u-s-5);
46
46
  }
47
+ .sv-header .sv-title { flex: 1 1 auto; }
48
+ .sv-ds-picker {
49
+ display: inline-flex;
50
+ align-items: baseline;
51
+ gap: var(--u-s-2);
52
+ font-family: var(--u-font-mono);
53
+ font-size: 10px;
54
+ letter-spacing: var(--tracking-sku);
55
+ text-transform: uppercase;
56
+ color: var(--u-fg-2);
57
+ }
58
+ .sv-ds-picker select {
59
+ font-family: var(--u-font-mono);
60
+ font-size: 11px;
61
+ color: var(--u-fg-0);
62
+ background: var(--u-bg-1);
63
+ border: 1px solid var(--u-border);
64
+ border-radius: var(--u-r-sm);
65
+ padding: 2px 6px;
66
+ text-transform: none;
67
+ letter-spacing: 0;
68
+ }
69
+ .sv-ds-description {
70
+ font-family: var(--u-font-body);
71
+ font-size: var(--type-sm);
72
+ line-height: 1.5;
73
+ color: var(--u-fg-1);
74
+ margin: 0 0 var(--u-s-5);
75
+ max-width: 72ch;
76
+ }
47
77
  .sv-sku {
48
78
  font-family: var(--u-font-mono);
49
79
  font-size: var(--type-lg);
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @file commands/annotation-strokes-command.ts — undo entry for annotation
3
+ * stroke add / erase / batch-translate.
4
+ * @scope plugins/design/dev-server/commands/annotation-strokes-command.ts
5
+ * @purpose Records a `Stroke[]` pair (before/after) and routes both
6
+ * directions through a single `putFn`. The server endpoint is
7
+ * `PUT /_api/annotations` which replaces the entire SVG; we
8
+ * match that shape rather than diffing.
9
+ *
10
+ * Per DDR-050 rev 2 the runtime command is rebuilt per iframe
11
+ * mount from a serializable `CommandRecord` so the stack
12
+ * survives canvas switches. The `putFn` here also updates the
13
+ * iframe's local React strokes state (see annotations-layer
14
+ * `putStrokes`) so undo/redo visibly refresh, not just PUT.
15
+ *
16
+ * Why per-stroke commands (not coalesced into a 300 ms window):
17
+ * matches Figma/FigJam — Cmd+Z reverts the most recent tap or
18
+ * pen stroke individually. Coalescing into windows led to a
19
+ * matoucí "why did Cmd+Z erase two of my last lines?" UX.
20
+ */
21
+
22
+ import type { Stroke } from '../annotations-layer.tsx';
23
+ import type { CommandRecord, CommandSinks, EditCommand } from '../undo-stack.ts';
24
+ import { registerCommand } from '../undo-stack.ts';
25
+
26
+ /**
27
+ * Push-once callable that submits a full `Stroke[]` to the server (or its
28
+ * test stub) AND updates the iframe's local strokes state. The
29
+ * annotations-layer `putStrokes` is the production binding.
30
+ */
31
+ export type StrokesPutFn = (next: readonly Stroke[]) => void | Promise<void>;
32
+
33
+ export interface AnnotationStrokesPayload {
34
+ before: readonly Stroke[];
35
+ after: readonly Stroke[];
36
+ }
37
+
38
+ export const ANNOTATION_STROKES_KIND = 'annotation-strokes';
39
+
40
+ export interface AnnotationStrokesCommandInit {
41
+ before: readonly Stroke[];
42
+ after: readonly Stroke[];
43
+ putFn: StrokesPutFn;
44
+ /** Optional label override (eraser → "erase 1 stroke"). */
45
+ label?: string;
46
+ /** Telemetry kind. */
47
+ kind?: string;
48
+ }
49
+
50
+ export function createAnnotationStrokesCommand(init: AnnotationStrokesCommandInit): EditCommand {
51
+ const { putFn } = init;
52
+ const before = init.before.map(cloneStroke);
53
+ const after = init.after.map(cloneStroke);
54
+ const label = init.label ?? defaultLabel(before, after);
55
+ const kind = init.kind ?? ANNOTATION_STROKES_KIND;
56
+
57
+ return {
58
+ kind,
59
+ label,
60
+ async do() {
61
+ await putFn(after.map(cloneStroke));
62
+ },
63
+ async undo() {
64
+ await putFn(before.map(cloneStroke));
65
+ },
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Build a persistable record from the same inputs as `createAnnotationStrokesCommand`.
71
+ * Used by the consumer alongside the EditCommand so the runtime side-effect
72
+ * and the stored shape share one snapshot.
73
+ */
74
+ export function buildAnnotationStrokesRecord(opts: {
75
+ before: readonly Stroke[];
76
+ after: readonly Stroke[];
77
+ label?: string;
78
+ }): CommandRecord<AnnotationStrokesPayload> {
79
+ const before = opts.before.map(cloneStroke);
80
+ const after = opts.after.map(cloneStroke);
81
+ return {
82
+ kind: ANNOTATION_STROKES_KIND,
83
+ label: opts.label ?? defaultLabel(before, after),
84
+ payload: { before, after },
85
+ };
86
+ }
87
+
88
+ // ─────────────────────────────────────────────────────────────────────────────
89
+ // Registry
90
+
91
+ registerCommand<AnnotationStrokesPayload>(ANNOTATION_STROKES_KIND, (record, sinks) => {
92
+ const putFn = sinks.strokesPutFn as StrokesPutFn | undefined;
93
+ if (!putFn) return null;
94
+ return createAnnotationStrokesCommand({
95
+ before: record.payload.before,
96
+ after: record.payload.after,
97
+ putFn,
98
+ label: record.label,
99
+ });
100
+ });
101
+
102
+ // ─────────────────────────────────────────────────────────────────────────────
103
+ // Internals
104
+
105
+ /**
106
+ * Structured clone — works for every Stroke variant because they're plain
107
+ * JSON-shaped (no Date / Map / DOM nodes). The pen `points: WorldPoint[]`
108
+ * array is nested-cloned too so callers can't poison the snapshot by
109
+ * mutating the source.
110
+ */
111
+ function cloneStroke<T extends Stroke>(s: T): T {
112
+ // structuredClone is available on every runtime we ship to (Bun, modern
113
+ // browsers, Node 17+). Fall back to JSON for the rare older harness.
114
+ if (typeof structuredClone === 'function') return structuredClone(s);
115
+ return JSON.parse(JSON.stringify(s)) as T;
116
+ }
117
+
118
+ function defaultLabel(before: readonly Stroke[], after: readonly Stroke[]): string {
119
+ const added = after.length - before.length;
120
+ if (added > 0) return `add ${added} stroke${added === 1 ? '' : 's'}`;
121
+ if (added < 0) {
122
+ const erased = -added;
123
+ return `erase ${erased} stroke${erased === 1 ? '' : 's'}`;
124
+ }
125
+ // Same count — could be translate / edit / fill-change.
126
+ return `edit ${after.length} stroke${after.length === 1 ? '' : 's'}`;
127
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @file commands/equal-spacing-command.ts — undo label helpers for
3
+ * equal-spacing / align gestures.
4
+ * @scope plugins/design/dev-server/commands/equal-spacing-command.ts
5
+ * @purpose Distribute + align both call `dragBus.commitPositions(moved)`,
6
+ * which wraps the move in a generic MoveArtboardsCommand. Per
7
+ * DDR-050 the underlying command type is shared (the inverse
8
+ * payload is identical — a layout snapshot pair) and only the
9
+ * HUD label differs. These helpers compute the labels.
10
+ *
11
+ * Why not a separate Command class? The wrapping logic would
12
+ * duplicate `before`/`after`/`patchFn` + add an indirection.
13
+ * A label is a one-line string; a factory is overkill.
14
+ * Keeping `MoveArtboardsCommand` as the only artboard-layout
15
+ * command keeps the future Yjs swap (Phase 8) one-for-one.
16
+ */
17
+
18
+ export type AlignMode = 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y';
19
+
20
+ /** "equal-space 3 artboards" — N must be ≥ 3 (distribute requires it). */
21
+ export function equalSpacingLabel(n: number): string {
22
+ return `equal-space ${n} artboard${n === 1 ? '' : 's'}`;
23
+ }
24
+
25
+ /** "align left 4 artboards" — N must be ≥ 2 (align requires it). */
26
+ export function alignLabel(mode: AlignMode, n: number): string {
27
+ return `align ${mode} ${n} artboard${n === 1 ? '' : 's'}`;
28
+ }