@titan-design/react-ui 0.9.3 → 0.11.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 (120) hide show
  1. package/dist/bodymap.js +78 -24
  2. package/dist/bodymap.js.map +1 -1
  3. package/dist/bodymap.mjs +78 -24
  4. package/dist/bodymap.mjs.map +1 -1
  5. package/dist/index.d.mts +429 -11
  6. package/dist/index.d.ts +429 -11
  7. package/dist/index.js +2386 -885
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2306 -836
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/{pages-DyEx-lBf.d.ts → pages-B1hqnGIC.d.ts} +149 -15
  12. package/dist/{pages-D7hOD4Vj.d.mts → pages-CvsFqNtx.d.mts} +149 -15
  13. package/dist/pages.d.mts +1 -1
  14. package/dist/pages.d.ts +1 -1
  15. package/dist/pages.js +1900 -1609
  16. package/dist/pages.js.map +1 -1
  17. package/dist/pages.mjs +1901 -1610
  18. package/dist/pages.mjs.map +1 -1
  19. package/dist/theme/index.d.mts +129 -34
  20. package/dist/theme/index.d.ts +129 -34
  21. package/dist/theme/index.js +95 -19
  22. package/dist/theme/index.js.map +1 -1
  23. package/dist/theme/index.mjs +92 -20
  24. package/dist/theme/index.mjs.map +1 -1
  25. package/dist/theme/tokens-css.js +88 -24
  26. package/dist/theme/tokens-css.js.map +1 -1
  27. package/dist/theme/tokens-css.mjs +88 -24
  28. package/dist/theme/tokens-css.mjs.map +1 -1
  29. package/package.json +8 -3
  30. package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
  31. package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
  32. package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
  33. package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
  34. package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
  35. package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
  36. package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
  37. package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
  38. package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
  39. package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
  40. package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
  41. package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
  42. package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
  43. package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
  44. package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
  45. package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
  46. package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
  47. package/src/components/custom/Fatigue/README.md +93 -0
  48. package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
  49. package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
  50. package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
  51. package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
  52. package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
  53. package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
  54. package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
  55. package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
  56. package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
  57. package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
  58. package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
  59. package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
  60. package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
  61. package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
  62. package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
  63. package/src/components/custom/Fatigue/index.ts +41 -0
  64. package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
  65. package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
  66. package/src/components/custom/Workout/BaseBadge.tsx +5 -5
  67. package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
  68. package/src/components/custom/Workout/ExerciseCard.test.tsx +4 -4
  69. package/src/components/custom/Workout/ExerciseCard.tsx +3 -1
  70. package/src/components/custom/Workout/ExerciseDetailPage.tsx +7 -1
  71. package/src/components/custom/Workout/README.md +62 -16
  72. package/src/components/custom/Workout/RowInventory.stories.tsx +5 -2
  73. package/src/components/custom/Workout/S3FullRail.stories.tsx +13 -1
  74. package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
  75. package/src/components/custom/Workout/SessionHeader.tsx +2 -2
  76. package/src/components/custom/Workout/SessionRail.stories.tsx +4 -1
  77. package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
  78. package/src/components/custom/Workout/SessionRail.tsx +55 -18
  79. package/src/components/custom/Workout/SetRow.test.tsx +6 -6
  80. package/src/components/custom/Workout/SetRow.tsx +9 -2
  81. package/src/components/custom/Workout/SupersetWrapper.stories.tsx +16 -25
  82. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -1
  83. package/src/components/custom/Workout/TempoDisplay.tsx +3 -7
  84. package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
  85. package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
  86. package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
  87. package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
  88. package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
  89. package/src/components/custom/Workout/VelocityStrip.test.tsx +196 -79
  90. package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
  91. package/src/components/custom/Workout/VolumeLandmarkBar.stories.tsx +4 -1
  92. package/src/components/custom/Workout/WorkoutPill.tsx +2 -2
  93. package/src/components/custom/Workout/index.ts +3 -0
  94. package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
  95. package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
  96. package/src/components/custom/charts/SetBarChart.tsx +651 -0
  97. package/src/components/custom/charts/live-rep-growth.ts +91 -0
  98. package/src/components/custom/index.ts +1 -0
  99. package/src/components/ui/surface/Surface.stories.tsx +195 -1
  100. package/src/components/ui/surface/Surface.test.tsx +148 -9
  101. package/src/components/ui/surface/Surface.tsx +37 -9
  102. package/src/components/ui/surface/SurfaceContext.ts +48 -8
  103. package/src/components/ui/surface/index.ts +1 -0
  104. package/src/components/ui/surface/surface.contract.test.ts +283 -0
  105. package/src/test/raw-color-patterns.test.ts +71 -0
  106. package/src/theme/ColorPalettes.stories.tsx +871 -0
  107. package/src/theme/ColorPrimitives.stories.tsx +300 -0
  108. package/src/theme/bar-paper.ts +59 -0
  109. package/src/theme/color-stories.coverage.test.ts +130 -0
  110. package/src/theme/color-story-kit.tsx +170 -0
  111. package/src/theme/config.ts +12 -0
  112. package/src/theme/elevation.ts +90 -67
  113. package/src/theme/global.css +35 -9
  114. package/src/theme/tokens/primitives.ts +40 -0
  115. package/src/theme/tokens/semantic.ts +63 -14
  116. package/tailwind.config.js +8 -0
  117. package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
  118. package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
  119. package/src/theme/ColorSystem.stories.tsx +0 -323
  120. package/src/theme/Colors.stories.tsx +0 -323
