@tangle-network/brand 1.29.0 → 1.30.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.30.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 29e2a49: One loop period, one shadow colour and one radius ramp.
8
+
9
+ - `--duration-ambient` (2000ms) and `--motion-ambient` (ease-in-out) are the only loop period on a page; the shared `tangle-breathe` keyframe breathes a running mark on them, opacity only. Reduced motion keeps that period, so the essential "still working" signal survives.
10
+ - `--shadow-color` is the one shadow colour, as HSL channels: black in dark, `220 40% 8%` in light. Every spine shadow, the agents and hospitality themes and system.css draw in it; hospitality's forest-tinted shadow becomes the light shadow colour. The dark `--shadow-overlay` no longer inverts into a white halo.
11
+ - Radius roles are one ramp: `--radius-tag` 4, `--radius-control` 8, `--radius-card` 12, `--radius-container` 16, `--radius-pill`. `--radius-chip`, `--radius-field` and `--radius-panel` keep their values as aliases of control, card and container.
12
+
3
13
  ## 1.29.0
4
14
 
5
15
  ### Minor Changes
package/README.md CHANGED
@@ -82,7 +82,7 @@ The Agent App light-default contract is an explicit exception to Brand's default
82
82
  /* Import globals.css separately only when the host already intends those globals. */
83
83
  ```
84
84
 
85
- Choose this **instead of** `styles`, `tokens.css` and `named-themes.css`; do not stack the two default contracts. The projection contains canonical declarations with a zero-specificity light root default, keeps all explicit mode selectors, and omits `--radius`, which the legacy consumer deliberately leaves to its host. It includes no global component rules, keyframes or automatic ladder/system imports.
85
+ Choose this **instead of** `styles`, `tokens.css` and `named-themes.css`; do not stack the two default contracts. The projection contains canonical declarations with a zero-specificity light root default, keeps all explicit mode selectors, and omits `--radius`, which the legacy consumer deliberately leaves to its host. It includes no global component rules or automatic ladder/system imports; its one keyframe, `tangle-breathe`, runs only where a component names it.
86
86
 
87
87
  This export is the shared migration boundary, **not a claim that Agent App has already migrated** and not a replacement for that package's entire stylesheet. Its component-specific variables, neutral-ramp API, keyframes and structural rules remain consumer-owned until a separate, explicit migration reconciles them. Remove duplicate shared palette declarations in that migration rather than placing competing global token systems on the same page.
88
88
 
@@ -123,9 +123,9 @@ Renderers that cannot read CSS variables (PDF, canvas, WebGL, email, the `theme-
123
123
 
124
124
  The base surface ladder is the one GTM proved out: dark is indigo-lifted `#0a0a14` / `#191826` / `#221f33` / `#2c2942`; light is a cool `#eceef3` canvas, white paper cards and overlays, and `#f1f2f7` nested wells. The cast stays below 0.1 chroma and ink stays achromatic, so indigo still reads as the interaction accent, not the base field. Existing named products may supply deliberate surface overrides.
125
125
 
126
- Chrome radii are 6/8/10/12px in both base modes; the composer has its own 26px role. Inter (variable, then static) leads body and display stacks, with Geist fallbacks; Geist Mono leads code with JetBrains Mono and system fallbacks. Runtime font/radius registrations retain their public override names; tests keep Tailwind registration defaults aligned with the canonical declarations.
126
+ Chrome radii are 6/8/10/12px in both base modes; the composer has its own 26px role. Role radii are one ramp: `--radius-tag` 4, `--radius-control` 8, `--radius-card` 12, `--radius-container` 16, and `--radius-pill` for every chip; `--radius-chip`, `--radius-field` and `--radius-panel` are older names of control, card and container. Every shadow is drawn in `--shadow-color`, HSL channels that are black in dark and `220 40% 8%` in light; a named theme retints its shadows by setting that one token. Inter (variable, then static) leads body and display stacks, with Geist fallbacks; Geist Mono leads code with JetBrains Mono and system fallbacks. Runtime font/radius registrations retain their public override names; tests keep Tailwind registration defaults aligned with the canonical declarations.
127
127
 
