@halogen-ui/tokens 0.1.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.
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Halogen — dimensional primitives: space, radius, size, border width, layer.
3
+ *
4
+ * Every value here is a decision from the aesthetic direction, not a default.
5
+ * The gaps in the spacing scale are deliberate: there is no 28, 36, 44 or 56,
6
+ * because a scale with every value in it is not a scale.
7
+ */
8
+
9
+ /**
10
+ * 4px base. Two sub-step values exist for hairlines and optical nudges only.
11
+ *
12
+ * The 2px step is named `half`, not `0.5`. A dot is not valid in a CSS custom
13
+ * property name, and `--halogen-space-0.5` does not merely fail on its own — the
14
+ * parser's error recovery discarded the surrounding declarations, which took out
15
+ * every spacing and color primitive declared before it. The page rendered with
16
+ * no padding and no gaps anywhere, while every utility still compiled and every
17
+ * test still passed.
18
+ */
19
+ export const space = {
20
+ 0: '0px',
21
+ px: '1px',
22
+ half: '2px',
23
+ 1: '4px',
24
+ 2: '8px',
25
+ 3: '12px',
26
+ 4: '16px',
27
+ 5: '20px',
28
+ 6: '24px',
29
+ 8: '32px',
30
+ 10: '40px',
31
+ 12: '48px',
32
+ 16: '64px',
33
+ 20: '80px',
34
+ 24: '96px',
35
+ 32: '128px',
36
+ 40: '160px',
37
+ 48: '192px',
38
+ /**
39
+ * 64 and 80 are card widths, not gaps.
40
+ *
41
+ * The scale's gaps are deliberate everywhere else — there is no 28, 36, 44 or
42
+ * 56, because a scale with every value in it is not a scale. These two earn a
43
+ * place because a horizontally scrolling rail needs a fixed item width, and
44
+ * `Rail` shipped asking for `w-64` and `w-80` against a scale that stopped at
45
+ * 48. Both compiled to nothing, so the default item width and the large one
46
+ * silently became `auto` — sm (192px) was the only width that worked, and
47
+ * `lg` was not larger than it.
48
+ */
49
+ 64: '256px',
50
+ 80: '320px',
51
+ } as const;
52
+
53
+ /**
54
+ * Radius. The nesting law — inner = outer − padding, clamped to the nearest
55
+ * step, never below `xs` — is enforced by a helper in @halogen-ui/react, not by
56
+ * convention. `full` is reserved for components that *are* pills.
57
+ */
58
+ export const radius = {
59
+ none: '0px',
60
+ xs: '4px',
61
+ sm: '6px',
62
+ md: '10px',
63
+ lg: '16px',
64
+ xl: '24px',
65
+ '2xl': '32px',
66
+ full: '9999px',
67
+ } as const;
68
+
69
+ /**
70
+ * Control heights. `md` is the default. The 44px minimum touch target is not on
71
+ * this scale because it is not a height — it is a hit-area floor applied via
72
+ * pseudo-element expansion, so a visually 32px control still takes a 44px tap.
73
+ */
74
+ export const control = {
75
+ sm: '32px',
76
+ md: '40px',
77
+ lg: '48px',
78
+ xl: '56px',
79
+ } as const;
80
+
81
+ /** Icon sizes. Three, and only three. */
82
+ export const icon = {
83
+ sm: '16px',
84
+ md: '20px',
85
+ lg: '24px',
86
+ } as const;
87
+
88
+ /** Icon stroke widths, scaled with size so optical weight stays constant. */
89
+ export const iconStroke = {
90
+ sm: '1.5',
91
+ md: '1.75',
92
+ lg: '2',
93
+ } as const;
94
+
95
+ export const borderWidth = {
96
+ 0: '0px',
97
+ 1: '1px',
98
+ 2: '2px',
99
+ } as const;
100
+
101
+ /** Minimum interactive hit area. Exceeds WCAG 2.5.8 AA (24px) deliberately. */
102
+ export const target = {
103
+ /** WCAG 2.5.8 AA floor — the absolute minimum, used only where 44 is impossible. */
104
+ min: '24px',
105
+ /** The Halogen house standard for anything reachable by touch. */
106
+ touch: '44px',
107
+ } as const;
108
+
109
+ /** Layout containers, from aesthetic direction §8. */
110
+ export const container = {
111
+ prose: '720px',
112
+ default: '1280px',
113
+ wide: '1440px',
114
+ } as const;
115
+
116
+ export const breakpoint = {
117
+ sm: '640px',
118
+ md: '768px',
119
+ lg: '1024px',
120
+ xl: '1280px',
121
+ '2xl': '1536px',
122
+ } as const;
123
+
124
+ /**
125
+ * Stacking order. Named rather than numeric at the call site so an overlay can
126
+ * never be given an arbitrary z-index, and so the layering relationship between
127
+ * a popover inside a dialog is a system decision rather than a local one.
128
+ */
129
+ export const layer = {
130
+ base: '0',
131
+ raised: '10',
132
+ sticky: '100',
133
+ overlay: '1000',
134
+ modal: '1100',
135
+ popover: '1200',
136
+ toast: '1300',
137
+ tooltip: '1400',
138
+ } as const;
139
+
140
+ /**
141
+ * Media aspect ratios.
142
+ *
143
+ * A token scale rather than free-form ratios, for the same reason spacing is a
144
+ * scale: three ratios used consistently reads as a system, and eleven ratios
145
+ * used once each reads as whatever each author felt like that afternoon.
146
+ *
147
+ * `wide` exists because it was already in use as `aspect-[21/9]` inside a lookup
148
+ * map in ContentCard — the only arbitrary value in the library, and invisible to
149
+ * the rule that forbids them until the rule learned to read class-shaped strings
150
+ * outside call sites.
151
+ */
152
+ export const aspect = {
153
+ /** Video, screenshots, most product media. */
154
+ video: '16 / 9',
155
+ /** Avatars, tiles, gallery thumbnails. */
156
+ square: '1 / 1',
157
+ /** A cinematic banner. Deliberately the only ratio wider than video. */
158
+ wide: '21 / 9',
159
+ } as const;
160
+
161
+ // No `portrait`. The scale holds the three ratios ContentCard actually offers;
162
+ // a fourth added "for completeness" is a token nothing uses, and the audit
163
+ // framework is explicit that tokens are not added to satisfy a checklist.
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Halogen — typographic primitives.
3
+ *
4
+ * Two scales, deliberately separate: a fixed UI scale for product surfaces and
5
+ * a fluid display scale for marketing. Mixing them is what produces interfaces
6
+ * where a dashboard header is accidentally 44px on a laptop.
7
+ */
8
+
9
+ export const fontFamily = {
10
+ /**
11
+ * `Geist`, not `Geist Sans`.
12
+ *
13
+ * The family is named **Geist**. This token asked for `'Geist Sans'` — a name
14
+ * no face has ever carried — from Phase 1 until Phase 6, so it matched
15
+ * nothing and every sans glyph in the system silently painted in
16
+ * `ui-sans-serif`. Measured to prove it: at 600/32px the string used for the
17
+ * check rendered 603.45px under `'Geist Sans'` and 603.45px under bare
18
+ * `ui-sans-serif` — identical — against 677.05px under `'Geist'`.
19
+ *
20
+ * The comment that used to sit here claimed "self-hosted, SIL OFL,
21
+ * redistributable to buyers" while the showcase loaded the family from
22
+ * Google's CDN. Now true: the variable faces are vendored in
23
+ * `packages/tokens/fonts/` with the OFL text beside them.
24
+ *
25
+ * Fallbacks are system faces with similar metrics so the page does not reflow
26
+ * when the webfont lands.
27
+ */
28
+ sans: "'Geist', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
29
+ mono: "'Geist Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace",
30
+ } as const;
31
+
32
+ /**
33
+ * The vendored faces, and what has to be true for shipping them to be legal.
34
+ *
35
+ * Geist is SIL Open Font License 1.1, Copyright (c) 2023 Vercel in
36
+ * collaboration with basement.studio. OFL permits bundling and sale as part of a
37
+ * larger work, provided the license text travels with the font and the font is
38
+ * not sold on its own. The copyright line declares no Reserved Font Name, so the
39
+ * renaming clause is moot here — and moot anyway, since the faces are shipped
40
+ * unmodified.
41
+ *
42
+ * Asserted in the token suite: the files exist, the license sits beside them,
43
+ * and the license text is really OFL 1.1.
44
+ */
45
+ export const VENDORED_FONTS = [
46
+ { family: 'Geist', file: 'Geist-Variable.woff2', weights: '100 900', style: 'normal' },
47
+ { family: 'Geist Mono', file: 'GeistMono-Variable.woff2', weights: '100 900', style: 'normal' },
48
+ ] as const;
49
+
50
+ export const FONT_LICENSE = {
51
+ file: 'LICENSE-Geist-OFL-1.1.txt',
52
+ spdx: 'OFL-1.1',
53
+ holder: 'Vercel, in collaboration with basement.studio',
54
+ permitsRedistribution: true,
55
+ } as const;
56
+
57
+ /**
58
+ * Four weights. 300 and lighter are absent by design: light weights bloom and
59
+ * thin on dark grounds, and this is a dark-first system.
60
+ */
61
+ export const fontWeight = {
62
+ regular: '400',
63
+ medium: '500',
64
+ semibold: '600',
65
+ bold: '700',
66
+ } as const;
67
+
68
+ /**
69
+ * Tracking law from aesthetic direction §7 — tightens as type grows, loosens as
70
+ * it shrinks. Encoded as named steps so a component cannot invent one.
71
+ */
72
+ export const tracking = {
73
+ /**
74
+ * 11px uppercase eyebrow.
75
+ *
76
+ * Matches the `2xs` step's own letter-spacing on purpose. Components reach for
77
+ * `tracking-wider` explicitly on eyebrows and table headers, which overrides
78
+ * whatever the size step declares — so if the two disagree, tightening the step
79
+ * changes nothing and the disagreement is invisible. One value, two routes to
80
+ * it.
81
+ */
82
+ wider: '0.015em',
83
+ /** <= 13px. Matches the `xs` step for the same reason. */
84
+ wide: '0.005em',
85
+ normal: '0em',
86
+ /** 16-20px */
87
+ tight: '-0.01em',
88
+ /** 20-32px */
89
+ tighter: '-0.015em',
90
+ /** 32-48px */
91
+ tightest: '-0.02em',
92
+ /** >= 48px */
93
+ display: '-0.03em',
94
+ /** the largest display step only */
95
+ displayTight: '-0.035em',
96
+ } as const;
97
+
98
+ export const lineHeight = {
99
+ none: '1',
100
+ 'display-2xl': '0.95',
101
+ 'display-xl': '1',
102
+ 'display-lg': '1.05',
103
+ 'display-md': '1.1',
104
+ 'display-sm': '1.2',
105
+ 'display-xs': '1.25',
106
+ snug: '1.35',
107
+ normal: '1.5',
108
+ relaxed: '1.6',
109
+ } as const;
110
+
111
+ /** Product/UI scale — fixed px. Tops out at `xl`; anything larger is display. */
112
+ export const fontSize = {
113
+ '2xs': '11px',
114
+ xs: '12px',
115
+ sm: '13px',
116
+ base: '14px',
117
+ md: '16px',
118
+ lg: '18px',
119
+ xl: '20px',
120
+ } as const;
121
+
122
+ /**
123
+ * Line-height and tracking paired to each size step.
124
+ *
125
+ * Tailwind pairs these via `--text-<name>--line-height` and
126
+ * `--text-<name>--letter-spacing`. Without the pairing a `text-*` utility sets
127
+ * the font size and leaves line-height inherited from the document — so a 60px
128
+ * display headline renders at a 1.5 body leading, which is exactly what
129
+ * happened: the hero read as two loosely stacked lines instead of one block.
130
+ *
131
+ * Pairing also makes the tracking law from the aesthetic direction automatic.
132
+ * Tracking tightens as type grows; encoding it here means a heading cannot be
133
+ * set at the wrong tracking by forgetting a second utility.
134
+ */
135
+ export const fontMetrics: Record<string, { lineHeight: string; tracking: string }> = {
136
+ /**
137
+ * The two smallest steps sit tighter than the "loosen as it shrinks" law
138
+ * would put them.
139
+ *
140
+ * The law is right about lowercase copy at small sizes, and wrong here: both
141
+ * of these steps are used almost exclusively for SHORT UPPERCASE labels —
142
+ * table headers, eyebrows, stat captions — and uppercase already carries its
143
+ * own optical spacing. At 0.06em an eleven-character header read as five
144
+ * separate words; 0.03em was still loose. 0.015em is the setting where an
145
+ * uppercase label reads as one word at 11px.
146
+ */
147
+ '2xs': { lineHeight: '16px', tracking: '0.015em' },
148
+ xs: { lineHeight: '16px', tracking: '0.005em' },
149
+ sm: { lineHeight: '20px', tracking: '0.01em' },
150
+ base: { lineHeight: '20px', tracking: '0em' },
151
+ md: { lineHeight: '24px', tracking: '0em' },
152
+ lg: { lineHeight: '28px', tracking: '-0.005em' },
153
+ xl: { lineHeight: '28px', tracking: '-0.01em' },
154
+ };
155
+
156
+ /** The display scale's own metrics. Leading tightens as the size grows. */
157
+ export const displayMetrics: Record<string, { lineHeight: string; tracking: string }> = {
158
+ xs: { lineHeight: '1.25', tracking: '-0.01em' },
159
+ sm: { lineHeight: '1.2', tracking: '-0.015em' },
160
+ md: { lineHeight: '1.1', tracking: '-0.02em' },
161
+ lg: { lineHeight: '1.05', tracking: '-0.025em' },
162
+ xl: { lineHeight: '1', tracking: '-0.03em' },
163
+ '2xl': { lineHeight: '0.95', tracking: '-0.035em' },
164
+ };
165
+
166
+ /**
167
+ * Display scale — fluid. `clamp()` bounds are the min and max from aesthetic
168
+ * direction §7; the middle term scales with viewport width, and every step is
169
+ * capped so a 4K monitor does not produce a 200px headline.
170
+ */
171
+ export const displaySize = {
172
+ xs: 'clamp(1.5rem, 1.4rem + 0.5vw, 1.5rem)',
173
+ sm: 'clamp(1.75rem, 1.6rem + 0.75vw, 1.875rem)',
174
+ md: 'clamp(2.25rem, 1.9rem + 1.5vw, 2.75rem)',
175
+ lg: 'clamp(2.75rem, 2.1rem + 3vw, 3.75rem)',
176
+ xl: 'clamp(3.5rem, 2.4rem + 5vw, 5.5rem)',
177
+ '2xl': 'clamp(4.5rem, 2.6rem + 8vw, 8rem)',
178
+ } as const;
179
+
180
+ /** Body copy never exceeds this measure. */
181
+ export const measure = {
182
+ prose: '68ch',
183
+ narrow: '46ch',
184
+ } as const;