@@ -7,29 +7,43 @@
7
7
  // (no global.css, no nativewind) — the bug class this primitive retires.
8
8
  import { createContext, useContext } from 'react'
9
9
  import { getSemanticColors, type ThemeMode } from '../../../theme/tokens/semantic'
10
+ import { surfaceRampDark } from '../../../theme/tokens/primitives'
10
11
 
11
12
  type ColorToken = keyof ReturnType<typeof getSemanticColors>
12
13
 
13
14
  /**
14
- * A Surface's depth on the dark charcoal ramp. Each level maps to an EXISTING
15
- * semantic surface/background token (already grounded in the charcoal ramp and
16
- * theme-aware) — Surface introduces no new hexes. Darkest → lightest:
17
- * background (#101010) < base (#161616) < elevated (#191919) < raised (#1C1C1C)
15
+ * A Surface's depth on the dark surface ramp. Each addressable level maps to an
16
+ * EXISTING semantic surface/background token (already grounded in the derived
17
+ * surface ramp — TD-surface-tokens S-1 — and theme-aware) — Surface introduces
18
+ * no new hexes. Darkest lightest:
19
+ * inset (#13100D) < background (#1C1916) < base (#252321) < elevated (#2A2827) < raised (#2D2C2B) < overlay (#302F2E)
20
+ *
21
+ * `inset` is the ramp's deepest pit — the sub-shell well. It's the floor a
22
+ * `<Surface pressed>` clamps at (see {@link pressedLevel}); it has no shipped
23
+ * semantic token yet, so {@link surfaceBackground} resolves it from the
24
+ * `surface-inset` token when present, else the `surfaceRampDark.inset` primitive.
18
25
  */
19
- export type SurfaceLevel = 'background' | 'base' | 'elevated' | 'raised' | 'overlay'
26
+ export type SurfaceLevel = 'inset' | 'background' | 'base' | 'elevated' | 'raised' | 'overlay'
20
27
 
21
28
  /** On-surface neutral text roles, resolved for the current surface + theme. */
22
29
  export type OnSurfaceRole = 'primary' | 'secondary' | 'tertiary'
23
30
 
24
- // Which semantic token backs each level. Values live in `semantic.ts` and stay
25
- // in sync with the charcoal ramp — this map never carries literal hexes.
31
+ // Which semantic token backs each ADDRESSABLE level. Values live in `semantic.ts`
32
+ // and stay in sync with the charcoal ramp — this map never carries literal hexes.
33
+ // `inset` is intentionally absent: it has no shipped semantic token yet, so it's
34
+ // resolved separately (token-or-primitive) in `surfaceBackground`.
26
35
  export const SURFACE_LEVEL_TOKEN = {
27
36
  background: 'background-base',
28
37
  base: 'surface-base',
29
38
  elevated: 'surface-elevated',
30
39
  raised: 'surface-raised',
31
40
  overlay: 'surface-overlay',
32
- } as const satisfies Record<SurfaceLevel, ColorToken>
41
+ } as const satisfies Record<Exclude<SurfaceLevel, 'inset'>, ColorToken>
42
+
43
+ // Darkest → lightest, INCLUDING the inset floor. A `<Surface pressed>` renders
44
+ // one index DOWN from its parent's level and clamps at `inset` (index 0). Kept
45
+ // in lockstep with the derived ramp and the surface.contract.test PLANE_ORDER.
46
+ const PRESSED_RAMP = ['inset', 'background', 'base', 'elevated', 'raised', 'overlay'] as const
33
47
 