128
- Existing `--transition-*` values are preserved. The shared `--duration-*`, `--ease-*` and `--motion-*` vocabulary comes from the existing Agent App contract. It does not apply animations automatically. Reduced motion zeroes those durations and re-resolves composites at theme boundaries. The adjacent `--interaction-*` roles are elapsed acknowledgment/loading thresholds for existing product instrumentation, not animation durations; reduced motion does not change them.
128
+ Existing `--transition-*` values are preserved. The shared `--duration-*`, `--ease-*` and `--motion-*` vocabulary comes from the existing Agent App contract. It does not apply animations automatically. Reduced motion zeroes those durations and re-resolves composites at theme boundaries. `--duration-ambient` (2000 ms; `--motion-ambient` adds ease-in-out) is the only loop period on a page: a running mark breathes on it with the `tangle-breathe` keyframe, opacity only. Reduced motion keeps that one, because it is the essential "still working" signal and moves nothing. The adjacent `--interaction-*` roles are elapsed acknowledgment/loading thresholds for existing product instrumentation, not animation durations; reduced motion does not change them.
129
129
 
130
130
  Focus uses `--focus-border`, `--focus-halo`, their danger variants, and `--border-strong`, derived from the local ring/border palette. UI's `focusField` / `focusRing` consume them. `globals.css` supplies the existing fallback for controls without their own focus treatment.
131
131
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/brand",
3
- "version": "1.29.0",
3
+ "version": "1.30.0",
4
4
  "description": "Tangle brand system — design tokens, logo, Tailwind v4 theme. Single source of truth for every Tangle app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  /* GENERATED by scripts/gen-compat.mjs. Do not edit.
2
2
  * Source: tokens.css + named-themes.css
3
- * SHA-256: 0a80a683644d15e5e56e938a3094142b9f1a2dd6f3fe136154af4b6bc7c033e0
3
+ * SHA-256: 83dde77644ce502513ced146ea7ed7dad93fc736195530c0c2bef82849bf1f21
4
4
  * Light-default compatibility; all explicit modes remain canonical.
5
5
  */
6
6
  :where(:root),
@@ -272,12 +272,12 @@
272
272
  --code-success: var(--surface-success-text);
273
273
  --code-comment: var(--syntax-comment);
274
274
  --code-error: var(--syntax-error);
275
- --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3);
276
- --shadow-dropdown: 0 8px 30px rgba(0, 0, 0, 0.45);
275
+ --shadow-color: 0 0% 0%;
276
+ --shadow-card: 0 1px 2px hsl(var(--shadow-color) / 0.3);
277
+ --shadow-dropdown: 0 8px 30px hsl(var(--shadow-color) / 0.45);
277
278
  --shadow-glow: none;
278
- --shadow-accent: 0 1px 2px rgba(0, 0, 0, 0.3);
279
- --shadow-overlay: 0 2px 6px hsl(var(--hsl-foreground) / 0.1),
280
- 0 16px 40px hsl(var(--hsl-foreground) / 0.16);
279
+ --shadow-accent: var(--shadow-card);
280
+ --shadow-overlay: 0 2px 6px hsl(var(--shadow-color) / 0.3), 0 16px 40px hsl(var(--shadow-color) / 0.5);
281
281
  --scrim: rgb(0 0 0 / 0.4);
282
282
  --scrim-strong: rgb(0 0 0 / 0.72);
283
283
  --on-media: #ffffff;
@@ -292,9 +292,12 @@
292
292
  --radius-full: 999px;
293
293
  --radius-composer: 26px;
294
294
  --radius-tag: 4px;
295
- --radius-chip: 8px;
296
- --radius-field: 12px;
297
- --radius-panel: 16px;
295
+ --radius-control: var(--radius-md);
296
+ --radius-card: var(--radius-xl);
297
+ --radius-container: 16px;
298
+ --radius-chip: var(--radius-control);
299
+ --radius-field: var(--radius-card);
300
+ --radius-panel: var(--radius-container);
298
301
  --radius-cover: 20px;
299
302
  --radius-sheet: 24px;
300
303
  --control-height: 2.25rem;
@@ -377,6 +380,8 @@
377
380
  --motion-dismiss: var(--duration-instant) var(--ease-exit);
378
381
  --motion-stream: var(--duration-stream) var(--ease-expo);
379
382
  --motion-arrive: var(--duration-arrive) var(--ease-expo);
383
+ --duration-ambient: 2000ms;
384
+ --motion-ambient: var(--duration-ambient) ease-in-out;
380
385
  --interaction-feedback-budget: 100ms;
381
386
  --interaction-pending-delay: 200ms;
382
387
  --interaction-long-work-delay: 1000ms;
@@ -533,10 +538,10 @@
533
538
  --run-mix-cancelled: #515367;
534
539
  --glass-bg-strong: var(--depth-1);
535
540
  --code-header-bg: color-mix(in srgb, hsl(var(--hsl-muted)) 50%, transparent);
