@titan-design/react-ui 0.5.0 → 0.6.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 +232 -185
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +232 -185
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +164 -8
- package/dist/index.d.ts +164 -8
- package/dist/index.js +835 -342
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +816 -343
- package/dist/index.mjs.map +1 -1
- package/dist/pages.js +570 -522
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +570 -522
- package/dist/pages.mjs.map +1 -1
- package/dist/theme/index.d.mts +461 -199
- package/dist/theme/index.d.ts +461 -199
- package/dist/theme/index.js +311 -97
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +303 -98
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.js +148 -139
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +148 -139
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +3 -1
- package/src/components/custom/DateTime/DateTime.stories.tsx +82 -6
- package/src/components/custom/DateTime/DateTime.test.tsx +29 -3
- package/src/components/custom/DateTime/DateTime.tsx +55 -15
- package/src/components/custom/Gauge/Gauge.test.tsx +2 -2
- package/src/components/custom/Gauge/Gauge.tsx +6 -3
- package/src/components/custom/Scatter/Scatter.tsx +3 -5
- package/src/components/custom/Treemap/Treemap.tsx +3 -5
- package/src/components/custom/Typography/Typography.stories.tsx +45 -27
- package/src/components/custom/Typography/Typography.test.tsx +13 -3
- package/src/components/custom/Typography/Typography.tsx +5 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +2 -1
- package/src/components/custom/Workout/BodyMap.tsx +6 -3
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +6 -3
- package/src/components/custom/Workout/CapacityBandChart.test.tsx +2 -2
- package/src/components/custom/Workout/CapacityBandChart.tsx +9 -6
- package/src/components/custom/Workout/DeviationBar.test.tsx +1 -1
- package/src/components/custom/Workout/DeviationBar.tsx +7 -4
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +2 -1
- package/src/components/custom/Workout/InputBar.tsx +2 -1
- package/src/components/custom/Workout/IntensityBar.test.tsx +8 -8
- package/src/components/custom/Workout/IntensityBar.tsx +10 -6
- package/src/components/custom/Workout/MesoCard.tsx +8 -3
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +1 -1
- package/src/components/custom/Workout/MesoProgressBar.tsx +3 -2
- package/src/components/custom/Workout/MesoStatusCard.tsx +20 -13
- package/src/components/custom/Workout/MuscleGroupChip.tsx +7 -4
- package/src/components/custom/Workout/PrBadge.tsx +5 -2
- package/src/components/custom/Workout/PrHistoryModal.tsx +4 -3
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +2 -1
- package/src/components/custom/Workout/ReadinessCheck.tsx +7 -4
- package/src/components/custom/Workout/RestTimer.tsx +2 -1
- package/src/components/custom/Workout/SetRow.tsx +2 -1
- package/src/components/custom/Workout/Sparkline.tsx +2 -1
- package/src/components/custom/Workout/StatusDot.test.tsx +2 -2
- package/src/components/custom/Workout/StatusDot.tsx +12 -9
- package/src/components/custom/Workout/StrengthTrendChart.tsx +9 -6
- package/src/components/custom/Workout/SupersetWrapper.tsx +2 -1
- package/src/components/custom/Workout/TempoDisplay.tsx +6 -3
- package/src/components/custom/Workout/VelocityStrip.test.tsx +3 -3
- package/src/components/custom/Workout/WeekRow.tsx +3 -2
- package/src/components/custom/Workout/WeightBadge.tsx +6 -2
- package/src/components/custom/Workout/WorkoutCard.tsx +5 -2
- package/src/components/custom/Workout/WorkoutPill.tsx +12 -8
- package/src/components/custom/Workout/icons.tsx +6 -67
- package/src/components/icons/SvgIcon.tsx +50 -0
- package/src/components/icons/icons.stories.tsx +57 -0
- package/src/components/icons/icons.test.tsx +43 -0
- package/src/components/icons/icons.tsx +45 -0
- package/src/components/icons/index.ts +4 -0
- package/src/components/index.ts +6 -0
- package/src/components/shell/BrandLockup.stories.tsx +27 -0
- package/src/components/shell/BrandLockup.test.tsx +23 -0
- package/src/components/shell/BrandLockup.tsx +47 -0
- package/src/components/shell/DeviceIndicator.stories.tsx +28 -0
- package/src/components/shell/DeviceIndicator.test.tsx +26 -0
- package/src/components/shell/DeviceIndicator.tsx +52 -0
- package/src/components/shell/DeviceMenu.stories.tsx +47 -0
- package/src/components/shell/DeviceMenu.test.tsx +40 -0
- package/src/components/shell/DeviceMenu.tsx +65 -0
- package/src/components/shell/DeviceRow.stories.tsx +38 -0
- package/src/components/shell/DeviceRow.test.tsx +37 -0
- package/src/components/shell/DeviceRow.tsx +58 -0
- package/src/components/shell/README.md +86 -0
- package/src/components/shell/SessionStatePill.stories.tsx +27 -0
- package/src/components/shell/SessionStatePill.test.tsx +25 -0
- package/src/components/shell/SessionStatePill.tsx +46 -0
- package/src/components/shell/TopBar.stories.tsx +49 -0
- package/src/components/shell/TopBar.test.tsx +36 -0
- package/src/components/shell/TopBar.tsx +89 -0
- package/src/components/shell/index.ts +8 -0
- package/src/components/ui/indicator/Indicator.stories.tsx +37 -1
- package/src/components/ui/indicator/Indicator.test.tsx +19 -1
- package/src/components/ui/indicator/Indicator.tsx +45 -6
- package/src/components/ui/indicator/index.ts +1 -1
- package/src/components/ui/spinner/Spinner.tsx +3 -2
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +2 -1
- package/src/theme/ColorSystem.stories.tsx +323 -0
- package/src/theme/Colors.stories.tsx +69 -1
- package/src/theme/barrel.ts +1 -0
- package/src/theme/config.ts +16 -3
- package/src/theme/extracted-colors-dataviz.ts +21 -0
- package/src/theme/extracted-colors-ui.ts +20 -0
- package/src/theme/global.css +64 -52
- package/src/theme/gradients.test.ts +25 -0
- package/src/theme/gradients.ts +36 -0
- package/src/theme/tokens/primitives.test.ts +232 -0
- package/src/theme/tokens/primitives.ts +247 -0
- package/src/theme/tokens/semantic.ts +93 -81
- package/src/theme/workout-tokens.ts +22 -21
- package/src/utils/avatar-color.ts +3 -3
- package/tailwind.config.js +10 -3
package/src/theme/global.css
CHANGED
|
@@ -16,36 +16,42 @@
|
|
|
16
16
|
/* Dark mode (default) */
|
|
17
17
|
:root {
|
|
18
18
|
--color-brand-primary: #FF7900;
|
|
19
|
-
--color-brand-primary-light: #
|
|
20
|
-
--color-brand-primary-dark: #
|
|
19
|
+
--color-brand-primary-light: #FFA063;
|
|
20
|
+
--color-brand-primary-dark: #DA5F00;
|
|
21
21
|
--color-brand-primary-subtle: rgba(255, 121, 0, 0.12);
|
|
22
|
-
--color-brand-secondary: #
|
|
23
|
-
--color-brand-secondary-light: #
|
|
24
|
-
--color-brand-secondary-dark: #
|
|
25
|
-
--color-brand-secondary-subtle: rgba(
|
|
26
|
-
--color-brand-primary-hover: #
|
|
27
|
-
--color-brand-primary-active: #
|
|
28
|
-
--color-brand-secondary-hover: #
|
|
29
|
-
--color-brand-secondary-active: #
|
|
22
|
+
--color-brand-secondary: #307B9B;
|
|
23
|
+
--color-brand-secondary-light: #2697B7;
|
|
24
|
+
--color-brand-secondary-dark: #2A617F;
|
|
25
|
+
--color-brand-secondary-subtle: rgba(48, 123, 155, 0.12);
|
|
26
|
+
--color-brand-primary-hover: #FFA063;
|
|
27
|
+
--color-brand-primary-active: #FFC7A2;
|
|
28
|
+
--color-brand-secondary-hover: #2697B7;
|
|
29
|
+
--color-brand-secondary-active: #01B5D1;
|
|
30
30
|
|
|
31
31
|
--color-on-brand-primary: #FFFFFF;
|
|
32
32
|
--color-on-brand-secondary: #FFFFFF;
|
|
33
33
|
|
|
34
|
-
--color-status-success: #
|
|
35
|
-
--color-status-success-light: #
|
|
36
|
-
--color-status-success-dark: #
|
|
37
|
-
--color-status-success-subtle: rgba(
|
|
34
|
+
--color-status-success: #2ED573;
|
|
35
|
+
--color-status-success-light: #58F69E;
|
|
36
|
+
--color-status-success-dark: #298732;
|
|
37
|
+
--color-status-success-subtle: rgba(46, 213, 115, 0.12);
|
|
38
38
|
--color-status-error: #D14343;
|
|
39
|
-
--color-status-error-light: #
|
|
40
|
-
--color-status-error-dark: #
|
|
39
|
+
--color-status-error-light: #E05254;
|
|
40
|
+
--color-status-error-dark: #A4221C;
|
|
41
41
|
--color-status-error-subtle: rgba(209, 67, 67, 0.12);
|
|
42
|
-
--color-status-
|
|
43
|
-
--color-status-
|
|
44
|
-
--color-status-
|
|
45
|
-
--color-status-
|
|
42
|
+
--color-status-error-vivid: #FF4757;
|
|
43
|
+
--color-status-error-vivid-light: #FF6070;
|
|
44
|
+
--color-status-error-vivid-dark: #C42539;
|
|
45
|
+
--color-status-error-vivid-subtle: rgba(255, 71, 87, 0.12);
|
|
46
|
+
--color-status-error-vivid-rgb: 255, 71, 87;
|
|
47
|
+
--color-border-prominent: #3C3C3C;
|
|
48
|
+
--color-status-warning: #F9B415;
|
|
49
|
+
--color-status-warning-light: #FFD352;
|
|
50
|
+
--color-status-warning-dark: #C27400;
|
|
51
|
+
--color-status-warning-subtle: rgba(249, 180, 21, 0.12);
|
|
46
52
|
--color-status-info: #2196F3;
|
|
47
|
-
--color-status-info-light: #
|
|
48
|
-
--color-status-info-dark: #
|
|
53
|
+
--color-status-info-light: #78C2FF;
|
|
54
|
+
--color-status-info-dark: #1072CB;
|
|
49
55
|
--color-status-info-subtle: rgba(33, 150, 243, 0.12);
|
|
50
56
|
|
|
51
57
|
--color-on-status-success: #FFFFFF;
|
|
@@ -119,10 +125,10 @@
|
|
|
119
125
|
|
|
120
126
|
/* RGB decomposed values for glow shadows */
|
|
121
127
|
--color-brand-primary-rgb: 255, 121, 0;
|
|
122
|
-
--color-brand-secondary-rgb:
|
|
123
|
-
--color-status-success-rgb:
|
|
128
|
+
--color-brand-secondary-rgb: 48, 123, 155;
|
|
129
|
+
--color-status-success-rgb: 46, 213, 115;
|
|
124
130
|
--color-status-error-rgb: 209, 67, 67;
|
|
125
|
-
--color-status-warning-rgb:
|
|
131
|
+
--color-status-warning-rgb: 249, 180, 21;
|
|
126
132
|
--color-status-info-rgb: 33, 150, 243;
|
|
127
133
|
--color-background-base-rgb: 16, 16, 16;
|
|
128
134
|
|
|
@@ -145,37 +151,43 @@
|
|
|
145
151
|
.light,
|
|
146
152
|
:root.light {
|
|
147
153
|
--color-brand-primary: #FF7900;
|
|
148
|
-
--color-brand-primary-light: #
|
|
149
|
-
--color-brand-primary-dark: #
|
|
154
|
+
--color-brand-primary-light: #FFA063;
|
|
155
|
+
--color-brand-primary-dark: #DA5F00;
|
|
150
156
|
--color-brand-primary-subtle: rgba(255, 121, 0, 0.08);
|
|
151
|
-
--color-brand-secondary: #
|
|
152
|
-
--color-brand-secondary-light: #
|
|
153
|
-
--color-brand-secondary-dark: #
|
|
154
|
-
--color-brand-secondary-subtle: rgba(
|
|
155
|
-
--color-brand-primary-hover: #
|
|
156
|
-
--color-brand-primary-active: #
|
|
157
|
-
--color-brand-secondary-hover: #
|
|
158
|
-
--color-brand-secondary-active: #
|
|
157
|
+
--color-brand-secondary: #307B9B;
|
|
158
|
+
--color-brand-secondary-light: #2697B7;
|
|
159
|
+
--color-brand-secondary-dark: #2A617F;
|
|
160
|
+
--color-brand-secondary-subtle: rgba(48, 123, 155, 0.08);
|
|
161
|
+
--color-brand-primary-hover: #DA5F00;
|
|
162
|
+
--color-brand-primary-active: #B94A00;
|
|
163
|
+
--color-brand-secondary-hover: #2A617F;
|
|
164
|
+
--color-brand-secondary-active: #22465F;
|
|
159
165
|
|
|
160
166
|
--color-on-brand-primary: #FFFFFF;
|
|
161
167
|
--color-on-brand-secondary: #FFFFFF;
|
|
162
168
|
|
|
163
|
-
--color-status-success: #
|
|
164
|
-
--color-status-success-light: #
|
|
165
|
-
--color-status-success-dark: #
|
|
166
|
-
--color-status-success-subtle: #
|
|
169
|
+
--color-status-success: #2ED573;
|
|
170
|
+
--color-status-success-light: #58F69E;
|
|
171
|
+
--color-status-success-dark: #298732;
|
|
172
|
+
--color-status-success-subtle: #E3FFEE;
|
|
167
173
|
--color-status-error: #D14343;
|
|
168
|
-
--color-status-error-light: #
|
|
169
|
-
--color-status-error-dark: #
|
|
170
|
-
--color-status-error-subtle: #
|
|
171
|
-
--color-status-
|
|
172
|
-
--color-status-
|
|
173
|
-
--color-status-
|
|
174
|
-
--color-status-
|
|
174
|
+
--color-status-error-light: #E05254;
|
|
175
|
+
--color-status-error-dark: #A4221C;
|
|
176
|
+
--color-status-error-subtle: #FFF4F4;
|
|
177
|
+
--color-status-error-vivid: #FF4757;
|
|
178
|
+
--color-status-error-vivid-light: #FF6070;
|
|
179
|
+
--color-status-error-vivid-dark: #C42539;
|
|
180
|
+
--color-status-error-vivid-subtle: rgba(255, 71, 87, 0.12);
|
|
181
|
+
--color-status-error-vivid-rgb: 255, 71, 87;
|
|
182
|
+
--color-border-prominent: #9CA3AF;
|
|
183
|
+
--color-status-warning: #F9B415;
|
|
184
|
+
--color-status-warning-light: #FFD352;
|
|
185
|
+
--color-status-warning-dark: #C27400;
|
|
186
|
+
--color-status-warning-subtle: #FFF7DD;
|
|
175
187
|
--color-status-info: #2196F3;
|
|
176
|
-
--color-status-info-light: #
|
|
177
|
-
--color-status-info-dark: #
|
|
178
|
-
--color-status-info-subtle: #
|
|
188
|
+
--color-status-info-light: #78C2FF;
|
|
189
|
+
--color-status-info-dark: #1072CB;
|
|
190
|
+
--color-status-info-subtle: #EFF8FF;
|
|
179
191
|
|
|
180
192
|
--color-on-status-success: #FFFFFF;
|
|
181
193
|
--color-on-status-error: #FFFFFF;
|
|
@@ -248,10 +260,10 @@
|
|
|
248
260
|
|
|
249
261
|
/* RGB decomposed values for glow shadows */
|
|
250
262
|
--color-brand-primary-rgb: 255, 121, 0;
|
|
251
|
-
--color-brand-secondary-rgb:
|
|
252
|
-
--color-status-success-rgb:
|
|
263
|
+
--color-brand-secondary-rgb: 48, 123, 155;
|
|
264
|
+
--color-status-success-rgb: 46, 213, 115;
|
|
253
265
|
--color-status-error-rgb: 209, 67, 67;
|
|
254
|
-
--color-status-warning-rgb:
|
|
266
|
+
--color-status-warning-rgb: 249, 180, 21;
|
|
255
267
|
--color-status-info-rgb: 33, 150, 243;
|
|
256
268
|
--color-background-base-rgb: 16, 16, 16;
|
|
257
269
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { linearGradient, surfaceGradient } from './gradients'
|
|
3
|
+
|
|
4
|
+
describe('gradients', () => {
|
|
5
|
+
it('builds a linear-gradient backgroundImage from two tokens (web CSS vars)', () => {
|
|
6
|
+
const style = linearGradient('surface-elevated', 'background-base', 180)
|
|
7
|
+
expect(style.backgroundImage).toBe(
|
|
8
|
+
'linear-gradient(180deg, var(--color-surface-elevated), var(--color-background-base))'
|
|
9
|
+
)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('respects a custom angle', () => {
|
|
13
|
+
expect(linearGradient('surface-base', 'background-base', 90).backgroundImage).toContain('90deg')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('defaults to a 180deg angle', () => {
|
|
17
|
+
expect(linearGradient('surface-base', 'background-base').backgroundImage).toContain('180deg')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('surfaceGradient.chrome is the elevated → base chrome wash', () => {
|
|
21
|
+
expect(surfaceGradient.chrome().backgroundImage).toBe(
|
|
22
|
+
'linear-gradient(180deg, var(--color-surface-elevated), var(--color-background-base))'
|
|
23
|
+
)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { resolveColor } from './resolve-color'
|
|
2
|
+
import { getSemanticColors, type ThemeMode } from './tokens/semantic'
|
|
3
|
+
|
|
4
|
+
type ColorToken = keyof ReturnType<typeof getSemanticColors>
|
|
5
|
+
|
|
6
|
+
/** A `backgroundImage` style — web-only (RN ignores it); type it loosely for RN style arrays. */
|
|
7
|
+
export type GradientStyle = { backgroundImage: string }
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A linear-gradient `backgroundImage` built from two semantic color tokens.
|
|
11
|
+
*
|
|
12
|
+
* Themeable + native-safe via {@link resolveColor}: on web the stops are CSS
|
|
13
|
+
* variables (light/dark switching keeps working); on native it resolves to hex.
|
|
14
|
+
* `backgroundImage` is a **web-only** paint, so pair this with a solid
|
|
15
|
+
* `bg-*` className fallback for native (see the shell TopBar).
|
|
16
|
+
*/
|
|
17
|
+
export function linearGradient(
|
|
18
|
+
from: ColorToken,
|
|
19
|
+
to: ColorToken,
|
|
20
|
+
angle = 180,
|
|
21
|
+
mode: ThemeMode = 'dark'
|
|
22
|
+
): GradientStyle {
|
|
23
|
+
return {
|
|
24
|
+
backgroundImage: `linear-gradient(${angle}deg, ${resolveColor(from, mode)}, ${resolveColor(to, mode)})`,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Named surface gradients — the shared substrate for gradient fills. Replaces
|
|
30
|
+
* per-component inline `linear-gradient` strings.
|
|
31
|
+
*/
|
|
32
|
+
export const surfaceGradient = {
|
|
33
|
+
/** Chrome bands (top bar, headers): elevated → base, a subtle dark wash. */
|
|
34
|
+
chrome: (mode: ThemeMode = 'dark'): GradientStyle =>
|
|
35
|
+
linearGradient('surface-elevated', 'background-base', 180, mode),
|
|
36
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
primitiveRamps,
|
|
4
|
+
categoricalPalette,
|
|
5
|
+
getCategoricalColor,
|
|
6
|
+
CATEGORICAL_CVD_SAFE_MAX,
|
|
7
|
+
divergingScale,
|
|
8
|
+
sequentialEffort,
|
|
9
|
+
bestTextColor,
|
|
10
|
+
} from './primitives'
|
|
11
|
+
|
|
12
|
+
// --- Minimal OKLab + Machado-2009 dichromacy math (ported from the color-system
|
|
13
|
+
// derivation tools) so the categorical CVD floor is a real, checked property. ---
|
|
14
|
+
const hexToRgb = (hex: string): [number, number, number] => {
|
|
15
|
+
const h = hex.replace('#', '')
|
|
16
|
+
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255) as [number, number, number]
|
|
17
|
+
}
|
|
18
|
+
const lin = (c: number) => (c >= 0.04045 ? ((c + 0.055) / 1.055) ** 2.4 : c / 12.92)
|
|
19
|
+
const toOklab = ([r, g, b]: number[]): [number, number, number] => {
|
|
20
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
|
|
21
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
|
|
22
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
|
|
23
|
+
return [
|
|
24
|
+
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
|
25
|
+
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
|
26
|
+
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
const mul = (M: number[], v: number[]) => [
|
|
30
|
+
M[0] * v[0] + M[1] * v[1] + M[2] * v[2],
|
|
31
|
+
M[3] * v[0] + M[4] * v[1] + M[5] * v[2],
|
|
32
|
+
M[6] * v[0] + M[7] * v[1] + M[8] * v[2],
|
|
33
|
+
]
|
|
34
|
+
// Machado-2009 dichromacy simulation matrices, severity 1.0. The palette solver
|
|
35
|
+
// optimizes worst-case deuteranopia/protanopia (red-green is the binding case) —
|
|
36
|
+
// match that 2-way metric here, not a stricter 3-way including tritan.
|
|
37
|
+
const DEUT = [0.367322, 0.860646, -0.227968, 0.280085, 0.672501, 0.047413, -0.01182, 0.04294, 0.968881]
|
|
38
|
+
const PROT = [0.152286, 1.052583, -0.204868, 0.114503, 0.786281, 0.099216, -0.003882, -0.048116, 1.051998]
|
|
39
|
+
const dist = (a: number[], b: number[]) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]) * 100
|
|
40
|
+
// worst-case perceived ΔE across deuteranopia and protanopia
|
|
41
|
+
const cvdDelta = (x: string, y: string) => {
|
|
42
|
+
const sim = (M: number[], hex: string) => toOklab(mul(M, hexToRgb(hex).map(lin)))
|
|
43
|
+
return Math.min(
|
|
44
|
+
...[DEUT, PROT].map((M) => dist(sim(M, x), sim(M, y))),
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const HEX6 = /^#[0-9A-F]{6}$/
|
|
49
|
+
const STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const
|
|
50
|
+
const HUES = ['red', 'orange', 'amber', 'green', 'cyan', 'blue', 'magenta'] as const
|
|
51
|
+
|
|
52
|
+
describe('primitiveRamps', () => {
|
|
53
|
+
it('has all seven consolidated hues', () => {
|
|
54
|
+
expect(Object.keys(primitiveRamps).sort()).toEqual([...HUES].sort())
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('gives every hue all eleven perceptual steps as uppercase 6-digit hex', () => {
|
|
58
|
+
for (const hue of HUES) {
|
|
59
|
+
const ramp = primitiveRamps[hue] as Record<number, string>
|
|
60
|
+
for (const step of STEPS) {
|
|
61
|
+
expect(ramp[step], `${hue}-${step}`).toMatch(HEX6)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('flows through each pinned anchor color at its step', () => {
|
|
67
|
+
// The step each ramp is pinned to its brand/semantic anchor hex.
|
|
68
|
+
const anchors: Record<string, string> = {
|
|
69
|
+
'red-600': '#D14343', // status-error
|
|
70
|
+
'orange-400': '#FF7900', // brand-primary
|
|
71
|
+
'amber-300': '#F9B415', // status-warning
|
|
72
|
+
'green-300': '#2ED573', // status-success
|
|
73
|
+
'cyan-300': '#22D3EE', // vivid cyan identity
|
|
74
|
+
'blue-500': '#2196F3', // status-info
|
|
75
|
+
'magenta-700': '#9C0D7A',
|
|
76
|
+
}
|
|
77
|
+
for (const [key, hex] of Object.entries(anchors)) {
|
|
78
|
+
const [hue, step] = key.split('-')
|
|
79
|
+
expect((primitiveRamps as never)[hue][Number(step)]).toBe(hex)
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('increases in darkness monotonically (OKLab L descends 50 -> 950)', () => {
|
|
84
|
+
for (const hue of HUES) {
|
|
85
|
+
const ramp = primitiveRamps[hue] as Record<number, string>
|
|
86
|
+
const lightness = STEPS.map((s) => toOklab(hexToRgb(ramp[s]).map(lin))[0])
|
|
87
|
+
for (let i = 1; i < lightness.length; i++) {
|
|
88
|
+
expect(lightness[i], `${hue} ${STEPS[i - 1]}->${STEPS[i]}`).toBeLessThan(lightness[i - 1])
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('categoricalPalette', () => {
|
|
95
|
+
it('has two ordered variants of seven colors', () => {
|
|
96
|
+
expect(categoricalPalette.default).toHaveLength(7)
|
|
97
|
+
expect(categoricalPalette.dark).toHaveLength(7)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('is all uppercase 6-digit hex', () => {
|
|
101
|
+
for (const variant of ['default', 'dark'] as const) {
|
|
102
|
+
for (const hex of categoricalPalette[variant]) {
|
|
103
|
+
expect(hex).toMatch(HEX6)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('holds the deut/protanopia dichromacy floor (deltaE >= 8) through the CVD-safe max', () => {
|
|
109
|
+
// The first CATEGORICAL_CVD_SAFE_MAX colors are legend-free CVD-safe; the 7th is extended.
|
|
110
|
+
for (const variant of ['default', 'dark'] as const) {
|
|
111
|
+
const colors = categoricalPalette[variant].slice(0, CATEGORICAL_CVD_SAFE_MAX)
|
|
112
|
+
let worst = Infinity
|
|
113
|
+
for (let i = 0; i < colors.length; i++) {
|
|
114
|
+
for (let j = i + 1; j < colors.length; j++) {
|
|
115
|
+
worst = Math.min(worst, cvdDelta(colors[i], colors[j]))
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
expect(worst, `${variant} min CVD deltaE (first ${CATEGORICAL_CVD_SAFE_MAX})`).toBeGreaterThanOrEqual(8)
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('is nested-stable: no color repeats within a variant', () => {
|
|
123
|
+
for (const variant of ['default', 'dark'] as const) {
|
|
124
|
+
expect(new Set(categoricalPalette[variant]).size).toBe(categoricalPalette[variant].length)
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('carries readable in-place text: default on black (>=4.5), dark on white (>=3.5)', () => {
|
|
129
|
+
const relLum = (hex: string) => {
|
|
130
|
+
const [r, g, b] = hexToRgb(hex).map(lin)
|
|
131
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
132
|
+
}
|
|
133
|
+
const onBlack = (hex: string) => (relLum(hex) + 0.05) / 0.05
|
|
134
|
+
const onWhite = (hex: string) => 1.05 / (relLum(hex) + 0.05)
|
|
135
|
+
// The in-place text guarantee covers the CVD-safe range; the 7th extended
|
|
136
|
+
// color trades contrast for separation and is meant to be legend-paired.
|
|
137
|
+
for (const hex of categoricalPalette.default.slice(0, CATEGORICAL_CVD_SAFE_MAX)) {
|
|
138
|
+
expect(onBlack(hex), `default ${hex} black-text contrast`).toBeGreaterThanOrEqual(4.5)
|
|
139
|
+
}
|
|
140
|
+
for (const hex of categoricalPalette.dark.slice(0, CATEGORICAL_CVD_SAFE_MAX)) {
|
|
141
|
+
expect(onWhite(hex), `dark ${hex} white-text contrast`).toBeGreaterThanOrEqual(3.5)
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('getCategoricalColor', () => {
|
|
147
|
+
it('returns default-variant colors by index', () => {
|
|
148
|
+
expect(getCategoricalColor(0)).toBe(categoricalPalette.default[0])
|
|
149
|
+
expect(getCategoricalColor(4)).toBe(categoricalPalette.default[4])
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('selects the requested variant', () => {
|
|
153
|
+
expect(getCategoricalColor(2, 'dark')).toBe(categoricalPalette.dark[2])
|
|
154
|
+
expect(getCategoricalColor(1, 'dark')).toBe(categoricalPalette.dark[1])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('wraps past the end of the palette', () => {
|
|
158
|
+
expect(getCategoricalColor(7)).toBe(categoricalPalette.default[0])
|
|
159
|
+
expect(getCategoricalColor(9)).toBe(categoricalPalette.default[2])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('clamps negative and fractional indices', () => {
|
|
163
|
+
expect(getCategoricalColor(-3)).toBe(categoricalPalette.default[0])
|
|
164
|
+
expect(getCategoricalColor(2.9)).toBe(categoricalPalette.default[2])
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const relLum = (hex: string) => {
|
|
169
|
+
const [r, g, b] = hexToRgb(hex).map(lin)
|
|
170
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
171
|
+
}
|
|
172
|
+
const bestContrast = (hex: string) => {
|
|
173
|
+
const l = relLum(hex)
|
|
174
|
+
return Math.max((l + 0.05) / 0.05, 1.05 / (l + 0.05))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
describe('divergingScale', () => {
|
|
178
|
+
it('is a five-stop diverging scale of uppercase hex', () => {
|
|
179
|
+
expect(divergingScale).toHaveLength(5)
|
|
180
|
+
for (const hex of divergingScale) expect(hex).toMatch(HEX6)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('has a light center and dark extremes (diverging shape)', () => {
|
|
184
|
+
const L = divergingScale.map((h) => toOklab(hexToRgb(h).map(lin))[0])
|
|
185
|
+
expect(L[2], 'optimal is the lightest').toBeGreaterThan(Math.max(L[0], L[4]))
|
|
186
|
+
expect(L[0], 'under is dark').toBeLessThan(L[1])
|
|
187
|
+
expect(L[4], 'over is dark').toBeLessThan(L[3])
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('holds a strong CVD floor (deltaE >= 10)', () => {
|
|
191
|
+
let worst = Infinity
|
|
192
|
+
for (let i = 0; i < divergingScale.length; i++) {
|
|
193
|
+
for (let j = i + 1; j < divergingScale.length; j++) {
|
|
194
|
+
worst = Math.min(worst, cvdDelta(divergingScale[i], divergingScale[j]))
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
expect(worst).toBeGreaterThanOrEqual(10)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('every cell carries a legible label via best-contrast text (>=4.5)', () => {
|
|
201
|
+
for (const hex of divergingScale) {
|
|
202
|
+
expect(bestContrast(hex), `${hex}`).toBeGreaterThanOrEqual(4.5)
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('sequentialEffort', () => {
|
|
208
|
+
it('is a six-stop ordinal scale of uppercase hex', () => {
|
|
209
|
+
expect(sequentialEffort).toHaveLength(6)
|
|
210
|
+
for (const hex of sequentialEffort) expect(hex).toMatch(HEX6)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('keeps adjacent stops distinguishable', () => {
|
|
214
|
+
for (let i = 0; i < sequentialEffort.length - 1; i++) {
|
|
215
|
+
expect(cvdDelta(sequentialEffort[i], sequentialEffort[i + 1]), `stop ${i}->${i + 1}`).toBeGreaterThanOrEqual(4.5)
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('every stop carries a legible label via best-contrast text (>=4.5)', () => {
|
|
220
|
+
for (const hex of sequentialEffort) {
|
|
221
|
+
expect(bestContrast(hex), `${hex}`).toBeGreaterThanOrEqual(4.5)
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
describe('bestTextColor', () => {
|
|
227
|
+
it('picks black on light fills and white on dark fills', () => {
|
|
228
|
+
expect(bestTextColor(primitiveRamps.green[300])).toBe('#000000')
|
|
229
|
+
expect(bestTextColor(primitiveRamps.red[700])).toBe('#FFFFFF')
|
|
230
|
+
expect(bestTextColor(primitiveRamps.blue[700])).toBe('#FFFFFF')
|
|
231
|
+
})
|
|
232
|
+
})
|