@titan-design/react-ui 0.9.3 → 0.10.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/dist/bodymap.js +78 -24
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +78 -24
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +31 -8
- package/dist/index.d.ts +31 -8
- package/dist/index.js +214 -113
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +210 -114
- package/dist/index.mjs.map +1 -1
- package/dist/pages.js +214 -157
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +214 -157
- package/dist/pages.mjs.map +1 -1
- package/dist/theme/index.d.mts +129 -34
- package/dist/theme/index.d.ts +129 -34
- package/dist/theme/index.js +95 -19
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +92 -20
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.js +88 -24
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +88 -24
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/custom/Workout/BaseBadge.tsx +5 -5
- package/src/components/custom/Workout/RowInventory.stories.tsx +5 -2
- package/src/components/custom/Workout/S3FullRail.stories.tsx +13 -1
- package/src/components/custom/Workout/SessionHeader.tsx +2 -2
- package/src/components/custom/Workout/SessionRail.stories.tsx +4 -1
- package/src/components/custom/Workout/SessionRail.tsx +5 -5
- package/src/components/custom/Workout/SupersetWrapper.stories.tsx +16 -25
- package/src/components/custom/Workout/SupersetWrapper.tsx +1 -1
- package/src/components/custom/Workout/TempoDisplay.tsx +3 -7
- package/src/components/custom/Workout/VolumeLandmarkBar.stories.tsx +4 -1
- package/src/components/custom/Workout/WorkoutPill.tsx +2 -2
- package/src/components/ui/surface/Surface.stories.tsx +195 -1
- package/src/components/ui/surface/Surface.test.tsx +148 -9
- package/src/components/ui/surface/Surface.tsx +37 -9
- package/src/components/ui/surface/SurfaceContext.ts +48 -8
- package/src/components/ui/surface/index.ts +1 -0
- package/src/components/ui/surface/surface.contract.test.ts +283 -0
- package/src/theme/ColorSystem.stories.tsx +232 -47
- package/src/theme/config.ts +12 -0
- package/src/theme/elevation.ts +90 -67
- package/src/theme/global.css +35 -9
- package/src/theme/tokens/primitives.ts +40 -0
- package/src/theme/tokens/semantic.ts +63 -14
- package/tailwind.config.js +8 -0
|
@@ -6,9 +6,12 @@ import { Surface } from './Surface'
|
|
|
6
6
|
import {
|
|
7
7
|
onSurfaceColors,
|
|
8
8
|
surfaceBackground,
|
|
9
|
+
pressedLevel,
|
|
9
10
|
useOnSurfaceColor,
|
|
11
|
+
useSurface,
|
|
10
12
|
useSurfaceMode,
|
|
11
13
|
} from './SurfaceContext'
|
|
14
|
+
import { getPressedRecessShadow } from '../../../theme/elevation'
|
|
12
15
|
|
|
13
16
|
// A descendant probe that renders the on-surface colour + mode it resolves from
|
|
14
17
|
// context, so tests can assert what a nested consumer would actually paint.
|
|
@@ -70,8 +73,9 @@ describe('Surface (card model)', () => {
|
|
|
70
73
|
|
|
71
74
|
it('paints the default flat dark card surface', () => {
|
|
72
75
|
render(<Surface testID="s" />)
|
|
73
|
-
// elevation 0 → base surface (surface-elevated
|
|
74
|
-
|
|
76
|
+
// elevation 0 → base surface (surface-elevated) with no lightening.
|
|
77
|
+
// TD-surface-tokens S-3 re-space: was #2A2827.
|
|
78
|
+
expect(screen.getByTestId('s')).toHaveStyle({ backgroundColor: '#2C2A28' })
|
|
75
79
|
})
|
|
76
80
|
|
|
77
81
|
describe('accessibility', () => {
|
|
@@ -89,11 +93,11 @@ describe('Surface (card model)', () => {
|
|
|
89
93
|
|
|
90
94
|
describe('Surface (named plane)', () => {
|
|
91
95
|
it.each([
|
|
92
|
-
['background', '#
|
|
93
|
-
['base', '#
|
|
94
|
-
['elevated', '#
|
|
95
|
-
['raised', '#
|
|
96
|
-
] as const)('maps level %s to the
|
|
96
|
+
['background', '#1C1916'],
|
|
97
|
+
['base', '#252321'],
|
|
98
|
+
['elevated', '#2C2A28'],
|
|
99
|
+
['raised', '#31302F'],
|
|
100
|
+
] as const)('maps level %s to the surface-ramp token %s', (level, hex) => {
|
|
97
101
|
render(<Surface level={level} testID="s" />)
|
|
98
102
|
expect(screen.getByTestId('s')).toHaveStyle({ backgroundColor: hex })
|
|
99
103
|
})
|
|
@@ -109,6 +113,137 @@ describe('Surface (named plane)', () => {
|
|
|
109
113
|
})
|
|
110
114
|
})
|
|
111
115
|
|
|
116
|
+
// A descendant probe that reports the surface LEVEL it resolves from context,
|
|
117
|
+
// so tests can assert what a nested Surface publishes to its children.
|
|
118
|
+
function LevelProbe({ label = 'lvl' }) {
|
|
119
|
+
const { level } = useSurface()
|
|
120
|
+
return <Text testID={label}>{level}</Text>
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// CIELAB L* (perceptual lightness) — same calc as surface.contract.test, so the
|
|
124
|
+
// "darker" assertions measure the metric the ramp was designed against.
|
|
125
|
+
function lstar(hex: string): number {
|
|
126
|
+
const h = hex.replace('#', '')
|
|
127
|
+
const lin = [0, 2, 4]
|
|
128
|
+
.map((i) => parseInt(h.slice(i, i + 2), 16) / 255)
|
|
129
|
+
.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4))
|
|
130
|
+
const y = 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
|
|
131
|
+
return y <= 0.008856 ? y * 903.3 : 116 * Math.cbrt(y) - 16
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
describe('Surface (pressed well)', () => {
|
|
135
|
+
it.each([
|
|
136
|
+
// TD-surface-tokens S-3 re-space: top steps widened (elevated #2C2A28,
|
|
137
|
+
// raised #31302F, overlay #373635); base/background unchanged.
|
|
138
|
+
['overlay', '#373635', '#31302F'], // → raised
|
|
139
|
+
['raised', '#31302F', '#2C2A28'], // → elevated
|
|
140
|
+
['elevated', '#2C2A28', '#252321'], // → base
|
|
141
|
+
['base', '#252321', '#1C1916'], // → background
|
|
142
|
+
] as const)(
|
|
143
|
+
'in a %s parent renders one ramp step down (%s → %s), darker than its parent',
|
|
144
|
+
(parent, parentHex, pressedHex) => {
|
|
145
|
+
render(
|
|
146
|
+
<Surface level={parent}>
|
|
147
|
+
<Surface pressed testID="well" />
|
|
148
|
+
</Surface>
|
|
149
|
+
)
|
|
150
|
+
expect(screen.getByTestId('well')).toHaveStyle({ backgroundColor: pressedHex })
|
|
151
|
+
expect(lstar(pressedHex)).toBeLessThan(lstar(parentHex))
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
it('with no enclosing Surface (default base) presses to the background plane', () => {
|
|
156
|
+
render(<Surface pressed testID="well" />)
|
|
157
|
+
expect(screen.getByTestId('well')).toHaveStyle({ backgroundColor: '#1C1916' })
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('clamps at the inset floor: pressed directly in background does not underflow', () => {
|
|
161
|
+
render(
|
|
162
|
+
<Surface level="background">
|
|
163
|
+
<Surface pressed testID="well" />
|
|
164
|
+
</Surface>
|
|
165
|
+
)
|
|
166
|
+
// background (#1C1916) steps down to the inset floor (#13100D), the pit.
|
|
167
|
+
expect(screen.getByTestId('well')).toHaveStyle({ backgroundColor: '#13100D' })
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('does not step below the floor: pressed within a floor-pressed well stays at inset', () => {
|
|
171
|
+
render(
|
|
172
|
+
<Surface level="background">
|
|
173
|
+
<Surface pressed>
|
|
174
|
+
<Surface pressed testID="deeper" />
|
|
175
|
+
</Surface>
|
|
176
|
+
</Surface>
|
|
177
|
+
)
|
|
178
|
+
expect(screen.getByTestId('deeper')).toHaveStyle({ backgroundColor: '#13100D' })
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('publishes the stepped-down level to descendants so a nested press steps again', () => {
|
|
182
|
+
render(
|
|
183
|
+
<Surface level="raised">
|
|
184
|
+
<Surface pressed>
|
|
185
|
+
<LevelProbe label="lvl" />
|
|
186
|
+
</Surface>
|
|
187
|
+
</Surface>
|
|
188
|
+
)
|
|
189
|
+
// raised → elevated; descendants read the well's own level.
|
|
190
|
+
expect(screen.getByTestId('lvl')).toHaveTextContent('elevated')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('adds an inner-shadow recess composed with the darker fill (web path)', () => {
|
|
194
|
+
render(
|
|
195
|
+
<Surface level="base">
|
|
196
|
+
<Surface pressed testID="well" />
|
|
197
|
+
</Surface>
|
|
198
|
+
)
|
|
199
|
+
const boxShadow = screen.getByTestId('well').style.boxShadow
|
|
200
|
+
expect(boxShadow).toContain('inset')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('reads recessed via the darker fill alone when the shadow path is absent (no-shadow/native)', () => {
|
|
204
|
+
// The recess is carried by BOTH fill + shadow; strip the shadow and the fill
|
|
205
|
+
// still darkens one ramp step, so a pressed surface never becomes invisible.
|
|
206
|
+
render(
|
|
207
|
+
<Surface level="base">
|
|
208
|
+
<Surface pressed testID="well" style={{ boxShadow: undefined }} />
|
|
209
|
+
</Surface>
|
|
210
|
+
)
|
|
211
|
+
expect(screen.getByTestId('well')).toHaveStyle({ backgroundColor: '#1C1916' })
|
|
212
|
+
expect(lstar('#1C1916')).toBeLessThan(lstar('#252321'))
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('rounds the well by default and honours an explicit rounded override', () => {
|
|
216
|
+
render(
|
|
217
|
+
<Surface level="base">
|
|
218
|
+
<Surface pressed testID="rounded" />
|
|
219
|
+
<Surface pressed rounded={false} testID="flat" />
|
|
220
|
+
</Surface>
|
|
221
|
+
)
|
|
222
|
+
expect(screen.getByTestId('rounded')).toBeInTheDocument()
|
|
223
|
+
expect(screen.getByTestId('flat')).toBeInTheDocument()
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe('pressedLevel helper', () => {
|
|
228
|
+
it.each([
|
|
229
|
+
['overlay', 'raised'],
|
|
230
|
+
['raised', 'elevated'],
|
|
231
|
+
['elevated', 'base'],
|
|
232
|
+
['base', 'background'],
|
|
233
|
+
['background', 'inset'],
|
|
234
|
+
['inset', 'inset'],
|
|
235
|
+
] as const)('steps %s down to %s (clamped at inset)', (parent, expected) => {
|
|
236
|
+
expect(pressedLevel(parent)).toBe(expected)
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
describe('getPressedRecessShadow', () => {
|
|
241
|
+
it('returns an inset recess tuned to the fill colour on the web path', () => {
|
|
242
|
+
const style = getPressedRecessShadow('#1C1916', 'dark') as { boxShadow?: string }
|
|
243
|
+
expect(style.boxShadow).toContain('inset')
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
|
|
112
247
|
describe('Surface on-surface colour context', () => {
|
|
113
248
|
it('gives descendants literal-hex dark text colours', () => {
|
|
114
249
|
render(
|
|
@@ -152,11 +287,15 @@ describe('Surface on-surface colour context', () => {
|
|
|
152
287
|
|
|
153
288
|
describe('surface colour helpers', () => {
|
|
154
289
|
it('surfaceBackground returns literal hex per level + mode', () => {
|
|
155
|
-
expect(surfaceBackground('elevated', 'dark')).toBe('#
|
|
156
|
-
expect(surfaceBackground('background', 'dark')).toBe('#
|
|
290
|
+
expect(surfaceBackground('elevated', 'dark')).toBe('#2C2A28')
|
|
291
|
+
expect(surfaceBackground('background', 'dark')).toBe('#1C1916')
|
|
157
292
|
expect(surfaceBackground('base', 'light')).toBe('#FFFFFF')
|
|
158
293
|
})
|
|
159
294
|
|
|
295
|
+
it('resolves the inset floor from the surfaceRampDark.inset primitive (no token yet)', () => {
|
|
296
|
+
expect(surfaceBackground('inset', 'dark')).toBe('#13100D')
|
|
297
|
+
})
|
|
298
|
+
|
|
160
299
|
it('onSurfaceColors returns the neutral text ramp as literal hex', () => {
|
|
161
300
|
expect(onSurfaceColors('dark')).toEqual({
|
|
162
301
|
primary: '#F3F4F6',
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import React, { useMemo } from 'react'
|
|
2
|
-
import { View, type ViewProps } from 'react-native'
|
|
2
|
+
import { View, type ViewProps, type ViewStyle } from 'react-native'
|
|
3
3
|
import { cn } from '../../../utils/cn'
|
|
4
4
|
import { type ThemeMode } from '../../../theme/tokens/semantic'
|
|
5
5
|
import {
|
|
6
6
|
type ElevationLevel,
|
|
7
7
|
getElevationSurface,
|
|
8
8
|
getElevationShadow,
|
|
9
|
+
getPressedRecessShadow,
|
|
9
10
|
getBaseSurfaceColor,
|
|
10
11
|
type GlowIntensity,
|
|
11
12
|
getGlowShadow,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
import {
|
|
14
15
|
SurfaceContext,
|
|
15
16
|
surfaceBackground,
|
|
17
|
+
pressedLevel,
|
|
16
18
|
useSurface,
|
|
17
19
|
type SurfaceContextValue,
|
|
18
20
|
type SurfaceLevel,
|
|
@@ -32,6 +34,14 @@ export interface SurfaceProps extends ViewProps {
|
|
|
32
34
|
* plane reads full-bleed. Composable with `elevation`'s glow.
|
|
33
35
|
*/
|
|
34
36
|
level?: SurfaceLevel
|
|
37
|
+
/**
|
|
38
|
+
* Sunken "well": render ONE ramp step DOWN from the parent surface's level —
|
|
39
|
+
* the symmetric twin of a raised surface stepping up — and add an inner-shadow
|
|
40
|
+
* recess so the surface reads pressed (darker fill + inset shadow). Relative to
|
|
41
|
+
* the enclosing Surface's level (not this surface's own `level`), and clamped
|
|
42
|
+
* at the ramp's `inset` floor so it never underflows. One level of depth only.
|
|
43
|
+
*/
|
|
44
|
+
pressed?: boolean
|
|
35
45
|
glowColor?: string
|
|
36
46
|
glowIntensity?: GlowIntensity
|
|
37
47
|
/**
|
|
@@ -59,6 +69,7 @@ export interface SurfaceProps extends ViewProps {
|
|
|
59
69
|
export function Surface({
|
|
60
70
|
elevation = 0,
|
|
61
71
|
level,
|
|
72
|
+
pressed = false,
|
|
62
73
|
glowColor,
|
|
63
74
|
glowIntensity,
|
|
64
75
|
theme,
|
|
@@ -72,18 +83,35 @@ export function Surface({
|
|
|
72
83
|
const mode = theme ?? inherited.mode
|
|
73
84
|
const isPlane = level != null
|
|
74
85
|
|
|
86
|
+
// Three grounded ways to own a background, in precedence order:
|
|
87
|
+
// - pressed: a well ONE ramp step DOWN from the parent level (relative),
|
|
88
|
+
// with an inner-shadow recess — clamps at the `inset` floor.
|
|
89
|
+
// - level (named plane): a flat charcoal plane straight from a token.
|
|
90
|
+
// - elevation (numeric): the raised-card model — lighten-from-base + shadow.
|
|
75
91
|
const baseColor = getBaseSurfaceColor(mode)
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
92
|
+
const resolvedLevel = pressed ? pressedLevel(inherited.level) : (level ?? inherited.level)
|
|
93
|
+
|
|
94
|
+
let backgroundColor: string
|
|
95
|
+
let shadowStyle: ViewStyle
|
|
96
|
+
if (pressed) {
|
|
97
|
+
backgroundColor = surfaceBackground(resolvedLevel, mode)
|
|
98
|
+
shadowStyle = getPressedRecessShadow(backgroundColor, mode)
|
|
99
|
+
} else if (isPlane) {
|
|
100
|
+
backgroundColor = surfaceBackground(level, mode)
|
|
101
|
+
// Planes own a flat full-bleed background — no depth shadow.
|
|
102
|
+
shadowStyle = {}
|
|
103
|
+
} else {
|
|
104
|
+
backgroundColor = getElevationSurface(baseColor, elevation, mode)
|
|
105
|
+
shadowStyle = getElevationShadow(baseColor, elevation, mode)
|
|
106
|
+
}
|
|
107
|
+
|
|
81
108
|
const glowStyle = glowColor ? getGlowShadow(glowColor, glowIntensity) : {}
|
|
82
|
-
|
|
109
|
+
// A pressed well and the card model round by default; a flat plane doesn't.
|
|
110
|
+
const applyRounded = rounded ?? (pressed || !isPlane)
|
|
83
111
|
|
|
84
112
|
const value = useMemo<SurfaceContextValue>(
|
|
85
|
-
() => ({ mode, level:
|
|
86
|
-
[mode,
|
|
113
|
+
() => ({ mode, level: resolvedLevel }),
|
|
114
|
+
[mode, resolvedLevel]
|
|
87
115
|
)
|
|
88
116
|
|
|
89
117
|
return (
|
|
@@ -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
|
|
15
|
-
* semantic surface/background token (already grounded in the
|
|
16
|
-
* theme-aware) — Surface introduces
|
|
17
|
-
*
|
|
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`
|
|
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
|
+
}
|
|
@@ -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
|
+
})
|