536
- --shadow-card: 0 1px 2px rgba(25, 28, 36, 0.06);
537
- --shadow-dropdown: 0 8px 30px rgba(25, 28, 36, 0.10);
538
- --shadow-glow: none;
539
- --shadow-accent: 0 1px 2px rgba(25, 28, 36, 0.06);
541
+ --shadow-color: 220 40% 8%;
542
+ --shadow-card: 0 1px 2px hsl(var(--shadow-color) / 0.06);
543
+ --shadow-dropdown: 0 8px 30px hsl(var(--shadow-color) / 0.1);
544
+ --shadow-overlay: 0 2px 6px hsl(var(--shadow-color) / 0.1), 0 16px 40px hsl(var(--shadow-color) / 0.16);
540
545
  }
541
546
  :where(:root),
542
547
  .dark,
@@ -640,6 +645,11 @@
640
645
  --chart-tier-a: #5b5bd6;
641
646
  --chart-tier-s: #272962;
642
647
  }
648
+ @keyframes tangle-breathe {
649
+ 50% {
650
+ opacity: 0.45;
651
+ }
652
+ }
643
653
  @media (prefers-reduced-motion: reduce) {
644
654
  :where(:root),
645
655
  .dark,
@@ -1262,9 +1272,9 @@
1262
1272
  --field-border: #798479;
1263
1273
  --border-accent: rgba(40, 88, 66, 0.20);
1264
1274
  --border-accent-hover: rgba(40, 88, 66, 0.36);
1265
- --shadow-card: 0 1px 2px rgba(23, 38, 29, 0.07), 0 4px 14px -4px rgba(23, 38, 29, 0.10);
1266
- --shadow-raised: 0 1px 1px rgba(23, 38, 29, 0.05), 0 2px 6px -2px rgba(23, 38, 29, 0.07);
1267
- --shadow-well: inset 0 1px 2px rgba(23, 38, 29, 0.06);
1275
+ --shadow-card: 0 1px 2px hsl(var(--shadow-color) / 0.07), 0 4px 14px -4px hsl(var(--shadow-color) / 0.1);
1276
+ --shadow-raised: 0 1px 1px hsl(var(--shadow-color) / 0.05), 0 2px 6px -2px hsl(var(--shadow-color) / 0.07);
1277
+ --shadow-well: inset 0 1px 2px hsl(var(--shadow-color) / 0.06);
1268
1278
  --edge-highlight: 0 0 0 0 transparent;
1269
1279
  --accent-text: #285842;
1270
1280
  --accent-surface-soft: rgba(40, 88, 66, 0.05);
@@ -1364,9 +1374,9 @@
1364
1374
  --field-border: #939c94;
1365
1375
  --border-accent: rgba(118, 183, 145, 0.35);
1366
1376
  --border-accent-hover: rgba(118, 183, 145, 0.50);
1367
- --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.45), 0 10px 24px -14px rgba(0, 0, 0, 0.6);
1368
- --shadow-raised: 0 1px 2px rgba(0, 0, 0, 0.35);
1369
- --shadow-well: inset 0 1px 2px rgba(0, 0, 0, 0.5);
1377
+ --shadow-card: 0 1px 2px hsl(var(--shadow-color) / 0.45), 0 10px 24px -14px hsl(var(--shadow-color) / 0.6);
1378
+ --shadow-raised: 0 1px 2px hsl(var(--shadow-color) / 0.35);
1379
+ --shadow-well: inset 0 1px 2px hsl(var(--shadow-color) / 0.5);
1370
1380
  --edge-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.06);
1371
1381
  --accent-text: #b2dbc2;
1372
1382
  --accent-surface-soft: rgba(118, 183, 145, 0.07);
@@ -1448,8 +1458,8 @@
1448
1458
  --border-default: #49474e;
1449
1459
  --border-hover: #625f69;
1450
1460
  --shadow-card: inset 0 0 0 1px rgb(255 255 255 / 0.06);
1451
- --shadow-dropdown: inset 0 0 0 1px rgb(255 255 255 / 0.08), 0 8px 24px rgb(0 0 0 / 0.5);
1452
- --shadow-overlay: inset 0 0 0 1px rgb(255 255 255 / 0.1), 0 16px 48px rgb(0 0 0 / 0.6);
1461
+ --shadow-dropdown: inset 0 0 0 1px rgb(255 255 255 / 0.08), 0 8px 24px hsl(var(--shadow-color) / 0.5);
1462
+ --shadow-overlay: inset 0 0 0 1px rgb(255 255 255 / 0.1), 0 16px 48px hsl(var(--shadow-color) / 0.6);
1453
1463
  }
