@baukit/ui-tokens 0.7.2 → 0.7.3

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.
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ getLayoutMode,
5
+ getScreenMaxWidth,
6
+ getTabContentInset,
7
+ getUsableContentHeight,
8
+ isShortViewport,
9
+ type LayoutBreakpoints,
10
+ } from './layout.js';
11
+
12
+ const BREAKPOINTS: LayoutBreakpoints = { medium: 768, expanded: 1024 };
13
+
14
+ const MAX_WIDTHS = { form: 720, reading: 720, dashboard: 1200 } as const;
15
+
16
+ const WIDTH_OPTIONS = {
17
+ maxWidths: MAX_WIDTHS,
18
+ narrowFallback: 'reading',
19
+ expandedOnly: ['dashboard'],
20
+ } as const;
21
+
22
+ describe('getLayoutMode', () => {
23
+ it.each([
24
+ [0, 'compact'],
25
+ [767, 'compact'],
26
+ [768, 'medium'],
27
+ [1023, 'medium'],
28
+ [1024, 'expanded'],
29
+ [1920, 'expanded'],
30
+ ])('classifies width %i as %s', (width, expected) => {
31
+ expect(getLayoutMode(width, BREAKPOINTS)).toBe(expected);
32
+ });
33
+
34
+ it('follows the breakpoints it is given, not a built-in set', () => {
35
+ const wide: LayoutBreakpoints = { medium: 1000, expanded: 1600 };
36
+
37
+ expect(getLayoutMode(900, wide)).toBe('compact');
38
+ expect(getLayoutMode(900, BREAKPOINTS)).toBe('medium');
39
+ });
40
+ });
41
+
42
+ describe('getScreenMaxWidth', () => {
43
+ it('returns the named width when the mode allows it', () => {
44
+ expect(getScreenMaxWidth('dashboard', 'expanded', WIDTH_OPTIONS)).toBe(1200);
45
+ expect(getScreenMaxWidth('form', 'compact', WIDTH_OPTIONS)).toBe(720);
46
+ });
47
+
48
+ it.each(['compact', 'medium'] as const)('narrows an expanded-only screen in %s', (mode) => {
49
+ expect(getScreenMaxWidth('dashboard', mode, WIDTH_OPTIONS)).toBe(MAX_WIDTHS.reading);
50
+ });
51
+
52
+ it('leaves screens outside the expanded-only list alone', () => {
53
+ expect(getScreenMaxWidth('form', 'medium', WIDTH_OPTIONS)).toBe(720);
54
+ });
55
+
56
+ it('rejects a screen name the product did not define', () => {
57
+ // Products that build the option set at runtime lose the compile-time check.
58
+ const unknown = 'missing' as keyof typeof MAX_WIDTHS;
59
+
60
+ expect(() => getScreenMaxWidth(unknown, 'expanded', WIDTH_OPTIONS)).toThrow(
61
+ 'unknown screen max width: missing',
62
+ );
63
+ });
64
+ });
65
+
66
+ describe('getTabContentInset', () => {
67
+ it('adds the tab bar and safe area below the expanded mode', () => {
68
+ expect(getTabContentInset('compact', 34, 56)).toBe(90);
69
+ expect(getTabContentInset('medium', 0, 56)).toBe(56);
70
+ });
71
+
72
+ it('needs no inset once the tab bar becomes a rail', () => {
73
+ expect(getTabContentInset('expanded', 34, 56)).toBe(0);
74
+ });
75
+
76
+ it('ignores a negative safe area', () => {
77
+ expect(getTabContentInset('compact', -10, 56)).toBe(56);
78
+ });
79
+ });
80
+
81
+ describe('getUsableContentHeight', () => {
82
+ it('subtracts footer and inset and clamps the result at zero', () => {
83
+ expect(getUsableContentHeight(720, 112, 128)).toBe(480);
84
+ expect(getUsableContentHeight(100, 112, 56)).toBe(0);
85
+ expect(getUsableContentHeight(0, 0, 0)).toBe(0);
86
+ });
87
+
88
+ it.each([
89
+ ['viewportHeight', Number.NaN, 0, 0],
90
+ ['viewportHeight', Number.POSITIVE_INFINITY, 0, 0],
91
+ ['viewportHeight', -1, 0, 0],
92
+ ['fixedFooterHeight', 100, Number.NaN, 0],
93
+ ['fixedFooterHeight', 100, Number.POSITIVE_INFINITY, 0],
94
+ ['fixedFooterHeight', 100, -1, 0],
95
+ ['bottomInset', 100, 0, Number.NaN],
96
+ ['bottomInset', 100, 0, Number.POSITIVE_INFINITY],
97
+ ['bottomInset', 100, 0, -1],
98
+ ] as const)('rejects an invalid %s', (name, viewportHeight, footerHeight, bottomInset) => {
99
+ expect(() => getUsableContentHeight(viewportHeight, footerHeight, bottomInset)).toThrow(
100
+ `${name} must be a finite non-negative number`,
101
+ );
102
+ });
103
+ });
104
+
105
+ describe('isShortViewport', () => {
106
+ it('includes the caller-supplied threshold boundary', () => {
107
+ expect(isShortViewport(599, 600)).toBe(true);
108
+ expect(isShortViewport(600, 600)).toBe(true);
109
+ expect(isShortViewport(601, 600)).toBe(false);
110
+ });
111
+
112
+ it.each([
113
+ [Number.NaN, 600, 'viewportHeight'],
114
+ [Number.POSITIVE_INFINITY, 600, 'viewportHeight'],
115
+ [-1, 600, 'viewportHeight'],
116
+ [600, Number.NaN, 'threshold'],
117
+ [600, Number.POSITIVE_INFINITY, 'threshold'],
118
+ [600, -1, 'threshold'],
119
+ ] as const)('rejects invalid dimensions', (viewportHeight, threshold, name) => {
120
+ expect(() => isShortViewport(viewportHeight, threshold)).toThrow(
121
+ `${name} must be a finite non-negative number`,
122
+ );
123
+ });
124
+ });
package/src/layout.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Layout mode arithmetic over a product's own breakpoint numbers. The rules are
3
+ * shared; the numbers are not, so every function takes them as an argument.
4
+ */
5
+
6
+ export type LayoutMode = 'compact' | 'medium' | 'expanded';
7
+
8
+ export interface LayoutBreakpoints {
9
+ /** Width at or above which the layout is `medium`. */
10
+ readonly medium: number;
11
+ /** Width at or above which the layout is `expanded`. */
12
+ readonly expanded: number;
13
+ }
14
+
15
+ export type ScreenMaxWidths = Readonly<Record<string, number>>;
16
+
17
+ export interface ScreenMaxWidthOptions<Widths extends ScreenMaxWidths> {
18
+ readonly maxWidths: Widths;
19
+ /** Key used when a wide screen is requested below the `expanded` mode. */
20
+ readonly narrowFallback: keyof Widths;
21
+ /** Keys that only reach their full width in the `expanded` mode. */
22
+ readonly expandedOnly: readonly (keyof Widths)[];
23
+ }
24
+
25
+ /** Classifies a viewport width. Ties go to the wider mode. */
26
+ export function getLayoutMode(width: number, breakpoints: LayoutBreakpoints): LayoutMode {
27
+ if (width >= breakpoints.expanded) return 'expanded';
28
+ if (width >= breakpoints.medium) return 'medium';
29
+ return 'compact';
30
+ }
31
+
32
+ /** Resolves a named content width, narrowing expanded-only keys below that mode. */
33
+ export function getScreenMaxWidth<Widths extends ScreenMaxWidths>(
34
+ screen: keyof Widths,
35
+ layoutMode: LayoutMode,
36
+ { maxWidths, narrowFallback, expandedOnly }: ScreenMaxWidthOptions<Widths>,
37
+ ): number {
38
+ const key = layoutMode !== 'expanded' && expandedOnly.includes(screen) ? narrowFallback : screen;
39
+ const width = maxWidths[key];
40
+ if (width === undefined) {
41
+ throw new Error(`unknown screen max width: ${String(key)}`);
42
+ }
43
+ return width;
44
+ }
45
+
46
+ /** Bottom padding that keeps content clear of a mobile tab bar and the safe area. */
47
+ export function getTabContentInset(
48
+ layoutMode: LayoutMode,
49
+ safeAreaBottom: number,
50
+ tabBarHeight: number,
51
+ ): number {
52
+ if (layoutMode === 'expanded') return 0;
53
+ return tabBarHeight + Math.max(0, safeAreaBottom);
54
+ }
55
+
56
+ function validateNonNegativeDimension(name: string, value: number): void {
57
+ if (!Number.isFinite(value) || value < 0) {
58
+ throw new RangeError(
59
+ `${name} must be a finite non-negative number; received ${String(value)}.`,
60
+ );
61
+ }
62
+ }
63
+
64
+ /** Returns the viewport height left after fixed footer and bottom inset. */
65
+ export function getUsableContentHeight(
66
+ viewportHeight: number,
67
+ fixedFooterHeight: number,
68
+ bottomInset: number,
69
+ ): number {
70
+ validateNonNegativeDimension('viewportHeight', viewportHeight);
71
+ validateNonNegativeDimension('fixedFooterHeight', fixedFooterHeight);
72
+ validateNonNegativeDimension('bottomInset', bottomInset);
73
+ return Math.max(0, viewportHeight - fixedFooterHeight - bottomInset);
74
+ }
75
+
76
+ /** True when the viewport height is at or below the caller's threshold. */
77
+ export function isShortViewport(viewportHeight: number, threshold: number): boolean {
78
+ validateNonNegativeDimension('viewportHeight', viewportHeight);
79
+ validateNonNegativeDimension('threshold', threshold);
80
+ return viewportHeight <= threshold;
81
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,46 @@
1
+ export interface ThemeColor {
2
+ /** A three- or six-digit hexadecimal sRGB color. */
3
+ readonly light: string;
4
+ /** A three- or six-digit hexadecimal sRGB color. */
5
+ readonly dark: string;
6
+ }
7
+
8
+ export interface ColorTokenGroup {
9
+ readonly [name: string]: ColorTokenGroup | ThemeColor;
10
+ }
11
+
12
+ export type TokenScale<T> = Readonly<Record<string, T>>;
13
+
14
+ export type DimensionValue = number | string;
15
+
16
+ export interface TypographyTokens {
17
+ readonly family: TokenScale<string>;
18
+ readonly size: TokenScale<DimensionValue>;
19
+ readonly weight: TokenScale<number>;
20
+ readonly lineHeight: TokenScale<DimensionValue>;
21
+ }
22
+
23
+ export interface MotionTokens {
24
+ readonly duration: TokenScale<DimensionValue>;
25
+ readonly easing: TokenScale<string>;
26
+ }
27
+
28
+ export interface ContrastPair {
29
+ /** A complete semantic path such as `color.text.primary`. */
30
+ readonly foreground: string;
31
+ /** A complete semantic path such as `color.background.primary`. */
32
+ readonly background: string;
33
+ /** Large text uses WCAG's 3:1 threshold instead of 4.5:1. */
34
+ readonly largeText?: boolean;
35
+ }
36
+
37
+ /** Cross-platform token source. It intentionally contains no component definitions. */
38
+ export interface DesignTokens {
39
+ readonly color: ColorTokenGroup;
40
+ readonly typography: TypographyTokens;
41
+ readonly space: TokenScale<DimensionValue>;
42
+ readonly radius: TokenScale<DimensionValue>;
43
+ readonly motion: MotionTokens;
44
+ readonly elevation: TokenScale<DimensionValue>;
45
+ readonly contrastPairs: readonly ContrastPair[];
46
+ }
@@ -0,0 +1,237 @@
1
+ import type { DesignTokens, ThemeColor } from './schema.js';
2
+
3
+ export interface ValidationIssue {
4
+ readonly path: string;
5
+ readonly message: string;
6
+ }
7
+
8
+ const TOKEN_NAME = /^[a-z][A-Za-z0-9]*$/u;
9
+ const HEX_COLOR = /^#[\dA-Fa-f]{3}(?:[\dA-Fa-f]{3})?$/u;
10
+
11
+ function isRecord(value: unknown): value is Record<string, unknown> {
12
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
13
+ }
14
+
15
+ function issue(issues: ValidationIssue[], path: string, message: string): void {
16
+ issues.push({ path, message });
17
+ }
18
+
19
+ function validateKeys(
20
+ value: Record<string, unknown>,
21
+ allowed: readonly string[],
22
+ path: string,
23
+ issues: ValidationIssue[],
24
+ ): void {
25
+ for (const key of Object.keys(value)) {
26
+ if (!allowed.includes(key)) {
27
+ issue(issues, `${path}.${key}`, 'is not a recognized field');
28
+ }
29
+ }
30
+ for (const key of allowed) {
31
+ if (!(key in value)) {
32
+ issue(issues, `${path}.${key}`, 'is required');
33
+ }
34
+ }
35
+ }
36
+
37
+ function validateTokenName(name: string, path: string, issues: ValidationIssue[]): void {
38
+ if (!TOKEN_NAME.test(name)) {
39
+ issue(issues, path, 'must start with a lowercase letter and contain only letters or digits');
40
+ }
41
+ }
42
+
43
+ function validateColorGroup(
44
+ value: unknown,
45
+ path: string,
46
+ depth: number,
47
+ issues: ValidationIssue[],
48
+ ): void {
49
+ if (!isRecord(value)) {
50
+ issue(issues, path, 'must be a color token group');
51
+ return;
52
+ }
53
+
54
+ const isLeaf = 'light' in value || 'dark' in value;
55
+ if (isLeaf) {
56
+ validateKeys(value, ['light', 'dark'], path, issues);
57
+ if (depth < 2) {
58
+ issue(
59
+ issues,
60
+ path,
61
+ 'must use a semantic group and token name (for example background.primary)',
62
+ );
63
+ }
64
+ for (const theme of ['light', 'dark'] as const) {
65
+ const color = value[theme];
66
+ if (typeof color !== 'string' || !HEX_COLOR.test(color)) {
67
+ issue(issues, `${path}.${theme}`, 'must be a #RGB or #RRGGBB hexadecimal color');
68
+ }
69
+ }
70
+ return;
71
+ }
72
+
73
+ const entries = Object.entries(value);
74
+ if (entries.length === 0) {
75
+ issue(issues, path, 'must contain at least one token');
76
+ }
77
+ for (const [name, child] of entries) {
78
+ validateTokenName(name, `${path}.${name}`, issues);
79
+ validateColorGroup(child, `${path}.${name}`, depth + 1, issues);
80
+ }
81
+ }
82
+
83
+ type ScalarKind = 'dimension' | 'positive-dimension' | 'string' | 'weight';
84
+
85
+ function validScalar(value: unknown, kind: ScalarKind): boolean {
86
+ if (kind === 'string') {
87
+ return typeof value === 'string' && value.length > 0;
88
+ }
89
+ if (kind === 'weight') {
90
+ return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 1000;
91
+ }
92
+ if (typeof value === 'string') {
93
+ return value.length > 0;
94
+ }
95
+ return (
96
+ typeof value === 'number' &&
97
+ Number.isFinite(value) &&
98
+ (kind === 'positive-dimension' ? value > 0 : value >= 0)
99
+ );
100
+ }
101
+
102
+ function validateScale(
103
+ value: unknown,
104
+ path: string,
105
+ kind: ScalarKind,
106
+ issues: ValidationIssue[],
107
+ ): void {
108
+ if (!isRecord(value)) {
109
+ issue(issues, path, 'must be a token scale');
110
+ return;
111
+ }
112
+ const entries = Object.entries(value);
113
+ if (entries.length === 0) {
114
+ issue(issues, path, 'must contain at least one token');
115
+ }
116
+ for (const [name, token] of entries) {
117
+ const tokenPath = `${path}.${name}`;
118
+ validateTokenName(name, tokenPath, issues);
119
+ if (!validScalar(token, kind)) {
120
+ issue(issues, tokenPath, `must be a valid ${kind.replace('-', ' ')}`);
121
+ }
122
+ }
123
+ }
124
+
125
+ function validateTypography(value: unknown, issues: ValidationIssue[]): void {
126
+ if (!isRecord(value)) {
127
+ issue(issues, '$.typography', 'must be an object');
128
+ return;
129
+ }
130
+ validateKeys(value, ['family', 'size', 'weight', 'lineHeight'], '$.typography', issues);
131
+ validateScale(value['family'], '$.typography.family', 'string', issues);
132
+ validateScale(value['size'], '$.typography.size', 'positive-dimension', issues);
133
+ validateScale(value['weight'], '$.typography.weight', 'weight', issues);
134
+ validateScale(value['lineHeight'], '$.typography.lineHeight', 'positive-dimension', issues);
135
+ }
136
+
137
+ function validateMotion(value: unknown, issues: ValidationIssue[]): void {
138
+ if (!isRecord(value)) {
139
+ issue(issues, '$.motion', 'must be an object');
140
+ return;
141
+ }
142
+ validateKeys(value, ['duration', 'easing'], '$.motion', issues);
143
+ validateScale(value['duration'], '$.motion.duration', 'dimension', issues);
144
+ validateScale(value['easing'], '$.motion.easing', 'string', issues);
145
+ }
146
+
147
+ function findColor(value: unknown, path: string): ThemeColor | undefined {
148
+ const segments = path.split('.');
149
+ if (segments.shift() !== 'color') {
150
+ return undefined;
151
+ }
152
+ let current: unknown = value;
153
+ for (const segment of segments) {
154
+ if (!isRecord(current)) {
155
+ return undefined;
156
+ }
157
+ current = current[segment];
158
+ }
159
+ if (
160
+ isRecord(current) &&
161
+ typeof current['light'] === 'string' &&
162
+ typeof current['dark'] === 'string'
163
+ ) {
164
+ return current as unknown as ThemeColor;
165
+ }
166
+ return undefined;
167
+ }
168
+
169
+ function validateContrastPairs(value: unknown, colors: unknown, issues: ValidationIssue[]): void {
170
+ if (!Array.isArray(value)) {
171
+ issue(issues, '$.contrastPairs', 'must be an array');
172
+ return;
173
+ }
174
+ value.forEach((pair: unknown, index: number) => {
175
+ const path = `$.contrastPairs[${String(index)}]`;
176
+ if (!isRecord(pair)) {
177
+ issue(issues, path, 'must be an object');
178
+ return;
179
+ }
180
+ for (const key of Object.keys(pair)) {
181
+ if (!['foreground', 'background', 'largeText'].includes(key)) {
182
+ issue(issues, `${path}.${key}`, 'is not a recognized field');
183
+ }
184
+ }
185
+ for (const role of ['foreground', 'background'] as const) {
186
+ const colorPath = pair[role];
187
+ if (typeof colorPath !== 'string' || findColor(colors, colorPath) === undefined) {
188
+ issue(issues, `${path}.${role}`, 'must reference an existing color token path');
189
+ }
190
+ }
191
+ if ('largeText' in pair && typeof pair['largeText'] !== 'boolean') {
192
+ issue(issues, `${path}.largeText`, 'must be a boolean');
193
+ }
194
+ });
195
+ }
196
+
197
+ /** Returns every structural problem with a path rooted at `$`. */
198
+ export function validateTokens(input: unknown): ValidationIssue[] {
199
+ const issues: ValidationIssue[] = [];
200
+ if (!isRecord(input)) {
201
+ return [{ path: '$', message: 'must be an object' }];
202
+ }
203
+
204
+ validateKeys(
205
+ input,
206
+ ['color', 'typography', 'space', 'radius', 'motion', 'elevation', 'contrastPairs'],
207
+ '$',
208
+ issues,
209
+ );
210
+ validateColorGroup(input['color'], '$.color', 0, issues);
211
+ validateTypography(input['typography'], issues);
212
+ validateScale(input['space'], '$.space', 'dimension', issues);
213
+ validateScale(input['radius'], '$.radius', 'dimension', issues);
214
+ validateMotion(input['motion'], issues);
215
+ validateScale(input['elevation'], '$.elevation', 'dimension', issues);
216
+ validateContrastPairs(input['contrastPairs'], input['color'], issues);
217
+ return issues;
218
+ }
219
+
220
+ export class TokenValidationError extends Error {
221
+ public readonly issues: readonly ValidationIssue[];
222
+
223
+ public constructor(issues: readonly ValidationIssue[]) {
224
+ super(issues.map(({ path, message }) => `${path}: ${message}`).join('\n'));
225
+ this.name = 'TokenValidationError';
226
+ this.issues = issues;
227
+ }
228
+ }
229
+
230
+ /** Validates unknown input and returns it with the design-token type. */
231
+ export function parseTokens(input: unknown): DesignTokens {
232
+ const issues = validateTokens(input);
233
+ if (issues.length > 0) {
234
+ throw new TokenValidationError(issues);
235
+ }
236
+ return input as DesignTokens;
237
+ }