@smicolon/fasla-ui 0.2.4 → 0.4.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 (72) hide show
  1. package/README.md +44 -20
  2. package/dist/index.cjs +319 -0
  3. package/dist/index.d.cts +5 -0
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.js +92 -24
  6. package/dist/lib/utils.cjs +11 -0
  7. package/dist/lib/utils.d.cts +9 -0
  8. package/dist/motion/index.cjs +61 -0
  9. package/dist/motion/index.d.cts +33 -0
  10. package/dist/registry/index.cjs +179 -0
  11. package/dist/registry/index.d.cts +17 -0
  12. package/dist/registry/index.js +32 -3
  13. package/dist/tokens/index.cjs +77 -0
  14. package/dist/tokens/index.d.cts +124 -0
  15. package/dist/tokens/index.d.ts +75 -6
  16. package/dist/tokens/index.js +59 -20
  17. package/package.json +17 -7
  18. package/registry/blocks/app-shell/AppShell.tsx +1 -1
  19. package/registry/blocks/data-table/DataTable.test.tsx +61 -0
  20. package/registry/blocks/data-table/DataTable.tsx +53 -16
  21. package/registry/blocks/data-table/index.ts +1 -0
  22. package/registry/blocks/empty-state/EmptyState.test.tsx +23 -0
  23. package/registry/blocks/empty-state/EmptyState.tsx +32 -33
  24. package/registry/blocks/form-section/FormSection.tsx +1 -1
  25. package/registry/blocks/navbar/Navbar.test.tsx +12 -0
  26. package/registry/blocks/navbar/Navbar.tsx +8 -23
  27. package/registry/blocks/page-header/PageHeader.tsx +5 -2
  28. package/registry/blocks/sidebar/Sidebar.test.tsx +39 -0
  29. package/registry/blocks/sidebar/Sidebar.tsx +36 -26
  30. package/registry/blocks/stats-card/StatsCard.tsx +12 -29
  31. package/registry/effects/animated-gradient/animated-gradient.tsx +19 -8
  32. package/registry/effects/border-beam/border-beam.tsx +77 -53
  33. package/registry/effects/effects.test.tsx +266 -0
  34. package/registry/effects/glow-card/glow-card.tsx +68 -43
  35. package/registry/effects/shimmer-button/shimmer-button.tsx +62 -23
  36. package/registry/effects/spotlight/spotlight.tsx +73 -29
  37. package/registry/effects/text-reveal/index.ts +1 -0
  38. package/registry/effects/text-reveal/text-reveal.test.tsx +66 -0
  39. package/registry/effects/text-reveal/text-reveal.tsx +70 -21
  40. package/registry/effects/typewriter-text/typewriter-text.tsx +39 -20
  41. package/registry/next14-tsconfig.test.ts +43 -0
  42. package/registry/text-props-undefined.test.tsx +73 -0
  43. package/registry/ui/avatar/avatar.test.tsx +316 -0
  44. package/registry/ui/avatar/avatar.tsx +235 -108
  45. package/registry/ui/avatar/index.ts +1 -1
  46. package/registry/ui/badge/badge.test.tsx +267 -32
  47. package/registry/ui/badge/badge.tsx +233 -21
  48. package/registry/ui/button/button.test.tsx +6 -0
  49. package/registry/ui/button/button.tsx +6 -23
  50. package/registry/ui/card/card.tsx +1 -1
  51. package/registry/ui/checkbox/checkbox.test.tsx +23 -0
  52. package/registry/ui/checkbox/checkbox.tsx +16 -27
  53. package/registry/ui/combobox/combobox.test.tsx +72 -0
  54. package/registry/ui/combobox/combobox.tsx +34 -59
  55. package/registry/ui/input/input.test.tsx +3 -3
  56. package/registry/ui/input/input.tsx +5 -5
  57. package/registry/ui/radio/index.ts +1 -0
  58. package/registry/ui/radio/radio.test.tsx +248 -0
  59. package/registry/ui/radio/radio.tsx +208 -0
  60. package/registry/ui/select/select.tsx +4 -14
  61. package/registry/ui/skeleton/index.ts +1 -0
  62. package/registry/ui/skeleton/skeleton.test.tsx +113 -29
  63. package/registry/ui/skeleton/skeleton.tsx +101 -41
  64. package/registry/ui/status-indicator/index.ts +6 -0
  65. package/registry/ui/status-indicator/status-indicator.test.tsx +148 -0
  66. package/registry/ui/status-indicator/status-indicator.tsx +87 -0
  67. package/registry/ui/switch/index.ts +1 -1
  68. package/registry/ui/switch/switch.test.tsx +323 -0
  69. package/registry/ui/switch/switch.tsx +254 -57
  70. package/registry/ui/textarea/textarea.tsx +1 -1
  71. package/registry.json +79 -16
  72. package/tailwind-preset.ts +143 -0