1454
1464
  [data-theme="agents-light"] {
1455
1465
  --bg-card: #ffffff;
@@ -1502,9 +1512,9 @@
1502
1512
  --border-subtle: #dbd8e0;
1503
1513
  --border-default: #d0cdd7;
1504
1514
  --border-hover: #bcbac7;
1505
- --shadow-card: 0 0 0 1px rgb(33 31 38 / 0.03), 0 2px 6px rgb(33 31 38 / 0.05), 0 4px 8px rgb(33 31 38 / 0.08);
1506
- --shadow-dropdown: 0 0 0 1px rgb(33 31 38 / 0.04), 0 6px 16px rgb(33 31 38 / 0.12);
1507
- --shadow-overlay: 0 8px 28px rgb(33 31 38 / 0.28);
1515
+ --shadow-card: 0 0 0 1px hsl(var(--shadow-color) / 0.03), 0 2px 6px hsl(var(--shadow-color) / 0.05), 0 4px 8px hsl(var(--shadow-color) / 0.08);
1516
+ --shadow-dropdown: 0 0 0 1px hsl(var(--shadow-color) / 0.04), 0 6px 16px hsl(var(--shadow-color) / 0.12);
1517
+ --shadow-overlay: 0 8px 28px hsl(var(--shadow-color) / 0.28);
1508
1518
  }
1509
1519
  [data-theme="website"]:where(:not(.dark)) {
1510
1520
  --hsl-background: 240 42.9% 97.3%;
@@ -393,9 +393,9 @@
393
393
  --border-hover: rgba(23, 38, 29, 0.28); --border-strong: #798479;
394
394
  --field-border: #798479;
395
395
  --border-accent: rgba(40, 88, 66, 0.20); --border-accent-hover: rgba(40, 88, 66, 0.36);
396
- --shadow-card: 0 1px 2px rgba(23, 38, 29, 0.07), 0 4px 14px -4px rgba(23, 38, 29, 0.10);
397
- --shadow-raised: 0 1px 1px rgba(23, 38, 29, 0.05), 0 2px 6px -2px rgba(23, 38, 29, 0.07);
398
- --shadow-well: inset 0 1px 2px rgba(23, 38, 29, 0.06);
396
+ --shadow-card: 0 1px 2px hsl(var(--shadow-color) / 0.07), 0 4px 14px -4px hsl(var(--shadow-color) / 0.1);
397
+ --shadow-raised: 0 1px 1px hsl(var(--shadow-color) / 0.05), 0 2px 6px -2px hsl(var(--shadow-color) / 0.07);
398
+ --shadow-well: inset 0 1px 2px hsl(var(--shadow-color) / 0.06);
399
399
  --edge-highlight: 0 0 0 0 transparent;
400
400
  --accent-text: #285842;
401
401
  --accent-surface-soft: rgba(40, 88, 66, 0.05); --accent-surface-strong: rgba(40, 88, 66, 0.13);
@@ -443,9 +443,9 @@
443
443
  --border-hover: rgba(227, 233, 229, 0.28); --border-strong: #939c94;
444
444
  --field-border: #939c94;
445
445
  --border-accent: rgba(118, 183, 145, 0.35); --border-accent-hover: rgba(118, 183, 145, 0.50);
446
- --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.45), 0 10px 24px -14px rgba(0, 0, 0, 0.6);
447
- --shadow-raised: 0 1px 2px rgba(0, 0, 0, 0.35);
448
- --shadow-well: inset 0 1px 2px rgba(0, 0, 0, 0.5);
446
+ --shadow-card: 0 1px 2px hsl(var(--shadow-color) / 0.45), 0 10px 24px -14px hsl(var(--shadow-color) / 0.6);
447
+ --shadow-raised: 0 1px 2px hsl(var(--shadow-color) / 0.35);
448
+ --shadow-well: inset 0 1px 2px hsl(var(--shadow-color) / 0.5);
449
449
  --edge-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.06);
450
450
  /* Lifted from #99caad so selected text on a primary tint still clears 4.5:1
451
451
  * over the lighter raised plane (L 37.9). */
@@ -517,8 +517,8 @@
517
517
  --border-default: #49474e;
518
518
  --border-hover: #625f69;
519
519
  --shadow-card: inset 0 0 0 1px rgb(255 255 255 / 0.06);
