@1agh/maude 0.23.0 → 0.24.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 (101) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +32 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +126 -2
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +74 -10
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  22. package/cli/lib/preflight.mjs +41 -10
  23. package/package.json +8 -8
  24. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  25. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  26. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  27. package/plugins/design/dev-server/api.ts +109 -4
  28. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  29. package/plugins/design/dev-server/bin/prep.sh +211 -0
  30. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  31. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  32. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  33. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  34. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  35. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  36. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  37. package/plugins/design/dev-server/canvas-shell.tsx +333 -19
  38. package/plugins/design/dev-server/client/app.jsx +155 -7
  39. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  40. package/plugins/design/dev-server/collab/index.ts +87 -9
  41. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  42. package/plugins/design/dev-server/collab/registry.ts +80 -2
  43. package/plugins/design/dev-server/collab/room.ts +21 -8
  44. package/plugins/design/dev-server/context-menu.tsx +167 -23
  45. package/plugins/design/dev-server/context.ts +24 -0
  46. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  47. package/plugins/design/dev-server/dist/client.bundle.js +126 -13
  48. package/plugins/design/dev-server/dist/comment-mount.js +78 -11
  49. package/plugins/design/dev-server/dist/styles.css +16 -0
  50. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  51. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  52. package/plugins/design/dev-server/fs-watch.ts +1 -0
  53. package/plugins/design/dev-server/http.ts +260 -20
  54. package/plugins/design/dev-server/input-router.tsx +26 -3
  55. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  56. package/plugins/design/dev-server/server.ts +123 -1
  57. package/plugins/design/dev-server/sync/agent.ts +95 -0
  58. package/plugins/design/dev-server/sync/codec.ts +155 -0
  59. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  60. package/plugins/design/dev-server/sync/index.ts +479 -35
  61. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  62. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  63. package/plugins/design/dev-server/sync/origins.ts +57 -0
  64. package/plugins/design/dev-server/sync/projection.ts +368 -0
  65. package/plugins/design/dev-server/sync/status.ts +115 -0
  66. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  67. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  68. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  69. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  70. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  71. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  72. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  73. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  74. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  75. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  76. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  77. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  78. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  79. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  80. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  81. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  82. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  83. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  84. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  85. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  86. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  87. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  88. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  89. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  90. package/plugins/design/dev-server/undo-stack.ts +20 -4
  91. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  92. package/plugins/design/dev-server/use-tool-mode.tsx +15 -12
  93. package/plugins/design/dev-server/ws.ts +40 -1
  94. package/plugins/design/templates/_shell.html +49 -1
  95. package/plugins/design/templates/canvas.tsx.template +13 -0
  96. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  97. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  98. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  99. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  100. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  101. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -73,6 +73,14 @@ export function createRoom(slug: string, callbacks: RoomCallbacks): Room {
73
73
  const awareness = new Awareness(doc);
74
74
 
75
75
  const conns = new Map<string, RoomConn>();
76
+ // clientID → owning conn.id, learned from the origin of each awareness update.
77
+ // Server-authoritative: we do NOT trust a client-supplied `__connId` (awareness
78
+ // is attacker-influenceable through a semi-trusted hub — DDR-054). On disconnect
79
+ // we remove exactly the clientIDs this conn published so other peers drop the
80
+ // avatar immediately, instead of waiting out the ~30s awareness timeout (which
81
+ // let phantom peers pile up on rapid reconnects — the cross-origin repro that
82
+ // surfaced this latent bug).
83
+ const connByClient = new Map<number, string>();
76
84
  let dirty = false;
77
85
  let flushTimer: ReturnType<typeof setTimeout> | null = null;
78
86
  let destroyed = false;
@@ -138,6 +146,13 @@ export function createRoom(slug: string, callbacks: RoomCallbacks): Room {
138
146
  if (changed.length === 0) return;
139
147
  const except =
140
148
  origin && typeof origin === 'object' && 'id' in origin ? (origin as RoomConn) : undefined;
149
+ // Learn which clientIDs belong to which conn from the update origin, so
150
+ // disconnect() can evict them precisely.
151
+ if (except) {
152
+ for (const id of added) connByClient.set(id, except.id);
153
+ for (const id of updated) connByClient.set(id, except.id);
154
+ for (const id of removed) connByClient.delete(id);
155
+ }
141
156
  broadcast(encodeAwarenessFrame(awareness, changed), except);
142
157
  }
143
158
  );
