@sovereignfs/ui 0.45.0 → 0.54.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.
Files changed (93) hide show
  1. package/dist/LegalLinks.module.css +24 -0
  2. package/dist/Markdown.module.css +49 -0
  3. package/dist/MobileAppsDrawer.module.css +81 -0
  4. package/dist/MobileFooter.module.css +73 -0
  5. package/dist/MobileHeader.module.css +63 -0
  6. package/dist/PageContainer.module.css +20 -0
  7. package/dist/SwipableMobileCarousel.module.css +55 -0
  8. package/dist/SwipableMobileCarouselDots.module.css +49 -0
  9. package/dist/SwipableMobileCarouselSlideParts.module.css +29 -0
  10. package/dist/index.d.ts +654 -6
  11. package/dist/index.js +1856 -1133
  12. package/dist/tokens/primitives.css +25 -0
  13. package/package.json +13 -9
  14. package/src/components/Badge/Badge.tsx +1 -7
  15. package/src/components/DeviceOnlyGate/DeviceOnlyGate.tsx +58 -0
  16. package/src/components/DeviceOnlyGate/__tests__/DeviceOnlyGate.test.tsx +45 -0
  17. package/src/components/DeviceStorageKeyGate/DeviceStorageKeyGate.tsx +100 -0
  18. package/src/components/DeviceStorageKeyGate/__tests__/DeviceStorageKeyGate.test.tsx +119 -0
  19. package/src/components/Dialog/Dialog.tsx +8 -59
  20. package/src/components/Drawer/Drawer.stories.tsx +44 -0
  21. package/src/components/Drawer/Drawer.tsx +18 -56
  22. package/src/components/Drawer/__tests__/Drawer.test.tsx +22 -0
  23. package/src/components/Icon/icons/index.ts +8 -0
  24. package/src/components/Icon/icons/menu.tsx +22 -0
  25. package/src/components/Icon/icons/smartphone.tsx +21 -0
  26. package/src/components/LegalLinks/LegalLinks.module.css +24 -0
  27. package/src/components/LegalLinks/LegalLinks.stories.tsx +22 -0
  28. package/src/components/LegalLinks/LegalLinks.tsx +40 -0
  29. package/src/components/Markdown/Markdown.module.css +49 -0
  30. package/src/components/Markdown/Markdown.stories.tsx +30 -0
  31. package/src/components/Markdown/Markdown.tsx +168 -0
  32. package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +81 -0
  33. package/src/components/MobileAppsDrawer/MobileAppsDrawer.tsx +78 -0
  34. package/src/components/MobileAppsDrawer/__tests__/MobileAppsDrawer.test.tsx +80 -0
  35. package/src/components/MobileFooter/MobileFooter.module.css +73 -0
  36. package/src/components/MobileFooter/MobileFooter.stories.tsx +47 -0
  37. package/src/components/MobileFooter/MobileFooter.tsx +109 -0
  38. package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +71 -0
  39. package/src/components/MobileHeader/MobileHeader.module.css +63 -0
  40. package/src/components/MobileHeader/MobileHeader.stories.tsx +57 -0
  41. package/src/components/MobileHeader/MobileHeader.tsx +77 -0
  42. package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +58 -0
  43. package/src/components/NavTabs/NavTabs.tsx +39 -11
  44. package/src/components/OfflineGate/OfflineGate.tsx +44 -0
  45. package/src/components/OfflineGate/__tests__/OfflineGate.test.tsx +66 -0
  46. package/src/components/PageContainer/PageContainer.module.css +20 -0
  47. package/src/components/PageContainer/PageContainer.tsx +31 -0
  48. package/src/components/PageContainer/__tests__/PageContainer.test.tsx +37 -0
  49. package/src/components/PageHeader/PageHeader.tsx +16 -2
  50. package/src/components/ResponsiveSurface/ResponsiveSurface.tsx +24 -0
  51. package/src/components/ResponsiveSurface/__tests__/ResponsiveSurface.test.tsx +73 -0
  52. package/src/components/Sheet/Sheet.tsx +9 -51
  53. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +55 -0
  54. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +227 -0
  55. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlide.tsx +35 -0
  56. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideBody.tsx +42 -0
  57. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideFooter.tsx +18 -0
  58. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideHeader.tsx +21 -0
  59. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideParts.module.css +29 -0
  60. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +133 -0
  61. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarouselDevGuard.test.tsx +61 -0
  62. package/src/components/SwipableMobileCarousel/context.ts +13 -0
  63. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +49 -0
  64. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +62 -0
  65. package/src/components/SwipableMobileCarouselDots/__tests__/SwipableMobileCarouselDots.test.tsx +63 -0
  66. package/src/hooks/__tests__/useCarouselRouteSync.test.tsx +126 -0
  67. package/src/hooks/__tests__/useIsOffline.test.tsx +53 -0
  68. package/src/hooks/__tests__/useOfflineTileState.test.tsx +69 -0
  69. package/src/hooks/__tests__/useResponsiveLayout.test.tsx +87 -0
  70. package/src/hooks/__tests__/useSnapCarousel.test.tsx +93 -0
  71. package/src/hooks/__tests__/useSwipeReveal.test.tsx +110 -0
  72. package/src/hooks/index.ts +18 -0
  73. package/src/hooks/useCarouselRouteSync.ts +92 -0
  74. package/src/hooks/useIsOffline.ts +41 -0
  75. package/src/hooks/useOfflineTileState.ts +45 -0
  76. package/src/hooks/useResponsiveLayout.ts +36 -0
  77. package/src/hooks/useSnapCarousel.ts +79 -0
  78. package/src/hooks/useSwipeReveal.ts +107 -0
  79. package/src/index.ts +63 -2
  80. package/src/overlay-shell.ts +94 -0
  81. package/src/stories/DesignSystemOverview.stories.tsx +322 -1
  82. package/src/stories/DeviceOnlyGate.stories.tsx +35 -0
  83. package/src/stories/DeviceStorageKeyGate.stories.tsx +57 -0
  84. package/src/stories/InteractionHooks.stories.tsx +483 -0
  85. package/src/stories/MobilePatterns.stories.tsx +247 -2
  86. package/src/stories/NavTabs.stories.tsx +18 -0
  87. package/src/stories/OfflineGate.stories.tsx +42 -0
  88. package/src/stories/PageContainer.stories.tsx +37 -0
  89. package/src/stories/PageHeader.stories.tsx +11 -0
  90. package/src/stories/ResponsiveSurface.stories.tsx +47 -0
  91. package/src/stories/SwipableMobileCarousel.stories.tsx +304 -0
  92. package/src/stories/TokenGallery.stories.tsx +65 -0
  93. package/src/tokens/primitives.css +25 -0