520
- --shadow-dropdown: inset 0 0 0 1px rgb(255 255 255 / 0.08), 0 8px 24px rgb(0 0 0 / 0.5);
521
- --shadow-overlay: inset 0 0 0 1px rgb(255 255 255 / 0.1), 0 16px 48px rgb(0 0 0 / 0.6);
520
+ --shadow-dropdown: inset 0 0 0 1px rgb(255 255 255 / 0.08), 0 8px 24px hsl(var(--shadow-color) / 0.5);
521
+ --shadow-overlay: inset 0 0 0 1px rgb(255 255 255 / 0.1), 0 16px 48px hsl(var(--shadow-color) / 0.6);
522
522
  }
523
523
  [data-theme="agents-light"] {
524
524
  --bg-card: #ffffff;
@@ -571,9 +571,9 @@
571
571
  --border-subtle: #dbd8e0;
572
572
  --border-default: #d0cdd7;
573
573
  --border-hover: #bcbac7;
574
- --shadow-card: 0 0 0 1px rgb(33 31 38 / 0.03), 0 2px 6px rgb(33 31 38 / 0.05), 0 4px 8px rgb(33 31 38 / 0.08);
575
- --shadow-dropdown: 0 0 0 1px rgb(33 31 38 / 0.04), 0 6px 16px rgb(33 31 38 / 0.12);
576
- --shadow-overlay: 0 8px 28px rgb(33 31 38 / 0.28);
574
+ --shadow-card: 0 0 0 1px hsl(var(--shadow-color) / 0.03), 0 2px 6px hsl(var(--shadow-color) / 0.05), 0 4px 8px hsl(var(--shadow-color) / 0.08);
575
+ --shadow-dropdown: 0 0 0 1px hsl(var(--shadow-color) / 0.04), 0 6px 16px hsl(var(--shadow-color) / 0.12);
576
+ --shadow-overlay: 0 8px 28px hsl(var(--shadow-color) / 0.28);
577
577
  }
578
578
  /* agents:end */
579
579
 