@@ -161,16 +176,14 @@ export function createRoom(slug: string, callbacks: RoomCallbacks): Room {
161
176
 
162
177
  function disconnect(conn: RoomConn): void {
163
178
  conns.delete(conn.id);
164
- // Awareness states keyed by the conn token; clean them up so other peers'
165
- // cursor renderers can drop the avatar.
166
- const states = awareness.getStates();
179
+ // Remove the awareness states this conn published (tracked by update origin
180
+ // in connByClient) so other peers' cursor renderers drop the avatar now,
181
+ // not after the ~30s awareness timeout.
167
182
  const stale: number[] = [];
168
- for (const clientId of states.keys()) {
169
- // Bridge: our Awareness state setters tag the state with `__connId`
170
- // matching conn.id; remove states whose owning conn just left.
171
- const state = states.get(clientId) as { __connId?: string } | undefined;
172
- if (state && state.__connId === conn.id) stale.push(clientId);
183
+ for (const [clientId, connId] of connByClient) {
184
+ if (connId === conn.id) stale.push(clientId);
173
185
  }
186
+ for (const clientId of stale) connByClient.delete(clientId);
174
187
  if (stale.length) removeAwarenessStates(awareness, stale, conn);
175
188
  }
176
189
 
@@ -55,6 +55,15 @@ export interface MenuItem {
55
55
  shortcut?: string;
56
56
  destructive?: boolean;
57
57
  disabled?: boolean;
58
+ /**
59
+ * Optional nested flyout (e.g. `Theme ▸ DS default / Light / Dark / Follow
60
+ * chrome`). When present the row opens a submenu on hover / ArrowRight /
61
+ * click and `onSelect` on THIS item is not invoked — only the chosen leaf's
62
+ * `onSelect` fires. A disabled leaf carries `disabledHint` for its title.
63
+ */
64
+ submenu?: MenuItem[];
65
+ /** Hover/title hint shown when the item is `disabled` (a11y affordance). */
66
+ disabledHint?: string;
58
67
  onSelect: (target: ContextTarget) => void;
59
68
  }
60
69
 
@@ -158,20 +167,20 @@ const MENU_CSS = `
158
167
  .dc-context-menu {
159
168
  position: fixed;
160
169
  z-index: 7;
161
- background: var(--u-bg-0, var(--bg-0, #fff));
162
- border: 1px solid var(--u-fg-0, #1c1917);
170
+ background: var(--maude-chrome-bg-0, #fff);
171
+ border: 1px solid var(--maude-chrome-fg-0, #1c1917);
163
172
  border-radius: 8px;
164
- box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
173
+ box-shadow: 0 6px 24px var(--maude-chrome-shadow, color-mix(in oklab, #1c1917 10%, transparent));
165
174
  padding: 4px;
166
175
  min-width: 220px;
167
- font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
176
+ font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
168
177
  font-size: 12px;
169
- color: var(--u-fg-0, var(--fg-0, rgba(20,15,10,0.92)));
178
+ color: var(--maude-chrome-fg-0, rgba(20,15,10,0.92));
170
179
  user-select: none;
171
180
  }
172
181
  .dc-context-menu .dc-menu-sep {
173
182
  height: 1px;
174
- background: var(--border-subtle, rgba(0,0,0,0.08));
183
+ background: var(--maude-chrome-border, rgba(0,0,0,0.08));
175
184
  margin: 4px -4px;
176
185
  }
177
186
  .dc-context-menu .dc-menu-item {
@@ -191,7 +200,7 @@ const MENU_CSS = `
191
200
  }
192
201
  .dc-context-menu .dc-menu-item:hover,
193
202
  .dc-context-menu .dc-menu-item:focus-visible {
194
- background: var(--u-bg-3, var(--bg-3, rgba(0,0,0,0.05)));
203
+ background: color-mix(in oklab, var(--maude-chrome-fg-0, #1c1917) 8%, transparent);
195
204
  outline: none;
196
205
  }
197
206
  .dc-context-menu .dc-menu-item[disabled] {
@@ -204,11 +213,28 @@ const MENU_CSS = `
204
213
  color: var(--accent-fg, #fff);
205
214
  }
206
215
  .dc-context-menu .dc-menu-shortcut {
207
- color: var(--fg-2, rgba(40,30,20,0.55));
216
+ color: var(--maude-chrome-fg-1, rgba(40,30,20,0.55));
208
217
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
209
218
  font-size: 10px;
210
219
  font-variant-numeric: tabular-nums;
211
220
  }
221
+ /* Submenu flyout (Theme ▸ …). Anchored to the right of its parent row; flips
222
+ left near the viewport edge via .is-flip. Reuses .dc-menu-item styling. */
223
+ .dc-context-menu .dc-menu-sub { position: relative; }
224
+ .dc-context-menu .dc-menu-caret { color: var(--maude-chrome-fg-1, rgba(40,30,20,0.55)); font-size: 11px; }
225
+ .dc-context-menu .dc-menu-flyout {
226
+ position: absolute;
227
+ top: -5px;
228
+ left: calc(100% + 3px);
229
+ min-width: 196px;
230
+ padding: 4px;
231
+ background: var(--maude-chrome-bg-0, #fff);
232
+ border: 1px solid var(--maude-chrome-fg-0, #1c1917);
233
+ border-radius: 8px;
234
+ box-shadow: 0 6px 24px var(--maude-chrome-shadow, color-mix(in oklab, #1c1917 10%, transparent));
235
+ z-index: 8;
236
+ }
237
+ .dc-context-menu .dc-menu-flyout.is-flip { left: auto; right: calc(100% + 3px); }
212
238
  `.trim();
213
239
 
214
240
  function ensureMenuStyles(): void {
@@ -346,21 +372,7 @@ function ContextMenuView({
346
372
  <div key={sectionKey} role="group">
347
373
  {si > 0 ? <div className="dc-menu-sep" aria-hidden="true" /> : null}
348
374
  {section.map((item) => (
349
- <button
350
- key={item.id}
351
- type="button"
352
- role="menuitem"
353
- disabled={item.disabled}
354
- className={`dc-menu-item${item.destructive ? ' is-destructive' : ''}`}
355
- onClick={() => {
356
- if (item.disabled) return;
357
- item.onSelect(target);
358
- onClose();
359
- }}
360
- >
361
- <span>{item.label}</span>
362
- {item.shortcut ? <span className="dc-menu-shortcut">{item.shortcut}</span> : null}
363
- </button>
375
+ <MenuItemRow key={item.id} item={item} target={target} onClose={onClose} />
364
376
  ))}
365
377
  </div>
366
378
  );
@@ -368,3 +380,135 @@ function ContextMenuView({
368
380
  </div>
369
381
  );
370
382
  }
383
+
384
+ // ─────────────────────────────────────────────────────────────────────────────
385
+ // MenuItemRow — a single menu row. Plain rows render a button; rows with a
386
+ // `submenu` render a flyout (opens on hover / ArrowRight / click). Additive:
387
+ // existing flat registries (no `submenu`) take the plain-button path unchanged.
388
+
389
+ function MenuItemRow({
390
+ item,
391
+ target,
392
+ onClose,
393
+ }: {
394
+ item: MenuItem;
395
+ target: ContextTarget;
396
+ onClose: () => void;
397
+ }) {
398
+ const [subOpen, setSubOpen] = useState(false);
399
+ const [flip, setFlip] = useState(false);
400
+ const btnRef = useRef<HTMLButtonElement>(null);
401
+ const flyoutRef = useRef<HTMLDivElement>(null);
402
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
403
+
404
+ if (!item.submenu || item.submenu.length === 0) {
405
+ return (
406
+ <button
407
+ type="button"
408
+ role="menuitem"
409
+ disabled={item.disabled}
410
+ title={item.disabled ? item.disabledHint : undefined}
411
+ className={`dc-menu-item${item.destructive ? ' is-destructive' : ''}`}
412
+ onClick={() => {
413
+ if (item.disabled) return;
414
+ item.onSelect(target);
415
+ onClose();
416
+ }}
417
+ >
418
+ <span>{item.label}</span>
419
+ {item.shortcut ? <span className="dc-menu-shortcut">{item.shortcut}</span> : null}
420
+ </button>
421
+ );
422
+ }
423
+
424
+ const cancelClose = () => {
425
+ if (closeTimer.current) {
426
+ clearTimeout(closeTimer.current);
427
+ closeTimer.current = null;
428
+ }
429
+ };
430
+ const open = (focusFirst = false) => {
431
+ cancelClose();
432
+ const r = btnRef.current?.getBoundingClientRect();
433
+ if (r && typeof window !== 'undefined') setFlip(r.right + 200 > window.innerWidth);
434
+ setSubOpen(true);
435
+ if (focusFirst) {
436
+ setTimeout(() => {
437
+ flyoutRef.current
438
+ ?.querySelector<HTMLButtonElement>('button.dc-menu-item:not([disabled])')
439
+ ?.focus();
440
+ }, 0);
441
+ }
442
+ };
443
+ const scheduleClose = () => {
444
+ cancelClose();
445
+ closeTimer.current = setTimeout(() => setSubOpen(false), 140);
446
+ };
447
+
448
+ return (
449
+ <div className="dc-menu-sub" onMouseEnter={() => open()} onMouseLeave={scheduleClose}>
450
+ <button
451
+ ref={btnRef}
452
+ type="button"
453
+ role="menuitem"
454
+ aria-haspopup="menu"
455
+ aria-expanded={subOpen}
456
+ disabled={item.disabled}
457
+ className="dc-menu-item"
458
+ onClick={() => (subOpen ? setSubOpen(false) : open())}
459
+ onKeyDown={(e) => {
460
+ if (e.key === 'ArrowRight' || e.key === 'Enter' || e.key === ' ') {
461
+ e.preventDefault();
462
+ open(true);
463
+ } else if (e.key === 'ArrowLeft' || e.key === 'Escape') {
464
+ if (subOpen) {
465
+ e.stopPropagation();
466
+ setSubOpen(false);
467
+ }
468
+ }
469
+ }}
470
+ >
471
+ <span>{item.label}</span>
472
+ <span className="dc-menu-caret" aria-hidden="true">
473
+
474
+ </span>
475
+ </button>
476
+ {subOpen ? (
477
+ <div
478
+ ref={flyoutRef}
479
+ className={`dc-menu-flyout${flip ? ' is-flip' : ''}`}
480
+ role="menu"
481
+ onMouseEnter={cancelClose}
482
+ onMouseLeave={scheduleClose}
483
+ >
484
+ {item.submenu.map((sub) => (
485
+ <button
486
+ key={sub.id}
487
+ type="button"
488
+ role="menuitem"
489
+ disabled={sub.disabled}
490
+ title={sub.disabled ? sub.disabledHint : undefined}
491
+ className={`dc-menu-item${sub.destructive ? ' is-destructive' : ''}`}
492
+ onClick={() => {
493
+ if (sub.disabled) return;
494
+ sub.onSelect(target);
495
+ onClose();
496
+ }}
497
+ onKeyDown={(e) => {
498
+ if (e.key === 'ArrowLeft' || e.key === 'Escape') {
499
+ e.preventDefault();
500
+ e.stopPropagation();
501
+ setSubOpen(false);
502
+ btnRef.current?.focus();
503
+ }
504
+ }}
505
+ >
506
+ <span>{sub.label}</span>
507
+ {sub.shortcut ? <span className="dc-menu-shortcut">{sub.shortcut}</span> : null}
508
+ </button>
509
+ ))}
510
+ </div>
511
+ ) : null}
512
+ </div>
513
+ );
514
+ }
@@ -117,6 +117,30 @@ export interface Context {
117
117
  projectLabel: string;
118
118
  paths: Paths;
119
119
  bus: Bus;
120
+ /**
121
+ * T2 (9.1-A) — origin of the segregated canvas-content server (e.g.
122
+ * `http://localhost:51234`), set by server.ts once the second listener binds.
123
+ * The client reads it via /_config to build absolute, cross-origin iframe
124
+ * URLs. Undefined in tests / before the canvas listener boots.
125
+ */
126
+ canvasOrigin?: string;
127
+ /**
128
+ * T2 (9.1-A) — origin of the MAIN dev-server listener (e.g.
129
+ * `http://localhost:4399`), set by server.ts once the primary listener binds.
130
+ * Used by `cspForCanvasShell` to allowlist the legit embedder in the canvas
131
+ * origin's `frame-ancestors`. Undefined in tests / before boot.
132
+ */
133
+ mainOrigin?: string;
134
+ /**
135
+ * Phase 9.2 (DDR-064) — `MAUDE_SHARED_DOC` feature flag. When true, the
136
+ * collab room's Y.Doc becomes the SINGLE shared doc per canvas: the
137
+ * hub-facing HocuspocusProvider attaches to it directly (no second doc, no
138
+ * disk-mediated reconcile). Default `false`/undefined = the proven two-doc
139
+ * path = zero regression. Set by server.ts from the env; tests set it
140
+ * directly on the Context they construct. Threaded through Phase A; the flag
141
+ * gates behavior starting in Phase B.
142
+ */
143
+ sharedDoc?: boolean;
120
144
  }
121
145
 
122
146
  function resolveRepoRoot(): string {
@@ -41,14 +41,14 @@ const CTX_TOOLBAR_CSS = `
41
41
  align-items: stretch;
42
42
  gap: 2px;
43
43
  padding: 4px;
44
- background: var(--u-bg-0, var(--bg-0, #ffffff));
45
- border: 1px solid var(--u-fg-0, #1c1917);
44
+ background: var(--maude-chrome-bg-0, #ffffff);
45
+ border: 1px solid var(--maude-chrome-fg-0, #1c1917);
46
46
  border-radius: 8px;
47
- box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
48
- font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
47
+ box-shadow: 0 6px 24px var(--maude-chrome-shadow, color-mix(in oklab, #1c1917 10%, transparent));
48
+ font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
49
49
  font-size: 11px;
50
50
  letter-spacing: 0.02em;
51
- color: var(--u-fg-0, #1a1a1a);
51
+ color: var(--maude-chrome-fg-0, #1a1a1a);
52
52
  user-select: none;
53
53
  opacity: 0;
54
54
  transition: opacity 100ms cubic-bezier(0.4, 0, 0.2, 1);
@@ -72,8 +72,8 @@ const CTX_TOOLBAR_CSS = `
72
72
  }
73
73
  .dc-elem-ctx-tb .dc-elem-ctx-count {
74
74
  padding: 4px 8px 4px 10px;
75
- color: var(--fg-1, rgba(40,30,20,0.7));
76
- border-right: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08));
75
+ color: var(--maude-chrome-fg-1, rgba(40,30,20,0.7));
76
+ border-right: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.08));
77
77
  margin-right: 2px;
78
78
  font-variant-numeric: tabular-nums;
79
79
  }