@@ -0,0 +1,304 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { SwipableMobileCarousel } from '../components/SwipableMobileCarousel/SwipableMobileCarousel';
4
+ import { SwipableMobileCarouselSlide } from '../components/SwipableMobileCarousel/SwipableMobileCarouselSlide';
5
+ import { SwipableMobileCarouselSlideHeader } from '../components/SwipableMobileCarousel/SwipableMobileCarouselSlideHeader';
6
+ import { SwipableMobileCarouselSlideBody } from '../components/SwipableMobileCarousel/SwipableMobileCarouselSlideBody';
7
+ import { SwipableMobileCarouselSlideFooter } from '../components/SwipableMobileCarousel/SwipableMobileCarouselSlideFooter';
8
+ import { SwipableMobileCarouselDots } from '../components/SwipableMobileCarouselDots/SwipableMobileCarouselDots';
9
+
10
+ const SLIDES = [
11
+ { key: 'lists', label: 'Lists' },
12
+ { key: 'groceries', label: 'Groceries' },
13
+ { key: 'errands', label: 'Errands' },
14
+ { key: 'work', label: 'Work' },
15
+ ];
16
+
17
+ function slideBodyStyle(): React.CSSProperties {
18
+ return {
19
+ display: 'flex',
20
+ alignItems: 'center',
21
+ justifyContent: 'center',
22
+ height: '100%',
23
+ fontFamily: 'var(--sv-font-family)',
24
+ color: 'var(--sv-color-text-primary)',
25
+ };
26
+ }
27
+
28
+ function headerStyle(): React.CSSProperties {
29
+ return {
30
+ padding: 'var(--sv-space-4)',
31
+ fontFamily: 'var(--sv-font-family)',
32
+ fontWeight: 'var(--sv-font-weight-semibold)',
33
+ color: 'var(--sv-color-text-primary)',
34
+ borderBottom: '1px solid var(--sv-color-border)',
35
+ };
36
+ }
37
+
38
+ function CarouselFrame({ children }: { children: React.ReactNode }) {
39
+ return (
40
+ <div
41
+ style={{
42
+ height: 480,
43
+ maxWidth: 375,
44
+ margin: '0 auto',
45
+ border: '1px solid var(--sv-color-border)',
46
+ borderRadius: 'var(--sv-radius-md)',
47
+ overflow: 'hidden',
48
+ }}
49
+ >
50
+ {children}
51
+ </div>
52
+ );
53
+ }
54
+
55
+ function DefaultDemo() {
56
+ const [activeIndex, setActiveIndex] = useState(0);
57
+ return (
58
+ <CarouselFrame>
59
+ <SwipableMobileCarousel
60
+ activeIndex={activeIndex}
61
+ onSettle={setActiveIndex}
62
+ aria-label="Task lists"
63
+ >
64
+ {SLIDES.map((slide) => (
65
+ <SwipableMobileCarouselSlide key={slide.key} slideKey={slide.key} label={slide.label}>
66
+ <SwipableMobileCarouselSlideHeader>
67
+ <div style={headerStyle()}>{slide.label}</div>
68
+ </SwipableMobileCarouselSlideHeader>
69
+ <SwipableMobileCarouselSlideBody>
70
+ <div style={slideBodyStyle()}>{slide.label} content</div>
71
+ </SwipableMobileCarouselSlideBody>
72
+ </SwipableMobileCarouselSlide>
73
+ ))}
74
+ </SwipableMobileCarousel>
75
+ </CarouselFrame>
76
+ );
77
+ }
78
+
79
+ function LazyLoadingBodyDemo() {
80
+ const [activeIndex, setActiveIndex] = useState(0);
81
+ const [loadedKeys, setLoadedKeys] = useState<Set<string>>(new Set(['lists']));
82
+
83
+ function simulateLoad(key: string) {
84
+ setTimeout(() => setLoadedKeys((s) => new Set(s).add(key)), 1200);
85
+ }
86
+
87
+ return (
88
+ <CarouselFrame>
89
+ <SwipableMobileCarousel
90
+ activeIndex={activeIndex}
91
+ onSettle={(i) => {
92
+ setActiveIndex(i);
93
+ const key = SLIDES[i]?.key;
94
+ if (key && !loadedKeys.has(key)) simulateLoad(key);
95
+ }}
96
+ aria-label="Task lists (simulated fetch)"
97
+ >
98
+ {SLIDES.map((slide) => (
99
+ <SwipableMobileCarouselSlide key={slide.key} slideKey={slide.key} label={slide.label}>
100
+ {/* The title renders immediately from already-known metadata —
101
+ only the Body below waits on its own simulated 1.2s fetch. */}
102
+ <SwipableMobileCarouselSlideHeader>
103
+ <div style={headerStyle()}>{slide.label}</div>
104
+ </SwipableMobileCarouselSlideHeader>
105
+ <SwipableMobileCarouselSlideBody loading={!loadedKeys.has(slide.key)}>
106
+ <div style={slideBodyStyle()}>{slide.label} content (loaded)</div>
107
+ </SwipableMobileCarouselSlideBody>
108
+ </SwipableMobileCarouselSlide>
109
+ ))}
110
+ </SwipableMobileCarousel>
111
+ </CarouselFrame>
112
+ );
113
+ }
114
+
115
+ function CustomIndicatorDemo() {
116
+ const [activeIndex, setActiveIndex] = useState(0);
117
+ return (
118
+ <CarouselFrame>
119
+ <SwipableMobileCarousel
120
+ activeIndex={activeIndex}
121
+ onSettle={setActiveIndex}
122
+ aria-label="Task lists"
123
+ renderIndicator={({ count, activeIndex: i, onJump }) => (
124
+ <div
125
+ style={{
126
+ position: 'absolute',
127
+ top: 8,
128
+ right: 8,
129
+ fontFamily: 'var(--sv-font-family)',
130
+ fontSize: 'var(--sv-font-size-sm)',
131
+ color: 'var(--sv-color-text-primary)',
132
+ background: 'var(--sv-color-surface-raised)',
133
+ borderRadius: 'var(--sv-radius-md)',
134
+ padding: 'var(--sv-space-1) var(--sv-space-2)',
135
+ }}
136
+ >
137
+ <button type="button" onClick={() => onJump(Math.max(0, i - 1))}>
138
+ ‹
139
+ </button>{' '}
140
+ {i + 1} / {count}{' '}
141
+ <button type="button" onClick={() => onJump(Math.min(count - 1, i + 1))}>
142
+ ›
143
+ </button>
144
+ </div>
145
+ )}
146
+ >
147
+ {SLIDES.map((slide) => (
148
+ <SwipableMobileCarouselSlide key={slide.key} slideKey={slide.key} label={slide.label}>
149
+ <SwipableMobileCarouselSlideHeader>
150
+ <div style={headerStyle()}>{slide.label}</div>
151
+ </SwipableMobileCarouselSlideHeader>
152
+ <SwipableMobileCarouselSlideBody>
153
+ <div style={slideBodyStyle()}>{slide.label} content</div>
154
+ </SwipableMobileCarouselSlideBody>
155
+ </SwipableMobileCarouselSlide>
156
+ ))}
157
+ </SwipableMobileCarousel>
158
+ </CarouselFrame>
159
+ );
160
+ }
161
+
162
+ function NoIndicatorDemo() {
163
+ const [activeIndex, setActiveIndex] = useState(0);
164
+ return (
165
+ <CarouselFrame>
166
+ <SwipableMobileCarousel
167
+ activeIndex={activeIndex}
168
+ onSettle={setActiveIndex}
169
+ aria-label="Task lists"
170
+ renderIndicator={null}
171
+ >
172
+ {SLIDES.map((slide) => (
173
+ <SwipableMobileCarouselSlide key={slide.key} slideKey={slide.key} label={slide.label}>
174
+ <SwipableMobileCarouselSlideHeader>
175
+ <div style={headerStyle()}>{slide.label}</div>
176
+ </SwipableMobileCarouselSlideHeader>
177
+ <SwipableMobileCarouselSlideBody>
178
+ <div style={slideBodyStyle()}>{slide.label} content</div>
179
+ </SwipableMobileCarouselSlideBody>
180
+ </SwipableMobileCarouselSlide>
181
+ ))}
182
+ </SwipableMobileCarousel>
183
+ </CarouselFrame>
184
+ );
185
+ }
186
+
187
+ function FooterDemo() {
188
+ const [activeIndex, setActiveIndex] = useState(0);
189
+ return (
190
+ <CarouselFrame>
191
+ <SwipableMobileCarousel
192
+ activeIndex={activeIndex}
193
+ onSettle={setActiveIndex}
194
+ aria-label="Task lists"
195
+ >
196
+ {SLIDES.map((slide) => (
197
+ <SwipableMobileCarouselSlide key={slide.key} slideKey={slide.key} label={slide.label}>
198
+ <SwipableMobileCarouselSlideHeader>
199
+ <div style={headerStyle()}>{slide.label}</div>
200
+ </SwipableMobileCarouselSlideHeader>
201
+ <SwipableMobileCarouselSlideBody>
202
+ <div style={slideBodyStyle()}>{slide.label} content</div>
203
+ </SwipableMobileCarouselSlideBody>
204
+ <SwipableMobileCarouselSlideFooter>
205
+ <div
206
+ style={{
207
+ padding: 'var(--sv-space-3) var(--sv-space-4)',
208
+ borderTop: '1px solid var(--sv-color-border)',
209
+ fontFamily: 'var(--sv-font-family)',
210
+ fontSize: 'var(--sv-font-size-sm)',
211
+ color: 'var(--sv-color-text-muted)',
212
+ }}
213
+ >
214
+ + Add to {slide.label}
215
+ </div>
216
+ </SwipableMobileCarouselSlideFooter>
217
+ </SwipableMobileCarouselSlide>
218
+ ))}
219
+ </SwipableMobileCarousel>
220
+ </CarouselFrame>
221
+ );
222
+ }
223
+
224
+ function DotsStandaloneDemo() {
225
+ const [activeIndex, setActiveIndex] = useState(0);
226
+ return (
227
+ <SwipableMobileCarouselDots
228
+ count={4}
229
+ activeIndex={activeIndex}
230
+ onJump={setActiveIndex}
231
+ labels={['Lists', 'Groceries', 'Errands', 'Work']}
232
+ aria-label="Task lists"
233
+ />
234
+ );
235
+ }
236
+
237
+ const meta = {
238
+ title: 'Components/SwipableMobileCarousel',
239
+ component: SwipableMobileCarousel,
240
+ parameters: {
241
+ layout: 'padded',
242
+ docs: {
243
+ description: {
244
+ component:
245
+ "A compound component for swiping between full-width slides (each an independent route/view on mobile), wrapping useSnapCarousel. Owns rendering and mount-window mechanics only — it has no opinion on where slide data lives. Do NOT aggregate cross-slide data or mount a detail overlay (Sheet, Dialog) inside a Slide's children; see docs/design-system.md's carousel section.",
246
+ },
247
+ },
248
+ },
249
+ // Every story below supplies its own render(), which is stateful (activeIndex
250
+ // lives in useState) — these args only satisfy the required-prop typing.
251
+ args: {
252
+ activeIndex: 0,
253
+ onSettle: () => {},
254
+ 'aria-label': 'Task lists',
255
+ children: null,
256
+ },
257
+ } satisfies Meta<typeof SwipableMobileCarousel>;
258
+
259
+ export default meta;
260
+ type Story = StoryObj<typeof meta>;
261
+
262
+ export const Default: Story = { render: () => <DefaultDemo /> };
263
+
264
+ export const LazyLoadingBody: Story = {
265
+ render: () => <LazyLoadingBodyDemo />,
266
+ parameters: {
267
+ docs: {
268
+ description: {
269
+ story:
270
+ 'Swipe to a new slide — its title renders immediately from already-known metadata, while only the Body region shows a brief loading placeholder for its simulated 1.2s fetch. This is the fix for the "whole slide blanks out, title included, until its own fetch resolves" bug.',
271
+ },
272
+ },
273
+ },
274
+ };
275
+
276
+ export const CustomIndicator: Story = { render: () => <CustomIndicatorDemo /> };
277
+
278
+ export const NoIndicator: Story = { render: () => <NoIndicatorDemo /> };
279
+
280
+ export const WithFooter: Story = { render: () => <FooterDemo /> };
281
+
282
+ export const MobileViewport: Story = {
283
+ render: () => <DefaultDemo />,
284
+ parameters: { viewport: { defaultViewport: 'mobile1' } },
285
+ };
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // SwipableMobileCarouselDots — standalone, also this carousel's default
289
+ // indicator. Grouped in this file rather than its own (Table's sub-parts
290
+ // share one story file too), though it has its own component subfolder and
291
+ // barrel export for independent reuse.
292
+ // ---------------------------------------------------------------------------
293
+
294
+ export const DotsStandalone: Story = {
295
+ render: () => <DotsStandaloneDemo />,
296
+ parameters: {
297
+ docs: {
298
+ description: {
299
+ story:
300
+ 'SwipableMobileCarouselDots used on its own, outside a carousel — a real role="tablist"/role="tab" component (tappable, labeled, focusable), unlike the aria-hidden decorative dots both sovereign-tasks and sovereign-shopper currently hand-roll.',
301
+ },
302
+ },
303
+ },
304
+ };
@@ -109,6 +109,20 @@ const FONT_SIZE_TOKENS = [
109
109
  '--sv-font-size-2xl',
110
110
  ];
