@titan-design/react-ui 0.11.0 → 0.12.1
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 +27 -22
- package/dist/index.d.ts +27 -22
- package/dist/index.js +366 -561
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +360 -553
- package/dist/index.mjs.map +1 -1
- package/dist/pages.js +242 -407
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +242 -407
- package/dist/pages.mjs.map +1 -1
- package/dist/theme/index.d.mts +250 -1157
- package/dist/theme/index.d.ts +250 -1157
- 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/dist/theme/tokens.d.mts +904 -0
- package/dist/theme/tokens.d.ts +904 -0
- package/dist/theme/tokens.js +856 -0
- package/dist/theme/tokens.js.map +1 -0
- package/dist/theme/tokens.mjs +833 -0
- package/dist/theme/tokens.mjs.map +1 -0
- package/package.json +7 -1
- 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 +3 -3
- package/src/components/custom/Fatigue/FatigueLights.stories.tsx +2 -2
- package/src/components/custom/Fatigue/GhostSpark.stories.tsx +3 -3
- package/src/components/custom/Fatigue/LiveFatigueCard.tsx +1 -1
- package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +2 -2
- package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +2 -2
- package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +4 -4
- package/src/components/custom/Fatigue/VelocityHero.stories.tsx +3 -3
- package/src/components/custom/Fatigue/VerdictHero.stories.tsx +2 -2
- package/src/components/custom/Fatigue/fatigue-tokens.ts +12 -12
- 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/ExerciseCard.test.tsx +3 -3
- package/src/components/custom/Workout/ExerciseCard.tsx +2 -2
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +4 -4
- 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 +1 -1
- 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/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.tsx +13 -5
- package/src/components/custom/Workout/SetBar.tsx +3 -3
- package/src/components/custom/Workout/SetRow.test.tsx +6 -3
- 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.test.tsx +5 -2
- package/src/components/custom/Workout/VelocityStrip.tsx +4 -4
- 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/setHeadingKit.tsx +14 -8
- package/src/components/custom/Workout/velocity-story-kit.tsx +2 -2
- package/src/components/custom/charts/SetBarChart.tsx +3 -3
- 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/README.md +1 -1
- package/src/components/shell/SessionRailSpecimen.stories.tsx +1 -1
- 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 +58 -45
- 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/theme/ColorPalettes.stories.tsx +41 -38
- package/src/theme/ColorPrimitives.stories.tsx +89 -41
- 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 +15 -20
- package/src/theme/color-story-kit.tsx +1 -1
- 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/token-export-guard.test.ts +104 -0
- package/src/theme/tokens/primitives.ts +63 -116
- package/src/theme/tokens/semantic.ts +130 -82
- package/src/utils/colors.ts +1 -1
- package/tailwind.config.js +2 -5
- package/src/theme/bar-paper.ts +0 -59
- package/src/theme/shadows.stories.tsx +0 -523
- package/src/theme/shadows.ts +0 -439
package/src/theme/barrel.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// top for the native consumers that deliberately import it.
|
|
9
9
|
export * from './tokens'
|
|
10
10
|
export * from './config'
|
|
11
|
-
export * from './
|
|
11
|
+
export * from './materials'
|
|
12
|
+
export * from './color-utils'
|
|
12
13
|
export * from './elevation'
|
|
13
14
|
export * from './manifest'
|
|
14
15
|
export { resolveColor } from './resolve-color'
|
|
@@ -22,24 +23,16 @@ export type { ThemeProviderProps, ThemeProviderMode } from './ThemeProvider'
|
|
|
22
23
|
export { getSemanticColors, type ThemeMode } from './tokens/semantic'
|
|
23
24
|
export { getThemeCSSVars, gluestackConfig } from './config'
|
|
24
25
|
export {
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
hexToRgb,
|
|
27
|
+
rgbToHsv,
|
|
28
|
+
hsvToRgb,
|
|
29
|
+
rgbToHex,
|
|
27
30
|
lighten,
|
|
28
31
|
darken,
|
|
29
32
|
getHoverColors,
|
|
30
33
|
isDark,
|
|
31
|
-
hexToRgb,
|
|
32
|
-
rgbToHsv,
|
|
33
|
-
// Shadow creation functions
|
|
34
|
-
createRaisedShadow,
|
|
35
|
-
createRaisedHoverShadow,
|
|
36
|
-
createPressedShadow,
|
|
37
|
-
createPressedHoverShadow,
|
|
38
|
-
createFlatShadow,
|
|
39
|
-
shadowColors,
|
|
40
34
|
type ShadowIntensity,
|
|
41
|
-
|
|
42
|
-
} from './shadows'
|
|
35
|
+
} from './color-utils'
|
|
43
36
|
export {
|
|
44
37
|
getElevationConfig,
|
|
45
38
|
getElevationSurface,
|
|
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
|
|
|
2
2
|
import { readFileSync } from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
|
-
import {
|
|
5
|
+
import { greyRamp, SURFACE_PLANE_STEPS } from './tokens/primitives'
|
|
6
6
|
import { semanticColorsDark } from './tokens/semantic'
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -11,7 +11,7 @@ import { semanticColorsDark } from './tokens/semantic'
|
|
|
11
11
|
* The color stories are the documentation of record for the token layer, and
|
|
12
12
|
* they have drifted twice: once when the categorical palette replaced the
|
|
13
13
|
* Paul-Tol scale, and again when the v0.10.0 surface redesign added four
|
|
14
|
-
* planes (`surface-
|
|
14
|
+
* planes (`surface-overlay`, `background-frame`,
|
|
15
15
|
* `background-subtle`) that no story rendered. Both times the values stayed
|
|
16
16
|
* correct while the *coverage* silently fell behind — which a screenshot test
|
|
17
17
|
* cannot catch, because a story that was never written renders nothing.
|
|
@@ -41,7 +41,9 @@ const storySources = ['ColorPrimitives.stories.tsx', 'ColorPalettes.stories.tsx'
|
|
|
41
41
|
.join('\n')
|
|
42
42
|
|
|
43
43
|
/** Every plane a surface/background token is allowed to land on. */
|
|
44
|
-
const RAMP_PLANES = new Set<string>(
|
|
44
|
+
const RAMP_PLANES = new Set<string>(
|
|
45
|
+
Object.values(SURFACE_PLANE_STEPS).map((step) => greyRamp[step])
|
|
46
|
+
)
|
|
45
47
|
|
|
46
48
|
/**
|
|
47
49
|
* Token families the color stories are NOT the documentation of record for,
|
|
@@ -69,9 +71,9 @@ describe('Foundations/Color story coverage', () => {
|
|
|
69
71
|
|
|
70
72
|
expect(
|
|
71
73
|
offRamp,
|
|
72
|
-
'surface/background tokens whose value is not
|
|
73
|
-
'
|
|
74
|
-
'
|
|
74
|
+
'surface/background tokens whose value is not one of the greyRamp steps ' +
|
|
75
|
+
'named in SURFACE_PLANE_STEPS. Either fold the value into the ramp, or add ' +
|
|
76
|
+
'the plane to SURFACE_PLANES in ColorPalettes.stories.tsx and document why ' +
|
|
75
77
|
'it sits outside the ramp.'
|
|
76
78
|
).toEqual([])
|
|
77
79
|
})
|
|
@@ -86,22 +88,15 @@ describe('Foundations/Color story coverage', () => {
|
|
|
86
88
|
})
|
|
87
89
|
|
|
88
90
|
it('the surface story renders every ramp plane', () => {
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
'surfaceRampDark.base',
|
|
95
|
-
'surfaceRampDark.elevated',
|
|
96
|
-
'surfaceRampDark.raised',
|
|
97
|
-
'surfaceRampDark.overlay',
|
|
98
|
-
'backgroundFrameDark',
|
|
99
|
-
]
|
|
100
|
-
const missing = referenced.filter((ref) => !storySources.includes(ref))
|
|
91
|
+
// Each plane is a greyRamp step now, so the ladder must reference the step
|
|
92
|
+
// rather than a named member of a separate ramp object.
|
|
93
|
+
const missing = Object.values(SURFACE_PLANE_STEPS)
|
|
94
|
+
.map((step) => `greyRamp[${step}]`)
|
|
95
|
+
.filter((ref) => !storySources.includes(ref))
|
|
101
96
|
|
|
102
97
|
expect(missing, 'ramp planes absent from the SURFACE_PLANES ladder').toEqual([])
|
|
103
|
-
// Guard the count too: a
|
|
104
|
-
expect(Object.keys(
|
|
98
|
+
// Guard the count too: a plane added to the ramp must gain a row.
|
|
99
|
+
expect(Object.keys(SURFACE_PLANE_STEPS)).toHaveLength(6)
|
|
105
100
|
})
|
|
106
101
|
|
|
107
102
|
it('every other semantic token is named in a color story', () => {
|
|
@@ -22,7 +22,7 @@ import { semanticColorsDark } from './tokens/semantic'
|
|
|
22
22
|
* reads as one. Sourced from the token layer rather than a literal — the color
|
|
23
23
|
* stories should not be where raw hexes creep back in.
|
|
24
24
|
*/
|
|
25
|
-
export const SWATCH_BORDER = semanticColorsDark['
|
|
25
|
+
export const SWATCH_BORDER = semanticColorsDark['hairline-default']
|
|
26
26
|
|
|
27
27
|
export function SectionIntro({ children }: { children: ReactNode }) {
|
|
28
28
|
return <Text className="text-text-secondary mb-6">{children}</Text>
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colour math shared by the depth system.
|
|
3
|
+
*
|
|
4
|
+
* This file used to be `shadows.ts` and shipped a neumorphic shadow builder —
|
|
5
|
+
* dual opposing shadows, dark bottom-right and light top-left. That was removed
|
|
6
|
+
* in TD-07.16. Neumorphism needs a mid-tone background (roughly 75-90%
|
|
7
|
+
* lightness) so BOTH shadows have somewhere to fall; our surfaces sit near 10%,
|
|
8
|
+
* which leaves the dark half nothing to fall onto. It collapsed to a one-sided
|
|
9
|
+
* rim light and read as noise on the wall.
|
|
10
|
+
*
|
|
11
|
+
* Inline depth is a hairline now (`hairline-*`, self-normalising alpha), texture
|
|
12
|
+
* is `materials.ts`, and drop-shadow survives only for genuinely FLOATING
|
|
13
|
+
* things — see `elevation.ts`.
|
|
14
|
+
*
|
|
15
|
+
* What is left here is the HSV/hex maths that `elevation.ts` still uses to
|
|
16
|
+
* derive rim and shadow tints from whatever surface colour it is handed.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Color Math Utilities
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse a hex color to RGB values (0-255).
|
|
25
|
+
* Supports both #RGB and #RRGGBB formats.
|
|
26
|
+
*/
|
|
27
|
+
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
|
28
|
+
const cleaned = hex.replace('#', '')
|
|
29
|
+
|
|
30
|
+
let r: number, g: number, b: number
|
|
31
|
+
|
|
32
|
+
if (cleaned.length === 3) {
|
|
33
|
+
r = parseInt(cleaned[0] + cleaned[0], 16)
|
|
34
|
+
g = parseInt(cleaned[1] + cleaned[1], 16)
|
|
35
|
+
b = parseInt(cleaned[2] + cleaned[2], 16)
|
|
36
|
+
} else if (cleaned.length === 6) {
|
|
37
|
+
r = parseInt(cleaned.slice(0, 2), 16)
|
|
38
|
+
g = parseInt(cleaned.slice(2, 4), 16)
|
|
39
|
+
b = parseInt(cleaned.slice(4, 6), 16)
|
|
40
|
+
} else {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { r, g, b }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Convert RGB (0-255) to HSV (h: 0-360, s: 0-1, v: 0-1).
|
|
49
|
+
*/
|
|
50
|
+
export function rgbToHsv(r: number, g: number, b: number): { h: number; s: number; v: number } {
|
|
51
|
+
const rNorm = r / 255
|
|
52
|
+
const gNorm = g / 255
|
|
53
|
+
const bNorm = b / 255
|
|
54
|
+
|
|
55
|
+
const max = Math.max(rNorm, gNorm, bNorm)
|
|
56
|
+
const min = Math.min(rNorm, gNorm, bNorm)
|
|
57
|
+
const delta = max - min
|
|
58
|
+
|
|
59
|
+
// Value
|
|
60
|
+
const v = max
|
|
61
|
+
|
|
62
|
+
// Saturation
|
|
63
|
+
const s = max === 0 ? 0 : delta / max
|
|
64
|
+
|
|
65
|
+
// Hue
|
|
66
|
+
let h = 0
|
|
67
|
+
if (delta !== 0) {
|
|
68
|
+
if (max === rNorm) {
|
|
69
|
+
h = 60 * (((gNorm - bNorm) / delta) % 6)
|
|
70
|
+
} else if (max === gNorm) {
|
|
71
|
+
h = 60 * ((bNorm - rNorm) / delta + 2)
|
|
72
|
+
} else {
|
|
73
|
+
h = 60 * ((rNorm - gNorm) / delta + 4)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (h < 0) h += 360
|
|
77
|
+
|
|
78
|
+
return { h, s, v }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Convert HSV (h: 0-360, s: 0-1, v: 0-1) to RGB (0-255).
|
|
83
|
+
*/
|
|
84
|
+
export function hsvToRgb(h: number, s: number, v: number): { r: number; g: number; b: number } {
|
|
85
|
+
const c = v * s
|
|
86
|
+
const x = c * (1 - Math.abs((h / 60) % 2 - 1))
|
|
87
|
+
const m = v - c
|
|
88
|
+
|
|
89
|
+
let rPrime: number, gPrime: number, bPrime: number
|
|
90
|
+
|
|
91
|
+
if (h >= 0 && h < 60) {
|
|
92
|
+
rPrime = c; gPrime = x; bPrime = 0
|
|
93
|
+
} else if (h >= 60 && h < 120) {
|
|
94
|
+
rPrime = x; gPrime = c; bPrime = 0
|
|
95
|
+
} else if (h >= 120 && h < 180) {
|
|
96
|
+
rPrime = 0; gPrime = c; bPrime = x
|
|
97
|
+
} else if (h >= 180 && h < 240) {
|
|
98
|
+
rPrime = 0; gPrime = x; bPrime = c
|
|
99
|
+
} else if (h >= 240 && h < 300) {
|
|
100
|
+
rPrime = x; gPrime = 0; bPrime = c
|
|
101
|
+
} else {
|
|
102
|
+
rPrime = c; gPrime = 0; bPrime = x
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
r: Math.round((rPrime + m) * 255),
|
|
107
|
+
g: Math.round((gPrime + m) * 255),
|
|
108
|
+
b: Math.round((bPrime + m) * 255),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Convert RGB values to hex string.
|
|
114
|
+
*/
|
|
115
|
+
export function rgbToHex(r: number, g: number, b: number): string {
|
|
116
|
+
const toHex = (n: number) => {
|
|
117
|
+
const clamped = Math.max(0, Math.min(255, Math.round(n)))
|
|
118
|
+
return clamped.toString(16).padStart(2, '0')
|
|
119
|
+
}
|
|
120
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Lighten a color by increasing its HSV Value component.
|
|
125
|
+
*
|
|
126
|
+
* @param hex - The hex color to lighten
|
|
127
|
+
* @param amount - Amount to increase V by (0-1)
|
|
128
|
+
*/
|
|
129
|
+
export function lighten(hex: string, amount: number = 0.1): string {
|
|
130
|
+
const rgb = hexToRgb(hex)
|
|
131
|
+
if (!rgb) return hex
|
|
132
|
+
|
|
133
|
+
const hsv = rgbToHsv(rgb.r, rgb.g, rgb.b)
|
|
134
|
+
const newV = Math.min(1, hsv.v + amount)
|
|
135
|
+
|
|
136
|
+
const newRgb = hsvToRgb(hsv.h, hsv.s, newV)
|
|
137
|
+
return rgbToHex(newRgb.r, newRgb.g, newRgb.b)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Darken a color by decreasing its HSV Value component.
|
|
142
|
+
*
|
|
143
|
+
* @param hex - The hex color to darken
|
|
144
|
+
* @param amount - Amount to decrease V by (0-1)
|
|
145
|
+
*/
|
|
146
|
+
export function darken(hex: string, amount: number = 0.1): string {
|
|
147
|
+
const rgb = hexToRgb(hex)
|
|
148
|
+
if (!rgb) return hex
|
|
149
|
+
|
|
150
|
+
const hsv = rgbToHsv(rgb.r, rgb.g, rgb.b)
|
|
151
|
+
const newV = Math.max(0, hsv.v - amount)
|
|
152
|
+
|
|
153
|
+
const newRgb = hsvToRgb(hsv.h, hsv.s, newV)
|
|
154
|
+
return rgbToHex(newRgb.r, newRgb.g, newRgb.b)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get hover colors for a button based on its background color.
|
|
159
|
+
* Automatically adapts intensity based on the color's luminance.
|
|
160
|
+
*
|
|
161
|
+
* @param bgColor - The button's background color (hex)
|
|
162
|
+
* @param intensity - How strong the hover effect should be ('subtle' | 'medium' | 'strong')
|
|
163
|
+
*/
|
|
164
|
+
export function getHoverColors(
|
|
165
|
+
bgColor: string,
|
|
166
|
+
intensity: 'subtle' | 'medium' | 'strong' = 'medium'
|
|
167
|
+
): { raised: string; pressed: string } {
|
|
168
|
+
const amounts = {
|
|
169
|
+
subtle: 0.01,
|
|
170
|
+
medium: 0.02,
|
|
171
|
+
strong: 0.03,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const amount = amounts[intensity]
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
raised: lighten(bgColor, amount), // Raised hover: lighten (lifting up)
|
|
178
|
+
pressed: darken(bgColor, amount), // Pressed hover: darken (pressing down)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Check if a color is considered "dark" (for choosing text color).
|
|
184
|
+
* Uses HSV Value component for simplicity.
|
|
185
|
+
*/
|
|
186
|
+
export function isDark(hex: string): boolean {
|
|
187
|
+
const rgb = hexToRgb(hex)
|
|
188
|
+
if (!rgb) return true
|
|
189
|
+
const hsv = rgbToHsv(rgb.r, rgb.g, rgb.b)
|
|
190
|
+
return hsv.v < 0.6
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Shadow strength tiers. Retained because `elevation.ts` keys its level table on
|
|
195
|
+
* them; the neumorphic shadow BUILDERS that used to live here are gone.
|
|
196
|
+
*/
|
|
197
|
+
export type ShadowIntensity = 'subtle' | 'medium' | 'strong'
|
package/src/theme/config.ts
CHANGED
|
@@ -77,16 +77,12 @@ export const lightThemeCSSVars = {
|
|
|
77
77
|
'--color-surface-base': semanticColorsLight['surface-base'],
|
|
78
78
|
'--color-surface-elevated': semanticColorsLight['surface-elevated'],
|
|
79
79
|
'--color-surface-raised': semanticColorsLight['surface-raised'],
|
|
80
|
-
'--color-surface-inset': semanticColorsLight['surface-inset'],
|
|
81
80
|
|
|
82
81
|
'--color-background-base': semanticColorsLight['background-base'],
|
|
83
82
|
'--color-background-default': semanticColorsLight['background-default'],
|
|
84
83
|
'--color-background-subtle': semanticColorsLight['background-subtle'],
|
|
85
84
|
'--color-background-frame': semanticColorsLight['background-frame'],
|
|
86
85
|
|
|
87
|
-
'--color-border-default': semanticColorsLight['border-default'],
|
|
88
|
-
'--color-border-subtle': semanticColorsLight['border-subtle'],
|
|
89
|
-
'--color-border-strong': semanticColorsLight['border-strong'],
|
|
90
86
|
'--color-border-prominent': semanticColorsLight['border-prominent'],
|
|
91
87
|
'--color-border-focus': semanticColorsLight['border-focus'],
|
|
92
88
|
|
|
@@ -204,16 +200,12 @@ export const darkThemeCSSVars = {
|
|
|
204
200
|
'--color-surface-base': semanticColorsDark['surface-base'],
|
|
205
201
|
'--color-surface-elevated': semanticColorsDark['surface-elevated'],
|
|
206
202
|
'--color-surface-raised': semanticColorsDark['surface-raised'],
|
|
207
|
-
'--color-surface-inset': semanticColorsDark['surface-inset'],
|
|
208
203
|
|
|
209
204
|
'--color-background-base': semanticColorsDark['background-base'],
|
|
210
205
|
'--color-background-default': semanticColorsDark['background-default'],
|
|
211
206
|
'--color-background-subtle': semanticColorsDark['background-subtle'],
|
|
212
207
|
'--color-background-frame': semanticColorsDark['background-frame'],
|
|
213
208
|
|
|
214
|
-
'--color-border-default': semanticColorsDark['border-default'],
|
|
215
|
-
'--color-border-subtle': semanticColorsDark['border-subtle'],
|
|
216
|
-
'--color-border-strong': semanticColorsDark['border-strong'],
|
|
217
209
|
'--color-border-prominent': semanticColorsDark['border-prominent'],
|
|
218
210
|
'--color-border-focus': semanticColorsDark['border-focus'],
|
|
219
211
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import { composeStories } from '@storybook/react'
|
|
4
|
+
import * as stories from './DepthCalibration.stories'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The calibration rig is the gate on a release tag, and its whole value rests on
|
|
8
|
+
* one property: the answer key is NOT on screen until the operator asks for it.
|
|
9
|
+
* A leaked key does not make the panels look broken — it makes them look fine,
|
|
10
|
+
* and silently converts a measurement into a confirmation.
|
|
11
|
+
*
|
|
12
|
+
* The theme stories are outside `stories-smoke.test.tsx`'s
|
|
13
|
+
* `../components/**` glob, so this file also carries the render smoke for them.
|
|
14
|
+
*/
|
|
15
|
+
const { Hairlines, ToneSteps, PaperMaterial, Banding } = composeStories(stories)
|
|
16
|
+
const ALL = { Hairlines, ToneSteps, PaperMaterial, Banding }
|
|
17
|
+
|
|
18
|
+
describe('depth calibration rig', () => {
|
|
19
|
+
for (const [name, Story] of Object.entries(ALL)) {
|
|
20
|
+
it(`${name} renders`, () => {
|
|
21
|
+
const result = render(<Story />)
|
|
22
|
+
expect(result.container.firstChild).toBeTruthy()
|
|
23
|
+
result.unmount()
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
it('hides every answer until Reveal is pressed, then shows them', () => {
|
|
28
|
+
render(<Hairlines />)
|
|
29
|
+
|
|
30
|
+
// grey-975 is the ground of row 1; naming a ground names an answer.
|
|
31
|
+
expect(screen.queryByText(/grey-975/)).toBeNull()
|
|
32
|
+
expect(screen.queryByText(/decoy/)).toBeNull()
|
|
33
|
+
|
|
34
|
+
fireEvent.click(screen.getByText('Reveal key'))
|
|
35
|
+
|
|
36
|
+
expect(screen.getAllByText(/grey-975/).length).toBeGreaterThan(0)
|
|
37
|
+
expect(screen.getAllByText(/decoy/).length).toBe(4)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('seeds exactly four blank cells across four distinct grounds', () => {
|
|
41
|
+
render(<Hairlines />)
|
|
42
|
+
fireEvent.click(screen.getByText('Reveal key'))
|
|
43
|
+
|
|
44
|
+
// One decoy per row, so no row can be answered by "the blank one is here again".
|
|
45
|
+
const decoys = screen.getAllByText(/NONE \(decoy\)/)
|
|
46
|
+
expect(decoys).toHaveLength(4)
|
|
47
|
+
|
|
48
|
+
const rows = decoys.map((n) => n.textContent?.match(/^[A-D](\d)/)?.[1])
|
|
49
|
+
expect(new Set(rows).size).toBe(4)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('seeds an identical pair among the paper sheets', () => {
|
|
53
|
+
render(<PaperMaterial />)
|
|
54
|
+
expect(screen.queryByText(/FLAT \(decoy\)/)).toBeNull()
|
|
55
|
+
|
|
56
|
+
// Two byte-identical flats, so "all three look the same" stays an available
|
|
57
|
+
// answer rather than an admission — the material has to be picked OUT.
|
|
58
|
+
fireEvent.click(screen.getByText('Reveal key'))
|
|
59
|
+
expect(screen.getAllByText(/FLAT \(decoy\)/)).toHaveLength(2)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('pairs the smooth banding control with a hand-quantised reference', () => {
|
|
63
|
+
// Run 1 was VOID because nothing could band. Run 2 then found the smooth
|
|
64
|
+
// control ALSO reading clean, which is ambiguous: a blind panel and a render
|
|
65
|
+
// path that simply does not band both look like "no steps". The hard-edged
|
|
66
|
+
// reference disambiguates them, so the panel needs BOTH gradients — and
|
|
67
|
+
// neither may be one of the shipped-material fields.
|
|
68
|
+
const { container } = render(<Banding />)
|
|
69
|
+
expect(container.querySelectorAll('[style*="linear-gradient"]')).toHaveLength(2)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('labels the control and the reference distinctly, and only on reveal', () => {
|
|
73
|
+
render(<Banding />)
|
|
74
|
+
expect(screen.queryByText(/CONTROL/)).toBeNull()
|
|
75
|
+
expect(screen.queryByText(/REFERENCE/)).toBeNull()
|
|
76
|
+
|
|
77
|
+
fireEvent.click(screen.getByText('Reveal key'))
|
|
78
|
+
expect(screen.getAllByText(/CONTROL/)).toHaveLength(1)
|
|
79
|
+
expect(screen.getAllByText(/REFERENCE/)).toHaveLength(1)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('seeds a no-floor-line decoy among the inset wells', () => {
|
|
83
|
+
render(<PaperMaterial />)
|
|
84
|
+
expect(screen.queryByText(/NONE \(decoy\)/)).toBeNull()
|
|
85
|
+
|
|
86
|
+
fireEvent.click(screen.getByText('Reveal key'))
|
|
87
|
+
expect(screen.getAllByText(/NONE \(decoy\)/)).toHaveLength(1)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('seeds an identical pair among the tone-step pairs', () => {
|
|
91
|
+
render(<ToneSteps />)
|
|
92
|
+
expect(screen.queryByText(/SAME \(decoy\)/)).toBeNull()
|
|
93
|
+
|
|
94
|
+
fireEvent.click(screen.getByText('Reveal key'))
|
|
95
|
+
expect(screen.getAllByText(/SAME \(decoy\)/)).toHaveLength(1)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -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) {
|