@sigx/lynx-zero 0.4.9

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.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +25 -0
  3. package/dist/components/SwiperIndicator.d.ts +43 -0
  4. package/dist/components/SwiperIndicator.d.ts.map +1 -0
  5. package/dist/components/SwiperIndicator.js +272 -0
  6. package/dist/components/SwiperIndicator.js.map +1 -0
  7. package/dist/contract.d.ts +95 -0
  8. package/dist/contract.d.ts.map +1 -0
  9. package/dist/contract.js +30 -0
  10. package/dist/contract.js.map +1 -0
  11. package/dist/index.d.ts +30 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +41 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/layout/Center.d.ts +7 -0
  16. package/dist/layout/Center.d.ts.map +1 -0
  17. package/dist/layout/Center.js +25 -0
  18. package/dist/layout/Center.js.map +1 -0
  19. package/dist/layout/Col.d.ts +8 -0
  20. package/dist/layout/Col.d.ts.map +1 -0
  21. package/dist/layout/Col.js +34 -0
  22. package/dist/layout/Col.js.map +1 -0
  23. package/dist/layout/Row.d.ts +8 -0
  24. package/dist/layout/Row.d.ts.map +1 -0
  25. package/dist/layout/Row.js +34 -0
  26. package/dist/layout/Row.js.map +1 -0
  27. package/dist/layout/ScrollView.d.ts +6 -0
  28. package/dist/layout/ScrollView.d.ts.map +1 -0
  29. package/dist/layout/ScrollView.js +19 -0
  30. package/dist/layout/ScrollView.js.map +1 -0
  31. package/dist/layout/Spacer.d.ts +4 -0
  32. package/dist/layout/Spacer.d.ts.map +1 -0
  33. package/dist/layout/Spacer.js +12 -0
  34. package/dist/layout/Spacer.js.map +1 -0
  35. package/dist/preset/index.d.ts +31 -0
  36. package/dist/preset/index.d.ts.map +1 -0
  37. package/dist/preset/index.js +72 -0
  38. package/dist/preset/index.js.map +1 -0
  39. package/dist/shared/press.d.ts +3 -0
  40. package/dist/shared/press.d.ts.map +1 -0
  41. package/dist/shared/press.js +7 -0
  42. package/dist/shared/press.js.map +1 -0
  43. package/dist/shared/styles.d.ts +27 -0
  44. package/dist/shared/styles.d.ts.map +1 -0
  45. package/dist/shared/styles.js +62 -0
  46. package/dist/shared/styles.js.map +1 -0
  47. package/dist/shared/tabs-selection.d.ts +25 -0
  48. package/dist/shared/tabs-selection.d.ts.map +1 -0
  49. package/dist/shared/tabs-selection.js +45 -0
  50. package/dist/shared/tabs-selection.js.map +1 -0
  51. package/dist/styles/tokens.css +98 -0
  52. package/dist/theme/StatusBarSync.d.ts +42 -0
  53. package/dist/theme/StatusBarSync.d.ts.map +1 -0
  54. package/dist/theme/StatusBarSync.js +89 -0
  55. package/dist/theme/StatusBarSync.js.map +1 -0
  56. package/dist/theme/ThemeProvider.d.ts +144 -0
  57. package/dist/theme/ThemeProvider.d.ts.map +1 -0
  58. package/dist/theme/ThemeProvider.js +328 -0
  59. package/dist/theme/ThemeProvider.js.map +1 -0
  60. package/dist/theme/color-mix.d.ts +21 -0
  61. package/dist/theme/color-mix.d.ts.map +1 -0
  62. package/dist/theme/color-mix.js +65 -0
  63. package/dist/theme/color-mix.js.map +1 -0
  64. package/dist/theme/registry.d.ts +182 -0
  65. package/dist/theme/registry.d.ts.map +1 -0
  66. package/dist/theme/registry.js +182 -0
  67. package/dist/theme/registry.js.map +1 -0
  68. package/dist/theme/theme-state.d.ts +43 -0
  69. package/dist/theme/theme-state.d.ts.map +1 -0
  70. package/dist/theme/theme-state.js +94 -0
  71. package/dist/theme/theme-state.js.map +1 -0
  72. package/dist/theme/use-screen-theme.d.ts +4 -0
  73. package/dist/theme/use-screen-theme.d.ts.map +1 -0
  74. package/dist/theme/use-screen-theme.js +43 -0
  75. package/dist/theme/use-screen-theme.js.map +1 -0
  76. package/dist/theme/use-theme-colors.d.ts +48 -0
  77. package/dist/theme/use-theme-colors.d.ts.map +1 -0
  78. package/dist/theme/use-theme-colors.js +69 -0
  79. package/dist/theme/use-theme-colors.js.map +1 -0
  80. package/package.json +80 -0
  81. package/src/components/SwiperIndicator.tsx +519 -0
  82. package/src/contract.ts +136 -0
  83. package/src/index.ts +101 -0
  84. package/src/layout/Center.tsx +41 -0
  85. package/src/layout/Col.tsx +53 -0
  86. package/src/layout/Row.tsx +53 -0
  87. package/src/layout/ScrollView.tsx +38 -0
  88. package/src/layout/Spacer.tsx +18 -0
  89. package/src/preset/index.ts +77 -0
  90. package/src/shared/press.ts +6 -0
  91. package/src/shared/styles.ts +82 -0
  92. package/src/shared/tabs-selection.ts +57 -0
  93. package/src/styles/tokens.css +98 -0
  94. package/src/theme/StatusBarSync.tsx +104 -0
  95. package/src/theme/ThemeProvider.tsx +492 -0
  96. package/src/theme/color-mix.ts +68 -0
  97. package/src/theme/registry.ts +290 -0
  98. package/src/theme/theme-state.ts +112 -0
  99. package/src/theme/use-screen-theme.ts +42 -0
  100. package/src/theme/use-theme-colors.ts +99 -0