111
111
 
112
+ // Task 10.2 — in-app text-size control. --sv-text-size-scale is theme-stable
113
+ // like --sv-radius-scale, but unlike radius (a calc() chain resolved once at
114
+ // :root's declaration site — see the RADIUS_PRESETS comment below), rem units
115
+ // genuinely re-resolve against the root element's live computed font-size, so
116
+ // these swatches compute the result directly in JS rather than needing a real
117
+ // [data-text-size] toggle on the document root.
118
+ const TEXT_SIZE_PRESETS = [
119
+ { preset: 'default', scale: 1 },
120
+ { preset: 'large', scale: 1.125 },
121
+ { preset: 'larger', scale: 1.25 },
122
+ ] as const;
123
+
124
+ const TEXT_SIZE_BASE_PX = { md: 16, lg: 18, '2xl': 24 };
125
+
112
126
  const FONT_WEIGHT_TOKENS = [
113
127
  '--sv-font-weight-normal',
114
128
  '--sv-font-weight-medium',
@@ -247,6 +261,44 @@ function RadiusScaleRow({ token }: { token: string }) {
247
261
  );
248
262
  }
249
263
 
264
+ function TextSizePresetRow({ preset, scale }: { preset: string; scale: number }) {
265
+ return (
266
+ <div style={row}>
267
+ <div style={{ display: 'flex', gap: 12, alignItems: 'baseline', flexShrink: 0, width: 180 }}>
268
+ <span
269
+ style={{
270
+ fontSize: `${scale * TEXT_SIZE_BASE_PX.md}px`,
271
+ color: 'var(--sv-color-text-primary)',
272
+ }}
273
+ title="body (--sv-font-size-md)"
274
+ >
275
+ Aa
276
+ </span>
277
+ <span
278
+ style={{
279
+ fontSize: `${scale * TEXT_SIZE_BASE_PX.lg}px`,
280
+ color: 'var(--sv-color-text-primary)',
281
+ }}
282
+ title="large (--sv-font-size-lg)"
283
+ >
284
+ Aa
285
+ </span>
286
+ <span
287
+ style={{
288
+ fontSize: `${scale * TEXT_SIZE_BASE_PX['2xl']}px`,
289
+ color: 'var(--sv-color-text-primary)',
290
+ }}
291
+ title="heading (--sv-font-size-2xl)"
292
+ >
293
+ Aa
294
+ </span>
295
+ </div>
296
+ <span style={label}>{preset}</span>
297
+ <span style={value}>scale {scale}</span>
298
+ </div>
299
+ );
300
+ }
301
+
250
302
  function RadiusPresetRow({ preset, scale }: { preset: string; scale: number }) {
251
303
  return (
252
304
  <div style={row}>
@@ -446,6 +498,19 @@ function TokenGalleryComponent() {
446
498
  ))}
