@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.
@@ -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
+ }