@@ -0,0 +1,519 @@
1
+ import {
2
+ component,
3
+ effect,
4
+ runOnMainThread,
5
+ signal,
6
+ useSharedValue,
7
+ type Define,
8
+ type PrimitiveSignal,
9
+ type SharedValue,
10
+ } from '@sigx/lynx';
11
+ import {
12
+ useSwiperDotProgress,
13
+ useSwiperDotScale,
14
+ useSwiperDotGrowX,
15
+ useSwiperDotTranslate,
16
+ } from '@sigx/lynx-gestures';
17
+ import { withTiming } from '@sigx/lynx-motion';
18
+ import { resolveColorToken, type ColorToken } from '../contract.js';
19
+
20
+ /**
21
+ * Visual style for the swiper page indicator.
22
+ *
23
+ * - `dots` — equally-spaced circles, the active one fades in via opacity.
24
+ * Today's default. Cheap (opacity-only MT mapper, no layout each frame).
25
+ * - `bar` — fixed track with a single sliding thumb. Single MT binding
26
+ * regardless of page count, so cheapest for very long carousels.
27
+ * - `pill` — the active dot stretches horizontally into a pill while
28
+ * neighbours stay circular. Uses `scaleX` so siblings don't reflow.
29
+ * - `numbered` — text counter like `2 / 5`. Pure BG-thread, no animation.
30
+ * - `scale-pulse` — circles where the active one scales up. No colour
31
+ * crossfade — pairs well with monochrome palettes.
32
+ */
33
+ export type SwiperIndicatorVariant =
34
+ | 'dots'
35
+ | 'bar'
36
+ | 'pill'
37
+ | 'numbered'
38
+ | 'scale-pulse';
39
+
40
+ export type SwiperIndicatorSize = 'xs' | 'sm' | 'md' | 'lg';
41
+
42
+ interface SizeSpec {
43
+ /** Dot diameter in px. */
44
+ dot: number;
45
+ /** Gap between dots in px. */
46
+ gap: number;
47
+ /** Bar track height in px. */
48
+ barHeight: number;
49
+ /** Numbered variant font size in px. */
50
+ fontSize: number;
51
+ }
52
+
53
+ const SIZE_TABLE: Record<SwiperIndicatorSize, SizeSpec> = {
54
+ xs: { dot: 4, gap: 4, barHeight: 3, fontSize: 11 },
55
+ sm: { dot: 6, gap: 6, barHeight: 4, fontSize: 12 },
56
+ md: { dot: 8, gap: 8, barHeight: 5, fontSize: 14 },
57
+ lg: { dot: 12, gap: 10, barHeight: 6, fontSize: 16 },
58
+ };
59
+
60
+ export type SwiperIndicatorProps =
61
+ & Define.Prop<'variant', SwiperIndicatorVariant, false>
62
+ /**
63
+ * Live MT pixel offset from the parent `<Swiper>` — full scroll-linked
64
+ * fidelity for the animated variants. When omitted but `index` is wired,
65
+ * the indicator derives one internally (#211): it glides between page
66
+ * positions with a timing curve on every index change. Mode is fixed at
67
+ * mount.
68
+ */
69
+ & Define.Prop<'offset', SharedValue<number>, false>
70
+ /** Page width in CSS px. Must match the Swiper's effective page width. */
71
+ & Define.Prop<'pageWidth', number, false>
72
+ /** Total page count. */
73
+ & Define.Prop<'count', number, true>
74
+ /**
75
+ * Current page (whole-units). Required for `numbered`, drives the
76
+ * derived offset in index-only mode, and consumed by all variants for
77
+ * tap-to-jump.
78
+ */
79
+ & Define.Prop<'index', PrimitiveSignal<number>, false>
80
+ & Define.Prop<'color', ColorToken, false>
81
+ & Define.Prop<'inactiveColor', ColorToken, false>
82
+ & Define.Prop<'size', SwiperIndicatorSize, false>
83
+ /**
84
+ * Tap-to-jump handler. The receiver should typically write
85
+ * `index.value = i` to glide the swiper to that page.
86
+ */
87
+ & Define.Prop<'onDotPress', (index: number) => void, false>
88
+ & Define.Prop<'class', string, false>
89
+ & Define.Prop<'style', Record<string, string | number>, false>;
90
+
91
+ /**
92
+ * Themed swiper page indicator with five preset variants. Each variant
93
+ * is a thin shell over a headless hook from `@sigx/lynx-gestures` (see
94
+ * `useSwiperDotProgress`, `useSwiperDotScale`, `useSwiperDotGrowX`,
95
+ * `useSwiperDotTranslate`). For a fully custom indicator, compose the
96
+ * hooks yourself rather than forking this file.
97
+ *
98
+ * @example Scroll-linked (full fidelity, wired to a `<Swiper>`)
99
+ * ```tsx
100
+ * const offset = useSharedValue(0);
101
+ * const idx = signal({ value: 0 });
102
+ * <Swiper offset={offset} index={idx} width={W}>…</Swiper>
103
+ * <SwiperIndicator
104
+ * variant="pill"
105
+ * offset={offset}
106
+ * pageWidth={W}
107
+ * count={photos.length}
108
+ * index={idx}
109
+ * color="primary"
110
+ * onDotPress={(i) => { idx.value = i; }}
111
+ * />
112
+ * ```
113
+ *
114
+ * @example Index-only (no Swiper — the offset is derived internally)
115
+ * ```tsx
116
+ * const idx = signal({ value: 0 });
117
+ * <SwiperIndicator
118
+ * variant="dots"
119
+ * count={5}
120
+ * index={idx}
121
+ * onDotPress={(i) => { idx.value = i; }}
122
+ * />
123
+ * ```
124
+ */
125
+ /**
126
+ * Nominal page width for the internally-derived offset (index-only mode).
127
+ * The animated variants consume `offset` and `pageWidth` only as the ratio
128
+ * `offset / pageWidth`, so any non-zero constant works.
129
+ */
130
+ const SYNTHETIC_PAGE_WIDTH = 100;
131
+
132
+ /** Timing used when gliding the derived offset between page positions. */
133
+ const SYNTHETIC_GLIDE_SECONDS = 0.28;
134
+
135
+ export const SwiperIndicator = component<SwiperIndicatorProps>(({ props }) => {
136
+ // Index-only mode (#211), fixed at mount: with no live `offset` wired but
137
+ // an `index`, own a SharedValue and glide it to `index × nominal width`
138
+ // with a timing curve whenever the index changes — the same push a real
139
+ // `<Swiper>` makes, minus the scroll-linked in-between frames.
140
+ const indexOnly = props.offset == null && props.index != null;
141
+ const derivedOffset = useSharedValue(
142
+ indexOnly ? (props.index!.value | 0) * SYNTHETIC_PAGE_WIDTH : 0,
143
+ );
144
+ if (indexOnly) {
145
+ // Hoisted once — wrapping per call would allocate a new MT runner on
146
+ // every index change.
147
+ const glideTo = runOnMainThread((to: number) => {
148
+ 'main thread';
149
+ withTiming(derivedOffset, to, { duration: SYNTHETIC_GLIDE_SECONDS });
150
+ });
151
+ effect(() => {
152
+ glideTo((props.index!.value | 0) * SYNTHETIC_PAGE_WIDTH);
153
+ });
154
+ }
155
+
156
+ return () => {
157
+ const variant: SwiperIndicatorVariant = props.variant ?? 'dots';
158
+ const size = SIZE_TABLE[props.size ?? 'md'];
159
+ const activeColor = resolveColorToken(props.color ?? 'primary');
160
+ const inactiveColor = resolveColorToken(props.inactiveColor ?? 'base-content');
161
+ const offset = indexOnly ? derivedOffset : props.offset;
162
+ const pageWidth = indexOnly ? SYNTHETIC_PAGE_WIDTH : props.pageWidth;
163
+
164
+ if (variant === 'numbered') {
165
+ return (
166
+ <NumberedIndicator
167
+ count={props.count}
168
+ index={props.index ?? FALLBACK_INDEX}
169
+ color={activeColor}
170
+ fontSize={size.fontSize}
171
+ class={props.class}
172
+ style={props.style}
173
+ />
174
+ );
175
+ }
176
+
177
+ if (variant === 'bar') {
178
+ if (offset == null || pageWidth == null) return null;
179
+ return (
180
+ <BarIndicator
181
+ offset={offset}
182
+ pageWidth={pageWidth}
183
+ count={props.count}
184
+ activeColor={activeColor}
185
+ inactiveColor={inactiveColor}
186
+ barHeight={size.barHeight}
187
+ dotSize={size.dot}
188
+ gap={size.gap}
189
+ onDotPress={props.onDotPress}
190
+ class={props.class}
191
+ style={props.style}
192
+ />
193
+ );
194
+ }
195
+
196
+ if (offset == null || pageWidth == null) return null;
197
+ return (
198
+ <view
199
+ class={props.class}
200
+ style={{
201
+ display: 'flex',
202
+ flexDirection: 'row',
203
+ alignItems: 'center',
204
+ justifyContent: 'center',
205
+ gap: size.gap + 'px',
206
+ ...props.style,
207
+ }}
208
+ >
209
+ {Array.from({ length: props.count }, (_, i) => (
210
+ <Dot
211
+ key={i}
212
+ index={i}
213
+ offset={offset}
214
+ pageWidth={pageWidth}
215
+ variant={variant}
216
+ size={size}
217
+ activeColor={activeColor}
218
+ inactiveColor={inactiveColor}
219
+ onPress={props.onDotPress}
220
+ />
221
+ ))}
222
+ </view>
223
+ );
224
+ };
225
+ });
226
+
227
+ // ─────────────────────────────────────────────────────────────────────
228
+ // Per-variant pieces. Each owns a single `useAnimatedStyle` call-site
229
+ // (per-iteration call inside `.map()` is fine — call-sites are stable).
230
+
231
+ const FALLBACK_INDEX: PrimitiveSignal<number> = signal({ value: 0 });
232
+
233
+ type DotProps =
234
+ & Define.Prop<'index', number, true>
235
+ & Define.Prop<'offset', SharedValue<number>, true>
236
+ & Define.Prop<'pageWidth', number, true>
237
+ & Define.Prop<'variant', Exclude<SwiperIndicatorVariant, 'numbered' | 'bar'>, true>
238
+ & Define.Prop<'size', SizeSpec, true>
239
+ & Define.Prop<'activeColor', string, true>
240
+ & Define.Prop<'inactiveColor', string, true>
241
+ & Define.Prop<'onPress', (index: number) => void, false>;
242
+
243
+ type ResolvedDotProps = {
244
+ index: number;
245
+ offset: SharedValue<number>;
246
+ pageWidth: number;
247
+ variant: Exclude<SwiperIndicatorVariant, 'numbered' | 'bar'>;
248
+ size: SizeSpec;
249
+ activeColor: string;
250
+ inactiveColor: string;
251
+ onPress?: (index: number) => void;
252
+ };
253
+
254
+ const Dot = component<DotProps>(({ props }) => {
255
+ // Each branch picks a different headless hook. Variants that need
256
+ // *two* simultaneous channels (opacity AND scale, or scale AND scaleX)
257
+ // need two refs — one per element — because `useAnimatedStyle` is
258
+ // one-binding-per-element.
259
+ if (props.variant === 'dots') {
260
+ return DotsBody(props);
261
+ }
262
+ if (props.variant === 'pill') {
263
+ return PillBody(props);
264
+ }
265
+ // scale-pulse
266
+ return ScalePulseBody(props);
267
+ });
268
+
269
+ function DotsBody(props: ResolvedDotProps) {
270
+ const overlayRef = useSwiperDotProgress({
271
+ offset: props.offset,
272
+ pageWidth: props.pageWidth,
273
+ index: props.index,
274
+ });
275
+ return () => (
276
+ <view
277
+ catchtap={props.onPress ? () => props.onPress?.(props.index) : undefined}
278
+ style={{
279
+ width: props.size.dot + 'px',
280
+ height: props.size.dot + 'px',
281
+ borderRadius: (props.size.dot / 2) + 'px',
282
+ backgroundColor: withAlpha(props.inactiveColor, 0.4),
283
+ position: 'relative',
284
+ overflow: 'hidden',
285
+ }}
286
+ >
287
+ <view
288
+ main-thread:ref={overlayRef}
289
+ style={{
290
+ position: 'absolute',
291
+ left: '0',
292
+ top: '0',
293
+ right: '0',
294
+ bottom: '0',
295
+ backgroundColor: props.activeColor,
296
+ opacity: '0',
297
+ }}
298
+ />
299
+ </view>
300
+ );
301
+ }
302
+
303
+ function PillBody(props: ResolvedDotProps) {
304
+ // Pill stretches horizontally via scaleX (no layout cost) and brightens
305
+ // via opacity on the active-colour overlay. Both channels target the
306
+ // same dot — but each needs its own bound element, so we wrap the
307
+ // overlay inside a scaling shell.
308
+ const shellRef = useSwiperDotGrowX({
309
+ offset: props.offset,
310
+ pageWidth: props.pageWidth,
311
+ index: props.index,
312
+ inactive: 1,
313
+ active: 3,
314
+ });
315
+ const overlayRef = useSwiperDotProgress({
316
+ offset: props.offset,
317
+ pageWidth: props.pageWidth,
318
+ index: props.index,
319
+ });
320
+ return () => (
321
+ <view
322
+ catchtap={props.onPress ? () => props.onPress?.(props.index) : undefined}
323
+ main-thread:ref={shellRef}
324
+ style={{
325
+ width: props.size.dot + 'px',
326
+ height: props.size.dot + 'px',
327
+ borderRadius: (props.size.dot / 2) + 'px',
328
+ backgroundColor: withAlpha(props.inactiveColor, 0.4),
329
+ position: 'relative',
330
+ overflow: 'hidden',
331
+ transformOrigin: 'center center',
332
+ }}
333
+ >
334
+ <view
335
+ main-thread:ref={overlayRef}
336
+ style={{
337
+ position: 'absolute',
338
+ left: '0',
339
+ top: '0',
340
+ right: '0',
341
+ bottom: '0',
342
+ backgroundColor: props.activeColor,
343
+ opacity: '0',
344
+ }}
345
+ />
346
+ </view>
347
+ );
348
+ }
349
+
350
+ function ScalePulseBody(props: ResolvedDotProps) {
351
+ // No colour crossfade — pure scale. Active dot uses `activeColor`,
352
+ // inactive uses `inactiveColor` at low alpha. Visual is monochrome
353
+ // friendly.
354
+ const scaleRef = useSwiperDotScale({
355
+ offset: props.offset,
356
+ pageWidth: props.pageWidth,
357
+ index: props.index,
358
+ inactive: 1,
359
+ active: 1.6,
360
+ });
361
+ const opacityRef = useSwiperDotProgress({
362
+ offset: props.offset,
363
+ pageWidth: props.pageWidth,
364
+ index: props.index,
365
+ });
366
+ return () => (
367
+ <view
368
+ catchtap={props.onPress ? () => props.onPress?.(props.index) : undefined}
369
+ main-thread:ref={scaleRef}
370
+ style={{
371
+ width: props.size.dot + 'px',
372
+ height: props.size.dot + 'px',
373
+ borderRadius: (props.size.dot / 2) + 'px',
374
+ backgroundColor: withAlpha(props.inactiveColor, 0.4),
375
+ position: 'relative',
376
+ overflow: 'hidden',
377
+ }}
378
+ >
379
+ <view
380
+ main-thread:ref={opacityRef}
381
+ style={{
382
+ position: 'absolute',
383
+ left: '0',
384
+ top: '0',
385
+ right: '0',
386
+ bottom: '0',
387
+ backgroundColor: props.activeColor,
388
+ opacity: '0',
389
+ }}
390
+ />
391
+ </view>
392
+ );
393
+ }
394
+
395
+ // ─────────────────────────────────────────────────────────────────────
396
+ // Bar variant — one sliding thumb across a fixed track.
397
+
398
+ type BarProps =
399
+ & Define.Prop<'offset', SharedValue<number>, true>
400
+ & Define.Prop<'pageWidth', number, true>
401
+ & Define.Prop<'count', number, true>
402
+ & Define.Prop<'activeColor', string, true>
403
+ & Define.Prop<'inactiveColor', string, true>
404
+ & Define.Prop<'barHeight', number, true>
405
+ & Define.Prop<'dotSize', number, true>
406
+ & Define.Prop<'gap', number, true>
407
+ & Define.Prop<'onDotPress', (index: number) => void, false>
408
+ & Define.Prop<'class', string, false>
409
+ & Define.Prop<'style', Record<string, string | number>, false>;
410
+
411
+ const BarIndicator = component<BarProps>(({ props }) => {
412
+ // The thumb advances by (dot + gap) per page. We use the headless
413
+ // translate hook — a single MT binding regardless of page count.
414
+ const step = props.dotSize + props.gap;
415
+ const thumbRef = useSwiperDotTranslate({
416
+ offset: props.offset,
417
+ pageWidth: props.pageWidth,
418
+ step,
419
+ });
420
+
421
+ return () => {
422
+ const trackWidth = props.count * props.dotSize + Math.max(0, props.count - 1) * props.gap;
423
+ return (
424
+ <view
425
+ class={props.class}
426
+ style={{
427
+ position: 'relative',
428
+ width: trackWidth + 'px',
429
+ height: props.barHeight + 'px',
430
+ borderRadius: (props.barHeight / 2) + 'px',
431
+ backgroundColor: withAlpha(props.inactiveColor, 0.25),
432
+ overflow: 'visible',
433
+ ...props.style,
434
+ }}
435
+ >
436
+ {props.onDotPress
437
+ ? (
438
+ <view
439
+ style={{
440
+ position: 'absolute',
441
+ inset: '0',
442
+ display: 'flex',
443
+ flexDirection: 'row',
444
+ alignItems: 'center',
445
+ }}
446
+ >
447
+ {Array.from({ length: props.count }, (_, i) => (
448
+ <view
449
+ key={i}
450
+ catchtap={() => props.onDotPress?.(i)}
451
+ style={{
452
+ width: (props.dotSize + props.gap) + 'px',
453
+ height: '100%',
454
+ }}
455
+ />
456
+ ))}
457
+ </view>
458
+ )
459
+ : null}
460
+ <view
461
+ main-thread:ref={thumbRef}
462
+ style={{
463
+ position: 'absolute',
464
+ left: '0',
465
+ top: '0',
466
+ width: props.dotSize + 'px',
467
+ height: '100%',
468
+ borderRadius: (props.barHeight / 2) + 'px',
469
+ backgroundColor: props.activeColor,
470
+ }}
471
+ />
472
+ </view>
473
+ );
474
+ };
475
+ });
476
+
477
+ // ─────────────────────────────────────────────────────────────────────
478
+ // Numbered variant — pure BG-thread.
479
+
480
+ type NumberedProps =
481
+ & Define.Prop<'count', number, true>
482
+ & Define.Prop<'index', PrimitiveSignal<number>, true>
483
+ & Define.Prop<'color', string, true>
484
+ & Define.Prop<'fontSize', number, true>
485
+ & Define.Prop<'class', string, false>
486
+ & Define.Prop<'style', Record<string, string | number>, false>;
487
+
488
+ const NumberedIndicator = component<NumberedProps>(({ props }) => {
489
+ const label = signal({ value: '' });
490
+ effect(() => {
491
+ label.value = `${(props.index.value | 0) + 1} / ${props.count}`;
492
+ });
493
+ return () => (
494
+ <text
495
+ class={props.class}
496
+ style={{
497
+ color: props.color,
498
+ fontSize: props.fontSize + 'px',
499
+ fontWeight: '600',
500
+ ...props.style,
501
+ }}
502
+ >
503
+ {label.value}
504
+ </text>
505
+ );
506
+ });
507
+
508
+ // ─────────────────────────────────────────────────────────────────────
509
+ // Helpers
510
+
511
+ /**
512
+ * Apply an alpha to a CSS colour value. Works for `var(--color-*)`
513
+ * (uses `color-mix`) and for raw rgb/hex strings (uses `color-mix`
514
+ * too — broadly supported on the platforms Lynx targets).
515
+ */
516
+ function withAlpha(color: string, alpha: number): string {
517
+ const pct = Math.round(Math.max(0, Math.min(1, alpha)) * 100);
518
+ return `color-mix(in srgb, ${color} ${pct}%, transparent)`;
519
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The shared design-system contract.
3
+ *
4
+ * `@sigx/lynx-zero` is the design-system-neutral foundation that DS packages
5
+ * (`@sigx/lynx-daisyui`, `@sigx/lynx-heroui`, …) build on. This module is the
6
+ * *vocabulary* they agree on — size scales, semantic colors, theme token
7
+ * names, and common prop shapes — so that switching an app from one design
8
+ * system to another is mostly an import swap, not a rewrite.
9
+ *
10
+ * Rules of the contract:
11
+ *
12
+ * - DS packages **extend** these types, they never redeclare them. A daisy
13
+ * button is `color: ColorVariant` plus daisy-specific extras; its size IS
14
+ * `SizeScale`. Drift fails `pnpm typecheck`.
15
+ * - `variant` is intentionally NOT in the contract — fill style (outline,
16
+ * soft, bordered, flat, …) is design-system chrome and differs per DS.
17
+ * - Theme CSS custom-property NAMES are part of the contract (see below);
18
+ * the *values* come from each DS's registered themes.
19
+ *
20
+ * ## Structural token-name contract
21
+ *
22
+ * Every DS theme resolves against the same custom-property names:
23
+ *
24
+ * - Colors: `--color-<ColorToken>` (e.g. `--color-primary`, `--color-base-100`)
25
+ * - Roundness: `--radius-selector` | `--radius-field` | `--radius-box`
26
+ * - Sizing: `--size-selector` | `--size-field`, `--size-xs` … `--size-lg`
27
+ * - Text ramp: `--text-xs` … `--text-3xl` (app text, font-scaled)
28
+ * - Controls: `--font-xs` … `--font-lg` (control-internal labels, unscaled)
29
+ * - Misc: `--disabled-opacity`
30
+ */
31
+ import type { Define } from '@sigx/lynx';
32
+
33
+ /** The shared component size scale. */
34
+ export type SizeScale = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
35
+
36
+ /**
37
+ * Semantic color names — the shared `color` prop vocabulary. A DS maps each
38
+ * onto its palette (HeroUI: `danger`→`error`, `default`→`neutral`, …).
39
+ *
40
+ * Single source of truth: the `ColorVariant` union, the `-content` / `-soft`
41
+ * token derivations, and the runtime `COLOR_TOKENS` Set all derive from this
42
+ * tuple.
43
+ */
44
+ export const COLOR_VARIANT_LIST = [
45
+ 'primary', 'secondary', 'accent', 'neutral',
46
+ 'info', 'success', 'warning', 'error',
47
+ ] as const;
48
+
49
+ export type ColorVariant = typeof COLOR_VARIANT_LIST[number];
50
+
51
+ /**
52
+ * Tokens authored by every theme: each variant + its `-content` pairing,
53
+ * plus the base surfaces.
54
+ */
55
+ export type CoreColorToken =
56
+ | ColorVariant
57
+ | `${ColorVariant}-content`
58
+ | 'base-100' | 'base-200' | 'base-300' | 'base-content';
59
+
60
+ /**
61
+ * Soft (tinted-surface) tokens — one per variant, emitted as
62
+ * `--color-<variant>-soft`. Lynx CSS can't alpha-compose `var()` colors, so
63
+ * these are *materialized in the palette*: computed at theme registration
64
+ * (`Theme.softMix` of the variant color mixed into `base-100`) unless the
65
+ * theme provides them explicitly. They are what soft/flat component fills
66
+ * read (`btn-soft`, hero's `flat`).
67
+ */
68
+ export type SoftColorToken = `${ColorVariant}-soft`;
69
+
70
+ /**
71
+ * The full set of semantic color tokens every *registered* theme carries,
72
+ * exposed as `--color-<token>` CSS custom properties. Authors write the core
73
+ * tokens; the registry completes the soft ones.
74
+ */
75
+ export type ColorToken = CoreColorToken | SoftColorToken;
76
+
77
+ const COLOR_TOKEN_LIST: readonly ColorToken[] = [
78
+ ...COLOR_VARIANT_LIST.flatMap((v): ColorToken[] => [v, `${v}-content`, `${v}-soft`]),
79
+ 'base-100', 'base-200', 'base-300', 'base-content',
80
+ ];
81
+
82
+ const COLOR_TOKENS: ReadonlySet<ColorToken> = new Set(COLOR_TOKEN_LIST);
83
+
84
+ /**
85
+ * Resolve a color value to a CSS color string.
86
+ *
87
+ * - Known semantic tokens (e.g. `'base-100'`) → `var(--color-base-100)`.
88
+ * - Anything else (`'#ffaa00'`, `'rgb(…)'`, `'var(--my-custom)'`) passes
89
+ * through unchanged.
90
+ */
91
+ export function resolveColorToken(value: string): string {
92
+ return (COLOR_TOKENS as ReadonlySet<string>).has(value)
93
+ ? `var(--color-${value})`
94
+ : value;
95
+ }
96
+
97
+ /**
98
+ * Accepts a semantic color token (autocompleted) OR any raw CSS color
99
+ * string (`'#fff'`, `'rgb(…)'`, `'var(--foo)'`).
100
+ */
101
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/ban-types
102
+ export type BackgroundValue = ColorToken | (string & {});
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Common prop fragments — DS component props intersect these instead of
106
+ // redeclaring the conventions.
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /** Arbitrary extra classes appended after the DS-computed ones. */
110
+ export type WithClass = Define.Prop<'class', string, false>;
111
+
112
+ /** Disabled: non-interactive + DS disabled styling. */
113
+ export type WithDisabled = Define.Prop<'disabled', boolean, false>;
114
+
115
+ /** Semantic color of the component (`primary`, `error`, …). */
116
+ export type WithColor = Define.Prop<'color', ColorVariant, false>;
117
+
118
+ /** Component size on the shared scale. */
119
+ export type WithSize = Define.Prop<'size', SizeScale, false>;
120
+
121
+ /** The shared press event — sigx convention is `onPress`, not `onTap`/`onClick`. */
122
+ export type PressEvent = Define.Event<'press', void>;
123
+
124
+ /**
125
+ * Accessibility passthrough for interactive components — mirrors the
126
+ * `accessibility-*` surface `@sigx/lynx-gestures`'s `Pressable` accepts on
127
+ * its host view (the same node that owns the gesture handler, so
128
+ * screen-reader activation works). DS components intersect this and forward
129
+ * the props verbatim.
130
+ */
131
+ export type WithAccessibility =
132
+ & Define.Prop<'accessibility-element', boolean, false>
133
+ & Define.Prop<'accessibility-label', string, false>
134
+ & Define.Prop<'accessibility-role', string, false>
135
+ & Define.Prop<'accessibility-trait', string, false>
136
+ & Define.Prop<'accessibility-status', string, false>;