@ultimat3/ui 22.15.0 → 24.0.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/CATALOG.md +45 -14
- package/CLAUDE.md +16 -52
- package/README.md +69 -13
- package/package.json +6 -5
- package/src/a11y.ts +24 -10
- package/src/catalog/parse-component.ts +85 -12
- package/src/catalog/render-catalog.ts +21 -1
- package/src/components/Avatar.module.scss +1 -1
- package/src/components/BarChart.module.scss +12 -1
- package/src/components/BarChart.tsx +41 -15
- package/src/components/Button.tsx +3 -8
- package/src/components/Card.module.scss +1 -1
- package/src/components/Checkbox.module.scss +4 -4
- package/src/components/Checkbox.tsx +6 -0
- package/src/components/CommandPalette.module.scss +8 -5
- package/src/components/DataTable.module.scss +2 -0
- package/src/components/DataTable.tsx +135 -71
- package/src/components/Dialog.module.scss +2 -2
- package/src/components/Dropzone.module.scss +6 -4
- package/src/components/Dropzone.tsx +9 -8
- package/src/components/FileInput.module.scss +4 -2
- package/src/components/FileInput.tsx +6 -2
- package/src/components/Form.module.scss +2 -2
- package/src/components/Input.module.scss +1 -1
- package/src/components/Link.tsx +64 -12
- package/src/components/Meter.tsx +3 -3
- package/src/components/Pagination.tsx +89 -37
- package/src/components/Popover.module.scss +17 -20
- package/src/components/Popover.tsx +12 -5
- package/src/components/Radio.module.scss +2 -2
- package/src/components/Select.module.scss +3 -3
- package/src/components/Skeleton.module.scss +1 -1
- package/src/components/Spinner.module.scss +3 -3
- package/src/components/Switch.module.scss +2 -2
- package/src/components/Table.module.scss +2 -2
- package/src/components/Tabs.module.scss +11 -5
- package/src/components/Textarea.tsx +7 -4
- package/src/components/ThemeToggle.module.scss +1 -1
- package/src/components/Toast.module.scss +2 -2
- package/src/components/Tooltip.module.scss +8 -3
- package/src/components/bar-chart-view.ts +35 -5
- package/src/components/button-classes.ts +25 -0
- package/src/components/file-input-view.ts +31 -12
- package/src/components/link-classes.ts +38 -0
- package/src/components/link-target.ts +34 -1
- package/src/components/meter-view.ts +13 -0
- package/src/components/qr-matrix.ts +16 -23
- package/src/components/qr-reader.ts +187 -0
- package/src/components/relative-time-view.ts +11 -4
- package/src/form/form-binding.ts +9 -8
- package/src/icons/build-icons.ts +96 -20
- package/src/index.ts +15 -9
- package/src/sass-probe.ts +55 -0
- package/src/theme/brand.ts +22 -3
- package/src/theme/theme.ts +35 -10
- package/src/toast/toast-state.ts +0 -0
- package/src/tokens/_index.scss +3 -1
- package/src/tokens/_mixins.scss +56 -8
- package/src/tokens/_motion.scss +4 -0
- package/src/tokens/_stroke.scss +20 -0
- package/src/tokens/_units.scss +47 -0
- package/src/tokens/reset.scss +4 -4
- package/src/tokens/theme.scss +2 -0
- package/src/tokens/tokens.ts +6 -0
- package/src/theme/inline-script.ts +0 -49
|
@@ -27,16 +27,23 @@ const THRESHOLDS: readonly Threshold[] = [
|
|
|
27
27
|
{ unit: 'second', ms: 1000 },
|
|
28
28
|
];
|
|
29
29
|
|
|
30
|
+
const unitMs = (index: number): number => (THRESHOLDS[index] as Threshold).ms;
|
|
31
|
+
const roundIn = (delta: number, index: number): number => Math.round(delta / unitMs(index));
|
|
32
|
+
|
|
30
33
|
export function relativeTimeText(options: RelativeTimeOptions): string {
|
|
31
34
|
const target = toDate(options.value).getTime();
|
|
32
35
|
const base = options.now === undefined ? Date.now() : toDate(options.now).getTime();
|
|
33
36
|
const delta = target - base;
|
|
34
37
|
const magnitude = Math.abs(delta);
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
let index = THRESHOLDS.findIndex((candidate) => magnitude >= candidate.ms);
|
|
40
|
+
if (index < 0) index = THRESHOLDS.length - 1;
|
|
41
|
+
// Promote AFTER rounding: 59.6 minutes rounds to 60, and "60 minutes ago" is "1 hour ago".
|
|
42
|
+
while (index > 0 && Math.abs(roundIn(delta, index)) * unitMs(index) >= unitMs(index - 1)) {
|
|
43
|
+
index -= 1;
|
|
44
|
+
}
|
|
45
|
+
const threshold = THRESHOLDS[index] as Threshold;
|
|
46
|
+
const value = roundIn(delta, index);
|
|
40
47
|
|
|
41
48
|
const formatter = new Intl.RelativeTimeFormat(options.locale, {
|
|
42
49
|
numeric: options.numeric ?? 'auto',
|
package/src/form/form-binding.ts
CHANGED
|
@@ -148,15 +148,16 @@ export function createFormBinding<TValues, TResult>(
|
|
|
148
148
|
// has already changed, on the one screen where the user is watching for exactly that.
|
|
149
149
|
publish({ status: 'submitting', ...NO_FORM_ERRORS, result: undefined, issues: [] });
|
|
150
150
|
|
|
151
|
-
const schema = options.schema;
|
|
152
|
-
if (schema !== undefined) {
|
|
153
|
-
// The result's `value` is read by nothing. Deliberately: the parse below is the browser's
|
|
154
|
-
// opinion, and the only thing this file wants from it is which paths to complain about.
|
|
155
|
-
const local = issuesFromValidation(await schema['~standard'].validate(values));
|
|
156
|
-
if (local.length > 0) return failed(local);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
151
|
try {
|
|
152
|
+
// Inside the `try`: a validate that throws or rejects is a failed submit, never a `submit()`
|
|
153
|
+
// that rejects with the state left at `submitting` and the control refusing every retry.
|
|
154
|
+
const schema = options.schema;
|
|
155
|
+
if (schema !== undefined) {
|
|
156
|
+
// The result's `value` is read by nothing. Deliberately: the parse below is the browser's
|
|
157
|
+
// opinion, and the only thing this file wants from it is which paths to complain about.
|
|
158
|
+
const local = issuesFromValidation(await schema['~standard'].validate(values));
|
|
159
|
+
if (local.length > 0) return failed(local);
|
|
160
|
+
}
|
|
160
161
|
const result = await options.submit(values);
|
|
161
162
|
// The server accepted what the form held, so there is nothing left to lose. `touched` stays:
|
|
162
163
|
// the user has still visited those fields, and a hint that vanishes on save is a flicker.
|
package/src/icons/build-icons.ts
CHANGED
|
@@ -11,7 +11,17 @@ import { invalidIconDataError, runtimeMissingError } from '../errors';
|
|
|
11
11
|
/** Pinned: a floating version would silently redraw icons under an app that never asked. */
|
|
12
12
|
export const LUCIDE_VERSION = '1.31.0';
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/** Where one pinned version's node table lives. */
|
|
15
|
+
export const iconNodesUrl = (version: string): string =>
|
|
16
|
+
`https://cdn.jsdelivr.net/npm/lucide-static@${version}/icon-nodes.json`;
|
|
17
|
+
|
|
18
|
+
export const LUCIDE_ICON_NODES_URL = iconNodesUrl(LUCIDE_VERSION);
|
|
19
|
+
|
|
20
|
+
/** The registry's answer for the newest published `lucide-static` — what `--bump` pins to. */
|
|
21
|
+
export const LUCIDE_LATEST_URL = 'https://registry.npmjs.org/lucide-static/latest';
|
|
22
|
+
|
|
23
|
+
/** The tail of every cause about data the PIN published: why re-running alone cannot help. */
|
|
24
|
+
const PIN_REPEATS = `lucide-static@${LUCIDE_VERSION} is the pin that published it, so a re-run against the same pin repeats this; \`--bump\` raises LUCIDE_VERSION in packages/ui/src/icons/build-icons.ts to the latest release and regenerates`;
|
|
15
25
|
|
|
16
26
|
export const GLYPHS_DIR = new URL('./glyphs/', import.meta.url).pathname;
|
|
17
27
|
|
|
@@ -27,7 +37,7 @@ export function identifierFor(name: string): string {
|
|
|
27
37
|
* the network, so its shape is an assumption until it is checked.
|
|
28
38
|
*/
|
|
29
39
|
export function parseIconNodes(text: string): ReadonlyMap<string, IconGlyph> {
|
|
30
|
-
const parsed
|
|
40
|
+
const parsed = parseJson(text);
|
|
31
41
|
// `typeof [] === 'object'`, and an array of icons is a different upstream format, not this one.
|
|
32
42
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
33
43
|
throw invalidIconDataError(
|
|
@@ -42,8 +52,8 @@ export function parseIconNodes(text: string): ReadonlyMap<string, IconGlyph> {
|
|
|
42
52
|
// writes, so `../../index` is a delete plus an overwrite of a hand-written module.
|
|
43
53
|
if (!SAFE_ICON_NAME.test(name)) {
|
|
44
54
|
throw invalidIconDataError(
|
|
45
|
-
`carries ${renderCauseValue(name)} as an icon name, which is not the kebab-case shape every Lucide icon uses; the name becomes a file path and an exported identifier, so it cannot be escaped.
|
|
46
|
-
'bun run --filter @ultimat3/ui icons
|
|
55
|
+
`carries ${renderCauseValue(name)} as an icon name, which is not the kebab-case shape every Lucide icon uses; the name becomes a file path and an exported identifier, so it cannot be escaped. ${PIN_REPEATS}`,
|
|
56
|
+
'bun run --filter @ultimat3/ui icons --bump',
|
|
47
57
|
);
|
|
48
58
|
}
|
|
49
59
|
out.set(name, toGlyph(name, value));
|
|
@@ -51,6 +61,29 @@ export function parseIconNodes(text: string): ReadonlyMap<string, IconGlyph> {
|
|
|
51
61
|
return out;
|
|
52
62
|
}
|
|
53
63
|
|
|
64
|
+
/** The body as JSON, or the same coded refusal a wrong shape gets — never a bare `SyntaxError`. */
|
|
65
|
+
function parseJson(text: string): unknown {
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(text);
|
|
68
|
+
} catch {
|
|
69
|
+
throw invalidIconDataError(
|
|
70
|
+
`is not JSON (${renderCauseValue(text.slice(0, 80))}…); ${LUCIDE_ICON_NODES_URL} served an error page or a truncated body`,
|
|
71
|
+
'bun run --filter @ultimat3/ui icons',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A node that is not `[tag, attrs?]` with an object for attrs — refused, never skipped. */
|
|
77
|
+
function malformedNode(name: string, node: unknown): ReturnType<typeof invalidIconDataError> {
|
|
78
|
+
return invalidIconDataError(
|
|
79
|
+
`for the icon "${name}" carries ${renderCauseValue(node)} where a [tag, attributes] node belongs; skipping it would write a partial glyph. ${PIN_REPEATS}`,
|
|
80
|
+
'bun run --filter @ultimat3/ui icons --bump',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const isAttrs = (value: unknown): value is Record<string, unknown> =>
|
|
85
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
86
|
+
|
|
54
87
|
/**
|
|
55
88
|
* An icon name, as characters: lowercase kebab-case, no leading, trailing or doubled hyphen. The
|
|
56
89
|
* same allowlist-over-a-sink shape as `SAFE_ATTR_VALUE`, one layer up — the name is interpolated
|
|
@@ -68,10 +101,13 @@ export const SAFE_ATTR_VALUE = /^[\w\s.,+-]*$/;
|
|
|
68
101
|
|
|
69
102
|
function toGlyph(name: string, value: unknown): IconGlyph {
|
|
70
103
|
const glyph: (readonly [string, Readonly<Record<string, string>>])[] = [];
|
|
71
|
-
|
|
72
|
-
|
|
104
|
+
if (!Array.isArray(value)) throw malformedNode(name, value);
|
|
105
|
+
for (const node of value as unknown[]) {
|
|
106
|
+
if (!Array.isArray(node) || typeof node[0] !== 'string') throw malformedNode(name, node);
|
|
107
|
+
const rawAttrs: unknown = node[1] ?? {};
|
|
108
|
+
if (!isAttrs(rawAttrs)) throw malformedNode(name, node);
|
|
73
109
|
const attrs: Record<string, string> = {};
|
|
74
|
-
for (const [key, item] of Object.entries(
|
|
110
|
+
for (const [key, item] of Object.entries(rawAttrs)) {
|
|
75
111
|
// Upstream keys an element for its diff tooling; it is not an SVG attribute.
|
|
76
112
|
if (key === 'key') continue;
|
|
77
113
|
const text = String(item);
|
|
@@ -82,8 +118,8 @@ function toGlyph(name: string, value: unknown): IconGlyph {
|
|
|
82
118
|
// no legitimate Lucide artwork.
|
|
83
119
|
if (!SAFE_ATTR_VALUE.test(text)) {
|
|
84
120
|
throw invalidIconDataError(
|
|
85
|
-
`for the icon "${name}" carries ${renderCauseValue(text)} as "${key}", which is not glyph geometry;
|
|
86
|
-
'bun run --filter @ultimat3/ui icons
|
|
121
|
+
`for the icon "${name}" carries ${renderCauseValue(text)} as "${key}", which is not glyph geometry; ${PIN_REPEATS}`,
|
|
122
|
+
'bun run --filter @ultimat3/ui icons --bump',
|
|
87
123
|
);
|
|
88
124
|
}
|
|
89
125
|
attrs[key] = text;
|
|
@@ -95,8 +131,8 @@ function toGlyph(name: string, value: unknown): IconGlyph {
|
|
|
95
131
|
iconElements(glyph);
|
|
96
132
|
if (glyph.length === 0) {
|
|
97
133
|
throw invalidIconDataError(
|
|
98
|
-
`for the icon "${name}" carries no renderable node data;
|
|
99
|
-
'bun run --filter @ultimat3/ui icons
|
|
134
|
+
`for the icon "${name}" carries no renderable node data; ${PIN_REPEATS}`,
|
|
135
|
+
'bun run --filter @ultimat3/ui icons --bump',
|
|
100
136
|
);
|
|
101
137
|
}
|
|
102
138
|
return glyph;
|
|
@@ -136,23 +172,24 @@ export function moduleSource(name: string, glyph: IconGlyph): string {
|
|
|
136
172
|
async function format(): Promise<void> {
|
|
137
173
|
const biome = new URL('../../../../node_modules/.bin/biome', import.meta.url).pathname;
|
|
138
174
|
if (!(await Bun.file(biome).exists())) {
|
|
139
|
-
throw runtimeMissingError('the biome binary', 'bun install
|
|
175
|
+
throw runtimeMissingError('the biome binary', 'bun install');
|
|
140
176
|
}
|
|
141
|
-
|
|
177
|
+
// A formatter over one directory of generated files; a wedged one is the refusal below.
|
|
178
|
+
const result = Bun.spawnSync([biome, 'format', '--write', GLYPHS_DIR], { timeout: 60_000 });
|
|
142
179
|
if (result.exitCode !== 0) {
|
|
143
180
|
throw runtimeMissingError(
|
|
144
181
|
'a successful `biome format` over the generated glyphs',
|
|
145
|
-
|
|
182
|
+
'bunx biome format --write packages/ui/src/icons/glyphs',
|
|
146
183
|
);
|
|
147
184
|
}
|
|
148
185
|
}
|
|
149
186
|
|
|
150
|
-
export async function buildIcons(): Promise<number> {
|
|
151
|
-
const response = await fetch(
|
|
187
|
+
export async function buildIcons(version: string = LUCIDE_VERSION): Promise<number> {
|
|
188
|
+
const response = await fetch(iconNodesUrl(version));
|
|
152
189
|
if (!response.ok) {
|
|
153
190
|
throw runtimeMissingError(
|
|
154
|
-
`lucide-static@${
|
|
155
|
-
|
|
191
|
+
`lucide-static@${version} icon data (HTTP ${response.status} from ${iconNodesUrl(version)})`,
|
|
192
|
+
'bun run --filter @ultimat3/ui icons',
|
|
156
193
|
);
|
|
157
194
|
}
|
|
158
195
|
const glyphs = parseIconNodes(await response.text());
|
|
@@ -165,8 +202,47 @@ export async function buildIcons(): Promise<number> {
|
|
|
165
202
|
return glyphs.size;
|
|
166
203
|
}
|
|
167
204
|
|
|
205
|
+
/** A plain `major.minor.patch` — the only shape allowed into a TypeScript string literal here. */
|
|
206
|
+
const SEMVER = /^\d+\.\d+\.\d+$/;
|
|
207
|
+
|
|
208
|
+
/** `source` with its `LUCIDE_VERSION` line moved to `version`. Pure; refuses rather than guesses. */
|
|
209
|
+
export function withPin(source: string, version: string): string {
|
|
210
|
+
if (!SEMVER.test(version)) {
|
|
211
|
+
throw invalidIconDataError(
|
|
212
|
+
`answered ${renderCauseValue(version)} as the latest lucide-static version, which is not plain semver; ${LUCIDE_LATEST_URL} served something else`,
|
|
213
|
+
'bun run --filter @ultimat3/ui icons --bump',
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const pin = /^export const LUCIDE_VERSION = '[^']*';$/m;
|
|
217
|
+
if (!pin.test(source)) {
|
|
218
|
+
throw invalidIconDataError(
|
|
219
|
+
'has no `export const LUCIDE_VERSION = …` line in packages/ui/src/icons/build-icons.ts to move',
|
|
220
|
+
'git checkout -- packages/ui/src/icons/build-icons.ts',
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return source.replace(pin, `export const LUCIDE_VERSION = '${version}';`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Moves the pin to the registry's latest release, in this very file, and answers the version. */
|
|
227
|
+
async function bumpPin(): Promise<string> {
|
|
228
|
+
const response = await fetch(LUCIDE_LATEST_URL);
|
|
229
|
+
if (!response.ok) {
|
|
230
|
+
throw runtimeMissingError(
|
|
231
|
+
`the latest lucide-static version (HTTP ${response.status} from ${LUCIDE_LATEST_URL})`,
|
|
232
|
+
'bun run --filter @ultimat3/ui icons --bump',
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const body: unknown = await response.json();
|
|
236
|
+
const version =
|
|
237
|
+
typeof body === 'object' && body !== null && 'version' in body ? String(body.version) : '';
|
|
238
|
+
const file = new URL(import.meta.url).pathname;
|
|
239
|
+
await Bun.write(file, withPin(await Bun.file(file).text(), version));
|
|
240
|
+
return version;
|
|
241
|
+
}
|
|
242
|
+
|
|
168
243
|
if (import.meta.main) {
|
|
169
|
-
const
|
|
170
|
-
const
|
|
244
|
+
const version = Bun.argv.includes('--bump') ? await bumpPin() : LUCIDE_VERSION;
|
|
245
|
+
const count = await buildIcons(version);
|
|
246
|
+
const json = { ok: true, icons: count, lucide: version, dir: GLYPHS_DIR };
|
|
171
247
|
console.log(Bun.argv.includes('--json') ? JSON.stringify(json) : `${count} icon modules written`);
|
|
172
248
|
}
|
package/src/index.ts
CHANGED
|
@@ -89,7 +89,12 @@ export {
|
|
|
89
89
|
settleActive,
|
|
90
90
|
stepActive,
|
|
91
91
|
} from './components/command-palette-view';
|
|
92
|
-
export type {
|
|
92
|
+
export type {
|
|
93
|
+
Column,
|
|
94
|
+
DataTableCallbackProps,
|
|
95
|
+
DataTableLinkProps,
|
|
96
|
+
DataTableProps,
|
|
97
|
+
} from './components/DataTable';
|
|
93
98
|
export { DataTable } from './components/DataTable';
|
|
94
99
|
export type { DateTimeProps } from './components/DateTime';
|
|
95
100
|
export { DateTime } from './components/DateTime';
|
|
@@ -160,7 +165,7 @@ export { boxFor, reservedRatio, sourceSetsFor, srcsetFor } from './components/im
|
|
|
160
165
|
export type { LoadMoreInput, LoadMoreState } from './components/infinite-scroll-view';
|
|
161
166
|
export type { KbdProps } from './components/Kbd';
|
|
162
167
|
export { Kbd } from './components/Kbd';
|
|
163
|
-
export type { LinkProps } from './components/Link';
|
|
168
|
+
export type { ButtonLinkProps, LinkProps, TextLinkProps } from './components/Link';
|
|
164
169
|
export { Link } from './components/Link';
|
|
165
170
|
export type { LocaleSwitcherProps } from './components/LocaleSwitcher';
|
|
166
171
|
export { LocaleSwitcher, localeLabel } from './components/LocaleSwitcher';
|
|
@@ -176,7 +181,11 @@ export type { MoneyFormatter, MoneyInput, MoneyViewOptions } from './components/
|
|
|
176
181
|
export { moneyText, toMoney } from './components/money-view';
|
|
177
182
|
export type { PageHeaderProps } from './components/PageHeader';
|
|
178
183
|
export { PageHeader } from './components/PageHeader';
|
|
179
|
-
export type {
|
|
184
|
+
export type {
|
|
185
|
+
PaginationCallbackProps,
|
|
186
|
+
PaginationLinkProps,
|
|
187
|
+
PaginationProps,
|
|
188
|
+
} from './components/Pagination';
|
|
180
189
|
export { Pagination } from './components/Pagination';
|
|
181
190
|
export type { Placement, PopoverProps } from './components/Popover';
|
|
182
191
|
export { Popover } from './components/Popover';
|
|
@@ -303,12 +312,6 @@ export {
|
|
|
303
312
|
useUi,
|
|
304
313
|
} from './theme/context';
|
|
305
314
|
export { INERT_SOLID_RUNTIME } from './theme/inert-runtime';
|
|
306
|
-
export {
|
|
307
|
-
THEME_INLINE_SCRIPT,
|
|
308
|
-
themeInlineScriptCspSource,
|
|
309
|
-
themeInlineScriptHash,
|
|
310
|
-
themeInlineScriptTag,
|
|
311
|
-
} from './theme/inline-script';
|
|
312
315
|
export type { UiProviderProps } from './theme/provider';
|
|
313
316
|
export { UiProvider } from './theme/provider';
|
|
314
317
|
// The slot is its own module so that registering a runtime does not drag `errors.ts` — and with it
|
|
@@ -321,6 +324,7 @@ export type { ThemeEnv } from './theme/theme';
|
|
|
321
324
|
export {
|
|
322
325
|
browserThemeEnv,
|
|
323
326
|
clearTheme,
|
|
327
|
+
defaultTheme,
|
|
324
328
|
initTheme,
|
|
325
329
|
isTheme,
|
|
326
330
|
osTheme,
|
|
@@ -328,6 +332,7 @@ export {
|
|
|
328
332
|
setTheme,
|
|
329
333
|
storedTheme,
|
|
330
334
|
THEME_ATTRIBUTE,
|
|
335
|
+
THEME_DEFAULT_ATTRIBUTE,
|
|
331
336
|
THEME_MEDIA_QUERY,
|
|
332
337
|
THEME_STORAGE_KEY,
|
|
333
338
|
toggleTheme,
|
|
@@ -378,5 +383,6 @@ export {
|
|
|
378
383
|
radiusTokens,
|
|
379
384
|
shadowTokens,
|
|
380
385
|
spaceTokens,
|
|
386
|
+
strokeTokens,
|
|
381
387
|
zTokens,
|
|
382
388
|
} from './tokens/tokens';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// TEST-ONLY. Compiles a stylesheet with the compiler an app's build uses, so a test can read what
|
|
2
|
+
// Sass EMITS — a media query, a `clamp()`, the class list a mixin generates, an `@error` — instead
|
|
3
|
+
// of the source it was written as. Never exported from `index.ts`, like `jsx-probe.ts`.
|
|
4
|
+
|
|
5
|
+
/** The two members of Sass's API this file calls; the package itself is not a dependency of `ui`. */
|
|
6
|
+
interface SassApi {
|
|
7
|
+
compileString(source: string, options: { readonly url: URL }): { readonly css: string };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let loading: Promise<SassApi> | undefined;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `sass` is `@ultimat3/render`'s dependency — the package that compiles an app's sheets — and `ui`
|
|
14
|
+
* may not import `render` (tier 4, both). Resolved FROM render's directory, so a test compiles with
|
|
15
|
+
* the one compiler at the one version the lockfile pins and `ui` declares no second copy. Only a
|
|
16
|
+
* specifier is read from render; no module of it loads. Lazy, so importing this file has no effect.
|
|
17
|
+
*/
|
|
18
|
+
function sassCompiler(): Promise<SassApi> {
|
|
19
|
+
loading ??= import(
|
|
20
|
+
Bun.resolveSync('sass', new URL('../../render', import.meta.url).pathname)
|
|
21
|
+
) as Promise<SassApi>;
|
|
22
|
+
return loading;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Compile `source` as though it were the file at `path`, so its relative `@use`s resolve. */
|
|
26
|
+
export async function compileScss(source: string, path: string): Promise<string> {
|
|
27
|
+
const sass = await sassCompiler();
|
|
28
|
+
return sass.compileString(source, { url: new URL(`file://${path}`) }).css;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Compile a stylesheet that exists on disk. */
|
|
32
|
+
export async function compileScssFile(path: string): Promise<string> {
|
|
33
|
+
return compileScss(await Bun.file(path).text(), path);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The `@error` text Sass refused `source` with, or `undefined` when it compiled. A refusal that is
|
|
38
|
+
* not Sass's own — the compiler failing to load — is rethrown: it is not an answer about `source`.
|
|
39
|
+
*/
|
|
40
|
+
export async function scssRefusal(source: string, path: string): Promise<string | undefined> {
|
|
41
|
+
try {
|
|
42
|
+
await compileScss(source, path);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (typeof error === 'object' && error !== null && 'sassMessage' in error) {
|
|
45
|
+
return String(error.sassMessage);
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Every class a compiled stylesheet declares. */
|
|
53
|
+
export function declaredClasses(css: string): ReadonlySet<string> {
|
|
54
|
+
return new Set([...css.matchAll(/\.([A-Za-z][\w-]*)/g)].map((match) => match[1] as string));
|
|
55
|
+
}
|
package/src/theme/brand.ts
CHANGED
|
@@ -72,10 +72,11 @@ export function defineTheme(input: BrandInput): Brand {
|
|
|
72
72
|
// `html[data-theme]`. A brand that only wrote `:root` would lose to those attribute rules on
|
|
73
73
|
// specificity, so every level it emits is answered here, in the same order.
|
|
74
74
|
if (light.length > 0) blocks.push(rule("html[data-theme='light']", light));
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
blocks.push(
|
|
75
|
+
const media = darkMediaDeclarations(input.colors);
|
|
76
|
+
if (media.length > 0) {
|
|
77
|
+
blocks.push(`@media (prefers-color-scheme: dark) {\n${indent(rule(':root', media))}\n}`);
|
|
78
78
|
}
|
|
79
|
+
if (dark.length > 0) blocks.push(rule("html[data-theme='dark']", dark));
|
|
79
80
|
|
|
80
81
|
return Object.freeze({ css: blocks.join('\n\n') });
|
|
81
82
|
}
|
|
@@ -111,6 +112,24 @@ function indent(block: string): string {
|
|
|
111
112
|
.join('\n');
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
/**
|
|
116
|
+
* The dark media `:root` must answer every role the brand's own `:root` touched. That `:root`
|
|
117
|
+
* (light) comes after `theme.scss`'s dark media `:root` at equal specificity, so a role overridden
|
|
118
|
+
* in light only would otherwise reach an OS-dark document with no `data-theme` — scripting off, or
|
|
119
|
+
* storage throwing in the boot script — on the dark palette it was never measured against. Such a
|
|
120
|
+
* role is answered with the SHIPPED dark channels. Called after both scopes were validated.
|
|
121
|
+
*/
|
|
122
|
+
function darkMediaDeclarations(colors: BrandInput['colors']): string[] {
|
|
123
|
+
const out: string[] = [];
|
|
124
|
+
for (const role of COLOR_ROLES) {
|
|
125
|
+
const value =
|
|
126
|
+
colors?.dark?.[role] ??
|
|
127
|
+
(colors?.light?.[role] === undefined ? undefined : colorTokens.dark[role]);
|
|
128
|
+
if (value !== undefined) out.push(`--color-${role}: ${value};`);
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
114
133
|
/**
|
|
115
134
|
* Ordered by the canonical role list, not by the caller's object — a brand file rendered twice
|
|
116
135
|
* must be byte-identical, or every consumer's CSP hash and diff churns for nothing.
|
package/src/theme/theme.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// Theme resolution. One rule: an explicit choice in storage wins,
|
|
2
|
-
//
|
|
3
|
-
// testable without a DOM and reusable on the server.
|
|
1
|
+
// Theme resolution. One rule: an explicit choice in storage wins, then the theme the boot script
|
|
2
|
+
// already stamped on the document, then the app's `theme.defaultMode` (the OS for `'system'`). Every side effect
|
|
3
|
+
// goes through an injected `ThemeEnv`, so this is testable without a DOM and reusable on the server.
|
|
4
4
|
|
|
5
5
|
import { invalidThemeError, runtimeMissingError } from '../errors';
|
|
6
6
|
import type { Theme } from '../tokens/tokens';
|
|
@@ -9,11 +9,18 @@ export type { Theme };
|
|
|
9
9
|
|
|
10
10
|
export const THEME_STORAGE_KEY = 'ultimate.theme';
|
|
11
11
|
export const THEME_ATTRIBUTE = 'data-theme';
|
|
12
|
+
/** Where the boot script records `theme.defaultMode` itself — `'light'`, `'dark'` or `'system'`.
|
|
13
|
+
* `@ultimat3/render`'s `themeScriptBody` writes `${attribute}-default`; this is that name. */
|
|
14
|
+
export const THEME_DEFAULT_ATTRIBUTE = `${THEME_ATTRIBUTE}-default`;
|
|
12
15
|
export const THEME_MEDIA_QUERY = '(prefers-color-scheme: dark)';
|
|
13
16
|
|
|
14
17
|
/** The host capabilities theme control needs. Injected so tests need no DOM. */
|
|
15
18
|
export interface ThemeEnv {
|
|
16
19
|
read(): string | null;
|
|
20
|
+
/** The theme the document carries right now — what the boot script stamped, or the last apply. */
|
|
21
|
+
current(): string | null;
|
|
22
|
+
/** The app's `theme.defaultMode` as the boot stamped it, or `null` when no boot ran. */
|
|
23
|
+
appDefault(): string | null;
|
|
17
24
|
write(value: string): void;
|
|
18
25
|
remove(): void;
|
|
19
26
|
prefersDark(): boolean;
|
|
@@ -40,9 +47,25 @@ export function osTheme(env: ThemeEnv): Theme {
|
|
|
40
47
|
return env.prefersDark() ? 'dark' : 'light';
|
|
41
48
|
}
|
|
42
49
|
|
|
43
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* What "no explicit choice" means for this app: its fixed `theme.defaultMode` when it has one, the
|
|
52
|
+
* OS when it is `'system'` or unknown. Clearing a choice and following the OS both answer this, so
|
|
53
|
+
* an app that opens dark is never flipped light by an OS change or a "system" pick.
|
|
54
|
+
*/
|
|
55
|
+
export function defaultTheme(env: ThemeEnv): Theme {
|
|
56
|
+
const fixed = env.appDefault();
|
|
57
|
+
return isTheme(fixed) ? fixed : osTheme(env);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolution order: explicit stored choice -> the theme on the document -> OS preference. The
|
|
62
|
+
* middle step is the app's default: with `theme.defaultMode: 'dark'` the boot script stamps dark
|
|
63
|
+
* on a light-OS machine, and answering the OS here made the first toggle write the theme already
|
|
64
|
+
* on screen — a click that did nothing.
|
|
65
|
+
*/
|
|
44
66
|
export function resolveTheme(env: ThemeEnv): Theme {
|
|
45
|
-
|
|
67
|
+
const current = env.current();
|
|
68
|
+
return storedTheme(env) ?? (isTheme(current) ? current : defaultTheme(env));
|
|
46
69
|
}
|
|
47
70
|
|
|
48
71
|
/** Run once on load. Idempotent — safe after the inline head script. */
|
|
@@ -60,10 +83,10 @@ export function setTheme(theme: Theme, env: ThemeEnv = browserThemeEnv()): Theme
|
|
|
60
83
|
return theme;
|
|
61
84
|
}
|
|
62
85
|
|
|
63
|
-
/** Forget the explicit choice and
|
|
86
|
+
/** Forget the explicit choice and return to the app's default (the OS, for `'system'`). */
|
|
64
87
|
export function clearTheme(env: ThemeEnv = browserThemeEnv()): Theme {
|
|
65
88
|
env.remove();
|
|
66
|
-
const theme =
|
|
89
|
+
const theme = defaultTheme(env);
|
|
67
90
|
env.apply(theme);
|
|
68
91
|
return theme;
|
|
69
92
|
}
|
|
@@ -74,12 +97,12 @@ export function toggleTheme(env: ThemeEnv = browserThemeEnv()): Theme {
|
|
|
74
97
|
}
|
|
75
98
|
|
|
76
99
|
/**
|
|
77
|
-
* Follow the OS live — but only while the user has made no explicit choice
|
|
78
|
-
* Returns an unsubscribe so a component can drop it on cleanup.
|
|
100
|
+
* Follow the OS live — but only while the user has made no explicit choice AND the app's default
|
|
101
|
+
* is the OS. Returns an unsubscribe so a component can drop it on cleanup.
|
|
79
102
|
*/
|
|
80
103
|
export function watchOsTheme(env: ThemeEnv = browserThemeEnv()): () => void {
|
|
81
104
|
return env.observeOs(() => {
|
|
82
|
-
if (storedTheme(env) === null) env.apply(osTheme(env));
|
|
105
|
+
if (storedTheme(env) === null && !isTheme(env.appDefault())) env.apply(osTheme(env));
|
|
83
106
|
});
|
|
84
107
|
}
|
|
85
108
|
|
|
@@ -94,6 +117,8 @@ export function browserThemeEnv(): ThemeEnv {
|
|
|
94
117
|
const media = window.matchMedia(THEME_MEDIA_QUERY);
|
|
95
118
|
return {
|
|
96
119
|
read: () => safeStorage(() => window.localStorage.getItem(THEME_STORAGE_KEY), null),
|
|
120
|
+
current: () => document.documentElement.getAttribute(THEME_ATTRIBUTE),
|
|
121
|
+
appDefault: () => document.documentElement.getAttribute(THEME_DEFAULT_ATTRIBUTE),
|
|
97
122
|
write: (value) => safeStorage(() => window.localStorage.setItem(THEME_STORAGE_KEY, value)),
|
|
98
123
|
remove: () => safeStorage(() => window.localStorage.removeItem(THEME_STORAGE_KEY)),
|
|
99
124
|
prefersDark: () => media.matches,
|
package/src/toast/toast-state.ts
CHANGED
|
Binary file
|
package/src/tokens/_index.scss
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Single entry point for authors: `@use '../tokens' as t;` then `t.role('bg')`,
|
|
2
|
-
// `t.space(4)`, `@include t.focus-ring`. Forwards maps, functions, and mixins —
|
|
2
|
+
// `t.space(4)`, `t.rem(24px)`, `@include t.focus-ring`. Forwards maps, functions, and mixins —
|
|
3
3
|
// emits no CSS. Global custom properties come from `theme.scss`.
|
|
4
4
|
|
|
5
5
|
@forward 'breakpoints';
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
@forward 'radius' hide emit;
|
|
9
9
|
@forward 'shadow' hide emit, levels;
|
|
10
10
|
@forward 'space' hide emit;
|
|
11
|
+
@forward 'stroke' hide emit;
|
|
11
12
|
@forward 'typography' hide emit;
|
|
13
|
+
@forward 'units';
|
|
12
14
|
@forward 'z' hide emit;
|
|
13
15
|
@forward 'mixins';
|
package/src/tokens/_mixins.scss
CHANGED
|
@@ -4,15 +4,22 @@
|
|
|
4
4
|
|
|
5
5
|
@use 'sass:list';
|
|
6
6
|
@use 'sass:map';
|
|
7
|
+
@use 'sass:string';
|
|
7
8
|
@use 'breakpoints';
|
|
8
9
|
@use 'colors';
|
|
9
10
|
@use 'motion';
|
|
10
11
|
@use 'radius';
|
|
11
12
|
@use 'space';
|
|
13
|
+
@use 'stroke';
|
|
12
14
|
@use 'typography';
|
|
15
|
+
@use 'units';
|
|
13
16
|
|
|
14
17
|
/// Keyboard-only focus, drawn from the accent role so it themes automatically.
|
|
15
|
-
@mixin focus-ring(
|
|
18
|
+
@mixin focus-ring(
|
|
19
|
+
$role: 'accent',
|
|
20
|
+
$width: stroke.stroke(thick),
|
|
21
|
+
$offset: stroke.stroke(thick)
|
|
22
|
+
) {
|
|
16
23
|
&:focus-visible {
|
|
17
24
|
outline: $width solid colors.role($role);
|
|
18
25
|
outline-offset: $offset;
|
|
@@ -81,13 +88,54 @@
|
|
|
81
88
|
container-name: $name;
|
|
82
89
|
}
|
|
83
90
|
|
|
84
|
-
|
|
91
|
+
// --- Viewport breakpoints ----------------------------------------------------
|
|
92
|
+
// Three intents over ONE map, so a rung is defined in one place: at and above (`respond-to`),
|
|
93
|
+
// below (`respond-down`), and from one rung up to the next (`respond-between`). Prefer a container
|
|
94
|
+
// query to any of them — reach here only when the layout depends on the viewport itself.
|
|
95
|
+
|
|
96
|
+
/// What a max-width arm stops short of its rung by. `max-width: 768px` and `min-width: 768px` both
|
|
97
|
+
/// match at exactly 768px, which is how one rule ends up applying in two ranges at once; 0.02px is
|
|
98
|
+
/// below any width a browser reports and above the rounding that would collapse it.
|
|
99
|
+
$-under: 0.02px;
|
|
100
|
+
|
|
101
|
+
/// A rung's width. Compared as TEXT as well as by value: `2xl` in a map is the number 2 with the
|
|
102
|
+
/// unit `xl`, so the string `'2xl'` every stylesheet writes is a different key to `map.get`.
|
|
103
|
+
@function -rung($bp, $mixin) {
|
|
104
|
+
@each $name, $width in breakpoints.$breakpoints {
|
|
105
|
+
@if '#{$name}' == '#{$bp}' {
|
|
106
|
+
@return $width;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
@error string.unquote('X_TOKEN_UNKNOWN: #{$mixin}("#{$bp}") — that breakpoint is not in $breakpoints. fix: use one of #{map.keys(breakpoints.$breakpoints)}');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// At a rung and wider. Named rungs, never a raw px value.
|
|
85
113
|
@mixin respond-to($bp) {
|
|
86
|
-
@
|
|
87
|
-
@
|
|
114
|
+
@media (min-width: -rung($bp, 'respond-to')) {
|
|
115
|
+
@content;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// Narrower than a rung — the complement of `respond-to` on the same rung, with no shared pixel.
|
|
120
|
+
@mixin respond-down($bp) {
|
|
121
|
+
@media (max-width: (-rung($bp, 'respond-down') - $-under)) {
|
|
122
|
+
@content;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// From `$from` up to, and not including, `$to`.
|
|
127
|
+
@mixin respond-between($from, $to) {
|
|
128
|
+
$min: -rung($from, 'respond-between');
|
|
129
|
+
$max: -rung($to, 'respond-between');
|
|
130
|
+
|
|
131
|
+
@if $min == $max {
|
|
132
|
+
@error string.unquote('X_UI_INVALID_VALUE: respond-between("#{$from}", "#{$to}") is an empty range — both ends are #{$min}. fix: write respond-to("#{$from}"), or name a higher rung as $to');
|
|
133
|
+
}
|
|
134
|
+
@if $min > $max {
|
|
135
|
+
@error string.unquote('X_UI_INVALID_VALUE: respond-between("#{$from}", "#{$to}") is reversed — #{$min} is not below #{$max}. fix: write respond-between("#{$to}", "#{$from}")');
|
|
88
136
|
}
|
|
89
137
|
|
|
90
|
-
@media (min-width:
|
|
138
|
+
@media (min-width: $min) and (max-width: ($max - $-under)) {
|
|
91
139
|
@content;
|
|
92
140
|
}
|
|
93
141
|
}
|
|
@@ -156,8 +204,8 @@
|
|
|
156
204
|
scrollbar-color: colors.role($thumb) colors.role($track);
|
|
157
205
|
|
|
158
206
|
&::-webkit-scrollbar {
|
|
159
|
-
width: 10px;
|
|
160
|
-
height: 10px;
|
|
207
|
+
width: units.rem(10px);
|
|
208
|
+
height: units.rem(10px);
|
|
161
209
|
}
|
|
162
210
|
|
|
163
211
|
&::-webkit-scrollbar-track {
|
|
@@ -165,7 +213,7 @@
|
|
|
165
213
|
}
|
|
166
214
|
|
|
167
215
|
&::-webkit-scrollbar-thumb {
|
|
168
|
-
border:
|
|
216
|
+
border: stroke.stroke(thick) solid transparent;
|
|
169
217
|
border-radius: radius.radius(pill);
|
|
170
218
|
background: colors.role($thumb);
|
|
171
219
|
background-clip: content-box;
|
package/src/tokens/_motion.scss
CHANGED
|
@@ -16,6 +16,10 @@ $easing: (
|
|
|
16
16
|
spring: cubic-bezier(0.34, 1.56, 0.64, 1),
|
|
17
17
|
);
|
|
18
18
|
|
|
19
|
+
/// What reduced motion collapses every duration to. Never `0`: an animation that takes no time
|
|
20
|
+
/// fires no `animationend`, and a component waiting on that event waits forever.
|
|
21
|
+
$still: 0.01ms;
|
|
22
|
+
|
|
19
23
|
@mixin emit {
|
|
20
24
|
:root {
|
|
21
25
|
@each $name, $value in $duration {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Line weights: a border, an outline, a focus ring, an indicator. px on purpose — a line that
|
|
2
|
+
// grew with the reader's text size would be a different design at 200% zoom, not a bigger one.
|
|
3
|
+
|
|
4
|
+
$stroke: (
|
|
5
|
+
hairline: 1px,
|
|
6
|
+
thick: 2px,
|
|
7
|
+
heavy: 3px,
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
@mixin emit {
|
|
11
|
+
:root {
|
|
12
|
+
@each $name, $value in $stroke {
|
|
13
|
+
--stroke-#{$name}: #{$value};
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@function stroke($name) {
|
|
19
|
+
@return var(--stroke-#{$name});
|
|
20
|
+
}
|