@ccheever/exact-facet-core 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.
- package/facet-registry.json +548 -0
- package/package.json +22 -0
- package/src/__tests__/facet-overrides.test.ts +91 -0
- package/src/__tests__/theme-definition-resolution.test.ts +235 -0
- package/src/__tests__/theme-store-parity.test.ts +283 -0
- package/src/facet-catalog.ts +75 -0
- package/src/facet-overrides.ts +322 -0
- package/src/facet-params.generated.ts +65 -0
- package/src/facet-scorecard.ts +60 -0
- package/src/index.ts +16 -0
- package/src/internals.ts +1313 -0
- package/src/provenance-trace.ts +153 -0
- package/src/store-machinery.ts +128 -0
- package/src/theme-definition.ts +566 -0
- package/src/theme-store.ts +456 -0
- package/tsconfig.json +19 -0
package/src/internals.ts
ADDED
|
@@ -0,0 +1,1313 @@
|
|
|
1
|
+
// @system @ref LLP 0157 — facet-core: framework-neutral heart of the Facet design system.
|
|
2
|
+
//
|
|
3
|
+
// Everything in this module is pure data or pure functions over
|
|
4
|
+
// `theme + options -> style object`. It was moved verbatim from
|
|
5
|
+
// `@exact/facet`'s internals (LLP 0008) as the F0 extraction; the React
|
|
6
|
+
// package re-exports all of it, so the React-facing API is unchanged.
|
|
7
|
+
//
|
|
8
|
+
// Dependency rule (enforced by the `facet-core-import-boundary` check):
|
|
9
|
+
// this package may import from `@exact/core` only — no `react`, no
|
|
10
|
+
// `solid-js`, no `vue`, no `svelte`, and no `@exact/renderer`.
|
|
11
|
+
|
|
12
|
+
import { colorToRgba, parseColor } from '@exact/core/style/color';
|
|
13
|
+
import type {
|
|
14
|
+
SpringTransition,
|
|
15
|
+
TransitionEasing,
|
|
16
|
+
TransitionMap,
|
|
17
|
+
} from '@exact/core/style/transitions';
|
|
18
|
+
|
|
19
|
+
export type Scheme = 'light' | 'dark';
|
|
20
|
+
export type SizeClass = 'compact' | 'regular' | 'expanded';
|
|
21
|
+
export type PresencePhase = 'entering' | 'entered' | 'exiting';
|
|
22
|
+
export type FieldVariant = 'default' | 'filled' | 'ghost';
|
|
23
|
+
export type FieldValidationState = 'default' | 'error' | 'success';
|
|
24
|
+
export type MotionTransform = Array<
|
|
25
|
+
| { translateX: number }
|
|
26
|
+
| { translateY: number }
|
|
27
|
+
| { scale: number }
|
|
28
|
+
>;
|
|
29
|
+
|
|
30
|
+
export type DeepPartial<T> = {
|
|
31
|
+
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface ShadowSpec {
|
|
35
|
+
x: number;
|
|
36
|
+
y: number;
|
|
37
|
+
blur: number;
|
|
38
|
+
spread?: number;
|
|
39
|
+
color: string;
|
|
40
|
+
opacity?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SizeClassToken {
|
|
44
|
+
minWidth?: number;
|
|
45
|
+
maxWidth?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type SizeClassTokens = Record<SizeClass, SizeClassToken>;
|
|
49
|
+
|
|
50
|
+
// @system @ref LLP 0269 Part III — the resolved semantic token taxonomy.
|
|
51
|
+
//
|
|
52
|
+
// `FacetTheme` is the *resolved* theme components read: every leaf names its
|
|
53
|
+
// purpose, not its appearance, so themes, schemes, density, and locales vary
|
|
54
|
+
// automatically. It is the output of `resolve(definition)` — see
|
|
55
|
+
// `theme-definition.ts` for `FacetThemeDefinition` and `resolveFacetTheme`.
|
|
56
|
+
// The old flat `FacetTheme` (a hand-authored token bag) was deleted in this
|
|
57
|
+
// revision; there are no `LegacyFacet*` aliases (pre-release execution note).
|
|
58
|
+
|
|
59
|
+
/** A status colour family — fill, its muted background, on-fill text, the
|
|
60
|
+
* status text colour for muted surfaces, and a border. Status badges, alerts,
|
|
61
|
+
* validation states, and destructive actions read these rather than inventing
|
|
62
|
+
* one-off contrast pairs. */
|
|
63
|
+
export interface StatusColorSet {
|
|
64
|
+
fill: string;
|
|
65
|
+
muted: string;
|
|
66
|
+
text: string;
|
|
67
|
+
onFill: string;
|
|
68
|
+
border: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface FacetColorTokens {
|
|
72
|
+
/** Surface layers, lightest/recessed → most raised. `surface` is the subtle
|
|
73
|
+
* inset neutral (secondary control fill); `card`/`popover` are raised. */
|
|
74
|
+
background: {
|
|
75
|
+
body: string;
|
|
76
|
+
surface: string;
|
|
77
|
+
card: string;
|
|
78
|
+
popover: string;
|
|
79
|
+
inverted: string;
|
|
80
|
+
};
|
|
81
|
+
text: {
|
|
82
|
+
primary: string;
|
|
83
|
+
secondary: string;
|
|
84
|
+
disabled: string;
|
|
85
|
+
accent: string;
|
|
86
|
+
onAccent: string;
|
|
87
|
+
onDanger: string;
|
|
88
|
+
onSuccess: string;
|
|
89
|
+
onWarning: string;
|
|
90
|
+
};
|
|
91
|
+
icon: {
|
|
92
|
+
primary: string;
|
|
93
|
+
secondary: string;
|
|
94
|
+
disabled: string;
|
|
95
|
+
accent: string;
|
|
96
|
+
};
|
|
97
|
+
border: {
|
|
98
|
+
subtle: string;
|
|
99
|
+
default: string;
|
|
100
|
+
strong: string;
|
|
101
|
+
focus: string;
|
|
102
|
+
};
|
|
103
|
+
status: {
|
|
104
|
+
success: StatusColorSet;
|
|
105
|
+
warning: StatusColorSet;
|
|
106
|
+
danger: StatusColorSet;
|
|
107
|
+
info: StatusColorSet;
|
|
108
|
+
};
|
|
109
|
+
/** Generic interaction overlays composited over a base surface. */
|
|
110
|
+
overlay: {
|
|
111
|
+
scrim: string;
|
|
112
|
+
hover: string;
|
|
113
|
+
pressed: string;
|
|
114
|
+
selected: string;
|
|
115
|
+
};
|
|
116
|
+
accent: {
|
|
117
|
+
fill: string;
|
|
118
|
+
hover: string;
|
|
119
|
+
pressed: string;
|
|
120
|
+
text: string;
|
|
121
|
+
onFill: string;
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface FacetSpaceTokens {
|
|
126
|
+
xs: number;
|
|
127
|
+
sm: number;
|
|
128
|
+
md: number;
|
|
129
|
+
lg: number;
|
|
130
|
+
xl: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type DensitySize = 'sm' | 'md' | 'lg';
|
|
134
|
+
|
|
135
|
+
/** Density resolves comfort/spacing intent into concrete control sizing. The
|
|
136
|
+
* `mode` echoes the definition dial. `paddingScale` is the multiplier recipes
|
|
137
|
+
* apply at apply-time (LLP 0269 §6 context-computed values). */
|
|
138
|
+
export interface FacetDensityTokens {
|
|
139
|
+
mode: ThemeDensity;
|
|
140
|
+
controlHeight: Record<DensitySize, number>;
|
|
141
|
+
rowHeight: number;
|
|
142
|
+
iconSize: Record<DensitySize, number>;
|
|
143
|
+
minHitTarget: number;
|
|
144
|
+
paddingScale: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface FacetTypeRole {
|
|
148
|
+
fontSize: number;
|
|
149
|
+
lineHeight: number;
|
|
150
|
+
fontWeight: number;
|
|
151
|
+
/** A font-family stack (array form the renderer already understands on every
|
|
152
|
+
* platform), not a CSS comma string. */
|
|
153
|
+
fontFamily: readonly string[];
|
|
154
|
+
letterSpacing?: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface FacetTypeTokens {
|
|
158
|
+
caption: FacetTypeRole;
|
|
159
|
+
body: FacetTypeRole;
|
|
160
|
+
bodyStrong: FacetTypeRole;
|
|
161
|
+
label: FacetTypeRole;
|
|
162
|
+
title: FacetTypeRole;
|
|
163
|
+
heading: FacetTypeRole;
|
|
164
|
+
display: FacetTypeRole;
|
|
165
|
+
code: FacetTypeRole;
|
|
166
|
+
families: {
|
|
167
|
+
body: readonly string[];
|
|
168
|
+
heading: readonly string[];
|
|
169
|
+
mono: readonly string[];
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Semantic radius levels. `inner` is the concentric inner default; recipes
|
|
174
|
+
* compute per-instance concentricity via `concentricRadius(outer, padding)`. */
|
|
175
|
+
export interface FacetRadiusTokens {
|
|
176
|
+
none: number;
|
|
177
|
+
inner: number;
|
|
178
|
+
element: number;
|
|
179
|
+
container: number;
|
|
180
|
+
page: number;
|
|
181
|
+
full: number;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface FacetElevationTokens {
|
|
185
|
+
low: ShadowSpec;
|
|
186
|
+
medium: ShadowSpec;
|
|
187
|
+
high: ShadowSpec;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface FacetZIndexTokens {
|
|
191
|
+
base: number;
|
|
192
|
+
dropdown: number;
|
|
193
|
+
sticky: number;
|
|
194
|
+
modal: number;
|
|
195
|
+
toast: number;
|
|
196
|
+
tooltip: number;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface FacetMotionTokens {
|
|
200
|
+
reducedMotion: boolean;
|
|
201
|
+
/** The definition's motion multiplier, carried for inspection/agents. */
|
|
202
|
+
multiplier: number;
|
|
203
|
+
duration: {
|
|
204
|
+
fast: number;
|
|
205
|
+
normal: number;
|
|
206
|
+
slow: number;
|
|
207
|
+
};
|
|
208
|
+
easing: {
|
|
209
|
+
enter: TransitionEasing;
|
|
210
|
+
exit: TransitionEasing;
|
|
211
|
+
move: TransitionEasing;
|
|
212
|
+
/** 0260 Proposal I's single standard curve, aliased onto `move`. */
|
|
213
|
+
standard: TransitionEasing;
|
|
214
|
+
};
|
|
215
|
+
spring: {
|
|
216
|
+
default: SpringTransition;
|
|
217
|
+
snappy: SpringTransition;
|
|
218
|
+
};
|
|
219
|
+
preset: {
|
|
220
|
+
control: TransitionMap;
|
|
221
|
+
controlPress: TransitionMap;
|
|
222
|
+
selection: TransitionMap;
|
|
223
|
+
tabIndicator: TransitionMap;
|
|
224
|
+
fadeIn: TransitionMap;
|
|
225
|
+
fadeOut: TransitionMap;
|
|
226
|
+
scaleIn: TransitionMap;
|
|
227
|
+
popIn: TransitionMap;
|
|
228
|
+
popOut: TransitionMap;
|
|
229
|
+
slideUp: TransitionMap;
|
|
230
|
+
slideDown: TransitionMap;
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export type ThemeContrast = 'standard' | 'increased';
|
|
235
|
+
export type PlatformFamily = 'apple' | 'android' | 'windows' | 'web';
|
|
236
|
+
|
|
237
|
+
/** Adaptation preferences as resolved token input (LLP 0269 §9) — recipes and
|
|
238
|
+
* native bridges consume these; app code does not branch on them by default. */
|
|
239
|
+
export interface FacetAdaptationTokens {
|
|
240
|
+
scheme: Scheme;
|
|
241
|
+
sizeClass: SizeClass;
|
|
242
|
+
reducedMotion: boolean;
|
|
243
|
+
textScale: number;
|
|
244
|
+
contrast: ThemeContrast;
|
|
245
|
+
platform: PlatformFamily;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export interface FacetTheme {
|
|
249
|
+
name: string;
|
|
250
|
+
scheme: Scheme;
|
|
251
|
+
/** Active viewport/host size class resolved from `sizeClasses` (LLP 0195 S0). */
|
|
252
|
+
sizeClass: SizeClass;
|
|
253
|
+
/** Framework-neutral size-class tokens, shared by Contract and React lanes. */
|
|
254
|
+
sizeClasses: SizeClassTokens;
|
|
255
|
+
color: FacetColorTokens;
|
|
256
|
+
space: FacetSpaceTokens;
|
|
257
|
+
density: FacetDensityTokens;
|
|
258
|
+
type: FacetTypeTokens;
|
|
259
|
+
radius: FacetRadiusTokens;
|
|
260
|
+
elevation: FacetElevationTokens;
|
|
261
|
+
zIndex: FacetZIndexTokens;
|
|
262
|
+
motion: FacetMotionTokens;
|
|
263
|
+
adaptation: FacetAdaptationTokens;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Spec name from LLP 0269 §2. Kept as an alias so references to the
|
|
267
|
+
* "resolved theme" type resolve; the public name stays `FacetTheme`. */
|
|
268
|
+
export type ResolvedFacetTheme = FacetTheme;
|
|
269
|
+
|
|
270
|
+
/** The density dial values (LLP 0269 §5). Defined here because
|
|
271
|
+
* `FacetDensityTokens.mode` references it; the full definition lives in
|
|
272
|
+
* `theme-definition.ts`. */
|
|
273
|
+
export type ThemeDensity = 'compact' | 'default' | 'comfortable' | 'gigantic';
|
|
274
|
+
|
|
275
|
+
export const defaultSizeClasses: SizeClassTokens = {
|
|
276
|
+
compact: { maxWidth: 599 },
|
|
277
|
+
regular: { minWidth: 600, maxWidth: 1023 },
|
|
278
|
+
expanded: { minWidth: 1024 },
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const SIZE_CLASS_ORDER: SizeClass[] = ['compact', 'regular', 'expanded'];
|
|
282
|
+
|
|
283
|
+
export function resolveSizeClassForWidth(
|
|
284
|
+
width: number,
|
|
285
|
+
sizeClasses: SizeClassTokens = defaultSizeClasses,
|
|
286
|
+
): SizeClass {
|
|
287
|
+
const safeWidth = Number.isFinite(width) ? Math.max(0, width) : 0;
|
|
288
|
+
for (const name of SIZE_CLASS_ORDER) {
|
|
289
|
+
const token = sizeClasses[name];
|
|
290
|
+
const min = token.minWidth ?? Number.NEGATIVE_INFINITY;
|
|
291
|
+
const max = token.maxWidth ?? Number.POSITIVE_INFINITY;
|
|
292
|
+
if (safeWidth >= min && safeWidth <= max) {
|
|
293
|
+
return name;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return 'expanded';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export const facetSansFontFamily = [
|
|
300
|
+
'-apple-system',
|
|
301
|
+
'BlinkMacSystemFont',
|
|
302
|
+
'Inter',
|
|
303
|
+
'Segoe UI',
|
|
304
|
+
'system-ui',
|
|
305
|
+
'Helvetica Neue',
|
|
306
|
+
'sans-serif',
|
|
307
|
+
] as const;
|
|
308
|
+
|
|
309
|
+
function cloneTransitionMap(map: TransitionMap): TransitionMap {
|
|
310
|
+
return { ...map };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function stripTransformTransition(map: TransitionMap): TransitionMap {
|
|
314
|
+
const next = { ...map };
|
|
315
|
+
delete next.transform;
|
|
316
|
+
return next;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function buildFacetMotion(
|
|
320
|
+
reducedMotion: boolean,
|
|
321
|
+
multiplier: number = 1,
|
|
322
|
+
): FacetMotionTokens {
|
|
323
|
+
// The motion multiplier (LLP 0269 §8) is a definition dial; it scales every
|
|
324
|
+
// resolved duration. Clamp to a sane range so a hostile dial cannot freeze or
|
|
325
|
+
// stall the UI.
|
|
326
|
+
const scale = Number.isFinite(multiplier) ? clamp(multiplier, 0, 4) : 1;
|
|
327
|
+
const round = (ms: number): number => Math.max(0, Math.round(ms * scale));
|
|
328
|
+
const duration = {
|
|
329
|
+
fast: round(100),
|
|
330
|
+
normal: round(200),
|
|
331
|
+
slow: round(300),
|
|
332
|
+
} as const;
|
|
333
|
+
const easing = {
|
|
334
|
+
enter: 'easeOut' as const,
|
|
335
|
+
exit: 'easeIn' as const,
|
|
336
|
+
move: 'easeInOut' as const,
|
|
337
|
+
standard: 'easeInOut' as const,
|
|
338
|
+
};
|
|
339
|
+
const spring = {
|
|
340
|
+
default: {
|
|
341
|
+
type: 'spring' as const,
|
|
342
|
+
damping: 20,
|
|
343
|
+
stiffness: 300,
|
|
344
|
+
},
|
|
345
|
+
snappy: {
|
|
346
|
+
type: 'spring' as const,
|
|
347
|
+
damping: 22,
|
|
348
|
+
stiffness: 350,
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
const fadeIn: TransitionMap = {
|
|
352
|
+
opacity: { type: 'timing', duration: duration.normal, easing: easing.enter },
|
|
353
|
+
};
|
|
354
|
+
const fadeOut: TransitionMap = {
|
|
355
|
+
opacity: { type: 'timing', duration: round(150), easing: easing.exit },
|
|
356
|
+
};
|
|
357
|
+
const control: TransitionMap = {
|
|
358
|
+
backgroundColor: { type: 'timing', duration: duration.fast, easing: easing.move },
|
|
359
|
+
borderRadius: { type: 'timing', duration: duration.fast, easing: easing.move },
|
|
360
|
+
};
|
|
361
|
+
const controlPress: TransitionMap = reducedMotion
|
|
362
|
+
? cloneTransitionMap(control)
|
|
363
|
+
: {
|
|
364
|
+
...cloneTransitionMap(control),
|
|
365
|
+
transform: { type: 'timing', duration: duration.fast, easing: easing.move },
|
|
366
|
+
};
|
|
367
|
+
const selection: TransitionMap = reducedMotion
|
|
368
|
+
? {
|
|
369
|
+
backgroundColor: { type: 'timing', duration: duration.fast, easing: easing.move },
|
|
370
|
+
}
|
|
371
|
+
: {
|
|
372
|
+
backgroundColor: { type: 'timing', duration: duration.fast, easing: easing.move },
|
|
373
|
+
transform: { type: 'timing', duration: duration.fast, easing: easing.move },
|
|
374
|
+
};
|
|
375
|
+
const tabIndicator: TransitionMap = reducedMotion
|
|
376
|
+
? {
|
|
377
|
+
opacity: { type: 'timing', duration: duration.fast, easing: easing.move },
|
|
378
|
+
}
|
|
379
|
+
: {
|
|
380
|
+
opacity: { type: 'timing', duration: duration.fast, easing: easing.move },
|
|
381
|
+
transform: spring.snappy,
|
|
382
|
+
};
|
|
383
|
+
const scaleIn: TransitionMap = reducedMotion
|
|
384
|
+
? {}
|
|
385
|
+
: {
|
|
386
|
+
transform: spring.snappy,
|
|
387
|
+
};
|
|
388
|
+
const popIn: TransitionMap = reducedMotion
|
|
389
|
+
? cloneTransitionMap(fadeIn)
|
|
390
|
+
: {
|
|
391
|
+
opacity: { type: 'timing', duration: round(150), easing: easing.enter },
|
|
392
|
+
transform: { type: 'timing', duration: round(150), easing: easing.enter },
|
|
393
|
+
};
|
|
394
|
+
const popOut: TransitionMap = reducedMotion
|
|
395
|
+
? cloneTransitionMap(fadeOut)
|
|
396
|
+
: {
|
|
397
|
+
opacity: { type: 'timing', duration: duration.fast, easing: easing.exit },
|
|
398
|
+
transform: { type: 'timing', duration: duration.fast, easing: easing.exit },
|
|
399
|
+
};
|
|
400
|
+
const slideUp: TransitionMap = reducedMotion
|
|
401
|
+
? cloneTransitionMap(fadeIn)
|
|
402
|
+
: {
|
|
403
|
+
opacity: { type: 'timing', duration: round(250), easing: easing.enter },
|
|
404
|
+
transform: { type: 'timing', duration: round(250), easing: easing.enter },
|
|
405
|
+
};
|
|
406
|
+
const slideDown: TransitionMap = reducedMotion
|
|
407
|
+
? cloneTransitionMap(fadeOut)
|
|
408
|
+
: {
|
|
409
|
+
opacity: { type: 'timing', duration: duration.normal, easing: easing.exit },
|
|
410
|
+
transform: { type: 'timing', duration: duration.normal, easing: easing.exit },
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
reducedMotion,
|
|
415
|
+
multiplier: scale,
|
|
416
|
+
duration,
|
|
417
|
+
easing,
|
|
418
|
+
spring,
|
|
419
|
+
preset: {
|
|
420
|
+
control,
|
|
421
|
+
controlPress,
|
|
422
|
+
selection,
|
|
423
|
+
tabIndicator,
|
|
424
|
+
fadeIn,
|
|
425
|
+
fadeOut,
|
|
426
|
+
scaleIn,
|
|
427
|
+
popIn,
|
|
428
|
+
popOut,
|
|
429
|
+
slideUp,
|
|
430
|
+
slideDown,
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function applyMotionPreference(
|
|
436
|
+
animation: FacetMotionTokens,
|
|
437
|
+
reducedMotion: boolean,
|
|
438
|
+
): FacetMotionTokens {
|
|
439
|
+
if (!reducedMotion) {
|
|
440
|
+
return {
|
|
441
|
+
...animation,
|
|
442
|
+
reducedMotion: false,
|
|
443
|
+
preset: {
|
|
444
|
+
control: cloneTransitionMap(animation.preset.control),
|
|
445
|
+
controlPress: cloneTransitionMap(animation.preset.controlPress),
|
|
446
|
+
selection: cloneTransitionMap(animation.preset.selection),
|
|
447
|
+
tabIndicator: cloneTransitionMap(animation.preset.tabIndicator),
|
|
448
|
+
fadeIn: cloneTransitionMap(animation.preset.fadeIn),
|
|
449
|
+
fadeOut: cloneTransitionMap(animation.preset.fadeOut),
|
|
450
|
+
scaleIn: cloneTransitionMap(animation.preset.scaleIn),
|
|
451
|
+
popIn: cloneTransitionMap(animation.preset.popIn),
|
|
452
|
+
popOut: cloneTransitionMap(animation.preset.popOut),
|
|
453
|
+
slideUp: cloneTransitionMap(animation.preset.slideUp),
|
|
454
|
+
slideDown: cloneTransitionMap(animation.preset.slideDown),
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
...animation,
|
|
461
|
+
reducedMotion: true,
|
|
462
|
+
preset: {
|
|
463
|
+
control: stripTransformTransition(animation.preset.control),
|
|
464
|
+
controlPress: stripTransformTransition(animation.preset.controlPress),
|
|
465
|
+
selection: stripTransformTransition(animation.preset.selection),
|
|
466
|
+
tabIndicator: stripTransformTransition(animation.preset.tabIndicator),
|
|
467
|
+
fadeIn: stripTransformTransition(animation.preset.fadeIn),
|
|
468
|
+
fadeOut: stripTransformTransition(animation.preset.fadeOut),
|
|
469
|
+
scaleIn: stripTransformTransition(animation.preset.scaleIn),
|
|
470
|
+
popIn: stripTransformTransition(animation.preset.popIn),
|
|
471
|
+
popOut: stripTransformTransition(animation.preset.popOut),
|
|
472
|
+
slideUp: stripTransformTransition(animation.preset.slideUp),
|
|
473
|
+
slideDown: stripTransformTransition(animation.preset.slideDown),
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export function buildMotionTransform(
|
|
479
|
+
theme: FacetTheme,
|
|
480
|
+
transform: MotionTransform,
|
|
481
|
+
): MotionTransform | undefined {
|
|
482
|
+
return theme.motion.reducedMotion ? undefined : transform;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function resolvePlacementSide(
|
|
486
|
+
placement: string | undefined,
|
|
487
|
+
): 'top' | 'right' | 'bottom' | 'left' {
|
|
488
|
+
const side = placement?.split('-')[0];
|
|
489
|
+
if (side === 'top' || side === 'right' || side === 'left') {
|
|
490
|
+
return side;
|
|
491
|
+
}
|
|
492
|
+
return 'bottom';
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function resolveSheetHiddenTransform(
|
|
496
|
+
theme: FacetTheme,
|
|
497
|
+
side: 'left' | 'right' | 'bottom',
|
|
498
|
+
): MotionTransform | undefined {
|
|
499
|
+
if (side === 'left') {
|
|
500
|
+
return buildMotionTransform(theme, [{ translateX: -28 }]);
|
|
501
|
+
}
|
|
502
|
+
if (side === 'bottom') {
|
|
503
|
+
return buildMotionTransform(theme, [{ translateY: 28 }]);
|
|
504
|
+
}
|
|
505
|
+
return buildMotionTransform(theme, [{ translateX: 28 }]);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function resolveSheetVisibleTransform(
|
|
509
|
+
theme: FacetTheme,
|
|
510
|
+
side: 'left' | 'right' | 'bottom',
|
|
511
|
+
): MotionTransform | undefined {
|
|
512
|
+
if (side === 'bottom') {
|
|
513
|
+
return buildMotionTransform(theme, [{ translateY: 0 }]);
|
|
514
|
+
}
|
|
515
|
+
return buildMotionTransform(theme, [{ translateX: 0 }]);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function resolveFloatingHiddenTransform(
|
|
519
|
+
theme: FacetTheme,
|
|
520
|
+
placement: string | undefined,
|
|
521
|
+
distance: number = 8,
|
|
522
|
+
): MotionTransform | undefined {
|
|
523
|
+
const side = resolvePlacementSide(placement);
|
|
524
|
+
if (side === 'top') {
|
|
525
|
+
return buildMotionTransform(theme, [{ translateY: distance }]);
|
|
526
|
+
}
|
|
527
|
+
if (side === 'left') {
|
|
528
|
+
return buildMotionTransform(theme, [{ translateX: distance }]);
|
|
529
|
+
}
|
|
530
|
+
if (side === 'right') {
|
|
531
|
+
return buildMotionTransform(theme, [{ translateX: -distance }]);
|
|
532
|
+
}
|
|
533
|
+
return buildMotionTransform(theme, [{ translateY: -distance }]);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function resolveFloatingVisibleTransform(
|
|
537
|
+
theme: FacetTheme,
|
|
538
|
+
placement: string | undefined,
|
|
539
|
+
): MotionTransform | undefined {
|
|
540
|
+
const side = resolvePlacementSide(placement);
|
|
541
|
+
if (side === 'top' || side === 'bottom') {
|
|
542
|
+
return buildMotionTransform(theme, [{ translateY: 0 }]);
|
|
543
|
+
}
|
|
544
|
+
return buildMotionTransform(theme, [{ translateX: 0 }]);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export function resolvePresenceMotionStyle(
|
|
548
|
+
phase: PresencePhase,
|
|
549
|
+
options: {
|
|
550
|
+
enterTransition: TransitionMap;
|
|
551
|
+
exitTransition?: TransitionMap;
|
|
552
|
+
enteredStyle?: Record<string, unknown>;
|
|
553
|
+
enteringStyle?: Record<string, unknown>;
|
|
554
|
+
exitingStyle?: Record<string, unknown>;
|
|
555
|
+
},
|
|
556
|
+
): Record<string, unknown> {
|
|
557
|
+
const enteredStyle = options.enteredStyle ?? {};
|
|
558
|
+
if (phase === 'entered') {
|
|
559
|
+
return {
|
|
560
|
+
...enteredStyle,
|
|
561
|
+
transition: options.enterTransition,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
if (phase === 'entering') {
|
|
565
|
+
return {
|
|
566
|
+
...enteredStyle,
|
|
567
|
+
...(options.enteringStyle ?? {}),
|
|
568
|
+
transition: options.enterTransition,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
return {
|
|
572
|
+
...enteredStyle,
|
|
573
|
+
...(options.exitingStyle ?? options.enteringStyle ?? {}),
|
|
574
|
+
transition: options.exitTransition ?? options.enterTransition,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export function mergeFloatingMotionStyle(
|
|
579
|
+
floatingStyle: Record<string, unknown>,
|
|
580
|
+
motionStyle: Record<string, unknown>,
|
|
581
|
+
): Record<string, unknown> {
|
|
582
|
+
const floatingOpacity =
|
|
583
|
+
typeof floatingStyle.opacity === 'number'
|
|
584
|
+
? floatingStyle.opacity
|
|
585
|
+
: undefined;
|
|
586
|
+
const motionOpacity =
|
|
587
|
+
typeof motionStyle.opacity === 'number'
|
|
588
|
+
? motionStyle.opacity
|
|
589
|
+
: undefined;
|
|
590
|
+
|
|
591
|
+
return {
|
|
592
|
+
...floatingStyle,
|
|
593
|
+
...motionStyle,
|
|
594
|
+
opacity:
|
|
595
|
+
floatingOpacity !== undefined && motionOpacity !== undefined
|
|
596
|
+
? floatingOpacity * motionOpacity
|
|
597
|
+
: motionOpacity ?? floatingOpacity,
|
|
598
|
+
pointerEvents: floatingStyle.pointerEvents,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// `lightTheme` / `darkTheme` are no longer hand-authored token bags. They are
|
|
603
|
+
// `resolveFacetTheme(defaultThemeDefinition, { scheme })` and live in
|
|
604
|
+
// `theme-definition.ts` alongside the definition + resolver. The shipped Facet
|
|
605
|
+
// look (a cool near-monochrome neutral ramp, hairline borders, a single indigo
|
|
606
|
+
// accent — the Linear / Vercel / Stripe family) is now the *default
|
|
607
|
+
// definition's* resolved output. `index.ts` re-exports them.
|
|
608
|
+
|
|
609
|
+
const facetWarnedMessages = new Set<string>();
|
|
610
|
+
|
|
611
|
+
export function deepMerge<T extends Record<string, any>>(
|
|
612
|
+
base: T,
|
|
613
|
+
override: DeepPartial<T> | null | undefined,
|
|
614
|
+
): T {
|
|
615
|
+
if (!override) {
|
|
616
|
+
return base;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const result = { ...base } as T;
|
|
620
|
+
for (const key of Object.keys(override) as Array<keyof T>) {
|
|
621
|
+
const overrideValue = override[key];
|
|
622
|
+
if (overrideValue === undefined) {
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const baseValue = result[key];
|
|
627
|
+
if (
|
|
628
|
+
baseValue &&
|
|
629
|
+
typeof baseValue === 'object' &&
|
|
630
|
+
!Array.isArray(baseValue) &&
|
|
631
|
+
overrideValue &&
|
|
632
|
+
typeof overrideValue === 'object' &&
|
|
633
|
+
!Array.isArray(overrideValue)
|
|
634
|
+
) {
|
|
635
|
+
result[key] = deepMerge(baseValue, overrideValue as DeepPartial<typeof baseValue>) as T[keyof T];
|
|
636
|
+
} else {
|
|
637
|
+
result[key] = overrideValue as T[keyof T];
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return result;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export function provenanceProps(
|
|
645
|
+
componentName: string,
|
|
646
|
+
slotName?: string,
|
|
647
|
+
variantProps?: Record<string, unknown>,
|
|
648
|
+
sourceFilePath: string = 'packages/exact-facet/src',
|
|
649
|
+
): Record<string, unknown> {
|
|
650
|
+
return {
|
|
651
|
+
__exactComponentName: componentName,
|
|
652
|
+
__exactComponentSlot: slotName,
|
|
653
|
+
__exactSourceFilePath: sourceFilePath,
|
|
654
|
+
__exactVariantProps: variantProps ? JSON.stringify(variantProps) : undefined,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export function isFacetDevMode(): boolean {
|
|
659
|
+
const runtime = globalThis as typeof globalThis & {
|
|
660
|
+
__DEV__?: boolean;
|
|
661
|
+
process?: {
|
|
662
|
+
env?: {
|
|
663
|
+
NODE_ENV?: string;
|
|
664
|
+
};
|
|
665
|
+
};
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
if (typeof runtime.__DEV__ === 'boolean') {
|
|
669
|
+
return runtime.__DEV__;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return runtime.process?.env?.NODE_ENV !== 'production';
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
export function warnFacetOnce(message: string): void {
|
|
676
|
+
if (!isFacetDevMode() || facetWarnedMessages.has(message)) {
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
facetWarnedMessages.add(message);
|
|
681
|
+
console.warn(message);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export function resolveCompactInset(theme: FacetTheme): number {
|
|
685
|
+
return Math.max(1, Math.round(theme.space.xs / 3));
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Density-scaled padding (LLP 0269 §5): the apply-time mechanism that makes
|
|
690
|
+
* the density dial *felt*. Recipes pass a base from the space scale and the
|
|
691
|
+
* instance applies `density.paddingScale`. A non-finite or non-positive
|
|
692
|
+
* scale falls back to 1 so a malformed override can never zero out control
|
|
693
|
+
* padding. Default density (`paddingScale: 1`) is byte-identical to the
|
|
694
|
+
* unscaled base.
|
|
695
|
+
*/
|
|
696
|
+
export function resolveDensityPadding(theme: FacetTheme, base: number): number {
|
|
697
|
+
const scale = theme.density?.paddingScale;
|
|
698
|
+
const multiplier =
|
|
699
|
+
typeof scale === 'number' && Number.isFinite(scale) && scale > 0 ? scale : 1;
|
|
700
|
+
if (multiplier === 1) {
|
|
701
|
+
// Identity, exactly: the default density passes the base through
|
|
702
|
+
// untouched — byte-identical values AND an intact provenance sentinel
|
|
703
|
+
// (the D0 tracing still sees the raw token, e.g. `space.md`).
|
|
704
|
+
return base;
|
|
705
|
+
}
|
|
706
|
+
return Math.max(0, Math.round(base * multiplier));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export function resolveFocusRingInset(theme: FacetTheme): number {
|
|
710
|
+
return Math.max(1, Math.round(theme.space.xs / 2));
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export function resolveInlineInset(theme: FacetTheme): number {
|
|
714
|
+
return Math.max(theme.space.xs, Math.round((theme.space.xs + theme.space.sm) / 2));
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export function resolveFloatingViewportPadding(theme: FacetTheme): number {
|
|
718
|
+
return Math.round((theme.space.sm + theme.space.md) / 2);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
export function resolveThemeColorContrastPair(
|
|
722
|
+
foreground: string,
|
|
723
|
+
background: string,
|
|
724
|
+
): number | null {
|
|
725
|
+
const parsedForeground = parseColor(foreground);
|
|
726
|
+
const parsedBackground = parseColor(background);
|
|
727
|
+
if (!parsedForeground || !parsedBackground) {
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const alpha = parsedForeground.a / 255;
|
|
732
|
+
const compositedForeground = alpha >= 1
|
|
733
|
+
? parsedForeground
|
|
734
|
+
: {
|
|
735
|
+
r: Math.round(parsedForeground.r * alpha + parsedBackground.r * (1 - alpha)),
|
|
736
|
+
g: Math.round(parsedForeground.g * alpha + parsedBackground.g * (1 - alpha)),
|
|
737
|
+
b: Math.round(parsedForeground.b * alpha + parsedBackground.b * (1 - alpha)),
|
|
738
|
+
a: 255,
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
const toLinear = (channel: number): number => {
|
|
742
|
+
const normalized = channel / 255;
|
|
743
|
+
return normalized <= 0.03928
|
|
744
|
+
? normalized / 12.92
|
|
745
|
+
: ((normalized + 0.055) / 1.055) ** 2.4;
|
|
746
|
+
};
|
|
747
|
+
const luminance = (color: { r: number; g: number; b: number }): number => (
|
|
748
|
+
0.2126 * toLinear(color.r) +
|
|
749
|
+
0.7152 * toLinear(color.g) +
|
|
750
|
+
0.0722 * toLinear(color.b)
|
|
751
|
+
);
|
|
752
|
+
const foregroundLuminance = luminance(compositedForeground);
|
|
753
|
+
const backgroundLuminance = luminance(parsedBackground);
|
|
754
|
+
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
|
|
755
|
+
const darker = Math.min(foregroundLuminance, backgroundLuminance);
|
|
756
|
+
|
|
757
|
+
return Number(((lighter + 0.05) / (darker + 0.05)).toFixed(2));
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
export function validateFacetContrast(theme: FacetTheme): string[] {
|
|
761
|
+
const { color } = theme;
|
|
762
|
+
const surfaces = [
|
|
763
|
+
['body', color.background.body],
|
|
764
|
+
['surface', color.background.surface],
|
|
765
|
+
['card', color.background.card],
|
|
766
|
+
] as const;
|
|
767
|
+
|
|
768
|
+
const contrastChecks: Array<{ label: string; foreground: string; background: string }> = [];
|
|
769
|
+
for (const [name, bg] of surfaces) {
|
|
770
|
+
contrastChecks.push({ label: `text.primary on ${name}`, foreground: color.text.primary, background: bg });
|
|
771
|
+
contrastChecks.push({ label: `text.secondary on ${name}`, foreground: color.text.secondary, background: bg });
|
|
772
|
+
}
|
|
773
|
+
contrastChecks.push(
|
|
774
|
+
{ label: 'accent.onFill on accent.fill', foreground: color.accent.onFill, background: color.accent.fill },
|
|
775
|
+
{ label: 'accent.onFill on accent.hover', foreground: color.accent.onFill, background: color.accent.hover },
|
|
776
|
+
{ label: 'accent.onFill on accent.pressed', foreground: color.accent.onFill, background: color.accent.pressed },
|
|
777
|
+
{ label: 'status.success onFill', foreground: color.status.success.onFill, background: color.status.success.fill },
|
|
778
|
+
{ label: 'status.danger onFill', foreground: color.status.danger.onFill, background: color.status.danger.fill },
|
|
779
|
+
{ label: 'status.warning onFill', foreground: color.status.warning.onFill, background: color.status.warning.fill },
|
|
780
|
+
{ label: 'status.info onFill', foreground: color.status.info.onFill, background: color.status.info.fill },
|
|
781
|
+
);
|
|
782
|
+
|
|
783
|
+
return contrastChecks.flatMap((check) => {
|
|
784
|
+
const ratio = resolveThemeColorContrastPair(check.foreground, check.background);
|
|
785
|
+
if (ratio == null || ratio >= 4.5) {
|
|
786
|
+
return [];
|
|
787
|
+
}
|
|
788
|
+
return [`${check.label} (${ratio}:1)`];
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export function combineAccessibilityIds(
|
|
793
|
+
...ids: Array<string | undefined>
|
|
794
|
+
): string | undefined {
|
|
795
|
+
const uniqueIds = Array.from(
|
|
796
|
+
new Set(
|
|
797
|
+
ids
|
|
798
|
+
.flatMap((value) => (typeof value === 'string' ? value.split(/\s+/) : []))
|
|
799
|
+
.map((value) => value.trim())
|
|
800
|
+
.filter((value) => value.length > 0),
|
|
801
|
+
),
|
|
802
|
+
);
|
|
803
|
+
|
|
804
|
+
return uniqueIds.length > 0 ? uniqueIds.join(' ') : undefined;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
export function pickFieldTextStyle(
|
|
808
|
+
style: Record<string, unknown> | undefined,
|
|
809
|
+
): Record<string, unknown> {
|
|
810
|
+
if (!style) {
|
|
811
|
+
return {};
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const keys = [
|
|
815
|
+
'color',
|
|
816
|
+
'fontFamily',
|
|
817
|
+
'fontSize',
|
|
818
|
+
'fontStyle',
|
|
819
|
+
'fontWeight',
|
|
820
|
+
'letterSpacing',
|
|
821
|
+
'lineHeight',
|
|
822
|
+
'textAlign',
|
|
823
|
+
'textTransform',
|
|
824
|
+
] as const;
|
|
825
|
+
|
|
826
|
+
const textStyle: Record<string, unknown> = {};
|
|
827
|
+
for (const key of keys) {
|
|
828
|
+
if (style[key] !== undefined) {
|
|
829
|
+
textStyle[key] = style[key];
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
return textStyle;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
export function mergeEventHandlers<T extends (...args: any[]) => void>(
|
|
836
|
+
first: T | undefined,
|
|
837
|
+
second: T | undefined,
|
|
838
|
+
): T | undefined {
|
|
839
|
+
if (!first) {
|
|
840
|
+
return second;
|
|
841
|
+
}
|
|
842
|
+
if (!second) {
|
|
843
|
+
return first;
|
|
844
|
+
}
|
|
845
|
+
return (((...args: Parameters<T>) => {
|
|
846
|
+
first(...args);
|
|
847
|
+
second(...args);
|
|
848
|
+
}) as T);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Returns `color` with its alpha multiplied by `alpha` (0..1), as an rgba()
|
|
853
|
+
* string. Used wherever a web `boxShadow`/halo needs the translucent form of
|
|
854
|
+
* a token while native keeps the opaque color + a separate opacity field.
|
|
855
|
+
*/
|
|
856
|
+
export function withAlpha(color: string, alpha: number): string {
|
|
857
|
+
const parsed = parseColor(color);
|
|
858
|
+
if (!parsed) {
|
|
859
|
+
return color;
|
|
860
|
+
}
|
|
861
|
+
const weight = Math.max(0, Math.min(alpha, 1));
|
|
862
|
+
return colorToRgba({ ...parsed, a: Math.round(parsed.a * weight) });
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
export function applyShadow(shadow: ShadowSpec): Record<string, unknown> {
|
|
866
|
+
const opacity = shadow.opacity ?? 1;
|
|
867
|
+
return {
|
|
868
|
+
// The CSS string is the web truth and must carry the opacity itself —
|
|
869
|
+
// native reads the separate shadowColor/shadowOpacity fields instead.
|
|
870
|
+
boxShadow: `${shadow.x}px ${shadow.y}px ${shadow.blur}px ${shadow.spread ?? 0}px ${withAlpha(shadow.color, opacity)}`,
|
|
871
|
+
shadowColor: shadow.color,
|
|
872
|
+
shadowOffset: { width: shadow.x, height: shadow.y },
|
|
873
|
+
shadowRadius: shadow.blur,
|
|
874
|
+
shadowOpacity: opacity,
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
export function mixColors(base: string, target: string, amount: number): string {
|
|
879
|
+
const baseColor = parseColor(base);
|
|
880
|
+
const targetColor = parseColor(target);
|
|
881
|
+
if (!baseColor || !targetColor) {
|
|
882
|
+
return base;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const weight = Math.max(0, Math.min(amount, 1));
|
|
886
|
+
return colorToRgba({
|
|
887
|
+
r: Math.round(baseColor.r + (targetColor.r - baseColor.r) * weight),
|
|
888
|
+
g: Math.round(baseColor.g + (targetColor.g - baseColor.g) * weight),
|
|
889
|
+
b: Math.round(baseColor.b + (targetColor.b - baseColor.b) * weight),
|
|
890
|
+
a: Math.round(baseColor.a + (targetColor.a - baseColor.a) * weight),
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Solid hover/pressed states for the neutral inset surface
|
|
896
|
+
* (`color.background.surface`). The old flat theme carried precomputed
|
|
897
|
+
* `surfaceAltHovered` / `surfaceAltPressed`; the resolved taxonomy expresses
|
|
898
|
+
* the same intent as the overlay system, so secondary controls darken
|
|
899
|
+
* consistently with ghost controls. Computed as a solid because native paths
|
|
900
|
+
* prefer an opaque fill to a composited translucent overlay.
|
|
901
|
+
*/
|
|
902
|
+
export function resolveNeutralSurfaceState(
|
|
903
|
+
theme: FacetTheme,
|
|
904
|
+
state: 'rest' | 'hover' | 'pressed',
|
|
905
|
+
): string {
|
|
906
|
+
const base = theme.color.background.surface;
|
|
907
|
+
if (state === 'rest') {
|
|
908
|
+
return base;
|
|
909
|
+
}
|
|
910
|
+
return mixColors(base, theme.color.text.primary, state === 'pressed' ? 0.07 : 0.04);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
export function resolveInteractiveBadgeBackground(
|
|
914
|
+
theme: FacetTheme,
|
|
915
|
+
variant: 'neutral' | 'accent' | 'success' | 'danger',
|
|
916
|
+
pressed: boolean,
|
|
917
|
+
hovered: boolean,
|
|
918
|
+
): string {
|
|
919
|
+
if (variant === 'neutral') {
|
|
920
|
+
return resolveNeutralSurfaceState(theme, pressed ? 'pressed' : hovered ? 'hover' : 'rest');
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (variant === 'accent') {
|
|
924
|
+
return pressed
|
|
925
|
+
? theme.color.accent.pressed
|
|
926
|
+
: hovered
|
|
927
|
+
? theme.color.accent.hover
|
|
928
|
+
: theme.color.accent.fill;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const base = variant === 'success' ? theme.color.status.success.fill : theme.color.status.danger.fill;
|
|
932
|
+
return pressed
|
|
933
|
+
? mixColors(base, theme.color.text.primary, 0.18)
|
|
934
|
+
: hovered
|
|
935
|
+
? mixColors(base, theme.color.text.primary, 0.1)
|
|
936
|
+
: base;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Button focus feedback needs to survive three rendering situations:
|
|
941
|
+
*
|
|
942
|
+
* 1. React DOM, where CSS `box-shadow` is the most expressive visual ring.
|
|
943
|
+
* 2. The custom reconciler web path, where Exact normalizes shadow fields into
|
|
944
|
+
* canonical style/protocol data.
|
|
945
|
+
* 3. Native renderers, where border + shadow are available but CSS outline is
|
|
946
|
+
* not.
|
|
947
|
+
*
|
|
948
|
+
* Using border + shadow here keeps the focused state visible everywhere instead
|
|
949
|
+
* of relying on `outline*`, which only React DOM understands.
|
|
950
|
+
*/
|
|
951
|
+
export function resolveButtonFocusStyle(
|
|
952
|
+
theme: FacetTheme,
|
|
953
|
+
focusVisible: boolean,
|
|
954
|
+
pressed: boolean,
|
|
955
|
+
): Record<string, unknown> | null {
|
|
956
|
+
if (!focusVisible || pressed) {
|
|
957
|
+
return null;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Accent border + a translucent halo one step wider: the Stripe-style ring.
|
|
961
|
+
// On web the halo is the boxShadow string; native renders the same intent
|
|
962
|
+
// as an accent-colored soft glow via the shadow fields.
|
|
963
|
+
const ringWidth = resolveCompactInset(theme) + 1;
|
|
964
|
+
const ring = theme.color.border.focus;
|
|
965
|
+
return {
|
|
966
|
+
borderColor: ring,
|
|
967
|
+
boxShadow: `0 0 0 ${ringWidth}px ${withAlpha(ring, 0.28)}`,
|
|
968
|
+
shadowColor: ring,
|
|
969
|
+
shadowOffset: { width: 0, height: 0 },
|
|
970
|
+
shadowRadius: theme.space.xs,
|
|
971
|
+
shadowOpacity: 0.28,
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
export function resolvePressTransform(
|
|
976
|
+
theme: FacetTheme,
|
|
977
|
+
pressed: boolean,
|
|
978
|
+
): MotionTransform | undefined {
|
|
979
|
+
return buildMotionTransform(theme, [{ scale: pressed ? 0.98 : 1 }]);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
export function resolveIndicatorMotionStyle(
|
|
983
|
+
theme: FacetTheme,
|
|
984
|
+
phase: PresencePhase,
|
|
985
|
+
): Record<string, unknown> {
|
|
986
|
+
return resolvePresenceMotionStyle(phase, {
|
|
987
|
+
enterTransition: theme.motion.preset.popIn,
|
|
988
|
+
exitTransition: theme.motion.preset.popOut,
|
|
989
|
+
enteredStyle: {
|
|
990
|
+
opacity: 1,
|
|
991
|
+
transform: buildMotionTransform(theme, [{ scale: 1 }]),
|
|
992
|
+
},
|
|
993
|
+
enteringStyle: {
|
|
994
|
+
opacity: 0,
|
|
995
|
+
transform: buildMotionTransform(theme, [{ scale: 0.72 }]),
|
|
996
|
+
},
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
export function resolveToggleThumbStyle(
|
|
1001
|
+
theme: FacetTheme,
|
|
1002
|
+
checked: boolean,
|
|
1003
|
+
): Record<string, unknown> {
|
|
1004
|
+
const offset = checked ? 18 : 0;
|
|
1005
|
+
return {
|
|
1006
|
+
marginLeft: theme.motion.reducedMotion ? offset : 0,
|
|
1007
|
+
transform: buildMotionTransform(theme, [{ translateX: offset }]),
|
|
1008
|
+
transition: theme.motion.preset.selection,
|
|
1009
|
+
// iOS-style thumb: accent.onFill (white in both schemes) so it stays
|
|
1010
|
+
// crisp on the unchecked neutral track and the checked accent track,
|
|
1011
|
+
// separated from either by a soft drop shadow instead of a border.
|
|
1012
|
+
backgroundColor: theme.color.accent.onFill,
|
|
1013
|
+
...applyShadow({
|
|
1014
|
+
x: 0,
|
|
1015
|
+
y: 1,
|
|
1016
|
+
blur: 3,
|
|
1017
|
+
color: theme.scheme === 'dark' ? '#000000' : '#101828',
|
|
1018
|
+
opacity: theme.scheme === 'dark' ? 0.5 : 0.25,
|
|
1019
|
+
}),
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
export function resolveFieldRingColor(
|
|
1024
|
+
theme: FacetTheme,
|
|
1025
|
+
validationState: FieldValidationState,
|
|
1026
|
+
): string {
|
|
1027
|
+
if (validationState === 'error') {
|
|
1028
|
+
return theme.color.status.danger.fill;
|
|
1029
|
+
}
|
|
1030
|
+
if (validationState === 'success') {
|
|
1031
|
+
return theme.color.status.success.fill;
|
|
1032
|
+
}
|
|
1033
|
+
return theme.color.accent.fill;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
export function resolveFieldLabelColor(
|
|
1037
|
+
theme: FacetTheme,
|
|
1038
|
+
options: {
|
|
1039
|
+
validationState: FieldValidationState;
|
|
1040
|
+
disabled: boolean;
|
|
1041
|
+
focused: boolean;
|
|
1042
|
+
},
|
|
1043
|
+
): string {
|
|
1044
|
+
if (options.disabled) {
|
|
1045
|
+
return theme.color.text.secondary;
|
|
1046
|
+
}
|
|
1047
|
+
if (options.validationState === 'error') {
|
|
1048
|
+
return theme.color.status.danger.fill;
|
|
1049
|
+
}
|
|
1050
|
+
if (options.validationState === 'success') {
|
|
1051
|
+
return theme.color.status.success.fill;
|
|
1052
|
+
}
|
|
1053
|
+
if (options.focused) {
|
|
1054
|
+
return theme.color.accent.fill;
|
|
1055
|
+
}
|
|
1056
|
+
return theme.color.text.secondary;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
export function resolveFieldChromeStyle(
|
|
1060
|
+
theme: FacetTheme,
|
|
1061
|
+
options: {
|
|
1062
|
+
variant: FieldVariant;
|
|
1063
|
+
validationState: FieldValidationState;
|
|
1064
|
+
disabled: boolean;
|
|
1065
|
+
focused: boolean;
|
|
1066
|
+
hovered: boolean;
|
|
1067
|
+
},
|
|
1068
|
+
): Record<string, unknown> {
|
|
1069
|
+
const { variant, validationState, disabled, focused, hovered } = options;
|
|
1070
|
+
// Hover strengthens the hairline without jumping all the way to a text
|
|
1071
|
+
// color; focus hands the border to the accent (plus the halo overlay the
|
|
1072
|
+
// field components render).
|
|
1073
|
+
const hoverBorderColor = mixColors(theme.color.border.default, theme.color.text.primary, 0.22);
|
|
1074
|
+
const interactiveBorderColor =
|
|
1075
|
+
validationState === 'error'
|
|
1076
|
+
? theme.color.status.danger.fill
|
|
1077
|
+
: validationState === 'success'
|
|
1078
|
+
? theme.color.status.success.fill
|
|
1079
|
+
: focused
|
|
1080
|
+
? theme.color.border.focus
|
|
1081
|
+
: hovered
|
|
1082
|
+
? hoverBorderColor
|
|
1083
|
+
: theme.color.border.default;
|
|
1084
|
+
|
|
1085
|
+
// Default-variant fields sit on the raised card surface; filled fields use
|
|
1086
|
+
// the recessed neutral surface.
|
|
1087
|
+
const baseBackground =
|
|
1088
|
+
variant === 'ghost'
|
|
1089
|
+
? 'transparent'
|
|
1090
|
+
: variant === 'filled'
|
|
1091
|
+
? theme.color.background.surface
|
|
1092
|
+
: theme.color.background.card;
|
|
1093
|
+
// Default-variant fields keep their clean base fill in every state — the
|
|
1094
|
+
// border and focus halo carry the feedback. Filled fields lighten on hover
|
|
1095
|
+
// and settle back once focused; ghost fields tint like ghost buttons.
|
|
1096
|
+
const hoveredBackground =
|
|
1097
|
+
variant === 'ghost'
|
|
1098
|
+
? theme.color.overlay.hover
|
|
1099
|
+
: variant === 'filled'
|
|
1100
|
+
? (focused ? theme.color.background.surface : resolveNeutralSurfaceState(theme, 'hover'))
|
|
1101
|
+
: baseBackground;
|
|
1102
|
+
const disabledBackground =
|
|
1103
|
+
variant === 'ghost'
|
|
1104
|
+
? 'transparent'
|
|
1105
|
+
: theme.color.background.surface;
|
|
1106
|
+
|
|
1107
|
+
return {
|
|
1108
|
+
minHeight: theme.density.controlHeight.md,
|
|
1109
|
+
flexDirection: 'row',
|
|
1110
|
+
alignItems: variant === 'ghost' ? 'flex-end' : 'center',
|
|
1111
|
+
width: '100%',
|
|
1112
|
+
backgroundColor: disabled
|
|
1113
|
+
? disabledBackground
|
|
1114
|
+
: hovered || focused
|
|
1115
|
+
? hoveredBackground
|
|
1116
|
+
: baseBackground,
|
|
1117
|
+
borderColor: variant === 'filled' && !focused && validationState === 'default'
|
|
1118
|
+
? 'transparent'
|
|
1119
|
+
: interactiveBorderColor,
|
|
1120
|
+
borderWidth: variant === 'ghost' ? 0 : 1,
|
|
1121
|
+
borderBottomWidth: variant === 'ghost' ? 1 : undefined,
|
|
1122
|
+
borderRadius: variant === 'ghost' ? 0 : theme.radius.container,
|
|
1123
|
+
transition: theme.motion.preset.control,
|
|
1124
|
+
opacity: disabled ? 0.5 : 1,
|
|
1125
|
+
overflow: 'visible',
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
export function resolveFieldInputStyle(
|
|
1130
|
+
theme: FacetTheme,
|
|
1131
|
+
options: {
|
|
1132
|
+
variant: FieldVariant;
|
|
1133
|
+
loading: boolean;
|
|
1134
|
+
multiline?: boolean;
|
|
1135
|
+
rows?: number;
|
|
1136
|
+
style?: Record<string, unknown>;
|
|
1137
|
+
},
|
|
1138
|
+
): Record<string, unknown> {
|
|
1139
|
+
// LLP 0269 §5 (D10): field paddings are density-scaled at apply time. The
|
|
1140
|
+
// loading clearance stays unscaled — it reserves room for the spinner,
|
|
1141
|
+
// whose size does not track the density dial.
|
|
1142
|
+
const inlinePadding = resolveDensityPadding(theme, theme.space.md);
|
|
1143
|
+
const blockPadding = resolveDensityPadding(theme, theme.space.sm);
|
|
1144
|
+
const rightPadding = options.loading ? theme.space.xl + resolveInlineInset(theme) : inlinePadding;
|
|
1145
|
+
|
|
1146
|
+
return {
|
|
1147
|
+
flex: 1,
|
|
1148
|
+
minHeight: options.multiline
|
|
1149
|
+
? Math.max((options.rows ?? 4) * 24, 88)
|
|
1150
|
+
: theme.density.controlHeight.md,
|
|
1151
|
+
backgroundColor: 'transparent',
|
|
1152
|
+
color: theme.color.text.primary,
|
|
1153
|
+
fontFamily: theme.type.body.fontFamily,
|
|
1154
|
+
fontSize: theme.type.body.fontSize,
|
|
1155
|
+
lineHeight: theme.type.body.lineHeight,
|
|
1156
|
+
borderWidth: 0,
|
|
1157
|
+
borderRadius: 0,
|
|
1158
|
+
paddingLeft: options.variant === 'ghost' ? 0 : inlinePadding,
|
|
1159
|
+
paddingRight: options.variant === 'ghost' ? Math.max(rightPadding - inlinePadding, 0) : rightPadding,
|
|
1160
|
+
paddingTop: blockPadding,
|
|
1161
|
+
paddingBottom: blockPadding,
|
|
1162
|
+
textAlignVertical: options.multiline ? 'top' : undefined,
|
|
1163
|
+
...(options.style ?? {}),
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
export function clamp(value: number, min: number, max: number): number {
|
|
1168
|
+
return Math.min(max, Math.max(min, value));
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
export type FacetRenderMode = 'native' | 'composed';
|
|
1172
|
+
|
|
1173
|
+
export interface PlatformCapabilities {
|
|
1174
|
+
platform: 'ios' | 'android' | 'web' | 'macos' | string;
|
|
1175
|
+
supports(moduleName: string): boolean;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
export interface NativeSliderChangeEvent {
|
|
1179
|
+
nativeEvent?: {
|
|
1180
|
+
value?: unknown;
|
|
1181
|
+
};
|
|
1182
|
+
value?: unknown;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
export function getFacetPlatform(): PlatformCapabilities['platform'] {
|
|
1186
|
+
const runtimeGlobals = globalThis as typeof globalThis & {
|
|
1187
|
+
__exactPlatform?: string;
|
|
1188
|
+
HermesInternal?: unknown;
|
|
1189
|
+
process?: {
|
|
1190
|
+
platform?: string;
|
|
1191
|
+
versions?: Record<string, unknown>;
|
|
1192
|
+
};
|
|
1193
|
+
};
|
|
1194
|
+
|
|
1195
|
+
// Keep Facet's platform check local so the package does not need to reach
|
|
1196
|
+
// into runtime-internal entry points just to choose native vs composed
|
|
1197
|
+
// control leaves.
|
|
1198
|
+
let platform = 'unknown';
|
|
1199
|
+
if (typeof runtimeGlobals.__exactPlatform === 'string' && runtimeGlobals.__exactPlatform.length > 0) {
|
|
1200
|
+
platform = runtimeGlobals.__exactPlatform;
|
|
1201
|
+
} else if (typeof document !== 'undefined' && document.createElement) {
|
|
1202
|
+
platform = 'web';
|
|
1203
|
+
} else if (runtimeGlobals.process) {
|
|
1204
|
+
const versions = runtimeGlobals.process.versions;
|
|
1205
|
+
const isExactProcess =
|
|
1206
|
+
!!versions &&
|
|
1207
|
+
typeof versions === 'object' &&
|
|
1208
|
+
(typeof versions.ibex === 'string' || typeof versions.exact === 'string');
|
|
1209
|
+
if (!isExactProcess && typeof runtimeGlobals.process.platform === 'string') {
|
|
1210
|
+
platform = runtimeGlobals.process.platform;
|
|
1211
|
+
}
|
|
1212
|
+
} else if (typeof runtimeGlobals.HermesInternal !== 'undefined') {
|
|
1213
|
+
platform = 'ios';
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
if (platform === 'darwin' || platform === 'mac' || platform === 'macos') {
|
|
1217
|
+
return 'macos';
|
|
1218
|
+
}
|
|
1219
|
+
return platform as PlatformCapabilities['platform'];
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
export function readModuleMetadata(moduleName: string): { moduleId: number } | null {
|
|
1223
|
+
const exactRuntime = (
|
|
1224
|
+
globalThis as typeof globalThis & {
|
|
1225
|
+
exact?: {
|
|
1226
|
+
getModuleMetadata?: (name: string) => { moduleId: number } | null;
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
).exact;
|
|
1230
|
+
|
|
1231
|
+
if (typeof exactRuntime?.getModuleMetadata !== 'function') {
|
|
1232
|
+
return null;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
return exactRuntime.getModuleMetadata(moduleName);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
export function createPlatformCapabilities(): PlatformCapabilities {
|
|
1239
|
+
const platform = getFacetPlatform();
|
|
1240
|
+
|
|
1241
|
+
return {
|
|
1242
|
+
platform,
|
|
1243
|
+
supports(moduleName: string): boolean {
|
|
1244
|
+
if (platform === 'web') {
|
|
1245
|
+
return false;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
const metadata = readModuleMetadata(moduleName);
|
|
1249
|
+
return !!metadata && Number.isInteger(metadata.moduleId) && metadata.moduleId > 0;
|
|
1250
|
+
},
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
export function resolveRenderMode(
|
|
1255
|
+
componentName: string,
|
|
1256
|
+
moduleName: string,
|
|
1257
|
+
nativePreference: boolean | undefined,
|
|
1258
|
+
platform: PlatformCapabilities,
|
|
1259
|
+
): FacetRenderMode {
|
|
1260
|
+
if (nativePreference === false) {
|
|
1261
|
+
return 'composed';
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const supported = platform.supports(moduleName);
|
|
1265
|
+
if (nativePreference === true && !supported) {
|
|
1266
|
+
throw new Error(
|
|
1267
|
+
`[Facet] ${componentName} native={true} requires ${moduleName} support on ${platform.platform}.`,
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
return supported ? 'native' : 'composed';
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
export function getSliderStep(min: number, max: number, requestedStep: number | undefined): number {
|
|
1275
|
+
if (typeof requestedStep === 'number' && isFinite(requestedStep) && requestedStep > 0) {
|
|
1276
|
+
return requestedStep;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
const range = max - min;
|
|
1280
|
+
if (range <= 0) {
|
|
1281
|
+
return 1;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
return Math.max(range / 20, 0.01);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
export function snapSliderValue(value: number, min: number, max: number, step: number): number {
|
|
1288
|
+
if (!isFinite(value)) {
|
|
1289
|
+
return min;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
const clamped = clamp(value, min, max);
|
|
1293
|
+
if (!isFinite(step) || step <= 0) {
|
|
1294
|
+
return clamped;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
const snapped = min + Math.round((clamped - min) / step) * step;
|
|
1298
|
+
return clamp(Number(snapped.toFixed(4)), min, max);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
export function formatSliderValue(value: number): string {
|
|
1302
|
+
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
export function extractNativeSliderValue(
|
|
1306
|
+
event: NativeSliderChangeEvent,
|
|
1307
|
+
fallbackValue: number,
|
|
1308
|
+
): number {
|
|
1309
|
+
const candidate = event.nativeEvent?.value ?? event.value;
|
|
1310
|
+
return typeof candidate === 'number' && isFinite(candidate)
|
|
1311
|
+
? candidate
|
|
1312
|
+
: fallbackValue;
|
|
1313
|
+
}
|