@@ -1,89 +1,286 @@
1
1
  import * as React from "react"
2
+ import { cva } from "class-variance-authority"
2
3
  import { cn } from "../../../src/lib/utils"
3
4
 
4
- export interface SwitchProps
5
- extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "size"> {
6
- /** Label text */
7
- label?: string
8
- /** Description text */
9
- description?: string
10
- /** Size variant */
11
- size?: "sm" | "default" | "lg"
12
- }
5
+ /**
6
+ * Root layout. Figma's two Types:
7
+ * - `control-first` (the default): track, then label. The description sits
8
+ * under the label, so it is indented by the track width plus the 8px gap,
9
+ * exactly as Figma's 40/48/56px indent.
10
+ * - `label-first`: label at the start, track pushed to the end — a settings
11
+ * row. It fills its container; the description stays under the label.
12
+ *
13
+ * `group/switch` is a *named* group so the `:has(:checked)` lookups below can
14
+ * never be answered by an unrelated checkbox in some outer `.group`.
15
+ *
16
+ * Disabled dims the whole component, label and description included — Figma
17
+ * puts 50% opacity on the variant root, not on the track.
18
+ */
19
+ const switchVariants = cva(
20
+ "group/switch cursor-pointer items-start gap-2 has-[:disabled]:pointer-events-none has-[:disabled]:opacity-50",
21
+ {
22
+ variants: {
23
+ layout: {
24
+ "control-first": "inline-flex",
25
+ "label-first": "flex justify-between",
26
+ },
27
+ },
28
+ defaultVariants: {
29
+ layout: "control-first",
30
+ },
31
+ }
32
+ )
13
33
 