34
48
  const ON_SURFACE_TOKEN = {
35
49
  primary: 'text-primary',
@@ -80,7 +94,33 @@ export function useOnSurfaceColor(role: OnSurfaceRole = 'primary'): string {
80
94
  return getSemanticColors(useSurfaceMode())[ON_SURFACE_TOKEN[role]]
81
95
  }
82
96
 
97
+ /**
98
+ * The inset floor hex — the ramp's deepest pit that a pressed surface clamps at.
99
+ * Prefers the `surface-inset` semantic token (theme-aware; being promoted in a
100
+ * parallel token change) and falls back to the `surfaceRampDark.inset` primitive
101
+ * (#13100D, dark-ramp only) until that token ships.
102
+ */
103
+ function insetFloor(mode: ThemeMode): string {
104
+ const colors = getSemanticColors(mode) as Record<string, string>
105
+ return colors['surface-inset'] ?? surfaceRampDark.inset
106
+ }
107
+
83
108
  /** The background hex for a surface level under a theme mode (literal hex). */
84
109
  export function surfaceBackground(level: SurfaceLevel, mode: ThemeMode): string {
110
+ if (level === 'inset') return insetFloor(mode)
85
111
  return getSemanticColors(mode)[SURFACE_LEVEL_TOKEN[level]]
86
112
  }
113
+
114
+ /**
115
+ * The level a `<Surface pressed>` resolves to: its parent's level stepped one
116
+ * index DOWN the ramp — the symmetric twin of a raised surface stepping up —
117
+ * clamped at the `inset` floor so a pressed surface never underflows the pit.
118
+ * overlay→raised, raised→elevated, elevated→base, base→background,
119
+ * background→inset, inset→inset (clamped).
120
+ * Pressed exposes ONE level of depth only (no pressed-2).
121
+ */
122
+ export function pressedLevel(parent: SurfaceLevel): SurfaceLevel {
123
+ const index = PRESSED_RAMP.indexOf(parent)
124
+ const down = Math.max(0, index - 1)
125
+ return PRESSED_RAMP[down]
126
+ }
@@ -6,6 +6,7 @@ export {
6
6
  useOnSurfaceColor,
7
7
  onSurfaceColors,
8
8
  surfaceBackground,
9
+ pressedLevel,
9
10
  SURFACE_LEVEL_TOKEN,
10
11
  type SurfaceLevel,
11
12
  type OnSurfaceRole,
@@ -0,0 +1,283 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
3
+
4
+ /**
5
+ * Token-value contract for the dark surface ramp (TD-surface-tokens, S-1).
6
+ * Mirrors `config.completeness.test.ts`'s role — this fails CI on the raw
7
+ * token VALUES, independent of any component, the same way the categorical
8
+ * palette locks its eligibility mask with a value-level test.
9
+ *
10
+ * Scope: DARK mode only. The north-star diagnosis
11
+ * (coordination/design-explorations/surface-system-north-star.md) measured and
12
+ * fixed the dark ramp specifically; light mode has its own latent collisions
13
+ * (e.g. `surface-overlay` === `surface-base`, both `#FFFFFF`) that were never
14
+ * part of this investigation — flagged as a follow-up, not silently patched
15
+ * here (see the report / open item list).
16
+ *
17
+ * ── R1 note — why this does NOT assert a flat ΔL* >= 4 on every step ──
18
+ * An earlier draft of the north-star doc (§3) proposed 5 EVEN steps at
19
+ * ΔL*≈4. The LOCKED derivation that ships here (§ "Surface-ramp SYSTEM —
20
+ * derived, not hand-picked") deliberately supersedes that with DIMINISHING
21
+ * steps — the frame->content jump is biggest, each plane above adds less.
22
+ * As of S-3 (this re-space) the steps are 4.5 / 4.5 / 3.5 / 3 / 2.5
23
+ * (inset->background / background->base / base->elevated / elevated->raised /
24
+ * raised->overlay) — the top three widened from the original 2.5/2/1.5 taper
25
+ * so the content planes read as distinct without leaning on the hairline
26
+ * alone (R3 still carries the rest, per "lightness is a *secondary* cue").
27
+ * So R1 here asserts what the locked design actually guarantees: strict
28
+ * monotonicity, a >=4 L* foundational jump at the frame/shell boundary, and
29
+ * a >=2.5 L* floor on each of the three re-spaced upper steps — not a flat
30
+ * "always diminishing" rule (quantizing to whole hex bytes can make two
31
+ * adjacent re-spaced steps land within ~0.02 L* of each other; the floor is
32
+ * the guarantee that matters, not strict ordering between them).
33
+ */
34
+
35
+ // --- CIELAB L* (perceptual lightness), matching the calculation in
36
+ // surface-lab-shared.tsx's `lstar()` (the derivation source) verbatim, so
37
+ // this test measures the SAME metric the ramp was designed against. ---
38
+ function channels(hex: string): [number, number, number] {
39
+ const h = hex.replace('#', '')
40
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)) as [number, number, number]
41
+ }
42
+
43
+ function lstar(hex: string): number {
44
+ const lin = channels(hex)
45
+ .map((c) => c / 255)
46
+ .map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4))
47
+ const y = 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
48
+ return y <= 0.008856 ? y * 903.3 : 116 * Math.cbrt(y) - 16
49
+ }
50
+
51
+ /** Flatten an rgba(...) alpha color over an opaque hex background -> opaque hex. */
52
+ function compositeOver(baseHex: string, overlay: string): string {
53
+ const match = overlay.match(
54
+ /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)/
55
+ )
56
+ if (!match) throw new Error(`not an rgba() color: ${overlay}`)
57
+ const [, r, g, b, a = '1'] = match
58
+ const alpha = parseFloat(a)
59
+ const base = channels(baseHex)
60
+ const tint = [parseFloat(r), parseFloat(g), parseFloat(b)]
61
+ const mixed = base.map((c, i) => c * (1 - alpha) + tint[i] * alpha)
62
+ return (
63
+ '#' +
64
+ mixed
65
+ .map((v) =>
66
+ Math.max(0, Math.min(255, Math.round(v)))
67
+ .toString(16)
68
+ .padStart(2, '0')
69
+ )
70
+ .join('')
71
+ )
72
+ }
73
+
74
+ const dark = getSemanticColors('dark')
75
+
76
+ // The 6-plane derivation, in darkest -> lightest order. `inset` is now a real
77
+ // shipped token (`surface-inset`, promoted at S-3 — see semantic.ts comment).
78
+ const RAMP_HEX = {
79
+ inset: dark['surface-inset'],
80
+ background: dark['background-base'],
81
+ base: dark['surface-base'],
82
+ elevated: dark['surface-elevated'],
83
+ raised: dark['surface-raised'],
84
+ overlay: dark['surface-overlay'],
85
+ } as const
86
+
87
+ const PLANE_ORDER = ['inset', 'background', 'base', 'elevated', 'raised', 'overlay'] as const
88
+
89
+ describe('surface ramp contract (dark) — token-value guardrails', () => {
90
+ it('ships the deriveSurfaceRamp() output verbatim for the addressable planes', () => {
91
+ // Locked values from surface-system-north-star.md (re-spaced S-3) — see
92
+ // surfaceRampDark in primitives.ts for the derivation this must match
93
+ // byte-for-byte. background/base/inset are unchanged by the re-space.
94
+ expect(dark['surface-inset']).toBe('#13100D')
95
+ expect(dark['background-base']).toBe('#1C1916')
96
+ expect(dark['surface-base']).toBe('#252321')
97
+ expect(dark['surface-elevated']).toBe('#2C2A28')
98
+ expect(dark['surface-raised']).toBe('#31302F')
99
+ expect(dark['surface-overlay']).toBe('#373635')
100
+ })
101
+
102
+ describe('R1 — monotonic ramp with a real foundational jump', () => {
103
+ it('is strictly increasing in L* across all 6 planes', () => {
104
+ const Ls = PLANE_ORDER.map((name) => lstar(RAMP_HEX[name]))
105
+ for (let i = 1; i < Ls.length; i++) {
106
+ expect(
107
+ Ls[i],
108
+ `${PLANE_ORDER[i]} should be lighter than ${PLANE_ORDER[i - 1]}`
109
+ ).toBeGreaterThan(Ls[i - 1])
110
+ }
111
+ })
112
+
113
+ it('clears ΔL* >= 4 at the two foundational steps (inset->background, background->base)', () => {
114
+ const dL_insetToBackground = lstar(RAMP_HEX.background) - lstar(RAMP_HEX.inset)
115
+ const dL_backgroundToBase = lstar(RAMP_HEX.base) - lstar(RAMP_HEX.background)
116
+ expect(dL_insetToBackground).toBeGreaterThanOrEqual(4)
117
+ expect(dL_backgroundToBase).toBeGreaterThanOrEqual(4)
118
+ })
119
+
120
+ it('each re-spaced upper step (base->elevated->raised->overlay) clears ΔL* >= 2.5', () => {
121
+ // S-3 widened the top three steps to 3.5/3/2.5 specifically so they no
122
+ // longer need to lean on the "keep diminishing" clause the old 2.5/2/1.5
123
+ // taper required — each step now stands on its own >=2.5 L* floor.
124
+ // (Quantizing to whole hex bytes can make two of these land within
125
+ // ~0.02 L* of strict diminishing order; that's expected and not asserted.)
126
+ const upperSteps = ['base', 'elevated', 'raised', 'overlay'] as const
127
+ for (let i = 1; i < upperSteps.length; i++) {
128
+ const d = lstar(RAMP_HEX[upperSteps[i]]) - lstar(RAMP_HEX[upperSteps[i - 1]])
129
+ expect(d, `${upperSteps[i - 1]} -> ${upperSteps[i]}`).toBeGreaterThanOrEqual(2.5)
130
+ }
131
+ })
132
+ })
133
+
134
+ describe('R2 — no footgun collisions', () => {
135
+ it('surface-elevated and surface-overlay are distinct (previously both #191919)', () => {
136
+ expect(dark['surface-elevated']).not.toBe(dark['surface-overlay'])
137
+ })
138
+
139
+ it('the 5 addressable surface-family hexes are pairwise distinct', () => {
140
+ const family = [
141
+ 'surface-base',
142
+ 'surface-elevated',
143
+ 'surface-raised',
144
+ 'surface-overlay',
145
+ ] as const
146
+ const values = family.map((k) => dark[k])
147
+ expect(new Set(values).size).toBe(values.length)
148
+ })
149
+
150
+ it('no solid border token equals any surface/background fill hex', () => {
151
+ const surfaceHexes = new Set([
152
+ dark['background-base'],
153
+ dark['background-default'],
154
+ dark['background-subtle'],
155
+ dark['background-frame'],
156
+ dark['surface-base'],
157
+ dark['surface-elevated'],
158
+ dark['surface-raised'],
159
+ dark['surface-overlay'],
160
+ dark['surface-input'],
161
+ dark['surface-inset'],
162
+ ])
163
+ const solidBorders = [
164
+ 'border-default',
165
+ 'border-subtle',
166
+ 'border-strong',
167
+ 'border-prominent',
168
+ ] as const
169
+ for (const token of solidBorders) {
170
+ expect(
171
+ surfaceHexes.has(dark[token]),
172
+ `${token} (${dark[token]}) collides with a surface hex`
173
+ ).toBe(false)
174
+ }
175
+ })
176
+ })
177
+
178
+ describe('R3 — alpha hairline clears its ΔL* floor on every plane', () => {
179
+ // Doc floors (subtle>=6/default>=9/strong>=13) were calibrated on the OLD,
180
+ // much darker ramp (L* 4.7-10.3). The S-1 ramp compressed into a narrower,
181
+ // lighter band (L* 9-19.5); S-3's re-space widens it further still
182
+ // (L* 4.5-22.5) — the wider top steps push `overlay` even lighter, so the
183
+ // SAME alpha values composite to a slightly smaller (but still solid,
184
+ // still near-constant) ΔL* there. Floors below are re-measured against
185
+ // the S-3 ramp (default dropped 8->7 — the `overlay` plane now measures
186
+ // ~7.97, just under the old floor); a wall-display calibration pass (S-6)
187
+ // may retune the alpha values themselves.
188
+ const HAIRLINES = {
189
+ subtle: { token: 'hairline-subtle', floor: 5 },
190
+ default: { token: 'hairline-default', floor: 7 },
191
+ strong: { token: 'hairline-strong', floor: 12 },
192
+ } as const
193
+
194
+ for (const [name, { token, floor }] of Object.entries(HAIRLINES)) {
195
+ it(`${name} (${token}) clears ΔL* >= ${floor} on every ramp plane`, () => {
196
+ const hairlineColor = dark[token as keyof typeof dark]
197
+ for (const plane of PLANE_ORDER) {
198
+ const planeHex = RAMP_HEX[plane]
199
+ const composited = compositeOver(planeHex, hairlineColor)
200
+ const dL = lstar(composited) - lstar(planeHex)
201
+ expect(dL, `${name} on ${plane}`).toBeGreaterThanOrEqual(floor)
202
+ }
203
+ })
204
+ }
205
+
206
+ it('is near-constant (self-normalizing) across all planes: spread < 4 L* per tier', () => {
207
+ // Measured spread (inset..overlay): subtle ~1.6, default ~2.2, strong ~3.2 —
208
+ // still far tighter than a solid border token (which swings ~5.6 -> 0
209
+ // across this same ramp, see R4). "Near-constant" is relative to that,
210
+ // not perfectly flat — alpha-over-white is sublinear in L* as the base
211
+ // lightens, which is exactly the R3 floor-vs-doc discrepancy noted above.
212
+ for (const { token } of Object.values(HAIRLINES)) {
213
+ const hairlineColor = dark[token as keyof typeof dark]
214
+ const deltas = PLANE_ORDER.map((plane) => {
215
+ const planeHex = RAMP_HEX[plane]
216
+ return lstar(compositeOver(planeHex, hairlineColor)) - lstar(planeHex)
217
+ })
218
+ expect(Math.max(...deltas) - Math.min(...deltas)).toBeLessThan(4)
219
+ }
220
+ })
221
+ })
222
+
223
+ describe('R4 — border-on-surface clears ΔL* >= 3 (fixes the invisible-hairline collision)', () => {
224
+ it('border-subtle is no longer invisible on surface-raised (the original bug)', () => {
225
+ const dL = Math.abs(lstar(dark['border-subtle']) - lstar(dark['surface-raised']))
226
+ expect(dL).toBeGreaterThanOrEqual(3)
227
+ })
228
+
229
+ it('border-subtle clears ΔL* >= 3 against base/elevated/raised/overlay', () => {
230
+ const planes = [
231
+ 'surface-base',
232
+ 'surface-elevated',
233
+ 'surface-raised',
234
+ 'surface-overlay',
235
+ ] as const
236
+ for (const plane of planes) {
237
+ const dL = Math.abs(lstar(dark['border-subtle']) - lstar(dark[plane]))
238
+ expect(dL, `border-subtle vs ${plane}`).toBeGreaterThanOrEqual(3)
239
+ }
240
+ })
241
+ })
242
+
243
+ describe('R5 — background-frame and surface-inset (S-3 new tokens)', () => {
244
+ it('background-frame is darker than background-base', () => {
245
+ expect(lstar(dark['background-frame'])).toBeLessThan(lstar(dark['background-base']))
246
+ })
247
+
248
+ it('background-frame is distinct from every surface/background token', () => {
249
+ const otherTokens = [
250
+ 'background-base',
251
+ 'background-default',
252
+ 'background-subtle',
253
+ 'surface-base',
254
+ 'surface-elevated',
255
+ 'surface-raised',
256
+ 'surface-overlay',
257
+ 'surface-input',
258
+ 'surface-inset',
259
+ ] as const
260
+ for (const token of otherTokens) {
261
+ expect(dark['background-frame'], `background-frame vs ${token}`).not.toBe(dark[token])
262
+ }
263
+ })
264
+
265
+ it('surface-inset is the darkest surface-family token', () => {
266
+ const surfaceFamily = [
267
+ 'surface-base',
268
+ 'surface-elevated',
269
+ 'surface-raised',
270
+ 'surface-overlay',
271
+ 'surface-input',
272
+ ] as const
273
+ const insetL = lstar(dark['surface-inset'])
274
+ for (const token of surfaceFamily) {
275
+ expect(insetL, `surface-inset vs ${token}`).toBeLessThan(lstar(dark[token]))
276
+ }
277
+ })
278
+
279
+ it('surface-inset is distinct from background-frame', () => {
280
+ expect(dark['surface-inset']).not.toBe(dark['background-frame'])
281
+ })
282
+ })
283
+ })
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { createRequire } from 'node:module'
3
+
4
+ const { extractRawColors } = createRequire(import.meta.url)(
5
+ '../../eslint-rules/raw-color-patterns.js'
6
+ ) as { extractRawColors: (text: string) => Array<{ value: string; id: string }> }
7
+
8
+ const ids = (text: string) => extractRawColors(text).map((c) => c.id)
9
+ const values = (text: string) => extractRawColors(text).map((c) => c.value)
10
+
11
+ /**
12
+ * Detection for the `no-raw-color` ratchet. The rule and its baseline generator
13
+ * share this module, so a gap here silently widens the allowance rather than
14
+ * failing loudly — worth pinning the notations directly.
15
+ */
16
+ describe('extractRawColors', () => {
17
+ it('detects every hex length', () => {
18
+ expect(ids('#fff')).toEqual(['hex'])
19
+ expect(ids('#ffff')).toEqual(['hex'])
20
+ expect(ids('#1C1916')).toEqual(['hex'])
21
+ expect(ids('#1C1916FF')).toEqual(['hex'])
22
+ })
23
+
24
+ it('detects functional notations, including ones with no occurrences today', () => {
25
+ for (const text of [
26
+ 'rgb(1,2,3)',
27
+ 'rgba(1,2,3,0.5)',
28
+ 'hsl(1 2% 3%)',
29
+ 'hsla(1,2%,3%,0.5)',
30
+ 'oklch(0.5 0.2 30)',
31
+ 'lab(50% 40 59)',
32
+ 'color-mix(in oklch, red, blue)',
33
+ ]) {
34
+ expect(ids(text), text).toContain('functional')
35
+ }
36
+ })
37
+
38
+ it('detects Tailwind colour utilities but not layout classes', () => {
39
+ expect(ids('bg-red-500')).toEqual(['twPalette'])
40
+ expect(ids('text-slate-200')).toEqual(['twPalette'])
41
+ expect(ids('bg-white')).toEqual(['twAchromatic'])
42
+ expect(ids('bg-[#1C1C1C]')).toEqual(['twArbitrary'])
43
+ expect(ids('flex flex-row gap-2 p-4 rounded-lg')).toEqual([])
44
+ // Semantic utilities are the desired output, not violations.
45
+ expect(ids('bg-surface-raised text-text-primary')).toEqual([])
46
+ })
47
+
48
+ it('treats a bare keyword as a colour only when it is the whole value', () => {
49
+ expect(ids('white')).toEqual(['named'])
50
+ expect(ids(' black ')).toEqual(['named'])
51
+ // Prose and identifiers must not match, or every comment becomes a violation.
52
+ expect(ids('white text on a black plane')).toEqual([])
53
+ expect(ids('whiteboard')).toEqual([])
54
+ })
55
+
56
+ it('does not flag absence or inheritance', () => {
57
+ // `transparent` encodes absence and `currentColor` defers to the cascade;
58
+ // neither has a token equivalent.
59
+ expect(ids('transparent')).toEqual([])
60
+ expect(ids('currentColor')).toEqual([])
61
+ })
62
+
63
+ it('normalises case so #FFF and #fff are the same debt', () => {
64
+ expect(values('#ABCDEF')).toEqual(['#abcdef'])
65
+ })
66
+
67
+ it('extracts every colour in a multi-colour literal', () => {
68
+ // The baseline funds each one, so both have to come back.
69
+ expect(values('linear-gradient(#111111, #222222)')).toEqual(['#111111', '#222222'])
70
+ })
71
+ })