447
499
  </Section>
448
500
 
501
+ <Section title="Text-size presets (task 10.2)">
502
+ <p style={{ color: 'var(--sv-color-text-muted)', fontSize: 13, marginBottom: 12 }}>
503
+ Account → Preferences → Appearance. Overrides <code>--sv-text-size-scale</code> via{' '}
504
+ <code>[data-text-size]</code> on <code>&lt;html&gt;</code> — every rem-based token above
505
+ (font sizes, but also spacing and radii) scales with it, since the root element&apos;s
506
+ font-size is what rem resolves against. Discharges the accessibility debt from disabling
507
+ pinch-zoom (<code>runtime/app/layout.tsx</code>).
508
+ </p>
509
+ {TEXT_SIZE_PRESETS.map((p) => (
510
+ <TextSizePresetRow key={p.preset} preset={p.preset} scale={p.scale} />
511
+ ))}
512
+ </Section>
513
+
449
514
  <Section title="Radius scale">
450
515
  {RADIUS_TOKENS.map((t) => (
451
516
  <RadiusScaleRow key={t} token={t} />
@@ -77,6 +77,16 @@
77
77
  --sv-font-weight-semibold: 600;
78
78
  --sv-font-weight-bold: 700;
79
79
 
80
+ /* In-app text-size control (task 10.2), discharging the pinch-zoom-disabled
81
+ accessibility debt (runtime/app/layout.tsx sets userScalable: false).
82
+ Every --sv-font-size-* above is rem-based, so scaling the root font size
83
+ scales the whole type system with no per-component changes. Driven by
84
+ [data-text-size] on <html>, set pre-paint by runtime/src/theme-script.ts
85
+ from the `sv-text-size` cookie — same mechanism as [data-theme]. Like
86
+ --sv-radius-scale, this is a theme-stable scale token with no separate
87
+ semantic tier. */
88
+ --sv-text-size-scale: 1;
89
+
80
90
  /* Icon sizes — scale tokens used directly by the Icon component */
81
91
  --sv-icon-size-xs: 0.75rem; /* 12px — inline metadata (e.g. a due-date badge) */
82
92
  --sv-icon-size-sm: 1rem; /* 16px — inline with body text */
@@ -130,3 +140,18 @@
130
140
  1
131
141
  ); /* slight overshoot — playful emphasis, use sparingly */
132
142
  }
143
+
144
+ /* Root font size scales with --sv-text-size-scale — see the token comment
145
+ above. calc(... * 100%) keeps the browser/OS text-zoom default intact at
146
+ scale 1 rather than hardcoding a px value. */
147
+ html {
148
+ font-size: calc(var(--sv-text-size-scale) * 100%);
149
+ }
150
+
151
+ :root[data-text-size='large'] {
152
+ --sv-text-size-scale: 1.125; /* +12.5% */
153
+ }
154
+
155
+ :root[data-text-size='larger'] {
156
+ --sv-text-size-scale: 1.25; /* +25% */
157
+ }