34
+ /**
35
+ * Geometry per size, straight from the Figma set: track, thumb, and where the
36
+ * thumb sits when on. The thumb is always inset 1px, so "on" is the track width
37
+ * less the thumb less 1px.
38
+ *
39
+ * `--sw-h` is the track height. With `--sw-line` (one line of label text, see
40
+ * `LINE` below) it sizes the band the track is centred in, and the label's
41
+ * padding. Every class is written out literally so Tailwind can find it.
42
+ */
14
43
  const sizeClasses = {
15
44
  sm: {
16
- track: "h-4 w-7",
17
- thumb: "h-3 w-3 data-[state=checked]:translate-x-3",
45
+ root: "[--sw-h:18px]",
46
+ track: "h-[18px] w-8",
47
+ thumb: "size-4 group-has-[:checked]/switch:start-[15px]",
18
48
  },
19
- default: {
20
- track: "h-5 w-9",
21
- thumb: "h-4 w-4 data-[state=checked]:translate-x-4",
49
+ md: {
50
+ root: "[--sw-h:20px]",
51
+ track: "h-5 w-10",
52
+ thumb: "size-[18px] group-has-[:checked]/switch:start-[21px]",
22
53
  },
23
54
  lg: {
24
- track: "h-6 w-11",
25
- thumb: "h-5 w-5 data-[state=checked]:translate-x-5",
55
+ root: "[--sw-h:24px]",
56
+ track: "h-6 w-12",
57
+ thumb: "size-[22px] group-has-[:checked]/switch:start-[25px]",
58
+ },
59
+ }
60
+
61
+ /**
62
+ * Colours per Figma Style. All `☾ Mode` tokens, so Dark follows on its own.
63
+ *
64
+ * - solid: track `input` → `primary`, thumb `primary-foreground`, and Figma's
65
+ * `shadow/default/xs` on the *track* (Tailwind's `shadow-sm` is the same
66
+ * 0 1 2 0 black/5%). Disabled keeps the same thumb; only the opacity changes.
67
+ * - outline: track `background` with a 1px stroke *outside* it, `input` →
68
+ * `primary`; the thumb takes the stroke colour. A CSS outline is the exact
69
+ * model of a Figma OUTSIDE stroke: painted, but not part of the box, so the
70
+ * track keeps Figma's size and the thumb its 1px inset.
71
+ *
72
+ * `focus` is the focus-ring element (see below). Figma draws focus as a 1px
73
+ * stroke in `ring` — inside the track for solid, replacing the outside stroke
74
+ * for outline, where an *on* outline keeps its `primary` stroke — plus a 3px
75
+ * halo. Figma's halo colour is a raw #a1a1aa at 50%; this uses the `ring`
76
+ * token at 50%, which is the same grey in Light and follows the mode in Dark.
77
+ */
78
+ const variantClasses = {
79
+ solid: {
80
+ track: "bg-input shadow-sm group-has-[:checked]/switch:bg-primary",
81
+ thumb: "bg-primary-foreground",
82
+ focus: "border border-ring",
83
+ },
84
+ outline: {
85
+ track:
86
+ "bg-background outline outline-1 outline-input group-has-[:checked]/switch:outline-primary",
87
+ thumb: "bg-input group-has-[:checked]/switch:bg-primary",
88
+ focus: "outline outline-1 outline-ring group-has-[:checked]/switch:outline-primary",
26
89
  },
27
90
  }
28
91
 
92
+ /**
93
+ * One line of `text-sm` label, in whichever script is rendering — 20px for
94
+ * Geist, 24px for Cairo. The ramp re-points `--leading-sm` under `[dir="rtl"]`,
95
+ * so nothing here is direction-specific. The fallback is English's 20 / 14,
96
+ * for a project without the Fasla preset. Same derivation as Radio's LINE_BOX.
97
+ */
98
+ const LINE = "[--sw-line:calc(var(--leading-sm,calc(20/14))*0.875rem)]"
99
+
100
+ /** The taller of one label line and the track. */
101
+ const BAND_HEIGHT = "h-[max(var(--sw-line),var(--sw-h))]"
102
+
103
+ export interface SwitchProps
104
+ extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "size"> {
105
+ /**
106
+ * Visual treatment: a filled track, or an outlined one. Declared here rather
107
+ * than inherited from `VariantProps`, which widens every variant with
108
+ * `| null` and shows a meaningless third option in Storybook.
109
+ */
110
+ variant?: "solid" | "outline"
111
+ /** Track before the label, or label first with the track at the end. */
112
+ layout?: "control-first" | "label-first"
113
+ /** Size variant */
114
+ size?: "sm" | "md" | "lg"
115
+ /** Label text. Without it (or `description`) pass `aria-label` for an accessible name. */
116
+ label?: string
117
+ /** Secondary line below the label */
118
+ description?: string
119
+ }
120
+
29
121
  const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
30
- ({ className, label, description, size = "default", id, ...props }, ref) => {
31
- const inputId = id || React.useId()
32
- const sizes = sizeClasses[size]
122
+ (
123
+ {
124
+ className,
125
+ variant = "solid",
126
+ layout = "control-first",
127
+ size = "md",
128
+ label,
129
+ description,
130
+ id,
131
+ "aria-label": ariaLabel,
132
+ "aria-labelledby": ariaLabelledBy,
133
+ "aria-describedby": ariaDescribedBy,
134
+ ...props
135
+ },
136
+ ref
137
+ ) => {
138
+ const generatedId = React.useId()
139
+ const inputId = id || generatedId
140
+ const labelId = `${inputId}-label`
141
+ const descriptionId = `${inputId}-description`
142
+ /*
143
+ * The wrapping <label> would name the switch with *all* its text, label
144
+ * and description together, while `aria-describedby` reads the description
145
+ * again. So the name points at the label alone, unless the caller named
146
+ * the switch themselves; and the description is linked only when the name
147
+ * comes from somewhere else, or it would be the name and the description.
148
+ */
149
+ const labelledBy = ariaLabelledBy ?? (label && !ariaLabel ? labelId : undefined)
150
+ const isNamedElsewhere = Boolean(label || ariaLabel || ariaLabelledBy)
151
+ // Plain join: these are ids, and `cn` would merge "text-sm text-lg" as classes.
152
+ const describedBy =
153
+ [description && isNamedElsewhere ? descriptionId : undefined, ariaDescribedBy]
154
+ .filter(Boolean)
155
+ .join(" ") || undefined
156
+ // Fall back to the defaults for an untyped caller passing null or a typo.
157
+ const sizes = sizeClasses[size] ?? sizeClasses.md
158
+ const colors = variantClasses[variant] ?? variantClasses.solid
159
+ const hasText = Boolean(label || description)
160
+
161
+ /*
162
+ * The track sits in a band one label line tall — or the track's height, if
163
+ * that is taller — and centres itself in it. So it aligns with the *first*
164
+ * line of a wrapped label instead of drifting to the middle of the block.
165
+ *
166
+ * The band is a *direct sibling of the input*, so a plain
167
+ * `peer-focus-visible` sets `--sw-focus` on it, and the focus ring inside
168
+ * the track reads that variable as its opacity. That is the one selector
169
+ * form `storybook-addon-pseudo-states` can force — it cannot force
170
+ * `:has(:focus-visible)` — and keeping the ring inside the track means it
171
+ * follows the track wherever padding on the root moves it.
172
+ */
173
+ const band = (
174
+ <span
175
+ aria-hidden="true"
176
+ className={cn(
177
+ "flex shrink-0 items-center peer-focus-visible:[--sw-focus:1]",
178
+ BAND_HEIGHT
179
+ )}
180
+ >
181
+ <span
182
+ data-slot="track"
183
+ className={cn(
184
+ "relative block rounded-full transition-colors motion-reduce:transition-none",
185
+ sizes.track,
186
+ colors.track
187
+ )}
188
+ >
189
+ {/*
190
+ * Positioned with `inset-inline-start`, a logical property, so the
191
+ * thumb travels towards the end of the line in either direction with
192
+ * no RTL-specific class: `start-px` is left in LTR and right in RTL.
193
+ */}
194
+ <span
195
+ data-slot="thumb"
196
+ className={cn(
197
+ "absolute start-px top-px block rounded-full transition-[inset-inline-start,background-color] motion-reduce:transition-none",
198
+ sizes.thumb,
199
+ colors.thumb
200
+ )}
201
+ />
202
+ {/*
203
+ * Laid exactly over the track. Figma draws focus as a 1px `ring`
204
+ * stroke (inside for solid, replacing the outside stroke for
205
+ * outline) plus a 3px halo; painted after the thumb, it never
206
+ * overlaps it, because the thumb is inset by that same 1px.
207
+ */}
208
+ <span
209
+ data-slot="focus-ring"
210
+ className={cn(
211
+ "pointer-events-none absolute inset-0 rounded-full opacity-[var(--sw-focus,0)] ring-[3px] ring-ring/50",
212
+ colors.focus
213
+ )}
214
+ />
215
+ </span>
216
+ </span>
217
+ )
218
+
219
+ const text = hasText && (
220
+ <span
221
+ className={cn(
222
+ "flex min-w-0 flex-col gap-0.5",
223
+ layout === "label-first" && "flex-1"
224
+ )}
225
+ >
226
+ {label && (
227
+ // Padded so a single line centres on a track taller than the line
228
+ // (lg in English: 24px track, 20px line). The padding is zero
229
+ // whenever the line is the taller of the two.
230
+ <span
231
+ id={labelId}
232
+ className="py-[max(0px,calc((var(--sw-h)-var(--sw-line))/2))] text-sm font-medium text-foreground"
233
+ >
234
+ {label}
235
+ </span>
236
+ )}
237
+ {description && (
238
+ <span
239
+ id={descriptionId}
240
+ className="text-xs font-light text-muted-foreground"
241
+ >
242
+ {description}
243
+ </span>
244
+ )}
245
+ </span>
246
+ )
33
247
 
34
- const switchElement = (
248
+ return (
35
249
  <label
36
250
  htmlFor={inputId}
37
251
  className={cn(
38
- "peer relative inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors",
39
- "focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
40
- "disabled:cursor-not-allowed disabled:opacity-50",
41
- "bg-input has-[:checked]:bg-primary",
42
- sizes.track,
252
+ switchVariants({ layout }),
253
+ LINE,
254
+ sizes.root,
43
255
  className
44
256
  )}
45
257
  >
46
258
  <input
47
259
  type="checkbox"
260
+ role="switch"
48
261
  ref={ref}
49
262
  id={inputId}
50
- className="sr-only"
263
+ className="peer sr-only"
264
+ aria-label={ariaLabel}
265
+ aria-labelledby={labelledBy}
266
+ aria-describedby={describedBy}
51
267
  {...props}
52
268
  />
53
- <span
54
- data-state={props.checked ? "checked" : "unchecked"}
55
- className={cn(
56
- "pointer-events-none block rounded-full bg-background shadow-lg ring-0 transition-transform",
57
- sizes.thumb
58
- )}
59
- />
269
+ {layout === "label-first" ? (
270
+ <>
271
+ {text}
272
+ {band}
273
+ </>
274
+ ) : (
275
+ <>
276
+ {band}
277
+ {text}
278
+ </>
279
+ )}
60
280
  </label>
61
281
  )
62
-
63
- if (!label && !description) {
64
- return switchElement
65
- }
66
-
67
- return (
68
- <div className="flex items-center justify-between gap-4">
69
- <div className="grid gap-1">
70
- {label && (
71
- <label
72
- htmlFor={inputId}
73
- className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
74
- >
75
- {label}
76
- </label>
77
- )}
78
- {description && (
79
- <p className="text-xs text-muted-foreground">{description}</p>
80
- )}
81
- </div>
82
- {switchElement}
83
- </div>
84
- )
85
282
  }
86
283
  )
87
284
  Switch.displayName = "Switch"
88
285
 
89
- export { Switch }
286
+ export { Switch, switchVariants }
@@ -61,7 +61,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
61
61
  return (
62
62
  <div className="relative">
63
63
  {textarea}
64
- <div className="absolute bottom-2 right-3 text-xs text-muted-foreground">
64
+ <div className="absolute bottom-2 end-3 text-xs text-muted-foreground">
65
65
  {maxLength ? `${count}/${maxLength}` : count}
66
66
  </div>
67
67
  </div>
package/registry.json CHANGED
@@ -47,11 +47,14 @@
47
47
  "name": "avatar",
48
48
  "type": "registry:ui",
49
49
  "title": "Avatar",
50
- "description": "Represent users with images, fallback content, and grouping.",
50
+ "description": "A user's photo, initials or icon, in three sizes and two radii, with an optional border and presence dot. Falls back from photo to initials to icon on its own.",
51
51
  "dependencies": [
52
- "class-variance-authority"
52
+ "class-variance-authority",
53
+ "lucide-react"
54
+ ],
55
+ "registryDependencies": [
56
+ "status-indicator"
53
57
  ],
54
- "registryDependencies": [],
55
58
  "files": [
56
59
  {
57
60
  "path": "registry/ui/avatar/avatar.tsx",
@@ -68,9 +71,10 @@
68
71
  "name": "badge",
69
72
  "type": "registry:ui",
70
73
  "title": "Badge",
71
- "description": "A badge component for status indicators with multiple variants.",
74
+ "description": "A compact inline label for status, category or metadata: solid, soft and outline types in six semantic tones, with an optional icon, avatar and close button.",
72
75
  "dependencies": [
73
- "class-variance-authority"
76
+ "class-variance-authority",
77
+ "lucide-react"
74
78
  ],
75
79
  "registryDependencies": [],
76
80
  "files": [
@@ -113,7 +117,8 @@
113
117
  "description": "A button component with multiple variants, sizes, and loading state.",
114
118
  "dependencies": [
115
119
  "@radix-ui/react-slot",
116
- "class-variance-authority"
120
+ "class-variance-authority",
121
+ "lucide-react"
117
122
  ],
118
123
  "registryDependencies": [],
119
124
  "files": [
@@ -152,7 +157,9 @@
152
157
  "type": "registry:ui",
153
158
  "title": "Checkbox",
154
159
  "description": "An accessible checkbox for binary selections with controlled state.",
155
- "dependencies": [],
160
+ "dependencies": [
161
+ "lucide-react"
162
+ ],
156
163
  "registryDependencies": [],
157
164
  "files": [
158
165
  {
@@ -166,12 +173,35 @@
166
173
  "forms"
167
174
  ]
168
175
  },
176
+ {
177
+ "name": "radio",
178
+ "type": "registry:ui",
179
+ "title": "Radio",
180
+ "description": "An accessible radio for choosing one option from a set, with a plain and a bordered layout variant.",
181
+ "dependencies": [
182
+ "class-variance-authority"
183
+ ],
184
+ "registryDependencies": [],
185
+ "files": [
186
+ {
187
+ "path": "registry/ui/radio/radio.tsx",
188
+ "type": "registry:ui",
189
+ "target": "components/ui/radio.tsx"
190
+ }
191
+ ],
192
+ "categories": [
193
+ "primitives",
194
+ "forms"
195
+ ]
196
+ },
169
197
  {
170
198
  "name": "combobox",
171
199
  "type": "registry:ui",
172
200
  "title": "Combobox",
173
201
  "description": "A searchable combobox with single or multiple selection.",
174
- "dependencies": [],
202
+ "dependencies": [
203
+ "lucide-react"
204
+ ],
175
205
  "registryDependencies": [],
176
206
  "files": [
177
207
  {
@@ -209,7 +239,9 @@
209
239
  "type": "registry:block",
210
240
  "title": "Empty State",
211
241
  "description": "Empty state component with icon, title, description, and actions.",
212
- "dependencies": [],
242
+ "dependencies": [
243
+ "lucide-react"
244
+ ],
213
245
  "registryDependencies": [],
214
246
  "files": [
215
247
  {
@@ -287,7 +319,9 @@
287
319
  "type": "registry:block",
288
320
  "title": "Navbar",
289
321
  "description": "Responsive navigation bar with desktop links and mobile menu.",
290
- "dependencies": [],
322
+ "dependencies": [
323
+ "lucide-react"
324
+ ],
291
325
  "registryDependencies": [],
292
326
  "files": [
293
327
  {
@@ -325,7 +359,9 @@
325
359
  "type": "registry:ui",
326
360
  "title": "Select",
327
361
  "description": "A select control for choosing one option from a list.",
328
- "dependencies": [],
362
+ "dependencies": [
363
+ "lucide-react"
364
+ ],
329
365
  "registryDependencies": [],
330
366
  "files": [
331
367
  {
@@ -365,7 +401,9 @@
365
401
  "type": "registry:block",
366
402
  "title": "Sidebar",
367
403
  "description": "Responsive sidebar navigation with collapsible structure.",
368
- "dependencies": [],
404
+ "dependencies": [
405
+ "lucide-react"
406
+ ],
369
407
  "registryDependencies": [],
370
408
  "files": [
371
409
  {
@@ -383,7 +421,7 @@
383
421
  "name": "skeleton",
384
422
  "type": "registry:ui",
385
423
  "title": "Skeleton",
386
- "description": "Loading skeleton placeholders with pre-built variants for text, avatars, and cards.",
424
+ "description": "Loading placeholders that replace shadcn's Skeleton: a plain block plus text, avatar, list item and card shapes that stay still under reduced motion.",
387
425
  "dependencies": [],
388
426
  "registryDependencies": [],
389
427
  "files": [
@@ -424,7 +462,9 @@
424
462
  "type": "registry:block",
425
463
  "title": "Stats Card",
426
464
  "description": "Display a metric with trend, icon, and supporting label.",
427
- "dependencies": [],
465
+ "dependencies": [
466
+ "lucide-react"
467
+ ],
428
468
  "registryDependencies": [],
429
469
  "files": [
430
470
  {
@@ -438,12 +478,35 @@
438
478
  "application"
439
479
  ]
440
480
  },
481
+ {
482
+ "name": "status-indicator",
483
+ "type": "registry:ui",
484
+ "title": "Status Indicator",
485
+ "description": "A presence dot — online, away, busy or offline — with an accessible name in the page's language, so colour never carries the status alone.",
486
+ "dependencies": [
487
+ "class-variance-authority"
488
+ ],
489
+ "registryDependencies": [],
490
+ "files": [
491
+ {
492
+ "path": "registry/ui/status-indicator/status-indicator.tsx",
493
+ "type": "registry:ui",
494
+ "target": "components/ui/status-indicator.tsx"
495
+ }
496
+ ],
497
+ "categories": [
498
+ "primitives",
499
+ "data-display"
500
+ ]
501
+ },
441
502
  {
442
503
  "name": "switch",
443
504
  "type": "registry:ui",
444
505
  "title": "Switch",
445
- "description": "An accessible switch for on-off settings.",
446
- "dependencies": [],
506
+ "description": "An accessible switch for on-off settings: solid and outline styles in three sizes, with the track before or after its label.",
507
+ "dependencies": [
508
+ "class-variance-authority"
509
+ ],
447
510
  "registryDependencies": [],
448
511
  "files": [
449
512
  {
@@ -0,0 +1,143 @@
1
+ import plugin from "tailwindcss/plugin"
2
+ import type { Config } from "tailwindcss"
3
+
4
+ /**
5
+ * Shared typography preset — the single place the Fasla type system lives.
6
+ *
7
+ * Every value here is read from the `Tailwind En/*` and `Tailwind AR/*` text
8
+ * styles in the Fasla Figma file. Figma is the source of truth: nothing in this
9
+ * file is rounded, averaged or invented. If a value looks wrong, fix the style
10
+ * in Figma and mirror it here — never the other way round.
11
+ *
12
+ * Geist carries LTR, Cairo carries RTL. The two ramps are 1:1 by size and
13
+ * deliberately not 1:1 by line-height — Cairo needs more vertical room for
14
+ * descenders and diacritics.
15
+ *
16
+ * Line-height is delivered as a CSS variable per rung, so `[dir="rtl"]`
17
+ * re-points the whole ramp at once. That is what makes every `text-*` utility
18
+ * direction-aware without a single component knowing about it.
19
+ */
20
+
21
+ /**
22
+ * Line-height as Figma states it: a px value against a px size.
23
+ *
24
+ * It is emitted as `calc(lineHeight / fontSize)` — a unitless ratio, so it
25
+ * stays correct when a rung is used at a responsive size, and exact, because
26
+ * CSS does the division at full precision. Eight of these ratios are
27
+ * non-terminating (18→26 is 1.444…), so writing them as decimals would mean
28
+ * rounding a source-of-truth value. calc() avoids that entirely.
29
+ *
30
+ * `null` means Figma has the rung on AUTO — the font's own metrics, which CSS
31
+ * spells `normal`. No rung is currently on AUTO: all 144 styles are bound, in
32
+ * both scripts, at every size. The mechanism stays because AUTO is a legitimate
33
+ * choice Figma can express, and a rung may return to it.
34
+ */
35
+ const LEADING = {
36
+ // size En AR
37
+ // XXS is the one rung below Tailwind's stock scale. Figma binds En to
38
+ // `typography/line-height/L-neg-0,5` (14) and AR to `L-0` (16).
39
+ xxs: /* 10 */ [/* 14 */ 14, /* 16 */ 16],
40
+ xs: /* 12 */ [/* 16 */ 16, /* 20 */ 20],
41
+ sm: /* 14 */ [/* 20 */ 20, /* 24 */ 24],
42
+ base: /* 16 */ [/* 24 */ 24, /* 28 */ 28],
43
+ lg: /* 18 */ [/* 28 */ 28, /* 32 */ 32],
44
+ xl: /* 20 */ [/* 28 */ 28, /* 36 */ 36],
45
+ "2xl": /* 24 */ [/* 32 */ 32, /* 40 */ 40],
46
+ "3xl": /* 30 */ [/* 40 */ 40, /* 50 */ 50],
47
+ "4xl": /* 36 */ [/* 44 */ 44, /* 60 */ 60],
48
+ "5xl": /* 48 */ [/* 58 */ 58, /* 72 */ 72],
49
+ "6xl": /* 60 */ [/* 72 */ 72, /* 90 */ 90],
50
+ "7xl": /* 72 */ [/* 90 */ 90, /* 108 */ 108],
51
+ "8xl": /* 96 */ [/* 108 */ 108, /* 144 */ 144],
52
+ "9xl": /* 128 */ [/* 144 */ 144, /* 192 */ 192],
53
+ // `Extra/*` — two styles that sit outside the XXS–9XL ramp but are part of it.
54
+ // Weight, underline and case are left to the ordinary utilities: `Extra/Link`
55
+ // is 400 + underline, `Extra/List Header` is 500 + uppercase. Uppercase is
56
+ // English-only; Arabic has no uppercase and must never be given one.
57
+ link: /* 16 */ [/* 24 */ 24, /* 24 */ 24],
58
+ "list-header": /* 14 */ [/* 16 */ 16, /* 16 */ 16],
59
+ } as const satisfies Record<string, readonly [number | null, number | null]>
60
+
61
+ /**
62
+ * Figma's size scale. From XS up it matches Tailwind's own scale 1:1; XXS
63
+ * (10px, `typography/font-size/xxs`) is Fasla's addition below it.
64
+ */
65
+ const SIZE_PX = {
66
+ xxs: 10,
67
+ xs: 12,
68
+ sm: 14,
69
+ base: 16,
70
+ lg: 18,
71
+ xl: 20,
72
+ "2xl": 24,
73
+ "3xl": 30,
74
+ "4xl": 36,
75
+ "5xl": 48,
76
+ "6xl": 60,
77
+ "7xl": 72,
78
+ "8xl": 96,
79
+ "9xl": 128,
80
+ link: 16,
81
+ "list-header": 14,
82
+ } as const
83
+
84
+ type Rung = keyof typeof LEADING
85
+
86
+ const rungs = Object.keys(LEADING) as Rung[]
87
+
88
+ const rem = (px: number) => `${px / 16}rem`
89
+ const cssVar = (rung: Rung) => `--leading-${rung}`
90
+
91
+ /** Exact, unitless, and never rounded — CSS divides at full precision. */
92
+ const ratio = (leadingPx: number, sizePx: number) => `calc(${leadingPx} / ${sizePx})`
93
+
94
+ /** `text-xl` resolves its leading through the variable, never a literal. */
95
+ const fontSize = Object.fromEntries(
96
+ rungs.map((rung) => [rung, [rem(SIZE_PX[rung]), `var(${cssVar(rung)})`]])
97
+ ) as Record<Rung, [string, string]>
98
+
99
+ const leadingFor = (index: 0 | 1) =>
100
+ Object.fromEntries(
101
+ rungs.map((rung) => {
102
+ const px = LEADING[rung][index]
103
+ return [cssVar(rung), px === null ? "normal" : ratio(px, SIZE_PX[rung])]
104
+ })
105
+ )
106
+
107
+ /**
108
+ * Figma sets `letterSpacing` to 0% on all 144 text styles, both scripts, every
109
+ * weight — so the ramp declares no tracking and components should set none.
110
+ * Tailwind's stock `tracking-*` scale is left in place for the one sanctioned
111
+ * exception, the letter-spaced eyebrow that design/DESIGN.md documents as
112
+ * deliberately detached. Reach for it only for that.
113
+ */
114
+
115
+ const preset = {
116
+ content: [],
117
+ theme: {
118
+ extend: {
119
+ fontFamily: {
120
+ // Brand V2.5 §14 — product tier.
121
+ sans: ["var(--font-geist-sans)", "system-ui", "sans-serif"],
122
+ mono: ["var(--font-geist-mono)", "ui-monospace", "monospace"],
123
+ // Brand V2.5 §14 — the Arabic face. Geist has no Arabic coverage.
124
+ arabic: ["var(--font-cairo)", "system-ui", "sans-serif"],
125
+ },
126
+ fontSize,
127
+ },
128
+ },
129
+ plugins: [
130
+ plugin(({ addBase }) => {
131
+ addBase({
132
+ ":root": leadingFor(0),
133
+ // Source order matters: same specificity as `:root`, so this must win by
134
+ // coming second. `dir` is on a wrapper element, not <html>, in both apps —
135
+ // the variables cascade down from wherever it sits.
136
+ '[dir="rtl"]': leadingFor(1),
137
+ })
138
+ }),
139
+ ],
140
+ } satisfies Config
141
+
142
+ export { LEADING, SIZE_PX }
143
+ export default preset