@titan-design/react-ui 0.10.0 → 0.12.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 +173 -343
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +173 -343
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +416 -18
- package/dist/index.d.ts +416 -18
- package/dist/index.js +2512 -1307
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2480 -1299
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-DyEx-lBf.d.ts → pages-B1hqnGIC.d.ts} +149 -15
- package/dist/{pages-D7hOD4Vj.d.mts → pages-CvsFqNtx.d.mts} +149 -15
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +1644 -1575
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +1645 -1576
- package/dist/pages.mjs.map +1 -1
- package/dist/theme/index.d.mts +409 -415
- package/dist/theme/index.d.ts +409 -415
- package/dist/theme/index.js +234 -408
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +227 -399
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.js +141 -140
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +141 -140
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +8 -3
- package/src/arch/Architecture.stories.tsx +9 -9
- package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
- package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
- package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
- package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
- package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
- package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
- package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
- package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
- package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
- package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
- package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
- package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
- package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
- package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
- package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
- package/src/components/custom/Fatigue/README.md +93 -0
- package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
- package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
- package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
- package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
- package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
- package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
- package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
- package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
- package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
- package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
- package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
- package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
- package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
- package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
- package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
- package/src/components/custom/Fatigue/index.ts +41 -0
- package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
- package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
- package/src/components/custom/Sidebar/Sidebar.tsx +4 -4
- package/src/components/custom/Table/Table.tsx +2 -2
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +3 -3
- package/src/components/custom/Workout/BaseBadge.test.tsx +6 -1
- package/src/components/custom/Workout/BaseBadge.tsx +1 -1
- package/src/components/custom/Workout/BodyMap.tsx +2 -2
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +2 -2
- package/src/components/custom/Workout/DeviationBar.test.tsx +2 -1
- package/src/components/custom/Workout/DeviationBar.tsx +3 -2
- package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
- package/src/components/custom/Workout/ExerciseCard.test.tsx +7 -7
- package/src/components/custom/Workout/ExerciseCard.tsx +5 -3
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +11 -5
- package/src/components/custom/Workout/InputBar.tsx +2 -2
- package/src/components/custom/Workout/LiveAuraFrame.stories.tsx +1 -1
- package/src/components/custom/Workout/LiveAuraFrame.test.tsx +1 -1
- package/src/components/custom/Workout/LiveAuraFrame.tsx +3 -3
- package/src/components/custom/Workout/MesoCard.tsx +3 -1
- package/src/components/custom/Workout/MuscleGroupChip.tsx +4 -3
- package/src/components/custom/Workout/PrHistoryModal.tsx +2 -2
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +1 -1
- package/src/components/custom/Workout/README.md +62 -16
- package/src/components/custom/Workout/ReadinessCheck.tsx +2 -2
- package/src/components/custom/Workout/RestTimer.tsx +2 -2
- package/src/components/custom/Workout/S3SetTypes.stories.tsx +3 -3
- package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
- package/src/components/custom/Workout/SessionHeader.stories.tsx +2 -2
- package/src/components/custom/Workout/SessionHeader.tsx +1 -1
- package/src/components/custom/Workout/SessionRail.stories.tsx +2 -2
- package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
- package/src/components/custom/Workout/SessionRail.tsx +63 -18
- package/src/components/custom/Workout/SetBar.tsx +3 -3
- package/src/components/custom/Workout/SetRow.test.tsx +12 -9
- package/src/components/custom/Workout/SetRow.tsx +9 -2
- package/src/components/custom/Workout/StatusDot.tsx +5 -4
- package/src/components/custom/Workout/StrengthTrendChart.tsx +1 -1
- package/src/components/custom/Workout/TrainingStatusPage.tsx +2 -2
- package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
- package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
- package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
- package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
- package/src/components/custom/Workout/VelocityStrip.test.tsx +199 -79
- package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
- package/src/components/custom/Workout/VolumeLandmarkBar.tsx +3 -3
- package/src/components/custom/Workout/WeightBadge.test.tsx +6 -1
- package/src/components/custom/Workout/WeightBadge.tsx +2 -1
- package/src/components/custom/Workout/WorkoutCard.tsx +3 -1
- package/src/components/custom/Workout/WorkoutPill.tsx +5 -2
- package/src/components/custom/Workout/ZoneTrack.tsx +4 -4
- package/src/components/custom/Workout/index.ts +3 -0
- package/src/components/custom/Workout/setHeadingKit.tsx +14 -8
- package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
- package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
- package/src/components/custom/charts/SetBarChart.tsx +651 -0
- package/src/components/custom/charts/live-rep-growth.ts +91 -0
- package/src/components/custom/index.ts +1 -0
- package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
- package/src/components/custom/stepper/Stepper.tsx +3 -3
- package/src/components/shell/DeviceMenu.tsx +2 -2
- package/src/components/shell/SideNav.tsx +1 -1
- package/src/components/shell/TopBar.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
- package/src/components/ui/avatar/Avatar.tsx +2 -2
- package/src/components/ui/badge/Badge.tsx +2 -2
- package/src/components/ui/card/Card.tsx +3 -3
- package/src/components/ui/checkbox/Checkbox.tsx +1 -1
- package/src/components/ui/collapse/Collapse.tsx +1 -1
- package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
- package/src/components/ui/drawer/Drawer.tsx +3 -3
- package/src/components/ui/form-field/FormField.tsx +1 -1
- package/src/components/ui/help-tip/HelpTip.tsx +1 -1
- package/src/components/ui/menu/Menu.tsx +2 -2
- package/src/components/ui/pill/Pill.tsx +2 -2
- package/src/components/ui/popover/Popover.stories.tsx +2 -2
- package/src/components/ui/popover/Popover.tsx +1 -1
- package/src/components/ui/progress/Progress.tsx +3 -3
- package/src/components/ui/radio/Radio.stories.tsx +1 -1
- package/src/components/ui/radio/Radio.tsx +4 -4
- package/src/components/ui/select/Select.tsx +3 -3
- package/src/components/ui/surface/Surface.stories.tsx +3 -3
- package/src/components/ui/surface/Surface.test.tsx +32 -19
- package/src/components/ui/surface/Surface.tsx +3 -3
- package/src/components/ui/surface/SurfaceContext.ts +19 -29
- package/src/components/ui/surface/surface.contract.test.ts +57 -44
- package/src/components/ui/switch/Switch.tsx +1 -1
- package/src/components/ui/tabs/Tabs.tsx +2 -2
- package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +5 -5
- package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +1 -1
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +36 -22
- package/src/stories/PresetShowcase.stories.tsx +21 -21
- package/src/test/raw-color-patterns.test.ts +71 -0
- package/src/theme/ColorPalettes.stories.tsx +874 -0
- package/src/theme/ColorPrimitives.stories.tsx +348 -0
- package/src/theme/Depth.stories.tsx +312 -0
- package/src/theme/DepthCalibration.stories.tsx +655 -0
- package/src/theme/ThemeProvider.tsx +1 -1
- package/src/theme/barrel.ts +7 -14
- package/src/theme/color-stories.coverage.test.ts +125 -0
- package/src/theme/color-story-kit.tsx +170 -0
- package/src/theme/color-utils.ts +197 -0
- package/src/theme/config.ts +0 -8
- package/src/theme/depth-calibration.test.tsx +97 -0
- package/src/theme/elevation.test.ts +62 -1
- package/src/theme/elevation.ts +34 -8
- package/src/theme/global.css +47 -53
- package/src/theme/grey-ramp.test.ts +166 -0
- package/src/theme/materials.test.ts +106 -0
- package/src/theme/materials.ts +211 -0
- package/src/theme/presets/audiobook.ts +4 -4
- package/src/theme/presets/types.ts +3 -3
- package/src/theme/semantic-contrast.test.ts +83 -0
- package/src/theme/tokens/primitives.ts +62 -115
- package/src/theme/tokens/semantic.ts +130 -82
- package/src/utils/colors.ts +1 -1
- package/tailwind.config.js +2 -5
- package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
- package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
- package/src/theme/ColorSystem.stories.tsx +0 -508
- package/src/theme/Colors.stories.tsx +0 -323
- package/src/theme/shadows.stories.tsx +0 -523
- package/src/theme/shadows.ts +0 -439
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { getGlowShadow } from './elevation'
|
|
2
|
+
import { getGlowShadow, getElevationSurface, getElevationShadow, getBaseSurfaceColor, FLOATING_ELEVATION_MIN } from './elevation'
|
|
3
3
|
|
|
4
4
|
describe('getGlowShadow', () => {
|
|
5
5
|
it('returns shadow style for valid hex color', () => {
|
|
@@ -25,3 +25,64 @@ describe('getGlowShadow', () => {
|
|
|
25
25
|
expect(defaultStyle).toEqual(mediumStyle)
|
|
26
26
|
})
|
|
27
27
|
})
|
|
28
|
+
|
|
29
|
+
// ── TD-07.16: the depth model ───────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
describe('elevation depth model', () => {
|
|
32
|
+
const hex2rgb = (h: string): [number, number, number] => {
|
|
33
|
+
const n = parseInt(h.slice(1), 16)
|
|
34
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
|
35
|
+
}
|
|
36
|
+
const srgb2lin = (c: number) => {
|
|
37
|
+
const s = c / 255
|
|
38
|
+
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
|
|
39
|
+
}
|
|
40
|
+
const fl = (t: number) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116)
|
|
41
|
+
const Lstar = (hex: string) => {
|
|
42
|
+
const [r, g, b] = hex2rgb(hex).map(srgb2lin)
|
|
43
|
+
return 116 * fl(r * 0.2126729 + g * 0.7151522 + b * 0.072175) - 16
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const base = getBaseSurfaceColor('dark')
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* With drop-shadow gated off below FLOATING_ELEVATION_MIN, TONE is the only
|
|
50
|
+
* thing separating levels 0-3. Level 1 previously moved 0.01 (~2 RGB), which
|
|
51
|
+
* is under the perceptual floor — so the level existed in the type system and
|
|
52
|
+
* nowhere on screen.
|
|
53
|
+
*/
|
|
54
|
+
it('separates every adjacent content level by a visible tonal step', () => {
|
|
55
|
+
const levels = [0, 1, 2, 3] as const
|
|
56
|
+
for (let i = 1; i < levels.length; i++) {
|
|
57
|
+
const prev = Lstar(getElevationSurface(base, levels[i - 1], 'dark'))
|
|
58
|
+
const cur = Lstar(getElevationSurface(base, levels[i], 'dark'))
|
|
59
|
+
const step = cur - prev
|
|
60
|
+
expect(step, `L${levels[i - 1]}→L${levels[i]} moves ΔL* ${step.toFixed(2)}`).toBeGreaterThan(1)
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('gets lighter as it rises, never darker', () => {
|
|
65
|
+
const ls = ([0, 1, 2, 3, 4, 5] as const).map((lv) => Lstar(getElevationSurface(base, lv, 'dark')))
|
|
66
|
+
for (let i = 1; i < ls.length; i++) expect(ls[i]).toBeGreaterThan(ls[i - 1])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('casts NO outward shadow below the floating threshold', () => {
|
|
70
|
+
// Dark-on-dark drop shadows are inert — they cost a composite layer and
|
|
71
|
+
// show nothing. Inline hierarchy is the hairline's job.
|
|
72
|
+
for (const lv of [1, 2, 3] as const) {
|
|
73
|
+
expect(getElevationShadow(base, lv, 'dark'), `level ${lv}`).toEqual({})
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('does cast one at and above the floating threshold', () => {
|
|
78
|
+
for (const lv of [FLOATING_ELEVATION_MIN, 5] as const) {
|
|
79
|
+
expect(Object.keys(getElevationShadow(base, lv, 'dark')).length, `level ${lv}`).toBeGreaterThan(0)
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('keeps the recess on inset levels — a well is not a cast shadow', () => {
|
|
84
|
+
for (const lv of [-1, -2] as const) {
|
|
85
|
+
expect(Object.keys(getElevationShadow(base, lv, 'dark')).length, `level ${lv}`).toBeGreaterThan(0)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
})
|
package/src/theme/elevation.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { Platform, type ViewStyle } from 'react-native'
|
|
25
|
-
import { lighten, darken, hexToRgb, rgbToHsv, hsvToRgb, rgbToHex } from './
|
|
25
|
+
import { lighten, darken, hexToRgb, rgbToHsv, hsvToRgb, rgbToHex } from './color-utils'
|
|
26
26
|
import { semanticColorsLight, semanticColorsDark } from './tokens/semantic'
|
|
27
27
|
|
|
28
28
|
export type ElevationLevel = -2 | -1 | 0 | 1 | 2 | 3 | 4 | 5
|
|
@@ -71,7 +71,13 @@ export const elevationSystem: Record<ElevationLevel, ElevationConfig> = {
|
|
|
71
71
|
// Level 1: Subtle elevation (outline cards, subtle containers)
|
|
72
72
|
[1]: {
|
|
73
73
|
name: 'subtle',
|
|
74
|
-
|
|
74
|
+
// The whole ladder was re-spaced in TD-07.16. It used to run
|
|
75
|
+
// 0.01/0.02/0.04/0.06/0.08, where the first step was ~2 RGB values — under
|
|
76
|
+
// the perceptual floor, so level 1 existed in the type system and nowhere on
|
|
77
|
+
// screen. With drop-shadow now gated off below FLOATING_ELEVATION_MIN, TONE
|
|
78
|
+
// is the only thing separating 0-3, so every step has to actually land.
|
|
79
|
+
// Even 0.025 spacing, verified in elevation.test.ts as ΔL* > 1 per step.
|
|
80
|
+
colorAdjustment: 0.025,
|
|
75
81
|
shadowIntensity: 'subtle',
|
|
76
82
|
shadowStyle: 'raised',
|
|
77
83
|
description: 'Subtle elevation (outline cards, subtle containers)',
|
|
@@ -80,7 +86,7 @@ export const elevationSystem: Record<ElevationLevel, ElevationConfig> = {
|
|
|
80
86
|
// Level 2: Standard elevation (default cards, buttons)
|
|
81
87
|
[2]: {
|
|
82
88
|
name: 'standard',
|
|
83
|
-
colorAdjustment: 0.
|
|
89
|
+
colorAdjustment: 0.05,
|
|
84
90
|
shadowIntensity: 'medium',
|
|
85
91
|
shadowStyle: 'raised',
|
|
86
92
|
description: 'Standard elevation (default cards, raised buttons)',
|
|
@@ -89,7 +95,7 @@ export const elevationSystem: Record<ElevationLevel, ElevationConfig> = {
|
|
|
89
95
|
// Level 3: Prominent elevation (important cards, modals)
|
|
90
96
|
[3]: {
|
|
91
97
|
name: 'prominent',
|
|
92
|
-
colorAdjustment: 0.
|
|
98
|
+
colorAdjustment: 0.075,
|
|
93
99
|
shadowIntensity: 'strong',
|
|
94
100
|
shadowStyle: 'raised',
|
|
95
101
|
description: 'Prominent elevation (important cards, modals, dropdowns)',
|
|
@@ -98,7 +104,7 @@ export const elevationSystem: Record<ElevationLevel, ElevationConfig> = {
|
|
|
98
104
|
// Level 4: High elevation (overlays, popovers)
|
|
99
105
|
[4]: {
|
|
100
106
|
name: 'high',
|
|
101
|
-
colorAdjustment: 0.
|
|
107
|
+
colorAdjustment: 0.10,
|
|
102
108
|
shadowIntensity: 'strong',
|
|
103
109
|
shadowStyle: 'raised',
|
|
104
110
|
description: 'High elevation (overlays, popovers, tooltips)',
|
|
@@ -107,7 +113,7 @@ export const elevationSystem: Record<ElevationLevel, ElevationConfig> = {
|
|
|
107
113
|
// Level 5: Floating (toasts, floating action buttons)
|
|
108
114
|
[5]: {
|
|
109
115
|
name: 'floating',
|
|
110
|
-
colorAdjustment: 0.
|
|
116
|
+
colorAdjustment: 0.13,
|
|
111
117
|
shadowIntensity: 'strong',
|
|
112
118
|
shadowStyle: 'raised',
|
|
113
119
|
description: 'Floating elevation (toasts, floating action buttons, highest z-index)',
|
|
@@ -425,6 +431,21 @@ function calculateElevationShadow(
|
|
|
425
431
|
* @param isHovered - Whether element is hovered
|
|
426
432
|
* @returns ViewStyle with shadow properties
|
|
427
433
|
*/
|
|
434
|
+
/**
|
|
435
|
+
* The lowest elevation that still casts an outward shadow.
|
|
436
|
+
*
|
|
437
|
+
* On a near-black surface a drop-shadow is inert: at levels 1-3 the shadow is
|
|
438
|
+
* dark-on-dark and contributes nothing a viewer can see, while still costing a
|
|
439
|
+
* composite layer. Material dropped shadow-as-hierarchy for the same reason.
|
|
440
|
+
* Inline hierarchy is the hairline's job (`hairline-*`), which composites by a
|
|
441
|
+
* constant amount over any plane.
|
|
442
|
+
*
|
|
443
|
+
* At 4+ the element is genuinely FLOATING over a backdrop — menu, popover,
|
|
444
|
+
* toast — where a large soft shadow describes separation from the page rather
|
|
445
|
+
* than a rank in a stack. Those should also carry a hairline ring.
|
|
446
|
+
*/
|
|
447
|
+
export const FLOATING_ELEVATION_MIN: ElevationLevel = 4
|
|
448
|
+
|
|
428
449
|
export function getElevationShadow(
|
|
429
450
|
baseColor: string,
|
|
430
451
|
level: ElevationLevel,
|
|
@@ -438,8 +459,13 @@ export function getElevationShadow(
|
|
|
438
459
|
return elevationShadowCache.get(cacheKey)!
|
|
439
460
|
}
|
|
440
461
|
|
|
441
|
-
// Calculate shadow
|
|
442
|
-
|
|
462
|
+
// Calculate shadow. Outward drop-shadow is gated to FLOATING levels only —
|
|
463
|
+
// see FLOATING_ELEVATION_MIN. Inset levels keep theirs; a recess reads from
|
|
464
|
+
// the inner shadow, which is a different mechanism from a cast shadow.
|
|
465
|
+
const result =
|
|
466
|
+
level > 0 && level < FLOATING_ELEVATION_MIN
|
|
467
|
+
? {}
|
|
468
|
+
: calculateElevationShadow(baseColor, level, theme, isHovered)
|
|
443
469
|
|
|
444
470
|
// Cache result
|
|
445
471
|
if (elevationShadowCache.size >= MAX_SHADOW_CACHE_SIZE) {
|
package/src/theme/global.css
CHANGED
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
--color-status-error-light: #E05254;
|
|
42
42
|
--color-status-error-dark: #A4221C;
|
|
43
43
|
--color-status-error-subtle: rgba(209, 67, 67, 0.12);
|
|
44
|
-
--color-status-error-vivid: #
|
|
45
|
-
--color-status-error-vivid-light: #
|
|
46
|
-
--color-status-error-vivid-dark: #
|
|
44
|
+
--color-status-error-vivid: #D14343;
|
|
45
|
+
--color-status-error-vivid-light: #E05254;
|
|
46
|
+
--color-status-error-vivid-dark: #A4221C;
|
|
47
47
|
--color-status-error-vivid-subtle: rgba(255, 71, 87, 0.12);
|
|
48
48
|
--color-status-error-vivid-rgb: 255, 71, 87;
|
|
49
|
-
--color-border-prominent: #
|
|
49
|
+
--color-border-prominent: #424140;
|
|
50
50
|
--color-status-warning: #F9B415;
|
|
51
51
|
--color-status-warning-light: #FFD352;
|
|
52
52
|
--color-status-warning-dark: #C27400;
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
--color-result-degrade-dark: #b30000;
|
|
70
70
|
--color-result-inconclusive: #9E9A97;
|
|
71
71
|
--color-result-inconclusive-light: rgba(158, 154, 151, 0.16);
|
|
72
|
-
--color-result-neutral: #
|
|
72
|
+
--color-result-neutral: #A29F9D;
|
|
73
73
|
|
|
74
74
|
--color-on-result-improve: #FFFFFF;
|
|
75
75
|
--color-on-result-degrade: #FFFFFF;
|
|
@@ -86,25 +86,24 @@
|
|
|
86
86
|
--color-data-9: #CAACCB;
|
|
87
87
|
--color-data-10: #EE8026;
|
|
88
88
|
|
|
89
|
-
--color-text-primary: #
|
|
90
|
-
--color-text-secondary: #
|
|
91
|
-
--color-text-tertiary: #
|
|
89
|
+
--color-text-primary: #F9F6F3;
|
|
90
|
+
--color-text-secondary: #A29F9D;
|
|
91
|
+
--color-text-tertiary: #888684;
|
|
92
92
|
--color-text-disabled: rgba(255, 255, 255, 0.38);
|
|
93
|
-
--color-text-inverse: #
|
|
93
|
+
--color-text-inverse: #1C1916;
|
|
94
94
|
--color-text-link: #828DF8;
|
|
95
|
-
--color-text-link-hover: #
|
|
95
|
+
--color-text-link-hover: #3CA8FF;
|
|
96
96
|
|
|
97
97
|
/* Surface ramp — warm-tapered DERIVED, shipped verbatim from deriveSurfaceRamp()
|
|
98
98
|
(TD-surface-tokens, S-1, re-spaced S-3). surface-overlay and surface-elevated
|
|
99
99
|
are distinct (were both #191919 pre-S-1); see surfaceRampDark in
|
|
100
|
-
tokens/primitives.ts.
|
|
101
|
-
|
|
100
|
+
tokens/primitives.ts. The ramp's floor is background-frame; the old
|
|
101
|
+
surface-inset plane was retired in TD-07.14 (delta-E 1.10 from frame). */
|
|
102
102
|
--color-surface-base: #252321;
|
|
103
103
|
--color-surface-elevated: #2C2A28;
|
|
104
104
|
--color-surface-raised: #31302F;
|
|
105
105
|
--color-surface-overlay: #373635;
|
|
106
106
|
--color-surface-input: #2C2A28;
|
|
107
|
-
--color-surface-inset: #13100D;
|
|
108
107
|
|
|
109
108
|
--color-background-base: #1C1916;
|
|
110
109
|
--color-background-default: #252321;
|
|
@@ -114,19 +113,20 @@
|
|
|
114
113
|
in tokens/primitives.ts. */
|
|
115
114
|
--color-background-frame: #100D0A;
|
|
116
115
|
|
|
117
|
-
--color-border-default: #1F1F1F;
|
|
118
|
-
--color-border-subtle: #1C1C1C;
|
|
119
|
-
--color-border-strong: #2C2C2C;
|
|
120
116
|
--color-border-focus: #828DF8;
|
|
121
|
-
--color-border-input: #
|
|
122
|
-
--color-border-input-hover: #
|
|
117
|
+
--color-border-input: #5A5958;
|
|
118
|
+
--color-border-input-hover: #72716F;
|
|
123
119
|
--color-border-input-focus: #828DF8;
|
|
124
|
-
--color-border-input-error: #
|
|
120
|
+
--color-border-input-error: #E05254;
|
|
125
121
|
|
|
126
|
-
/* Alpha hairline separators — self-normalizing on any plane (§4/S-2)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
122
|
+
/* Alpha hairline separators — self-normalizing on any plane (§4/S-2).
|
|
123
|
+
Retuned on the wall (VW-99): each tier steps onto the previous tier's
|
|
124
|
+
measured-good value, then +.01 across the family on run 2 for comfort.
|
|
125
|
+
Two decimals only — RNW quantises alpha to 8 bits.
|
|
126
|
+
Dark only; the light family below is unverified. */
|
|
127
|
+
--color-hairline-subtle: rgba(255, 255, 255, 0.10);
|
|
128
|
+
--color-hairline-default: rgba(255, 255, 255, 0.15);
|
|
129
|
+
--color-hairline-strong: rgba(255, 255, 255, 0.22);
|
|
130
130
|
|
|
131
131
|
--color-interactive-hover: rgba(255, 255, 255, 0.04);
|
|
132
132
|
--color-interactive-focus: rgba(255, 255, 255, 0.12);
|
|
@@ -135,9 +135,9 @@
|
|
|
135
135
|
--color-interactive-disabled: rgba(255, 255, 255, 0.12);
|
|
136
136
|
--color-interactive-disabled-text: rgba(255, 255, 255, 0.26);
|
|
137
137
|
|
|
138
|
-
--color-divider:
|
|
138
|
+
--color-divider: rgba(255, 255, 255, 0.09);
|
|
139
139
|
|
|
140
|
-
--color-avatar-background: #
|
|
140
|
+
--color-avatar-background: #5A5958;
|
|
141
141
|
--color-avatar-text: #FFFFFF;
|
|
142
142
|
|
|
143
143
|
/* RGB decomposed values for glow shadows */
|
|
@@ -193,12 +193,12 @@
|
|
|
193
193
|
--color-status-error-light: #E05254;
|
|
194
194
|
--color-status-error-dark: #A4221C;
|
|
195
195
|
--color-status-error-subtle: #FFF4F4;
|
|
196
|
-
--color-status-error-vivid: #
|
|
197
|
-
--color-status-error-vivid-light: #
|
|
198
|
-
--color-status-error-vivid-dark: #
|
|
196
|
+
--color-status-error-vivid: #D14343;
|
|
197
|
+
--color-status-error-vivid-light: #E05254;
|
|
198
|
+
--color-status-error-vivid-dark: #A4221C;
|
|
199
199
|
--color-status-error-vivid-subtle: rgba(255, 71, 87, 0.12);
|
|
200
200
|
--color-status-error-vivid-rgb: 255, 71, 87;
|
|
201
|
-
--color-border-prominent: #
|
|
201
|
+
--color-border-prominent: #A29F9D;
|
|
202
202
|
--color-status-warning: #F9B415;
|
|
203
203
|
--color-status-warning-light: #FFD352;
|
|
204
204
|
--color-status-warning-dark: #C27400;
|
|
@@ -221,7 +221,7 @@
|
|
|
221
221
|
--color-result-degrade-dark: #b30000;
|
|
222
222
|
--color-result-inconclusive: #9E9A97;
|
|
223
223
|
--color-result-inconclusive-light: rgba(158, 154, 151, 0.12);
|
|
224
|
-
--color-result-neutral: #
|
|
224
|
+
--color-result-neutral: #72716F;
|
|
225
225
|
|
|
226
226
|
--color-on-result-improve: #FFFFFF;
|
|
227
227
|
--color-on-result-degrade: #FFFFFF;
|
|
@@ -240,34 +240,28 @@
|
|
|
240
240
|
|
|
241
241
|
--color-text-primary: #121828;
|
|
242
242
|
--color-text-secondary: #65748B;
|
|
243
|
-
--color-text-tertiary: #
|
|
243
|
+
--color-text-tertiary: #A29F9D;
|
|
244
244
|
--color-text-disabled: rgba(55, 65, 81, 0.48);
|
|
245
245
|
--color-text-inverse: #FFFFFF;
|
|
246
|
-
--color-text-link: #
|
|
247
|
-
--color-text-link-hover: #
|
|
246
|
+
--color-text-link: #1072CB;
|
|
247
|
+
--color-text-link-hover: #135AA8;
|
|
248
248
|
|
|
249
249
|
--color-surface-base: #FFFFFF;
|
|
250
|
-
--color-surface-elevated: #
|
|
251
|
-
--color-surface-raised: #
|
|
250
|
+
--color-surface-elevated: #F9F6F3;
|
|
251
|
+
--color-surface-raised: #EDEAE7;
|
|
252
252
|
--color-surface-overlay: #FFFFFF;
|
|
253
|
-
--color-surface-input: #
|
|
254
|
-
/* Placeholder pairing (S-3) — light-mode counterpart of the dark ramp's
|
|
255
|
-
inset plane; not part of the dark-ramp investigation. */
|
|
256
|
-
--color-surface-inset: #D1D5DB;
|
|
253
|
+
--color-surface-input: #F9F6F3;
|
|
257
254
|
|
|
258
255
|
--color-background-base: #EBEBEB;
|
|
259
256
|
--color-background-default: #FFFFFF;
|
|
260
|
-
--color-background-subtle: #
|
|
261
|
-
/* Placeholder pairing
|
|
262
|
-
--color-background-frame: #
|
|
263
|
-
|
|
264
|
-
--color-border-
|
|
265
|
-
--color-border-
|
|
266
|
-
--color-border-
|
|
267
|
-
--color-border-focus: #
|
|
268
|
-
--color-border-input: #D1D5DB;
|
|
269
|
-
--color-border-input-hover: #9CA3AF;
|
|
270
|
-
--color-border-input-focus: #5048E5;
|
|
257
|
+
--color-background-subtle: #F9F6F3;
|
|
258
|
+
/* Placeholder pairing — light mode is deferred. */
|
|
259
|
+
--color-background-frame: #A29F9D;
|
|
260
|
+
|
|
261
|
+
--color-border-focus: #1072CB;
|
|
262
|
+
--color-border-input: #D4D1CE;
|
|
263
|
+
--color-border-input-hover: #A29F9D;
|
|
264
|
+
--color-border-input-focus: #1072CB;
|
|
271
265
|
--color-border-input-error: #D14343;
|
|
272
266
|
|
|
273
267
|
/* Alpha hairline separators — mirrors the dark-mode white-alpha family,
|
|
@@ -285,7 +279,7 @@
|
|
|
285
279
|
|
|
286
280
|
--color-divider: #E8E9EB;
|
|
287
281
|
|
|
288
|
-
--color-avatar-background: #
|
|
282
|
+
--color-avatar-background: #72716F;
|
|
289
283
|
--color-avatar-text: #FFFFFF;
|
|
290
284
|
|
|
291
285
|
/* RGB decomposed values for glow shadows */
|
|
@@ -349,7 +343,7 @@
|
|
|
349
343
|
}
|
|
350
344
|
|
|
351
345
|
.scrollbar-styled::-webkit-scrollbar-thumb {
|
|
352
|
-
background: var(--color-
|
|
346
|
+
background: var(--color-hairline-strong);
|
|
353
347
|
border-radius: 4px;
|
|
354
348
|
}
|
|
355
349
|
|
|
@@ -368,7 +362,7 @@
|
|
|
368
362
|
}
|
|
369
363
|
|
|
370
364
|
.scrollbar-thin::-webkit-scrollbar-thumb {
|
|
371
|
-
background: var(--color-
|
|
365
|
+
background: var(--color-hairline-strong);
|
|
372
366
|
border-radius: 2px;
|
|
373
367
|
}
|
|
374
368
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TD-07.14 / TD-07.15 — the warm grey ramp, and the gate it had to clear.
|
|
3
|
+
*
|
|
4
|
+
* This ramp was lost and re-derived four times before it was committed, so these
|
|
5
|
+
* tests exist to make the properties that were argued about EXPENSIVE to break,
|
|
6
|
+
* rather than leaving them in a document nobody reads. Each block corresponds to
|
|
7
|
+
* a decision someone had to make twice.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from 'vitest'
|
|
10
|
+
import { execFileSync } from 'node:child_process'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { greyRamp, SURFACE_PLANE_STEPS, primitiveRamps } from './tokens/primitives'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
// ── colour math (CIELAB D65 + WCAG relative luminance) ──────────────────────
|
|
16
|
+
const hex2rgb = (h: string): [number, number, number] => {
|
|
17
|
+
const n = parseInt(h.slice(1), 16)
|
|
18
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
|
19
|
+
}
|
|
20
|
+
const srgb2lin = (c: number) => {
|
|
21
|
+
const s = c / 255
|
|
22
|
+
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
|
|
23
|
+
}
|
|
24
|
+
const f = (t: number) => (t > 216 / 24389 ? Math.cbrt(t) : (841 / 108) * t + 4 / 29)
|
|
25
|
+
function lab(hex: string): [number, number, number] {
|
|
26
|
+
const [r, g, b] = hex2rgb(hex).map(srgb2lin)
|
|
27
|
+
const X = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047
|
|
28
|
+
const Y = 0.2126729 * r + 0.7151522 * g + 0.072175 * b
|
|
29
|
+
const Z = (0.0193339 * r + 0.119192 * g + 0.9503041 * b) / 1.08883
|
|
30
|
+
const [fx, fy, fz] = [f(X), f(Y), f(Z)]
|
|
31
|
+
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)]
|
|
32
|
+
}
|
|
33
|
+
const dE76 = (a: string, b: string) => {
|
|
34
|
+
const [p, q] = [lab(a), lab(b)]
|
|
35
|
+
return Math.hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2])
|
|
36
|
+
}
|
|
37
|
+
const Lstar = (hex: string) => lab(hex)[0]
|
|
38
|
+
const warmth = (hex: string) => {
|
|
39
|
+
const [r, , b] = hex2rgb(hex)
|
|
40
|
+
return r - b
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Machado-2009 dichromacy, severity 1.0. */
|
|
44
|
+
const CVD = {
|
|
45
|
+
deuteranopia: [0.367322, 0.860646, -0.227968, 0.280085, 0.672501, 0.047413, -0.01182, 0.04294, 0.968881],
|
|
46
|
+
protanopia: [0.152286, 1.052583, -0.204868, 0.114503, 0.786281, 0.099216, -0.003882, -0.048116, 1.051998],
|
|
47
|
+
tritanopia: [1.255528, -0.076749, -0.178779, -0.078411, 0.930809, 0.147602, 0.004733, 0.691367, 0.3039],
|
|
48
|
+
} as const
|
|
49
|
+
function simulateCvd(m: readonly number[], hex: string): string {
|
|
50
|
+
const [r, g, b] = hex2rgb(hex)
|
|
51
|
+
const out = [
|
|
52
|
+
m[0] * r + m[1] * g + m[2] * b,
|
|
53
|
+
m[3] * r + m[4] * g + m[5] * b,
|
|
54
|
+
m[6] * r + m[7] * g + m[8] * b,
|
|
55
|
+
]
|
|
56
|
+
return (
|
|
57
|
+
'#' +
|
|
58
|
+
out
|
|
59
|
+
.map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0'))
|
|
60
|
+
.join('')
|
|
61
|
+
.toUpperCase()
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const STEPS = Object.keys(greyRamp).map(Number).sort((a, b) => a - b)
|
|
66
|
+
const hexAt = (step: number) => greyRamp[step as keyof typeof greyRamp]
|
|
67
|
+
|
|
68
|
+
describe('greyRamp — shape', () => {
|
|
69
|
+
it('is monotonically darker as the step number rises', () => {
|
|
70
|
+
const ls = STEPS.map((s) => Lstar(hexAt(s)))
|
|
71
|
+
for (let i = 1; i < ls.length; i++) {
|
|
72
|
+
expect(ls[i], `step ${STEPS[i]} is not darker than ${STEPS[i - 1]}`).toBeLessThan(ls[i - 1])
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('reads warm at every step — R−B never drops below the +2 the generator guards', () => {
|
|
77
|
+
for (const s of STEPS) {
|
|
78
|
+
expect(warmth(hexAt(s)), `step ${s} (${hexAt(s)}) is not warm`).toBeGreaterThanOrEqual(2)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('has no two steps a reader could not tell apart', () => {
|
|
83
|
+
for (let i = 1; i < STEPS.length; i++) {
|
|
84
|
+
const d = dE76(hexAt(STEPS[i - 1]), hexAt(STEPS[i]))
|
|
85
|
+
expect(d, `steps ${STEPS[i - 1]}/${STEPS[i]} collapse at ΔE ${d.toFixed(2)}`).toBeGreaterThan(1.5)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The reason `975` exists at all. If a chromatic ramp is ever re-tuned so its
|
|
91
|
+
* darkest step drops below the bezel, the justification for the extra step
|
|
92
|
+
* evaporates and this ramp should be renumbered rather than left as folklore.
|
|
93
|
+
*/
|
|
94
|
+
it('extends below the chromatic grid — which is why 975 is not 950', () => {
|
|
95
|
+
const floor = [...Object.values(primitiveRamps)]
|
|
96
|
+
.map((r) => Lstar(r[950 as keyof typeof r] as string))
|
|
97
|
+
.sort((a, b) => a - b)
|
|
98
|
+
const median = floor[Math.floor(floor.length / 2)]
|
|
99
|
+
expect(Lstar(greyRamp[975])).toBeLessThan(median)
|
|
100
|
+
expect(median).toBeCloseTo(10.3, 0)
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('greyRamp — anchors are load-bearing', () => {
|
|
105
|
+
/**
|
|
106
|
+
* These six are byte-identical to the values that shipped in v0.10.0 as
|
|
107
|
+
* `surfaceRampDark` + `backgroundFrameDark`. That is what makes TD-07.14 a
|
|
108
|
+
* rename rather than a restyle: every surface keeps its exact pixel value.
|
|
109
|
+
* Changing one here silently restyles every surface in the system.
|
|
110
|
+
*/
|
|
111
|
+
const ANCHORS: Array<[keyof typeof greyRamp, string, string]> = [
|
|
112
|
+
[850, '#373635', 'surface overlay'],
|
|
113
|
+
[875, '#31302F', 'surface raised'],
|
|
114
|
+
[900, '#2C2A28', 'surface elevated'],
|
|
115
|
+
[925, '#252321', 'surface base'],
|
|
116
|
+
[950, '#1C1916', 'background base'],
|
|
117
|
+
[975, '#100D0A', 'background frame / bezel'],
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
it.each(ANCHORS)('step %s is %s (%s) — unchanged from v0.10.0', (step, hex) => {
|
|
121
|
+
expect(greyRamp[step]).toBe(hex)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('maps every surface plane to a real step, darkest first', () => {
|
|
125
|
+
const steps = Object.values(SURFACE_PLANE_STEPS)
|
|
126
|
+
for (const s of steps) expect(greyRamp[s]).toBeDefined()
|
|
127
|
+
expect([...steps].sort((a, b) => b - a)).toEqual(steps)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('has no `inset` — it was retired for being imperceptible from the frame', () => {
|
|
131
|
+
// The reason, kept as an assertion so nobody reintroduces it: the old inset
|
|
132
|
+
// #13100D sat ΔE 1.10 from the bezel, well under the ~2.3 JND.
|
|
133
|
+
expect(dE76('#13100D', greyRamp[975])).toBeLessThan(2.3)
|
|
134
|
+
expect(Object.keys(greyRamp)).not.toContain('inset')
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('greyRamp — accessibility gate (TD-07.15)', () => {
|
|
139
|
+
// NOTE: the on-surface TEXT-pairing gate lives in `semantic-contrast.test.ts`,
|
|
140
|
+
// added with the token repoint. It cannot pass until the text tokens actually
|
|
141
|
+
// move onto this ramp — on the shipped cool `neutral` scale, text-tertiary
|
|
142
|
+
// fails WCAG outright on the three lightest planes (2.96 / 2.72 / 2.49).
|
|
143
|
+
|
|
144
|
+
it('stays legible under all three dichromacies', () => {
|
|
145
|
+
for (const [mode, matrix] of Object.entries(CVD)) {
|
|
146
|
+
for (const s of STEPS) {
|
|
147
|
+
const shift = dE76(hexAt(s), simulateCvd(matrix, hexAt(s)))
|
|
148
|
+
expect(shift, `${mode} shifts grey-${s} by ΔE ${shift.toFixed(2)}`).toBeLessThan(3)
|
|
149
|
+
}
|
|
150
|
+
// Adjacent steps must stay separable AFTER simulation, or the depth
|
|
151
|
+
// ordering of the surface stack stops reading for those viewers.
|
|
152
|
+
for (let i = 1; i < STEPS.length; i++) {
|
|
153
|
+
const d = dE76(simulateCvd(matrix, hexAt(STEPS[i - 1])), simulateCvd(matrix, hexAt(STEPS[i])))
|
|
154
|
+
expect(d, `${mode}: grey-${STEPS[i - 1]}/${STEPS[i]} merge`).toBeGreaterThan(1.5)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('greyRamp — provenance', () => {
|
|
161
|
+
it('still matches the generator that derived it', () => {
|
|
162
|
+
const script = join(__dirname, '../../scripts/generate-grey-ramp.mjs')
|
|
163
|
+
// Throws (non-zero exit) if primitives.ts has drifted from the derivation.
|
|
164
|
+
expect(() => execFileSync('node', [script, '--check'], { encoding: 'utf8' })).not.toThrow()
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surface materials — the properties that make paper read as ONE material.
|
|
3
|
+
*
|
|
4
|
+
* These treatments are subtle by design (grain is a whisper at dark tones, the
|
|
5
|
+
* rim is one pixel), which makes them exactly the kind of thing that can be
|
|
6
|
+
* broken without anyone noticing until it looks wrong on the wall. So the
|
|
7
|
+
* numbers that define them are asserted rather than left as comments.
|
|
8
|
+
*
|
|
9
|
+
* The `tonal fill` and `dither` blocks that used to lead this file went with
|
|
10
|
+
* `tonalFill` and `ditherTile` in VW-99: the wall found the ΔL* 3 gradient
|
|
11
|
+
* indiscernible, and then found that this render path does not band at all, so
|
|
12
|
+
* the anti-banding layer was mitigating nothing. See the materials.ts header.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect } from 'vitest'
|
|
15
|
+
import { grainOpacityForTone, grainForTone, paperSheet, insetWell, barPaper } from './materials'
|
|
16
|
+
import { greyRamp } from './tokens/primitives'
|
|
17
|
+
|
|
18
|
+
const styleOf = (s: Record<string, unknown>) => s as { backgroundImage?: string; boxShadow?: string; backgroundColor?: string }
|
|
19
|
+
|
|
20
|
+
describe('grain', () => {
|
|
21
|
+
it('scales opacity with the tone it sits on', () => {
|
|
22
|
+
// A fixed opacity reads hot on a dark plane and vanishes on a bright one.
|
|
23
|
+
const dark = grainOpacityForTone(greyRamp[975])
|
|
24
|
+
const mid = grainOpacityForTone(greyRamp[500])
|
|
25
|
+
const light = grainOpacityForTone(greyRamp[200])
|
|
26
|
+
expect(dark).toBeLessThan(mid)
|
|
27
|
+
expect(mid).toBeLessThan(light)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('stays inside the band where grain reads matte rather than dirty', () => {
|
|
31
|
+
for (const step of Object.keys(greyRamp).map(Number)) {
|
|
32
|
+
const op = grainOpacityForTone(greyRamp[step as keyof typeof greyRamp])
|
|
33
|
+
expect(op, `grey-${step}`).toBeGreaterThanOrEqual(0.04)
|
|
34
|
+
expect(op, `grey-${step}`).toBeLessThanOrEqual(0.24)
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('survives malformed input instead of emitting NaN into a style', () => {
|
|
39
|
+
expect(grainOpacityForTone('not-a-colour')).toBeGreaterThan(0)
|
|
40
|
+
expect(grainForTone('not-a-colour')).not.toContain('NaN')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('paperSheet', () => {
|
|
45
|
+
it('carries grain ALONE — no dither, no gradient', () => {
|
|
46
|
+
const bg = styleOf(paperSheet(greyRamp[875])).backgroundImage!
|
|
47
|
+
// Count data URIs, not 'url(' — each SVG tile contains a `filter='url(#n)'`
|
|
48
|
+
// internally, so counting the token double-reports.
|
|
49
|
+
//
|
|
50
|
+
// Both of the layers this used to have were measured off the sheet in VW-99:
|
|
51
|
+
// the tonal fill was indiscernible from flat, and the dither was mitigating
|
|
52
|
+
// a banding artefact that a hand-drawn reference proved this render path
|
|
53
|
+
// does not produce. Asserted rather than merely deleted, so putting either
|
|
54
|
+
// back is a deliberate act with a wall run behind it, not a quiet revert.
|
|
55
|
+
expect(bg.split('data:image/svg+xml').length - 1, 'grain only, no dither tile').toBe(1)
|
|
56
|
+
expect(bg, 'the tonal fill was removed in VW-99').not.toContain('linear-gradient')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('keeps a solid backgroundColor so native does not fall through', () => {
|
|
60
|
+
// backgroundImage and multi-layer boxShadow are web-only. Without a real
|
|
61
|
+
// fill underneath, a paper surface renders as nothing on native.
|
|
62
|
+
expect(styleOf(paperSheet(greyRamp[875])).backgroundColor).toBe(greyRamp[875])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('carries a top rim and one soft contact shadow', () => {
|
|
66
|
+
const shadow = styleOf(paperSheet()).boxShadow!
|
|
67
|
+
expect(shadow).toContain('inset 0 1px 0')
|
|
68
|
+
expect(shadow.split(',').filter((s) => s.includes('px')).length).toBeGreaterThan(1)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('insetWell', () => {
|
|
73
|
+
it('cuts in from the top AND lights the floor', () => {
|
|
74
|
+
// The floor rim is the load-bearing half — a dark inset alone just reads as
|
|
75
|
+
// a darker rectangle. It is the light on the floor that says "below".
|
|
76
|
+
const shadow = styleOf(insetWell(greyRamp[950])).boxShadow!
|
|
77
|
+
expect(shadow).toContain('inset 0 2px')
|
|
78
|
+
expect(shadow, 'missing the bottom floor rim').toContain('inset 0 -1px 0 rgba(255,255,255,0.12)')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('is entirely inset — a well must not cast outward', () => {
|
|
82
|
+
const shadow = styleOf(insetWell()).boxShadow!
|
|
83
|
+
for (const layer of shadow.split(/,(?![^(]*\))/)) {
|
|
84
|
+
expect(layer.trim().startsWith('inset'), `outward layer: ${layer.trim()}`).toBe(true)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('barPaper', () => {
|
|
90
|
+
it('drops the rim light when mirrored', () => {
|
|
91
|
+
// Mirrored, a top rim would land on the shared axis of a diverging chart.
|
|
92
|
+
expect(styleOf(barPaper('#D14343', true)).boxShadow).not.toContain('inset')
|
|
93
|
+
expect(styleOf(barPaper('#D14343', false)).boxShadow).toContain('inset')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('throws its contact shadow away from the axis when mirrored', () => {
|
|
97
|
+
expect(styleOf(barPaper('#D14343', true)).boxShadow).toContain('0 -6px')
|
|
98
|
+
expect(styleOf(barPaper('#D14343', false)).boxShadow).toContain('0 6px')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('shares the one grain curve with paperSheet', () => {
|
|
102
|
+
// Same material, proportional texture — that is what makes a card and a bar
|
|
103
|
+
// look like they belong to the same system.
|
|
104
|
+
expect(styleOf(barPaper(greyRamp[500])).backgroundImage).toBe(grainForTone(greyRamp[500]))
|
|
105
|
+
})
|
|
106
|
+
})
|