@@ -301,7 +301,7 @@ for (const [name, mode] of [
301
301
  it("is a white page whose cards separate by shadow", () => {
302
302
  expect(declIn(css, "hsl-background")).toBe("0 0% 100%");
303
303
  expect(declIn(css, "hsl-card")).toBe("0 0% 100%");
304
- expect(declIn(css, "shadow-card")).toMatch(/rgb\(/);
304
+ expect(declIn(css, "shadow-card")).toMatch(/hsl\(var\(--shadow-color\)/);
305
305
  });
306
306
  }
307
307
  });
@@ -140,12 +140,15 @@
140
140
  * action. White text passes on every stop (5.37, 5.39 and 5.18:1). */
141
141
  --cta: linear-gradient(90deg, #5b5bd6 0%, #6e56cf 55%, #8e4ec6 100%);
142
142
  --cta-hover: linear-gradient(90deg, #5151cd 0%, #654dc4 55%, #8347b9 100%);
143
- /* Radius by role. The numeric --radius-sm..xl scale is unchanged, so no
144
- * existing surface re-rounds; new surfaces pick the role they play. */
143
+ /* Radius by role, the tokens.css ramp: tag 4, control 8, card 12,
144
+ * container 16, pill. Chip, field and panel are the older names. */
145
145
  --radius-tag: 4px;
146
- --radius-chip: 8px;
147
- --radius-field: 12px;
148
- --radius-panel: 16px;
146
+ --radius-control: 8px;
147
+ --radius-card: 12px;
148
+ --radius-container: 16px;
149
+ --radius-chip: var(--radius-control);
150
+ --radius-field: var(--radius-card);
151
+ --radius-panel: var(--radius-container);
149
152
  --radius-cover: 20px;
150
153
  --radius-sheet: 24px;
151
154
  --radius-pill: 999px;
@@ -191,10 +194,11 @@
191
194
  --text-figure--line-height: 2.75rem;
192
195
  --text-figure--letter-spacing: -0.02em;
193
196
  --text-figure--font-weight: 600;
197
+ --shadow-color: 0 0% 0%;
194
198
  /* Elevation. In dark, a white hairline states the edge; only floating layers cast a shadow. */
195
199
  --shadow-1: inset 0 0 0 1px rgb(255 255 255 / 0.06);
196
- --shadow-2: inset 0 0 0 1px rgb(255 255 255 / 0.08), 0 8px 24px rgb(0 0 0 / 0.5);
197
- --shadow-3: inset 0 0 0 1px rgb(255 255 255 / 0.1), 0 16px 48px rgb(0 0 0 / 0.6);
200
+ --shadow-2: inset 0 0 0 1px rgb(255 255 255 / 0.08), 0 8px 24px hsl(var(--shadow-color) / 0.5);
201
+ --shadow-3: inset 0 0 0 1px rgb(255 255 255 / 0.1), 0 16px 48px hsl(var(--shadow-color) / 0.6);
198
202
  --shadow-card: var(--shadow-1);
199
203
  --shadow-dropdown: var(--shadow-2);
200
204
  --shadow-overlay: var(--shadow-3);
@@ -332,12 +336,15 @@
332
336
  * action. White text passes on every stop (5.37, 5.39 and 5.18:1). */
333
337
  --cta: linear-gradient(90deg, #5b5bd6 0%, #6e56cf 55%, #8e4ec6 100%);
334
338
  --cta-hover: linear-gradient(90deg, #5151cd 0%, #654dc4 55%, #8347b9 100%);
335
- /* Radius by role. The numeric --radius-sm..xl scale is unchanged, so no
336
- * existing surface re-rounds; new surfaces pick the role they play. */
339
+ /* Radius by role, the tokens.css ramp: tag 4, control 8, card 12,
340
+ * container 16, pill. Chip, field and panel are the older names. */
337
341
  --radius-tag: 4px;
338
- --radius-chip: 8px;
339
- --radius-field: 12px;
340
- --radius-panel: 16px;
342
+ --radius-control: 8px;
343
+ --radius-card: 12px;
344
+ --radius-container: 16px;
345
+ --radius-chip: var(--radius-control);
346
+ --radius-field: var(--radius-card);
347
+ --radius-panel: var(--radius-container);
341
348
  --radius-cover: 20px;
342
349
  --radius-sheet: 24px;
343
350
  --radius-pill: 999px;
@@ -383,10 +390,11 @@
383
390
  --text-figure--line-height: 2.75rem;
384
391
  --text-figure--letter-spacing: -0.02em;
385
392
  --text-figure--font-weight: 600;
393
+ --shadow-color: 220 40% 8%;
386
394
  /* Elevation. In light, shadow separates a card from the white page. */
387
- --shadow-1: 0 0 0 1px rgb(33 31 38 / 0.03), 0 2px 6px rgb(33 31 38 / 0.05), 0 4px 8px rgb(33 31 38 / 0.08);
388
- --shadow-2: 0 0 0 1px rgb(33 31 38 / 0.04), 0 6px 16px rgb(33 31 38 / 0.12);
389
- --shadow-3: 0 8px 28px rgb(33 31 38 / 0.28);
395
+ --shadow-1: 0 0 0 1px hsl(var(--shadow-color) / 0.03), 0 2px 6px hsl(var(--shadow-color) / 0.05), 0 4px 8px hsl(var(--shadow-color) / 0.08);
396
+ --shadow-2: 0 0 0 1px hsl(var(--shadow-color) / 0.04), 0 6px 16px hsl(var(--shadow-color) / 0.12);
397
+ --shadow-3: 0 8px 28px hsl(var(--shadow-color) / 0.28);
390
398
  --shadow-card: var(--shadow-1);
391
399
  --shadow-dropdown: var(--shadow-2);
392
400
  --shadow-overlay: var(--shadow-3);
@@ -36,12 +36,10 @@
36
36
  --radius-xl: 12px;
37
37
  --radius-full: 999px;
38
38
 
39
- --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3);
40
- --shadow-dropdown: 0 8px 30px rgba(0, 0, 0, 0.45);
39
+ --shadow-card: 0 1px 2px hsl(var(--shadow-color) / 0.3);
40
+ --shadow-dropdown: 0 8px 30px hsl(var(--shadow-color) / 0.45);
41
41
  --shadow-glow: none;
42
- --shadow-overlay:
43
- 0 2px 6px hsl(var(--hsl-foreground) / 0.1),
44
- 0 16px 40px hsl(var(--hsl-foreground) / 0.16);
42
+ --shadow-overlay: 0 2px 6px hsl(var(--shadow-color) / 0.3), 0 16px 40px hsl(var(--shadow-color) / 0.5);
45
43
  }
46
44
 
47
45
  /* Inline variable-dependent utilities so their source is resolved on the
@@ -374,23 +374,18 @@
374
374
  --code-comment: var(--syntax-comment);
375
375
  --code-error: var(--syntax-error);
376
376
 
377
- /* Shadows — flat, no inner ring, no indigo glow */
378
- --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3);
379
- --shadow-dropdown: 0 8px 30px rgba(0, 0, 0, 0.45);
377
+ /* Shadows — flat, no inner ring, no glow. Every shadow is one colour,
378
+ --shadow-color, as HSL channels so each sets its own alpha: black here, a
379
+ deep cool ink in light. A shadow never inverts into a light halo; dark
380
+ elevation is the plane step plus --edge-highlight. --shadow-overlay is the
381
+ lift above --shadow-dropdown, for a surface that covers the page (a
382
+ drawer, a modal, a floating dock): a tight contact layer plus a wide one. */
383
+ --shadow-color: 0 0% 0%;
384
+ --shadow-card: 0 1px 2px hsl(var(--shadow-color) / 0.3);
385
+ --shadow-dropdown: 0 8px 30px hsl(var(--shadow-color) / 0.45);
380
386
  --shadow-glow: none;
381
- --shadow-accent: 0 1px 2px rgba(0, 0, 0, 0.3);
382
- /* The lift above --shadow-dropdown, for a surface that covers the page
383
- rather than sitting beside it: a drawer, a modal, a floating dock.
384
- Two layers — a tight contact shadow plus a wide ambient one.
385
- Alone among the shadows this is derived from --hsl-foreground instead of
386
- a fixed rgba, so it INVERTS with the theme. That is what makes it work in
387
- dark: a black shadow on a dark canvas renders as nothing (see the ladder
388
- note at the top of this file), while the foreground is near-white there,
389
- so the same declaration reads as a soft halo. One definition therefore
390
- serves both themes and the light block does not restate it. */
391
- --shadow-overlay:
392
- 0 2px 6px hsl(var(--hsl-foreground) / 0.1),
393
- 0 16px 40px hsl(var(--hsl-foreground) / 0.16);
387
+ --shadow-accent: var(--shadow-card);
388
+ --shadow-overlay: 0 2px 6px hsl(var(--shadow-color) / 0.3), 0 16px 40px hsl(var(--shadow-color) / 0.5);
394
389
  /* Scrim and media ink. A scrim dims the page behind a modal or sheet, or
395
390
  darkens a photo under its caption. It is black in every theme: a dimming
396
391
  layer that inverted in dark mode would brighten the page it covers.
@@ -410,12 +405,18 @@
410
405
  --radius-pill: 999px;
411
406
  --radius-full: 999px;
412
407
  --radius-composer: 26px; /* centered composer pill */
413
- /* Radius by role. New surfaces pick the role they play; system.css repeats
414
- these for pages that load it on its own ladders. */
408
+ /* Radius by role, one ramp: tag 4, control 8, card 12, container 16, and
409
+ --radius-pill for every chip and status pill. Chip, field and panel are
410
+ the older names of control, card and container. Cover and sheet are the
411
+ media and bottom-sheet radii. system.css repeats these for pages that load
412
+ it on its own ladders. */
415
413
  --radius-tag: 4px;
416
- --radius-chip: 8px;
417
- --radius-field: 12px;
418
- --radius-panel: 16px;
414
+ --radius-control: var(--radius-md);
415
+ --radius-card: var(--radius-xl);
416
+ --radius-container: 16px;
417
+ --radius-chip: var(--radius-control);
418
+ --radius-field: var(--radius-card);
419
+ --radius-panel: var(--radius-container);
419
420
  --radius-cover: 20px;
420
421
  --radius-sheet: 24px;
421
422
  --radius: 0.5rem; /* 8px chrome default (was 10px) */
@@ -527,6 +528,11 @@
527
528
  --motion-dismiss: var(--duration-instant) var(--ease-exit);
528
529
  --motion-stream: var(--duration-stream) var(--ease-expo);
529
530
  --motion-arrive: var(--duration-arrive) var(--ease-expo);
531
+ /* The only loop period on a page. A running mark breathes on it with
532
+ `tangle-breathe` (opacity, never position or scale), so every live thing
533
+ shares one rhythm. Reduced motion keeps it: see the rule below. */
534
+ --duration-ambient: 2000ms;
535
+ --motion-ambient: var(--duration-ambient) ease-in-out;
530
536
 
531
537
  /* Elapsed interaction thresholds, not animation durations. See
532
538
  * docs/design-bible.md#interaction. Reduced motion leaves these unchanged. */
@@ -737,11 +743,11 @@
737
743
  --glass-bg-strong: var(--depth-1);
738
744
  --code-header-bg: color-mix(in srgb, hsl(var(--hsl-muted)) 50%, transparent);
739
745
 
740
- /* Shadows — flat, no inner ring, no glow */
741
- --shadow-card: 0 1px 2px rgba(25, 28, 36, 0.06);
742
- --shadow-dropdown: 0 8px 30px rgba(25, 28, 36, 0.10);
743
- --shadow-glow: none;
744
- --shadow-accent: 0 1px 2px rgba(25, 28, 36, 0.06);
746
+ /* Shadows — the dark roles in the light shadow colour, at light alphas. */
747
+ --shadow-color: 220 40% 8%;
748
+ --shadow-card: 0 1px 2px hsl(var(--shadow-color) / 0.06);
749
+ --shadow-dropdown: 0 8px 30px hsl(var(--shadow-color) / 0.1);
750
+ --shadow-overlay: 0 2px 6px hsl(var(--shadow-color) / 0.1), 0 16px 40px hsl(var(--shadow-color) / 0.16);
745
751
  }
746
752
 
747
753
 
@@ -940,7 +946,11 @@
940
946
 
941
947
 
942
948
  /* Motion is opt-in at the component, but its reduced-motion preference is
943
- * shared. Re-declare composites at the same boundary as their durations. */
949
+ * shared. Re-declare composites at the same boundary as their durations.
950
+ * --duration-ambient is not zeroed: the essential running mark
951
+ * (data-motion="essential") keeps its opacity-only breathe, because it is the
952
+ * only "still working" signal and it never moves anything. */
953
+ @keyframes tangle-breathe { 50% { opacity: 0.45; } }
944
954
  @media (prefers-reduced-motion: reduce) {
945
955
  :root,
946
956
  .dark,
@@ -5,10 +5,12 @@ import {
5
5
  blockIn,
6
6
  compositeOver,
7
7
  contrastRatio,
8
+ declIn,
8
9
  hexIn,
9
10
  hexRelativeLuminanceIn,
10
11
  hslIn,
11
12
  hslToRgb,
13
+ resolveIn,
12
14
  } from "./css-test-utils";
13
15
 
14
16
  /**
@@ -608,3 +610,39 @@ describe("each colour is declared once: a role, then aliases", () => {
608
610
  expect(literalsOutsideTones(spine).size).toBeLessThanOrEqual(ceiling);
609
611
  });
610
612
  });
613
+
614
+ describe("shadow, radius and loop period are one role each", () => {
615
+ // The run-UI critique of 2026-10-27 (fixes 9, 10 and 22) found a white shadow
616
+ // haze in dark mode, six radii and seven loop periods. Each is now one role
617
+ // that every other name aliases, so a surface cannot drift to a second value.
618
+ it.each([
619
+ ["dark", DARK, "0 0% 0%"],
620
+ ["light", LIGHT, "220 40% 8%"],
621
+ ] as const)("%s: every shadow is drawn in --shadow-color and never inverts", (_, spine, colour) => {
622
+ expect(declIn(spine, "shadow-color")).toBe(colour);
623
+ for (const token of ["shadow-card", "shadow-dropdown", "shadow-overlay"]) {
624
+ const value = declIn(spine, token);
625
+ expect(value, `--${token}`).toMatch(/^[^,]*hsl\(var\(--shadow-color\) \/ [\d.]+\)/);
626
+ expect(value, `--${token} names a colour of its own`).not.toMatch(/rgba?\(|#[0-9a-f]{3,8}\b|--hsl-foreground/i);
627
+ }
628
+ });
629
+
630
+ it("draws radii from one ramp, and the older role names alias it", () => {
631
+ const ramp = { tag: "4px", control: "8px", card: "12px", container: "16px", pill: "999px" };
632
+ for (const [role, px] of Object.entries(ramp)) expect(resolveIn(DARK, `radius-${role}`), role).toBe(px);
633
+ for (const [older, role] of [["chip", "control"], ["field", "card"], ["panel", "container"]]) {
634
+ expect(declIn(DARK, `radius-${older}`)).toBe(`var(--radius-${role})`);
635
+ }
636
+ });
637
+
638
+ it("has one loop period, which reduced motion keeps for an opacity-only breathe", () => {
639
+ expect(declIn(DARK, "duration-ambient")).toBe("2000ms");
640
+ expect(declIn(DARK, "motion-ambient")).toBe("var(--duration-ambient) ease-in-out");
641
+ const reduced = tokens.match(/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/)![1];
642
+ expect(reduced).toContain("--duration-fast: 0ms");
643
+ expect(reduced, "the essential breathe keeps its period").not.toContain("--duration-ambient");
644
+ const breathe = tokens.match(/@keyframes tangle-breathe \{([\s\S]*?)\}\s*\}/)?.[1];
645
+ expect(breathe, "the shared breathe keyframe").toMatch(/opacity/);
646
+ expect(breathe, "a loop never moves or scales").not.toMatch(/transform|translate|scale|rotate/);
647
+ });
648
+ });