@nqlib/nqui 0.5.6 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{command-palette-DhoWGyk_.js → command-palette-CHUiGh5m.js} +2 -2
- package/dist/{command-palette-D24rOeE6.cjs → command-palette-DsvP2QNP.cjs} +2 -2
- package/dist/command.cjs.js +1 -1
- package/dist/command.es.js +1 -1
- package/dist/components/custom/table-of-contents.d.ts +2 -2
- package/dist/components/custom/table-of-contents.d.ts.map +1 -1
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/theme-appearance-menu.d.ts +9 -0
- package/dist/components/theme-appearance-menu.d.ts.map +1 -0
- package/dist/components/theme-toggle.d.ts +2 -0
- package/dist/components/theme-toggle.d.ts.map +1 -0
- package/dist/components/ui/checkbox.d.ts.map +1 -1
- package/dist/components/ui/combobox.d.ts +5 -3
- package/dist/components/ui/combobox.d.ts.map +1 -1
- package/dist/components/ui/toggle-group.d.ts.map +1 -1
- package/dist/{debug-panel-BjfW-YVo.js → debug-panel-BcYzsTp2.js} +1 -1
- package/dist/{debug-panel-CpqsKuxd.cjs → debug-panel-mwtujy5J.cjs} +1 -1
- package/dist/debug.cjs.js +1 -1
- package/dist/debug.es.js +1 -1
- package/dist/elevation-debate.html +286 -0
- package/dist/{input-shared-CDgy_NdJ.cjs → input-shared-C9Try5fg.cjs} +1 -1
- package/dist/{input-shared-NnOiyHpu.js → input-shared-DXf3Edqt.js} +1 -1
- package/dist/lib/floating-surface.d.ts +6 -2
- package/dist/lib/floating-surface.d.ts.map +1 -1
- package/dist/nqui.cjs.js +15 -13
- package/dist/nqui.es.js +2752 -2646
- package/dist/styles.css +151 -255
- package/docs/components/README.md +4 -1
- package/docs/components/nqui-combobox.md +15 -2
- package/docs/components/nqui-command-palette.md +7 -0
- package/docs/components/nqui-command.md +41 -0
- package/docs/components/nqui-scroll-area.md +69 -0
- package/docs/nqui-skills/AGENT_PROMPT.md +190 -0
- package/docs/nqui-skills/COMPONENTS_INDEX.md +51 -1
- package/docs/nqui-skills/COMPOSITION.md +321 -0
- package/docs/nqui-skills/ELEVATION.md +181 -0
- package/docs/nqui-skills/EVAL.md +148 -0
- package/docs/nqui-skills/HUMAN_GUIDE.md +18 -0
- package/docs/nqui-skills/MIGRATION.md +133 -0
- package/docs/nqui-skills/MOTION.md +189 -0
- package/docs/nqui-skills/README.md +2 -0
- package/docs/nqui-skills/READ_BUDGET.md +60 -0
- package/docs/nqui-skills/RECIPES.md +820 -0
- package/docs/nqui-skills/SKILL.md +58 -1
- package/docs/nqui-skills/STATES.md +154 -0
- package/docs/nqui-skills/THEMING.md +203 -0
- package/docs/nqui-skills/WRITING.md +205 -0
- package/docs/nqui-skills/_claude-commands/README.md +50 -0
- package/docs/nqui-skills/_claude-commands/design/SKILL.md +111 -0
- package/docs/nqui-skills/_claude-commands/edit/SKILL.md +97 -0
- package/docs/nqui-skills/adapt/SKILL.md +5 -2
- package/docs/nqui-skills/animate/SKILL.md +5 -2
- package/docs/nqui-skills/audit/SKILL.md +5 -2
- package/docs/nqui-skills/bolder/SKILL.md +5 -2
- package/docs/nqui-skills/clarify/SKILL.md +5 -2
- package/docs/nqui-skills/colorize/SKILL.md +5 -2
- package/docs/nqui-skills/delight/SKILL.md +5 -4
- package/docs/nqui-skills/distill/SKILL.md +5 -2
- package/docs/nqui-skills/impeccable/SKILL.md +0 -16
- package/docs/nqui-skills/impeccable/reference/INDEX.md +26 -0
- package/docs/nqui-skills/layout/SKILL.md +5 -2
- package/docs/nqui-skills/nqui-components/SKILL.md +33 -9
- package/docs/nqui-skills/nqui-composition/SKILL.md +148 -0
- package/docs/nqui-skills/nqui-data-tables/SKILL.md +127 -0
- package/docs/nqui-skills/nqui-design-system/SKILL.md +22 -1
- package/docs/nqui-skills/nqui-install/SKILL.md +1 -0
- package/docs/nqui-skills/overdrive/SKILL.md +5 -2
- package/docs/nqui-skills/polish/SKILL.md +5 -4
- package/docs/nqui-skills/quieter/SKILL.md +5 -2
- package/docs/nqui-skills/shape/SKILL.md +5 -2
- package/docs/nqui-skills/typeset/SKILL.md +5 -2
- package/package.json +2 -1
- package/scripts/build-styles.js +4 -3
- package/scripts/cli.js +2 -0
- package/scripts/install-claude-skills.js +148 -0
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nqui-recipes
|
|
3
|
+
description: Concrete component combinations for common product situations. Use when you know WHAT you want to build but not WHICH components to combine. Each recipe is opinionated — copy the blueprint, then adapt. Pair with nqui-composition/SKILL.md for the philosophy.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# nqui Recipes — Component Combos
|
|
7
|
+
|
|
8
|
+
These are not exhaustive — they are the **canonical answer** for situations that show up on every product. Copy the blueprint, then adapt. If you find yourself reaching outside a recipe, ask whether you genuinely need to or whether you're decorating.
|
|
9
|
+
|
|
10
|
+
Naming convention: a recipe is named for the **user's intent**, not the components used. "Confirm a destructive action" — not "AlertDialog recipe."
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Selection & state recipes
|
|
15
|
+
|
|
16
|
+
### 1. Status pill (tag with semantic meaning)
|
|
17
|
+
|
|
18
|
+
**Intent:** show status that the user reads at a glance — open / in-progress / done, online / offline, paid / overdue.
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
<Badge variant="secondary">In progress</Badge>
|
|
22
|
+
// or with a colored dot for stronger affordance:
|
|
23
|
+
<div className="inline-flex items-center gap-1.5">
|
|
24
|
+
<span className="size-2 rounded-full bg-emerald-500" />
|
|
25
|
+
<span className="text-xs font-medium">Active</span>
|
|
26
|
+
</div>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Don't:** use `border-left: 4px solid color` on a card to indicate status. Use a `Badge` or dot.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
### 2. Priority indicator inside a dense list
|
|
34
|
+
|
|
35
|
+
**Intent:** mark each row in a long list with a priority, without dominating the row.
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
<Item variant="ghost">
|
|
39
|
+
<ItemMedia>
|
|
40
|
+
<span className={`size-2 rounded-full ${priorityColor}`} />
|
|
41
|
+
</ItemMedia>
|
|
42
|
+
<ItemContent>
|
|
43
|
+
<ItemTitle>Implement OAuth flow</ItemTitle>
|
|
44
|
+
</ItemContent>
|
|
45
|
+
<ItemMedia>
|
|
46
|
+
<Avatar className="size-5"><AvatarFallback>AT</AvatarFallback></Avatar>
|
|
47
|
+
</ItemMedia>
|
|
48
|
+
</Item>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Key choice:** 8×8 dot > colored Badge for dense lists. A dot encodes a single dimension efficiently; a Badge takes more horizontal space and competes with the title.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
### 3. Bulk selection with sticky action bar
|
|
56
|
+
|
|
57
|
+
**Intent:** user selects multiple rows, then takes one action on all of them.
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
// Each row
|
|
61
|
+
<Item variant="ghost">
|
|
62
|
+
<ItemMedia><Checkbox checked={isSelected} onCheckedChange={...} /></ItemMedia>
|
|
63
|
+
<ItemContent>...</ItemContent>
|
|
64
|
+
</Item>
|
|
65
|
+
|
|
66
|
+
// Below the list, sticky action bar appears when selection > 0
|
|
67
|
+
{selectedCount > 0 && (
|
|
68
|
+
<div className="sticky bottom-4 mx-auto flex items-center gap-2 rounded-full bg-foreground text-background px-4 py-2 shadow-lg">
|
|
69
|
+
<span className="text-sm">{selectedCount} selected</span>
|
|
70
|
+
<Separator orientation="vertical" className="h-4 bg-background/30" />
|
|
71
|
+
<Button size="sm" variant="ghost" className="text-background hover:bg-background/10">Archive</Button>
|
|
72
|
+
<Button size="sm" variant="ghost" className="text-background hover:bg-background/10">Move</Button>
|
|
73
|
+
<Button size="sm" variant="ghost" className="text-background hover:bg-background/10" onClick={clearAll}>Cancel</Button>
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Why this combo:** sticky pill at bottom is the Linear / Gmail pattern. It doesn't shift layout, it appears in a fixed location, it shows the count + actions in one strip.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Form recipes
|
|
83
|
+
|
|
84
|
+
### 4. Settings page (one topic per surface)
|
|
85
|
+
|
|
86
|
+
**Intent:** user changes preferences grouped by theme.
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
<form>
|
|
90
|
+
<FieldSet>
|
|
91
|
+
<FieldLegend>General</FieldLegend>
|
|
92
|
+
<FieldGroup className="flex flex-col gap-5 max-w-lg">
|
|
93
|
+
<Field>
|
|
94
|
+
<FieldLabel htmlFor="name">Display name</FieldLabel>
|
|
95
|
+
<Input id="name" />
|
|
96
|
+
<FieldDescription>Shown to teammates.</FieldDescription>
|
|
97
|
+
</Field>
|
|
98
|
+
<Field>
|
|
99
|
+
<FieldLabel>Region</FieldLabel>
|
|
100
|
+
<Select>...</Select>
|
|
101
|
+
</Field>
|
|
102
|
+
<Field orientation="horizontal" className="items-center justify-between rounded-lg border p-3">
|
|
103
|
+
<FieldContent>
|
|
104
|
+
<FieldLabel>Email digest</FieldLabel>
|
|
105
|
+
<FieldDescription>Weekly Monday summary.</FieldDescription>
|
|
106
|
+
</FieldContent>
|
|
107
|
+
<Switch />
|
|
108
|
+
</Field>
|
|
109
|
+
</FieldGroup>
|
|
110
|
+
</FieldSet>
|
|
111
|
+
<div className="flex gap-2 pt-6 border-t">
|
|
112
|
+
<Button type="submit">Save</Button>
|
|
113
|
+
<Button type="button" variant="outline">Cancel</Button>
|
|
114
|
+
</div>
|
|
115
|
+
</form>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**Don't:** wrap every field in its own Card. The FieldSet IS the grouping.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
### 5. Inline edit (click cell → edit in place → save)
|
|
123
|
+
|
|
124
|
+
**Intent:** user edits a value without leaving context (renaming a file, changing a status).
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
{isEditing ? (
|
|
128
|
+
<InputGroup>
|
|
129
|
+
<InputGroupInput value={draft} onChange={...} autoFocus />
|
|
130
|
+
<InputGroupButton onClick={save}>Save</InputGroupButton>
|
|
131
|
+
<InputGroupButton onClick={cancel} variant="ghost">Cancel</InputGroupButton>
|
|
132
|
+
</InputGroup>
|
|
133
|
+
) : (
|
|
134
|
+
<button
|
|
135
|
+
className="-mx-1 -my-0.5 rounded px-1 py-0.5 text-left hover:bg-muted/60 focus-visible:bg-muted/60"
|
|
136
|
+
onClick={() => setIsEditing(true)}
|
|
137
|
+
>
|
|
138
|
+
{value}
|
|
139
|
+
</button>
|
|
140
|
+
)}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**Why this combo:** the negative margin + hover background gives a subtle affordance that the text is editable without making it look like an input by default. Apple does this in Finder for filenames.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
### 6. Form validation with inline error
|
|
148
|
+
|
|
149
|
+
**Intent:** validate as the user moves between fields; show errors without alarming them.
|
|
150
|
+
|
|
151
|
+
```tsx
|
|
152
|
+
<Field data-invalid={hasError}>
|
|
153
|
+
<FieldLabel htmlFor="email">Work email</FieldLabel>
|
|
154
|
+
<Input id="email" aria-invalid={hasError} aria-describedby="email-error" />
|
|
155
|
+
{hasError && <FieldError id="email-error">Use your company email</FieldError>}
|
|
156
|
+
</Field>
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**Don't:** use a global `Alert` at the top of the form for per-field errors. Inline beside the field is faster to fix and lower-anxiety.
|
|
160
|
+
|
|
161
|
+
**Do use a top `Alert`** for server-level errors that don't map to a field: "Couldn't reach the server. Try again."
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Navigation & search recipes
|
|
166
|
+
|
|
167
|
+
### 7. Filter + search + sort toolbar
|
|
168
|
+
|
|
169
|
+
**Intent:** narrow a list of items.
|
|
170
|
+
|
|
171
|
+
```tsx
|
|
172
|
+
<div className="flex flex-col gap-2 border-b px-4 py-3">
|
|
173
|
+
{/* Search row */}
|
|
174
|
+
<div className="relative">
|
|
175
|
+
<Search01Icon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
|
176
|
+
<Input className="pl-8 h-7" placeholder="Search…" />
|
|
177
|
+
</div>
|
|
178
|
+
{/* Filter row in muted container */}
|
|
179
|
+
<div className="rounded-md border border-input/50 bg-muted/30 p-1 flex items-center gap-1">
|
|
180
|
+
<ToggleGroup type="single" value={filter} onValueChange={setFilter} size="sm">
|
|
181
|
+
<ToggleGroupItem value="all">All</ToggleGroupItem>
|
|
182
|
+
<ToggleGroupItem value="open">Open</ToggleGroupItem>
|
|
183
|
+
<ToggleGroupItem value="done">Done</ToggleGroupItem>
|
|
184
|
+
</ToggleGroup>
|
|
185
|
+
<Separator orientation="vertical" className="h-5 mx-1" />
|
|
186
|
+
<Select size="sm">
|
|
187
|
+
<SelectTrigger className="h-6 border-0 shadow-none"><SelectValue /></SelectTrigger>
|
|
188
|
+
<SelectContent>...</SelectContent>
|
|
189
|
+
</Select>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**Anti-pattern:** putting `ToggleGroup` directly on `bg-background`. It floats and competes with the list below. Always nest in `bg-muted/30` or `bg-muted/40`.
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
### 8. Power-user command palette (Cmd+K)
|
|
199
|
+
|
|
200
|
+
**Intent:** keyboard-driven search across pages and quick actions.
|
|
201
|
+
|
|
202
|
+
```tsx
|
|
203
|
+
<CommandPalette open={open} onOpenChange={setOpen}>
|
|
204
|
+
<CommandInput placeholder="Search pages and actions…" />
|
|
205
|
+
<CommandList>
|
|
206
|
+
<CommandEmpty>No results.</CommandEmpty>
|
|
207
|
+
<CommandGroup heading="Pages">
|
|
208
|
+
{pages.map(page => (
|
|
209
|
+
<CommandItem key={page.path} onSelect={() => navigate(page.path)}>
|
|
210
|
+
{page.title}
|
|
211
|
+
<CommandShortcut>⌘{page.shortcut}</CommandShortcut>
|
|
212
|
+
</CommandItem>
|
|
213
|
+
))}
|
|
214
|
+
</CommandGroup>
|
|
215
|
+
<CommandSeparator />
|
|
216
|
+
<CommandGroup heading="Actions">
|
|
217
|
+
<CommandItem onSelect={createNew}>Create new…<CommandShortcut>⌘N</CommandShortcut></CommandItem>
|
|
218
|
+
</CommandGroup>
|
|
219
|
+
</CommandList>
|
|
220
|
+
</CommandPalette>
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**Key choice:** CommandShortcut on the right of each item, not as a separate column. The Kbd badge in-row is faster to scan.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
### 9. Breadcrumb in detail view header
|
|
228
|
+
|
|
229
|
+
**Intent:** show user's location and let them go back one level.
|
|
230
|
+
|
|
231
|
+
```tsx
|
|
232
|
+
<Breadcrumb>
|
|
233
|
+
<BreadcrumbList>
|
|
234
|
+
<BreadcrumbItem><BreadcrumbLink asChild><Link to="/">Projects</Link></BreadcrumbLink></BreadcrumbItem>
|
|
235
|
+
<BreadcrumbSeparator />
|
|
236
|
+
<BreadcrumbItem><BreadcrumbLink asChild><Link to="/projects/atp">ATP</Link></BreadcrumbLink></BreadcrumbItem>
|
|
237
|
+
<BreadcrumbSeparator />
|
|
238
|
+
<BreadcrumbItem><BreadcrumbPage>Issue #412</BreadcrumbPage></BreadcrumbItem>
|
|
239
|
+
</BreadcrumbList>
|
|
240
|
+
</Breadcrumb>
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**Don't:** put a back arrow in addition to the breadcrumb. One affordance, used consistently.
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## State recipes (loading / empty / error)
|
|
248
|
+
|
|
249
|
+
### 10. The three states every list needs
|
|
250
|
+
|
|
251
|
+
**Intent:** never show a blank screen.
|
|
252
|
+
|
|
253
|
+
```tsx
|
|
254
|
+
{loading ? (
|
|
255
|
+
// Skeleton matches the shape of the real content
|
|
256
|
+
<div className="flex flex-col gap-1 p-2">
|
|
257
|
+
{[1,2,3].map(n => (
|
|
258
|
+
<div key={n} className="flex gap-3 px-3 py-2.5">
|
|
259
|
+
<Skeleton className="size-2 rounded-full mt-1" />
|
|
260
|
+
<div className="flex flex-col gap-1.5 flex-1">
|
|
261
|
+
<Skeleton className="h-3 w-16" />
|
|
262
|
+
<Skeleton className="h-4 w-3/4" />
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
))}
|
|
266
|
+
</div>
|
|
267
|
+
) : items.length === 0 ? (
|
|
268
|
+
<Empty className="py-12">
|
|
269
|
+
<EmptyHeader>
|
|
270
|
+
<EmptyMedia variant="icon"><FolderIcon /></EmptyMedia>
|
|
271
|
+
<EmptyTitle>No issues yet</EmptyTitle>
|
|
272
|
+
<EmptyDescription>Create your first issue to start tracking.</EmptyDescription>
|
|
273
|
+
</EmptyHeader>
|
|
274
|
+
<EmptyContent><Button>New issue</Button></EmptyContent>
|
|
275
|
+
</Empty>
|
|
276
|
+
) : error ? (
|
|
277
|
+
<Alert variant="destructive">
|
|
278
|
+
<AlertTitle>Couldn't load issues</AlertTitle>
|
|
279
|
+
<AlertDescription>{error.message}</AlertDescription>
|
|
280
|
+
<AlertAction onClick={retry}>Try again</AlertAction>
|
|
281
|
+
</Alert>
|
|
282
|
+
) : (
|
|
283
|
+
<ItemGroup>{items.map(...)}</ItemGroup>
|
|
284
|
+
)}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
**Don't:** show a generic `Spinner` for list loading. Skeletons set expectations about what's coming.
|
|
288
|
+
**Don't:** use a `Toast` for load errors. The user is looking at the list; the error belongs in the list.
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
### 11. Confirm a destructive action
|
|
293
|
+
|
|
294
|
+
**Intent:** double-check before deleting.
|
|
295
|
+
|
|
296
|
+
```tsx
|
|
297
|
+
<AlertDialog>
|
|
298
|
+
<AlertDialogTrigger asChild>
|
|
299
|
+
<Button variant="destructive">Delete project</Button>
|
|
300
|
+
</AlertDialogTrigger>
|
|
301
|
+
<AlertDialogContent>
|
|
302
|
+
<AlertDialogHeader>
|
|
303
|
+
<AlertDialogTitle>Delete this project?</AlertDialogTitle>
|
|
304
|
+
<AlertDialogDescription>
|
|
305
|
+
All 412 issues and 1.2 GB of attachments will be removed. This cannot be undone.
|
|
306
|
+
</AlertDialogDescription>
|
|
307
|
+
</AlertDialogHeader>
|
|
308
|
+
<AlertDialogFooter>
|
|
309
|
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
310
|
+
<AlertDialogAction onClick={confirmDelete}>Delete</AlertDialogAction>
|
|
311
|
+
</AlertDialogFooter>
|
|
312
|
+
</AlertDialogContent>
|
|
313
|
+
</AlertDialog>
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**Make the description specific.** "Are you sure?" is useless. State exactly what will be lost.
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
### 12. Optimistic-success toast with undo
|
|
321
|
+
|
|
322
|
+
**Intent:** user takes an action; we apply it immediately and let them undo.
|
|
323
|
+
|
|
324
|
+
```tsx
|
|
325
|
+
function handleArchive(id) {
|
|
326
|
+
optimisticArchive(id) // updates UI immediately
|
|
327
|
+
toast.success("Issue archived", {
|
|
328
|
+
action: { label: "Undo", onClick: () => optimisticRestore(id) },
|
|
329
|
+
duration: 6000,
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
**Key choice:** 6-second duration (Gmail standard). Long enough to react, short enough not to linger.
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## Layout recipes
|
|
339
|
+
|
|
340
|
+
### 13. Page header with breadcrumb + actions
|
|
341
|
+
|
|
342
|
+
**Intent:** the top of every detail page.
|
|
343
|
+
|
|
344
|
+
```tsx
|
|
345
|
+
<div className="flex items-start justify-between gap-4 border-b px-6 py-4">
|
|
346
|
+
<div className="flex flex-col gap-2 min-w-0">
|
|
347
|
+
<Breadcrumb>...</Breadcrumb>
|
|
348
|
+
<h1 className="text-xl font-semibold truncate">Page title</h1>
|
|
349
|
+
<p className="text-sm text-muted-foreground">One sentence describing what this page is.</p>
|
|
350
|
+
</div>
|
|
351
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
352
|
+
<Button variant="outline" size="sm">Share</Button>
|
|
353
|
+
<Button size="sm">Primary action</Button>
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
**Single source of "where am I" + "what can I do here." Don't repeat the page title anywhere below this header.**
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
### 14. Metric row on a dashboard
|
|
363
|
+
|
|
364
|
+
**Intent:** show 3-4 key numbers at the top of a data page.
|
|
365
|
+
|
|
366
|
+
```tsx
|
|
367
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 px-6 py-4">
|
|
368
|
+
{metrics.map(m => (
|
|
369
|
+
<div key={m.label} className="flex flex-col gap-1">
|
|
370
|
+
<span className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">{m.label}</span>
|
|
371
|
+
<span className="text-2xl font-semibold tabular-nums">{m.value}</span>
|
|
372
|
+
{m.delta && (
|
|
373
|
+
<span className={`text-xs ${m.delta > 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
|
374
|
+
{m.delta > 0 ? '↑' : '↓'} {Math.abs(m.delta)}% vs last week
|
|
375
|
+
</span>
|
|
376
|
+
)}
|
|
377
|
+
</div>
|
|
378
|
+
))}
|
|
379
|
+
</div>
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
**Don't:** wrap each metric in a Card. The values ARE the focus — Cards would make them compete with each other and dilute attention.
|
|
383
|
+
**Don't:** put fake numbers (`12,345`). Use real data or skip the section.
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
### 15. Resizable sidebar that remembers width
|
|
388
|
+
|
|
389
|
+
**Intent:** persistent app sidebar the user can resize.
|
|
390
|
+
|
|
391
|
+
```tsx
|
|
392
|
+
<ResizablePanelGroup direction="horizontal" autoSaveId="app-sidebar">
|
|
393
|
+
<ResizablePanel defaultSize={20} minSize={15} maxSize={35} collapsible>
|
|
394
|
+
<Sidebar>...</Sidebar>
|
|
395
|
+
</ResizablePanel>
|
|
396
|
+
<ResizableHandle withHandle />
|
|
397
|
+
<ResizablePanel defaultSize={80}>
|
|
398
|
+
<Outlet />
|
|
399
|
+
</ResizablePanel>
|
|
400
|
+
</ResizablePanelGroup>
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
**Note:** `ResizablePanelGroup` requires a parent with bounded width/height. If it's not sizing, see `COMPOSITION.md` → Master-Detail Split → CSS-flex fallback.
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
## Anti-recipes (what NOT to combine)
|
|
408
|
+
|
|
409
|
+
These combinations look reasonable but are wrong. They show up in AI-generated code constantly.
|
|
410
|
+
|
|
411
|
+
| ❌ Anti-combo | ✅ Use instead | Why |
|
|
412
|
+
|--------------|---------------|-----|
|
|
413
|
+
| `Card` + `Card` (nested) | `Card` + `Separator` for sub-sections | Nested cards add visual weight without adding meaning. Flatten. |
|
|
414
|
+
| `RadioGroup` in a horizontal toolbar | `ToggleGroup type="single"` | RadioGroup is for forms (stacked, with descriptions). ToggleGroup is for inline choice. |
|
|
415
|
+
| `Dialog` for a 1-question yes/no | `AlertDialog` if destructive, `Popover` if not | Dialog is for input forms. Anything smaller is a different component. |
|
|
416
|
+
| `Tooltip` as the only label for an icon button | `aria-label` on the button + Tooltip for power users | Tooltips don't exist on touch. The button must be understandable without one. |
|
|
417
|
+
| `Sheet` for a confirmation | `AlertDialog` | Sheets are for content/forms. Confirmations are modal-centered. |
|
|
418
|
+
| `Spinner` for list loading | `Skeleton` matching the shape | Skeletons preview what's coming; spinners just say "wait." |
|
|
419
|
+
| `Toast` for form errors | Inline `FieldError` next to the field | Errors must be co-located with the cause. |
|
|
420
|
+
| `border-left: 4px solid color` as status | `Badge` or colored dot | Side-stripe borders are the #1 AI design tell. |
|
|
421
|
+
| Two `variant="default"` buttons side-by-side | One default + one outline | Two primaries = no primary. |
|
|
422
|
+
| `Dialog` with >5 fields | `Sheet` (side panel) or a dedicated route | Long forms in modals trap users and lose scroll context. |
|
|
423
|
+
| `font-bold` on every label | `text-sm font-medium` for labels, `font-semibold` for headings only | Bold inflation kills hierarchy. |
|
|
424
|
+
|
|
425
|
+
---
|
|
426
|
+
|
|
427
|
+
## Auth recipes
|
|
428
|
+
|
|
429
|
+
Auth is the most-requested pattern that the kit must cover well. Get these wrong and consumers feel the whole kit is unreliable.
|
|
430
|
+
|
|
431
|
+
### 16. Login form (email + password)
|
|
432
|
+
|
|
433
|
+
**Intent:** the canonical sign-in screen.
|
|
434
|
+
|
|
435
|
+
```tsx
|
|
436
|
+
<div className="flex min-h-screen items-center justify-center px-4">
|
|
437
|
+
<Card className="w-full max-w-sm">
|
|
438
|
+
<CardHeader>
|
|
439
|
+
<CardTitle>Sign in</CardTitle>
|
|
440
|
+
<CardDescription>Continue to your workspace.</CardDescription>
|
|
441
|
+
</CardHeader>
|
|
442
|
+
<CardContent>
|
|
443
|
+
<form onSubmit={handleSubmit}>
|
|
444
|
+
<FieldGroup className="flex flex-col gap-4">
|
|
445
|
+
<Field>
|
|
446
|
+
<FieldLabel htmlFor="email">Work email</FieldLabel>
|
|
447
|
+
<Input id="email" type="email" autoComplete="email" required />
|
|
448
|
+
</Field>
|
|
449
|
+
<Field>
|
|
450
|
+
<div className="flex items-center justify-between">
|
|
451
|
+
<FieldLabel htmlFor="password">Password</FieldLabel>
|
|
452
|
+
<Link to="/reset" className="text-xs text-muted-foreground hover:underline">Forgot?</Link>
|
|
453
|
+
</div>
|
|
454
|
+
<Input id="password" type="password" autoComplete="current-password" required />
|
|
455
|
+
</Field>
|
|
456
|
+
{error && <FieldError>{error}</FieldError>}
|
|
457
|
+
</FieldGroup>
|
|
458
|
+
<Button type="submit" disabled={isPending} className="w-full mt-6">
|
|
459
|
+
{isPending ? <><Spinner className="size-4" />Signing in…</> : "Sign in"}
|
|
460
|
+
</Button>
|
|
461
|
+
</form>
|
|
462
|
+
</CardContent>
|
|
463
|
+
<CardFooter className="flex justify-center text-xs text-muted-foreground">
|
|
464
|
+
Don't have an account? <Link to="/signup" className="ml-1 text-foreground hover:underline">Create one</Link>
|
|
465
|
+
</CardFooter>
|
|
466
|
+
</Card>
|
|
467
|
+
</div>
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
**Rules:**
|
|
471
|
+
- Single primary action ("Sign in") with loading state. Never "Submit".
|
|
472
|
+
- "Forgot?" link inline with the password label, not below the form.
|
|
473
|
+
- Errors: specific ("Incorrect email or password.") not generic ("Auth failed").
|
|
474
|
+
- `autoComplete` attributes mandatory — password managers depend on them.
|
|
475
|
+
- Single card, centered. Not a sidebar+content layout for a sign-in screen.
|
|
476
|
+
|
|
477
|
+
### 17. Signup (OAuth + email)
|
|
478
|
+
|
|
479
|
+
**Intent:** offer one or two OAuth providers + email signup.
|
|
480
|
+
|
|
481
|
+
```tsx
|
|
482
|
+
<Card className="w-full max-w-sm">
|
|
483
|
+
<CardHeader>
|
|
484
|
+
<CardTitle>Create your account</CardTitle>
|
|
485
|
+
</CardHeader>
|
|
486
|
+
<CardContent className="flex flex-col gap-4">
|
|
487
|
+
{/* OAuth row */}
|
|
488
|
+
<div className="flex flex-col gap-2">
|
|
489
|
+
<Button variant="outline" onClick={() => signInWith("google")} className="w-full">
|
|
490
|
+
<GoogleIcon /> Continue with Google
|
|
491
|
+
</Button>
|
|
492
|
+
<Button variant="outline" onClick={() => signInWith("github")} className="w-full">
|
|
493
|
+
<GithubIcon /> Continue with GitHub
|
|
494
|
+
</Button>
|
|
495
|
+
</div>
|
|
496
|
+
|
|
497
|
+
{/* Divider */}
|
|
498
|
+
<div className="relative my-2">
|
|
499
|
+
<Separator />
|
|
500
|
+
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card px-2 text-[10px] uppercase tracking-wider text-muted-foreground">
|
|
501
|
+
or with email
|
|
502
|
+
</span>
|
|
503
|
+
</div>
|
|
504
|
+
|
|
505
|
+
{/* Email form */}
|
|
506
|
+
<form onSubmit={handleSubmit}>
|
|
507
|
+
<FieldGroup className="flex flex-col gap-4">
|
|
508
|
+
<Field>
|
|
509
|
+
<FieldLabel htmlFor="email">Work email</FieldLabel>
|
|
510
|
+
<Input id="email" type="email" autoComplete="email" required />
|
|
511
|
+
</Field>
|
|
512
|
+
<Field>
|
|
513
|
+
<FieldLabel htmlFor="password">Password</FieldLabel>
|
|
514
|
+
<Input id="password" type="password" autoComplete="new-password" required />
|
|
515
|
+
<FieldDescription>At least 8 characters.</FieldDescription>
|
|
516
|
+
</Field>
|
|
517
|
+
</FieldGroup>
|
|
518
|
+
<Button type="submit" className="w-full mt-6">Create account</Button>
|
|
519
|
+
</form>
|
|
520
|
+
|
|
521
|
+
<p className="text-[11px] text-center text-muted-foreground mt-2">
|
|
522
|
+
By creating an account you agree to our{" "}
|
|
523
|
+
<Link to="/terms" className="text-foreground hover:underline">Terms</Link>{" "}
|
|
524
|
+
and <Link to="/privacy" className="text-foreground hover:underline">Privacy Policy</Link>.
|
|
525
|
+
</p>
|
|
526
|
+
</CardContent>
|
|
527
|
+
</Card>
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
**Rules:**
|
|
531
|
+
- OAuth buttons are `variant="outline"` (secondary visual weight — choice, not push).
|
|
532
|
+
- "Continue with X" not "Sign in with X" — works for both signup and login.
|
|
533
|
+
- Divider uses a centered label, not just a horizontal line.
|
|
534
|
+
- Terms/Privacy in muted small text below the form, not as a checkbox above. (Implicit consent on signup is industry standard for SaaS; require explicit checkbox only if you have a legal reason.)
|
|
535
|
+
- `autoComplete="new-password"` on signup password field (different from login).
|
|
536
|
+
|
|
537
|
+
### 18. Password reset (two-step flow)
|
|
538
|
+
|
|
539
|
+
**Intent:** request reset, then enter new password.
|
|
540
|
+
|
|
541
|
+
```tsx
|
|
542
|
+
// Step 1: request reset
|
|
543
|
+
<Card className="w-full max-w-sm">
|
|
544
|
+
<CardHeader>
|
|
545
|
+
<CardTitle>Reset your password</CardTitle>
|
|
546
|
+
<CardDescription>We'll email you a link to set a new password.</CardDescription>
|
|
547
|
+
</CardHeader>
|
|
548
|
+
<CardContent>
|
|
549
|
+
<form>
|
|
550
|
+
<FieldGroup>
|
|
551
|
+
<Field>
|
|
552
|
+
<FieldLabel htmlFor="email">Email</FieldLabel>
|
|
553
|
+
<Input id="email" type="email" required />
|
|
554
|
+
</Field>
|
|
555
|
+
</FieldGroup>
|
|
556
|
+
<Button type="submit" className="w-full mt-6">Send reset link</Button>
|
|
557
|
+
</form>
|
|
558
|
+
</CardContent>
|
|
559
|
+
</Card>
|
|
560
|
+
|
|
561
|
+
// Step 2: success state (after submit)
|
|
562
|
+
<Card className="w-full max-w-sm">
|
|
563
|
+
<CardContent className="flex flex-col items-center text-center gap-3 py-8">
|
|
564
|
+
<div className="size-10 rounded-full bg-muted flex items-center justify-center">
|
|
565
|
+
<CheckIcon className="size-5 text-foreground" />
|
|
566
|
+
</div>
|
|
567
|
+
<div className="flex flex-col gap-1">
|
|
568
|
+
<p className="text-base font-semibold">Check your email</p>
|
|
569
|
+
<p className="text-sm text-muted-foreground">
|
|
570
|
+
We sent a reset link to <span className="text-foreground">{email}</span>.
|
|
571
|
+
It expires in 1 hour.
|
|
572
|
+
</p>
|
|
573
|
+
</div>
|
|
574
|
+
<Button variant="outline" size="sm" asChild>
|
|
575
|
+
<Link to="/login">Back to sign in</Link>
|
|
576
|
+
</Button>
|
|
577
|
+
</CardContent>
|
|
578
|
+
</Card>
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
**Rules:**
|
|
582
|
+
- ALWAYS show the success state on submit, even if the email doesn't exist. (Security: don't leak whether an email is registered.)
|
|
583
|
+
- Include the email address in the success message — confirms what the user submitted.
|
|
584
|
+
- Mention expiration window — manages expectations, reduces support tickets.
|
|
585
|
+
- "Back to sign in" link in case they came here by mistake.
|
|
586
|
+
|
|
587
|
+
### 19. Magic link (passwordless)
|
|
588
|
+
|
|
589
|
+
**Intent:** email link, no password ever.
|
|
590
|
+
|
|
591
|
+
```tsx
|
|
592
|
+
<Card className="w-full max-w-sm">
|
|
593
|
+
<CardHeader>
|
|
594
|
+
<CardTitle>Sign in to Acme</CardTitle>
|
|
595
|
+
<CardDescription>Enter your email — we'll send a sign-in link.</CardDescription>
|
|
596
|
+
</CardHeader>
|
|
597
|
+
<CardContent>
|
|
598
|
+
<form onSubmit={handleSubmit}>
|
|
599
|
+
<FieldGroup>
|
|
600
|
+
<Field>
|
|
601
|
+
<FieldLabel htmlFor="email">Work email</FieldLabel>
|
|
602
|
+
<Input id="email" type="email" autoComplete="email" required />
|
|
603
|
+
</Field>
|
|
604
|
+
</FieldGroup>
|
|
605
|
+
<Button type="submit" disabled={isPending} className="w-full mt-6">
|
|
606
|
+
{isPending ? "Sending…" : "Send sign-in link"}
|
|
607
|
+
</Button>
|
|
608
|
+
</form>
|
|
609
|
+
<Separator className="my-6" />
|
|
610
|
+
<Button variant="ghost" size="sm" className="w-full" asChild>
|
|
611
|
+
<Link to="/sso"><Building2Icon className="size-4" /> Sign in with SSO</Link>
|
|
612
|
+
</Button>
|
|
613
|
+
</CardContent>
|
|
614
|
+
</Card>
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
**Rules:**
|
|
618
|
+
- Same security rule as password reset: show success regardless of whether the email is registered.
|
|
619
|
+
- SSO link below a separator if the product supports both — don't put it inline competing with the primary email flow.
|
|
620
|
+
|
|
621
|
+
### 20. OTP / 2FA code entry
|
|
622
|
+
|
|
623
|
+
**Intent:** 6-digit verification code (post-login, post-magic-link, or post-signup).
|
|
624
|
+
|
|
625
|
+
```tsx
|
|
626
|
+
<Card className="w-full max-w-sm">
|
|
627
|
+
<CardHeader>
|
|
628
|
+
<CardTitle>Enter verification code</CardTitle>
|
|
629
|
+
<CardDescription>
|
|
630
|
+
We sent a 6-digit code to <span className="text-foreground">{email}</span>.
|
|
631
|
+
</CardDescription>
|
|
632
|
+
</CardHeader>
|
|
633
|
+
<CardContent className="flex flex-col gap-4">
|
|
634
|
+
<InputOTP maxLength={6} value={code} onChange={setCode}>
|
|
635
|
+
<InputOTPGroup>
|
|
636
|
+
<InputOTPSlot index={0} />
|
|
637
|
+
<InputOTPSlot index={1} />
|
|
638
|
+
<InputOTPSlot index={2} />
|
|
639
|
+
<InputOTPSlot index={3} />
|
|
640
|
+
<InputOTPSlot index={4} />
|
|
641
|
+
<InputOTPSlot index={5} />
|
|
642
|
+
</InputOTPGroup>
|
|
643
|
+
</InputOTP>
|
|
644
|
+
|
|
645
|
+
{error && <FieldError>{error}</FieldError>}
|
|
646
|
+
|
|
647
|
+
<Button onClick={verify} disabled={code.length < 6 || isPending} className="w-full">
|
|
648
|
+
{isPending ? "Verifying…" : "Verify"}
|
|
649
|
+
</Button>
|
|
650
|
+
|
|
651
|
+
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
652
|
+
<span>Code expires in {timeLeft}</span>
|
|
653
|
+
<button onClick={resend} disabled={!canResend} className="text-foreground hover:underline disabled:text-muted-foreground disabled:no-underline">
|
|
654
|
+
Resend code
|
|
655
|
+
</button>
|
|
656
|
+
</div>
|
|
657
|
+
</CardContent>
|
|
658
|
+
</Card>
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
**Rules:**
|
|
662
|
+
- Auto-submit when 6 digits entered — don't make the user click Verify after typing the last digit.
|
|
663
|
+
- Show the email so users know which inbox to check.
|
|
664
|
+
- "Resend code" rate-limited (30-60s cooldown). Show countdown, then enable.
|
|
665
|
+
- Errors are specific: "Incorrect code." not "Verification failed."
|
|
666
|
+
|
|
667
|
+
### 21. Email verification banner (post-signup)
|
|
668
|
+
|
|
669
|
+
**Intent:** unobtrusive reminder for unverified accounts.
|
|
670
|
+
|
|
671
|
+
```tsx
|
|
672
|
+
{!user.emailVerified && (
|
|
673
|
+
<Alert>
|
|
674
|
+
<AlertTitle>Verify your email</AlertTitle>
|
|
675
|
+
<AlertDescription>
|
|
676
|
+
Check <span className="text-foreground">{user.email}</span> for a verification link.
|
|
677
|
+
{" "}
|
|
678
|
+
<button onClick={resend} className="text-foreground hover:underline">
|
|
679
|
+
Resend
|
|
680
|
+
</button>
|
|
681
|
+
</AlertDescription>
|
|
682
|
+
</Alert>
|
|
683
|
+
)}
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
**Rules:**
|
|
687
|
+
- Use `Alert` (informational), not a modal — don't block the user.
|
|
688
|
+
- Place at the top of the app shell, above main content.
|
|
689
|
+
- Resend link inline in the description, not as a separate button.
|
|
690
|
+
- Don't dismiss it permanently — let it persist until verified.
|
|
691
|
+
|
|
692
|
+
### 22. SSO landing (enterprise)
|
|
693
|
+
|
|
694
|
+
**Intent:** workspace/email entry → redirect to SSO provider.
|
|
695
|
+
|
|
696
|
+
```tsx
|
|
697
|
+
<Card className="w-full max-w-sm">
|
|
698
|
+
<CardHeader>
|
|
699
|
+
<CardTitle>Sign in with SSO</CardTitle>
|
|
700
|
+
<CardDescription>Enter your work email to find your organization.</CardDescription>
|
|
701
|
+
</CardHeader>
|
|
702
|
+
<CardContent>
|
|
703
|
+
<form onSubmit={lookup}>
|
|
704
|
+
<FieldGroup>
|
|
705
|
+
<Field>
|
|
706
|
+
<FieldLabel htmlFor="sso-email">Work email</FieldLabel>
|
|
707
|
+
<Input id="sso-email" type="email" required />
|
|
708
|
+
<FieldDescription>We'll redirect you to your SSO provider.</FieldDescription>
|
|
709
|
+
</Field>
|
|
710
|
+
</FieldGroup>
|
|
711
|
+
<Button type="submit" className="w-full mt-6">Continue</Button>
|
|
712
|
+
</form>
|
|
713
|
+
</CardContent>
|
|
714
|
+
<CardFooter className="text-xs text-muted-foreground justify-center">
|
|
715
|
+
<Link to="/login" className="hover:underline">Use email and password instead</Link>
|
|
716
|
+
</CardFooter>
|
|
717
|
+
</Card>
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
**Rules:**
|
|
721
|
+
- Hide the actual SSO provider from the user — they enter email, the system figures out the provider from the domain.
|
|
722
|
+
- Always offer an escape hatch ("use password instead") in case SSO is misconfigured or the user is on a personal account.
|
|
723
|
+
|
|
724
|
+
---
|
|
725
|
+
|
|
726
|
+
## Single-surface / refined recipes
|
|
727
|
+
|
|
728
|
+
These recipes apply when the surrounding shell uses single-surface mode (see `ELEVATION.md` Mode 3 — landing pages, docs, content-first apps). They're cleaner alternatives to recipes that work well on multi-surface shells but feel heavy without surrounding chrome.
|
|
729
|
+
|
|
730
|
+
### 23. Active nav indicator (inset bar, not bg-fill)
|
|
731
|
+
|
|
732
|
+
**Intent:** mark the active route in a sidebar / docs nav without using `bg-accent` as a pill. Used by Linear sidebar, Notion docs, Vercel docs, Boneyard.
|
|
733
|
+
|
|
734
|
+
```tsx
|
|
735
|
+
<NavLink
|
|
736
|
+
to={item.to}
|
|
737
|
+
className={({ isActive }) =>
|
|
738
|
+
[
|
|
739
|
+
"relative flex items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors",
|
|
740
|
+
// INSET LEFT BAR — appears only when active. 2px wide, full row height,
|
|
741
|
+
// sits in the left padding without shifting the label.
|
|
742
|
+
"before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2",
|
|
743
|
+
"before:h-4 before:w-[2px] before:rounded-full before:bg-foreground",
|
|
744
|
+
"before:opacity-0 before:transition-opacity",
|
|
745
|
+
isActive
|
|
746
|
+
? "text-foreground font-medium before:opacity-100"
|
|
747
|
+
: "text-muted-foreground hover:text-foreground hover:bg-muted/40",
|
|
748
|
+
].join(" ")
|
|
749
|
+
}
|
|
750
|
+
>
|
|
751
|
+
{item.label}
|
|
752
|
+
</NavLink>
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
**Rules:**
|
|
756
|
+
- Bar is **2px wide** — wider reads as a divider, narrower disappears at low DPI.
|
|
757
|
+
- Bar uses **`bg-foreground`** (or the brand color) — needs full contrast to register as an active marker.
|
|
758
|
+
- Active row gets **bolder text** (`font-medium`) AND the bar. Either alone is too weak; both together makes the active state unambiguous.
|
|
759
|
+
- Hover on inactive rows uses **`bg-muted/40`** — a soft pill — so hover and active are distinguishable (active = bar + bold; hover = soft pill).
|
|
760
|
+
- **Do NOT also fill with `bg-accent`** — defeats the point. The bar+bold combo IS the indicator.
|
|
761
|
+
|
|
762
|
+
**When to use this instead of bg-fill pill:**
|
|
763
|
+
- Single-surface shell (no surface contrast available — bg-fill would clash with the borderless canvas)
|
|
764
|
+
- Docs / marketing / content-first app where the nav is meant to defer to the content
|
|
765
|
+
- Sidebars where 6+ items would create too many filled pills if multiple states were highlighted
|
|
766
|
+
|
|
767
|
+
**When to KEEP bg-fill instead:**
|
|
768
|
+
- Dense product UI with high information density (Linear's issue list nav, our PMO sidebar with badges + icons + counts) — there, the bg-fill survives the noise better than a thin bar.
|
|
769
|
+
|
|
770
|
+
### 24. Concept demonstration (show, don't tell)
|
|
771
|
+
|
|
772
|
+
**Intent:** explain an abstract feature by pairing the REAL artifact with the ABSTRACT artifact side-by-side in one frame. Used by Boneyard (real UI vs skeleton), Vercel marketing (code before vs after), Anthropic docs (prompt vs response).
|
|
773
|
+
|
|
774
|
+
```tsx
|
|
775
|
+
<figure className="rounded-xl border border-border overflow-hidden">
|
|
776
|
+
{/* Top — labels for each half, monospace-ish small caps */}
|
|
777
|
+
<div className="grid grid-cols-2 px-6 pt-5 pb-2 text-[10px] uppercase tracking-wider text-muted-foreground font-semibold">
|
|
778
|
+
<span>Real UI</span>
|
|
779
|
+
<span>Skeleton</span>
|
|
780
|
+
</div>
|
|
781
|
+
|
|
782
|
+
{/* Body — the two artifacts, equal-width, same vertical rhythm */}
|
|
783
|
+
<div className="grid grid-cols-2 gap-6 px-6 pb-6">
|
|
784
|
+
<div className="flex flex-col gap-3">
|
|
785
|
+
{/* REAL — full-fidelity rendering with colour, icons, labels, data */}
|
|
786
|
+
<RealDashboard />
|
|
787
|
+
</div>
|
|
788
|
+
<div className="flex flex-col gap-3">
|
|
789
|
+
{/* ABSTRACT — same shapes/positions but stripped of colour and detail */}
|
|
790
|
+
<SkeletonDashboard />
|
|
791
|
+
</div>
|
|
792
|
+
</div>
|
|
793
|
+
</figure>
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
**Rules:**
|
|
797
|
+
- **Equal column widths** — asymmetry implies one is more important than the other; for "concept = output" pairing, they need to read as equals.
|
|
798
|
+
- **Same vertical rhythm** — both halves use the same `gap-*` values so eye-line moves left-right at the same heights. Misaligned rows kill the visual rhyme.
|
|
799
|
+
- **The contrast IS the explanation** — don't add an arrow or "→" between them. The user's brain does the comparison automatically.
|
|
800
|
+
- **Real side gets all the colour** — accent colors, icons, brand. The abstract side stays monochrome / grey. The colour drop-off is the second signal that "this is the stripped version."
|
|
801
|
+
- **Containing frame is borderless or has the lightest possible border** (`border-border`) — heavy frame would compete with the contrast inside.
|
|
802
|
+
- **Labels go above each half** in tiny uppercase muted text — not floating labels in the middle, not headers in their own rows. The labels are minor; the demonstration is the point.
|
|
803
|
+
|
|
804
|
+
**Don't use this pattern for:**
|
|
805
|
+
- Single concepts that don't benefit from comparison (just show the thing)
|
|
806
|
+
- More than 2 panels — 3+ becomes a comparison table, different recipe
|
|
807
|
+
- Steps in a sequence — use a numbered flow, not a side-by-side
|
|
808
|
+
|
|
809
|
+
---
|
|
810
|
+
|
|
811
|
+
## Combo synthesis — how to think about new ones
|
|
812
|
+
|
|
813
|
+
When you face a situation not listed here, ask:
|
|
814
|
+
|
|
815
|
+
1. **What is the user's verb?** (browse, decide, create, confirm, monitor, configure)
|
|
816
|
+
2. **Is it inline (in flow) or modal (interrupts flow)?**
|
|
817
|
+
3. **Does it need depth?** (overlays, drawers) or **breadth?** (always visible)
|
|
818
|
+
4. **What's the smallest set of components that achieves it without decoration?**
|
|
819
|
+
|
|
820
|
+
Then check `COMPONENTS_INDEX.md` decision trees, pick the components, and verify against this file's anti-recipes before writing JSX.
|