@nocobase/app-template-default 1.0.0-beta.67 → 1.0.0-beta.69

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 (87) hide show
  1. package/.agents/rules/components.md +2 -2
  2. package/.agents/rules/shell.md +5 -1
  3. package/AGENTS.md +5 -3
  4. package/Dockerfile.dockerignore +0 -1
  5. package/README.MD +3 -1
  6. package/client/app.ts +15 -0
  7. package/client/components/data-table/column-header.tsx +91 -0
  8. package/client/components/data-table/data-table.tsx +216 -0
  9. package/client/components/data-table/index.ts +13 -0
  10. package/client/components/data-table/pagination.tsx +165 -0
  11. package/client/components/data-table/view-options.tsx +77 -0
  12. package/client/components/page-header.tsx +1 -1
  13. package/client/components/permission-editor.tsx +589 -0
  14. package/client/components/permission-filter.tsx +455 -0
  15. package/client/components/route-overlay.tsx +26 -13
  16. package/client/components/status-badge.tsx +7 -1
  17. package/client/components/ui/checkbox.tsx +25 -0
  18. package/client/components/ui/sidebar.tsx +1 -4
  19. package/client/components/ui/switch.tsx +30 -0
  20. package/client/components/ui/table.tsx +113 -0
  21. package/client/extensions/nocobase-inbox/inbox-item.tsx +44 -58
  22. package/client/extensions/nocobase-inbox/inbox-list.tsx +73 -75
  23. package/client/index.tsx +4 -0
  24. package/client/layouts/components/app-sidebar.tsx +6 -6
  25. package/client/layouts/components/layout-header.tsx +1 -1
  26. package/client/layouts/components/navigation-menu.tsx +223 -12
  27. package/client/layouts/components/sidebar-footer.tsx +1 -1
  28. package/client/layouts/components/user-menu.tsx +1 -1
  29. package/client/layouts/use-sidebar-preference.ts +62 -42
  30. package/client/lib/navigation-transform.ts +8 -0
  31. package/client/lib/scrollbar.ts +88 -0
  32. package/client/locales/system/en-US.ts +11 -0
  33. package/client/locales/system/settings-en-US.ts +141 -0
  34. package/client/locales/system/settings-zh-CN.ts +135 -0
  35. package/client/locales/system/zh-CN.ts +22 -12
  36. package/client/pages/auth/auth-page.css +52 -0
  37. package/client/pages/auth/login.tsx +1 -1
  38. package/client/pages/auth/reset-password.tsx +57 -14
  39. package/client/pages/auth/shared.tsx +26 -24
  40. package/client/pages/home/index.tsx +2 -2
  41. package/client/pages/settings/hooks.tsx +137 -0
  42. package/client/pages/settings/permission-filters.tsx +119 -0
  43. package/client/pages/settings/permissions-model.ts +260 -0
  44. package/client/pages/settings/role-form.tsx +426 -0
  45. package/client/pages/settings/roles.tsx +163 -0
  46. package/client/pages/settings/routes.ts +79 -0
  47. package/client/pages/settings/shared.tsx +119 -0
  48. package/client/pages/settings/user-detail.tsx +268 -0
  49. package/client/pages/settings/user-form.tsx +427 -0
  50. package/client/pages/settings/users.tsx +451 -0
  51. package/client/routing/app-router.tsx +4 -1
  52. package/client/routing/route-navigation.ts +10 -1
  53. package/client/routing/session-preparation.tsx +65 -0
  54. package/client/styles.css +257 -24
  55. package/client/theme/theme-settings.tsx +12 -7
  56. package/client/theme/themes/compact.css +9 -14
  57. package/client/theme/themes/default.css +7 -12
  58. package/package.json +26 -25
  59. package/server/app.ts +2 -0
  60. package/server/config/notification.ts +4 -7
  61. package/tests/components/app-layout-routes.test.tsx +14 -7
  62. package/tests/components/auth-i18n.test.tsx +57 -5
  63. package/tests/components/data-table.test.tsx +127 -0
  64. package/tests/components/layout-i18n.test.tsx +10 -3
  65. package/tests/components/navigation-menu.test.tsx +155 -16
  66. package/tests/components/permission-editor.test.tsx +477 -0
  67. package/tests/components/permission-filter-binding.test.tsx +150 -0
  68. package/tests/components/permission-filter.test.tsx +202 -0
  69. package/tests/components/settings.test.tsx +294 -0
  70. package/tests/components/sidebar-permissions.test.tsx +17 -19
  71. package/tests/components/sidebar-preference.test.tsx +209 -14
  72. package/tests/components/status-badge.test.tsx +22 -0
  73. package/tests/components/user-permissions.test.tsx +142 -0
  74. package/tests/fixtures/theme-tokens.tsx +9 -1
  75. package/tests/logic/asset-url-build.test.ts +0 -1
  76. package/tests/logic/client-routes.test.ts +40 -0
  77. package/tests/logic/client-shell.test.tsx +10 -2
  78. package/tests/logic/config-example.test.ts +30 -0
  79. package/tests/logic/dev-client-watch.test.ts +8 -1
  80. package/tests/logic/route-overlay.test.tsx +42 -0
  81. package/tests/logic/scrollbar.test.ts +88 -0
  82. package/tests/logic/session-preparation.test.tsx +223 -0
  83. package/tests/logic/settings-permissions.test.ts +168 -0
  84. package/tests/logic/theme-tokens.test.ts +1 -0
  85. package/vite.config.ts +0 -22
  86. package/vitest.config.ts +1 -0
  87. package/client/agent-annotations-host.ts +0 -25
@@ -0,0 +1,455 @@
1
+ import { useId, useRef, useState, type ReactElement } from 'react';
2
+ import { Button } from '#components/ui/button';
3
+ import { Input } from '#components/ui/input';
4
+ import {
5
+ Select,
6
+ SelectContent,
7
+ SelectGroup,
8
+ SelectItem,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ } from '#components/ui/select';
12
+ import {
13
+ Popover,
14
+ PopoverContent,
15
+ PopoverDescription,
16
+ PopoverTitle,
17
+ PopoverTrigger,
18
+ } from '#components/ui/popover';
19
+
20
+ export interface PermissionFilterCondition {
21
+ readonly kind: 'condition';
22
+ readonly path: readonly string[];
23
+ readonly operator: string;
24
+ readonly value?: string | number | boolean;
25
+ }
26
+ export interface PermissionFilterValue {
27
+ readonly kind: 'group';
28
+ readonly logic: 'and' | 'or';
29
+ readonly items: readonly PermissionFilterCondition[];
30
+ }
31
+ export interface PermissionFilterLabels {
32
+ readonly configure: string;
33
+ readonly title: string;
34
+ readonly description: string;
35
+ readonly all: string;
36
+ readonly any: string;
37
+ readonly field: string;
38
+ readonly operator: string;
39
+ readonly valueType: string;
40
+ readonly value: string;
41
+ readonly text: string;
42
+ readonly number: string;
43
+ readonly boolean: string;
44
+ readonly true: string;
45
+ readonly false: string;
46
+ readonly add: string;
47
+ readonly limit: string;
48
+ readonly remove: string;
49
+ readonly cancel: string;
50
+ readonly apply: string;
51
+ readonly invalid: string;
52
+ readonly advanced: string;
53
+ readonly reconfigure: string;
54
+ readonly operators: Readonly<Record<string, string>>;
55
+ }
56
+ export interface PermissionFilterProps {
57
+ readonly open: boolean;
58
+ readonly onOpenChange: (open: boolean) => void;
59
+ readonly value: unknown;
60
+ readonly fields: readonly { value: string; label: string }[];
61
+ readonly labels: PermissionFilterLabels;
62
+ readonly disabled?: boolean;
63
+ readonly onApply: (value: PermissionFilterValue) => void;
64
+ }
65
+ interface DraftCondition {
66
+ id: number;
67
+ field: string;
68
+ operator: string;
69
+ type: string;
70
+ value: string;
71
+ }
72
+ const operators = [
73
+ '$eq',
74
+ '$ne',
75
+ '$includes',
76
+ '$gt',
77
+ '$gte',
78
+ '$lt',
79
+ '$lte',
80
+ '$empty',
81
+ '$notEmpty',
82
+ ];
83
+ function unary(operator: string): boolean {
84
+ return operator === '$empty' || operator === '$notEmpty';
85
+ }
86
+ function numeric(operator: string): boolean {
87
+ return ['$gt', '$gte', '$lt', '$lte'].includes(operator);
88
+ }
89
+ function read(
90
+ value: unknown,
91
+ ): { logic: 'and' | 'or'; rows: DraftCondition[] } | undefined {
92
+ if (!value || typeof value !== 'object') return undefined;
93
+ const root = value as { kind?: unknown; logic?: unknown; items?: unknown };
94
+ const items =
95
+ root.kind === 'condition'
96
+ ? [value]
97
+ : root.kind === 'group' &&
98
+ (root.logic === 'and' || root.logic === 'or') &&
99
+ Array.isArray(root.items)
100
+ ? root.items
101
+ : undefined;
102
+ if (!items?.length || items.length > 20) return undefined;
103
+ const rows: DraftCondition[] = [];
104
+ for (const item of items) {
105
+ if (!item || typeof item !== 'object') return undefined;
106
+ const node = item as {
107
+ kind?: unknown;
108
+ path?: unknown;
109
+ operator?: unknown;
110
+ value?: unknown;
111
+ };
112
+ if (
113
+ node.kind !== 'condition' ||
114
+ !Array.isArray(node.path) ||
115
+ node.path.length !== 1 ||
116
+ typeof node.path[0] !== 'string' ||
117
+ typeof node.operator !== 'string' ||
118
+ !operators.includes(node.operator) ||
119
+ (!unary(node.operator) &&
120
+ !['string', 'number', 'boolean'].includes(typeof node.value))
121
+ )
122
+ return undefined;
123
+ rows.push({
124
+ id: rows.length,
125
+ field: node.path[0],
126
+ operator: node.operator,
127
+ type:
128
+ typeof node.value === 'number'
129
+ ? 'number'
130
+ : typeof node.value === 'boolean'
131
+ ? 'boolean'
132
+ : 'text',
133
+ value:
134
+ typeof node.value === 'string'
135
+ ? node.value
136
+ : typeof node.value === 'number' || typeof node.value === 'boolean'
137
+ ? String(node.value)
138
+ : '',
139
+ });
140
+ }
141
+ return { logic: root.logic === 'or' ? 'or' : 'and', rows };
142
+ }
143
+ function Choice({
144
+ label,
145
+ items,
146
+ value,
147
+ onChange,
148
+ }: {
149
+ label: string;
150
+ items: readonly { value: string; label: string }[];
151
+ value: string;
152
+ onChange: (value: string) => void;
153
+ }): ReactElement {
154
+ return (
155
+ <Select
156
+ items={items}
157
+ value={value}
158
+ onValueChange={(next) => {
159
+ if (next !== null) onChange(next);
160
+ }}
161
+ >
162
+ <SelectTrigger size='sm' className='w-full' aria-label={label}>
163
+ <SelectValue />
164
+ </SelectTrigger>
165
+ <SelectContent className='w-auto max-w-[min(var(--container-sm),var(--available-width))] min-w-(--anchor-width) [&_[data-slot=select-item]>:first-child]:whitespace-normal'>
166
+ <SelectGroup>
167
+ {items.map((item) => (
168
+ <SelectItem key={item.value} value={item.value}>
169
+ {item.label}
170
+ </SelectItem>
171
+ ))}
172
+ </SelectGroup>
173
+ </SelectContent>
174
+ </Select>
175
+ );
176
+ }
177
+ function FilterDraft({
178
+ value,
179
+ fields,
180
+ labels: w,
181
+ onApply,
182
+ onCancel,
183
+ }: Pick<PermissionFilterProps, 'value' | 'fields' | 'labels' | 'onApply'> & {
184
+ onCancel: () => void;
185
+ }): ReactElement {
186
+ const [initial] = useState(() => read(value));
187
+ const empty = (): DraftCondition => ({
188
+ id: 0,
189
+ field: fields[0]?.value ?? '',
190
+ operator: '$eq',
191
+ type: 'text',
192
+ value: '',
193
+ });
194
+ const [advanced, setAdvanced] = useState(value !== undefined && !initial);
195
+ const [logic, setLogic] = useState<'and' | 'or'>(initial?.logic ?? 'and');
196
+ const [rows, setRows] = useState<DraftCondition[]>(
197
+ () => initial?.rows ?? [empty()],
198
+ );
199
+ const [nextId, setNextId] = useState(rows.length);
200
+ const id = useId();
201
+ const addRef = useRef<HTMLButtonElement>(null);
202
+ const update = (rowId: number, patch: Partial<DraftCondition>) =>
203
+ setRows((current) =>
204
+ current.map((row) => (row.id === rowId ? { ...row, ...patch } : row)),
205
+ );
206
+ const valid =
207
+ !advanced &&
208
+ rows.length > 0 &&
209
+ rows.every(
210
+ (row) =>
211
+ fields.some((field) => field.value === row.field) &&
212
+ (unary(row.operator) ||
213
+ (row.value.trim() !== '' &&
214
+ (row.type !== 'number' || Number.isFinite(Number(row.value))) &&
215
+ (row.type !== 'boolean' || ['true', 'false'].includes(row.value)))),
216
+ );
217
+ return (
218
+ <div
219
+ className='flex min-h-0 flex-col gap-3'
220
+ onKeyDown={(event) => {
221
+ if (
222
+ event.key === 'Enter' &&
223
+ !event.nativeEvent.isComposing &&
224
+ event.target instanceof HTMLInputElement
225
+ )
226
+ event.preventDefault();
227
+ }}
228
+ >
229
+ {advanced ? (
230
+ <div className='flex flex-col gap-3'>
231
+ <p className='text-sm text-muted-foreground'>{w.advanced}</p>
232
+ <Button
233
+ type='button'
234
+ variant='outline'
235
+ size='sm'
236
+ onClick={() => {
237
+ setAdvanced(false);
238
+ setRows([empty()]);
239
+ setNextId(1);
240
+ }}
241
+ >
242
+ {w.reconfigure}
243
+ </Button>
244
+ </div>
245
+ ) : (
246
+ <>
247
+ <Choice
248
+ label={w.title}
249
+ value={logic}
250
+ items={[
251
+ { value: 'and', label: w.all },
252
+ { value: 'or', label: w.any },
253
+ ]}
254
+ onChange={(value) => setLogic(value === 'or' ? 'or' : 'and')}
255
+ />
256
+ <div className='flex min-h-0 flex-col gap-3 overflow-y-auto'>
257
+ {rows.map((row, index) => (
258
+ <fieldset
259
+ key={row.id}
260
+ className='flex flex-col gap-2 rounded-lg border p-3'
261
+ >
262
+ <legend className='sr-only'>{`${w.field} ${index + 1}`}</legend>
263
+ <div className='grid grid-cols-1 gap-2 sm:grid-cols-2'>
264
+ <Choice
265
+ label={`${w.field} ${index + 1}`}
266
+ items={fields}
267
+ value={row.field}
268
+ onChange={(field) => update(row.id, { field })}
269
+ />
270
+ <Choice
271
+ label={`${w.operator} ${index + 1}`}
272
+ items={operators.map((value) => ({
273
+ value,
274
+ label: w.operators[value] ?? value,
275
+ }))}
276
+ value={row.operator}
277
+ onChange={(operator) =>
278
+ update(row.id, {
279
+ operator,
280
+ ...(numeric(operator)
281
+ ? { type: 'number' }
282
+ : operator === '$includes'
283
+ ? { type: 'text' }
284
+ : {}),
285
+ })
286
+ }
287
+ />
288
+ {!unary(row.operator) && (
289
+ <>
290
+ {['$eq', '$ne'].includes(row.operator) && (
291
+ <Choice
292
+ label={`${w.valueType} ${index + 1}`}
293
+ items={[
294
+ { value: 'text', label: w.text },
295
+ { value: 'number', label: w.number },
296
+ { value: 'boolean', label: w.boolean },
297
+ ]}
298
+ value={row.type}
299
+ onChange={(type) =>
300
+ update(row.id, { type, value: '' })
301
+ }
302
+ />
303
+ )}
304
+ {row.type === 'boolean' ? (
305
+ <Choice
306
+ label={`${w.value} ${index + 1}`}
307
+ items={[
308
+ { value: 'true', label: w.true },
309
+ { value: 'false', label: w.false },
310
+ ]}
311
+ value={row.value}
312
+ onChange={(value) => update(row.id, { value })}
313
+ />
314
+ ) : (
315
+ <Input
316
+ aria-label={`${w.value} ${index + 1}`}
317
+ aria-describedby={`${id}-validation`}
318
+ placeholder={w.value}
319
+ value={row.value}
320
+ inputMode={row.type === 'number' ? 'decimal' : 'text'}
321
+ onChange={(event) =>
322
+ update(row.id, { value: event.target.value })
323
+ }
324
+ />
325
+ )}
326
+ </>
327
+ )}
328
+ </div>
329
+ <div className='flex justify-end'>
330
+ <Button
331
+ type='button'
332
+ variant='ghost'
333
+ size='sm'
334
+ onClick={() => {
335
+ setRows((current) =>
336
+ current.filter((item) => item.id !== row.id),
337
+ );
338
+ queueMicrotask(() => addRef.current?.focus());
339
+ }}
340
+ >
341
+ {w.remove}
342
+ </Button>
343
+ </div>
344
+ </fieldset>
345
+ ))}
346
+ </div>
347
+ <Button
348
+ ref={addRef}
349
+ type='button'
350
+ variant='outline'
351
+ size='sm'
352
+ disabled={rows.length >= 20}
353
+ onClick={() => {
354
+ setRows((current) => [...current, { ...empty(), id: nextId }]);
355
+ setNextId((current) => current + 1);
356
+ }}
357
+ >
358
+ {w.add}
359
+ </Button>
360
+ {rows.length >= 20 && (
361
+ <p className='text-xs text-muted-foreground'>{w.limit}</p>
362
+ )}
363
+ {!valid && (
364
+ <p
365
+ id={`${id}-validation`}
366
+ className='text-sm text-muted-foreground'
367
+ >
368
+ {w.invalid}
369
+ </p>
370
+ )}
371
+ </>
372
+ )}
373
+ <div className='flex shrink-0 justify-end gap-2 border-t pt-3'>
374
+ <Button type='button' variant='outline' size='sm' onClick={onCancel}>
375
+ {w.cancel}
376
+ </Button>
377
+ <Button
378
+ type='button'
379
+ size='sm'
380
+ disabled={!valid}
381
+ onClick={() =>
382
+ onApply({
383
+ kind: 'group',
384
+ logic,
385
+ items: rows.map((row) => ({
386
+ kind: 'condition',
387
+ path: [row.field],
388
+ operator: row.operator,
389
+ ...(unary(row.operator)
390
+ ? {}
391
+ : {
392
+ value:
393
+ row.type === 'number'
394
+ ? Number(row.value)
395
+ : row.type === 'boolean'
396
+ ? row.value === 'true'
397
+ : row.value,
398
+ }),
399
+ })),
400
+ })
401
+ }
402
+ >
403
+ {w.apply}
404
+ </Button>
405
+ </div>
406
+ </div>
407
+ );
408
+ }
409
+ /** Native filter input; Apply edits the containing role draft, never a server directly. */
410
+ export function PermissionFilter({
411
+ open,
412
+ onOpenChange,
413
+ value,
414
+ fields,
415
+ labels,
416
+ disabled = false,
417
+ onApply,
418
+ }: PermissionFilterProps): ReactElement {
419
+ return (
420
+ <Popover open={open && !disabled} onOpenChange={onOpenChange}>
421
+ <PopoverTrigger
422
+ render={
423
+ <Button
424
+ type='button'
425
+ variant='outline'
426
+ size='sm'
427
+ className='h-6 px-2 text-xs'
428
+ disabled={disabled}
429
+ />
430
+ }
431
+ >
432
+ {labels.configure}
433
+ </PopoverTrigger>
434
+ <PopoverContent
435
+ align='start'
436
+ className='flex max-h-[min(36rem,var(--available-height))] w-xl max-w-(--available-width) flex-col gap-3 p-4'
437
+ >
438
+ <PopoverTitle>{labels.title}</PopoverTitle>
439
+ <PopoverDescription>{labels.description}</PopoverDescription>
440
+ {open && !disabled && (
441
+ <FilterDraft
442
+ value={value}
443
+ fields={fields}
444
+ labels={labels}
445
+ onCancel={() => onOpenChange(false)}
446
+ onApply={(filter) => {
447
+ onApply(filter);
448
+ onOpenChange(false);
449
+ }}
450
+ />
451
+ )}
452
+ </PopoverContent>
453
+ </Popover>
454
+ );
455
+ }
@@ -20,6 +20,8 @@ import {
20
20
  Dialog,
21
21
  DialogClose,
22
22
  DialogDescription,
23
+ DialogFooter,
24
+ DialogHeader,
23
25
  DialogOverlay,
24
26
  DialogPortal,
25
27
  DialogTitle,
@@ -147,7 +149,10 @@ export function RouteOverlay({
147
149
  return true;
148
150
  }}
149
151
  className={cn(
150
- 'fixed top-1/2 left-1/2 z-50 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-xl bg-popover text-popover-foreground shadow-lg outline-none duration-150 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95',
152
+ // The frame of `DialogContent` (position, surface, ring, motion), laid out as a column so the header and
153
+ // footer stay fixed while only the body scrolls within the viewport cap.
154
+ 'fixed top-1/2 left-1/2 z-50 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-xl bg-popover text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95',
155
+ // Route dialogs hold the 5-8 field forms of guideline I1, hence wider than the plain dialog's default.
151
156
  'flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl',
152
157
  // The viewport constraints are deliberate; all ordinary styling uses theme tokens.
153
158
  drawer &&
@@ -155,27 +160,35 @@ export function RouteOverlay({
155
160
  className,
156
161
  )}
157
162
  >
163
+ {/* The regions reproduce `DialogContent`'s `p-6 gap-5` around the dialog primitives, split so the body can
164
+ scroll on its own: the header and body own the padding, and the body's top padding is the gap. */}
158
165
  <header
159
166
  data-slot='route-overlay-header'
160
- className='flex shrink-0 flex-col gap-2 border-b p-6 pr-14'
167
+ className='shrink-0 px-6 pt-6'
161
168
  >
162
- <DialogTitle>{title}</DialogTitle>
163
- {description != null && (
164
- <DialogDescription>{description}</DialogDescription>
165
- )}
169
+ <DialogHeader>
170
+ <DialogTitle>{title}</DialogTitle>
171
+ {description != null && (
172
+ <DialogDescription>{description}</DialogDescription>
173
+ )}
174
+ </DialogHeader>
166
175
  </header>
167
176
  <div
168
177
  data-slot='route-overlay-body'
169
- className='min-h-0 flex-1 overflow-y-auto p-6'
178
+ className={cn(
179
+ 'min-h-0 flex-1 overflow-y-auto px-6 pt-5',
180
+ footer != null ? 'pb-5' : 'pb-6',
181
+ )}
170
182
  >
171
183
  {children}
172
184
  </div>
173
185
  {footer != null && (
174
- <footer
175
- data-slot='route-overlay-footer'
176
- className='flex shrink-0 flex-wrap justify-end gap-2 border-t bg-muted/30 p-6'
177
- >
178
- {footer}
186
+ <footer data-slot='route-overlay-footer' className='shrink-0'>
187
+ {/* `DialogFooter` pulls itself out of `DialogContent`'s padding with negative margins; here it
188
+ already sits at the panel's edges, and the panel clips the corners. */}
189
+ <DialogFooter className='mx-0 mb-0 rounded-none'>
190
+ {footer}
191
+ </DialogFooter>
179
192
  </footer>
180
193
  )}
181
194
  <DialogClose
@@ -183,7 +196,7 @@ export function RouteOverlay({
183
196
  <Button
184
197
  variant='ghost'
185
198
  size='icon-sm'
186
- className='absolute top-4 right-4'
199
+ className='absolute top-2 right-2'
187
200
  />
188
201
  }
189
202
  >
@@ -11,17 +11,23 @@ const tones = {
11
11
  'border-(--status-success)/20 bg-(--status-success-background) text-(--status-success)',
12
12
  };
13
13
 
14
- /** Quiet semantic status labels; reserve solid primary fills for actions. */
14
+ /**
15
+ * Quiet semantic status labels; reserve solid primary fills for actions. `size='sm'` is the small badge, for a status
16
+ * beside a title or on a dense card (`data-size` in `client/styles.css`).
17
+ */
15
18
  export function StatusBadge({
16
19
  tone = 'neutral',
20
+ size,
17
21
  className,
18
22
  ...props
19
23
  }: Omit<ComponentProps<typeof Badge>, 'variant'> & {
20
24
  readonly tone?: keyof typeof tones;
25
+ readonly size?: 'sm';
21
26
  }): ReactElement {
22
27
  return (
23
28
  <Badge
24
29
  {...props}
30
+ data-size={size}
25
31
  variant='outline'
26
32
  className={cn(tones[tone], className)}
27
33
  />
@@ -0,0 +1,25 @@
1
+ import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox';
2
+ import { cn } from 'cn';
3
+ import { CheckIcon } from 'lucide-react';
4
+
5
+ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
6
+ return (
7
+ <CheckboxPrimitive.Root
8
+ data-slot='checkbox'
9
+ className={cn(
10
+ 'peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary',
11
+ className,
12
+ )}
13
+ {...props}
14
+ >
15
+ <CheckboxPrimitive.Indicator
16
+ data-slot='checkbox-indicator'
17
+ className='grid place-content-center text-current transition-none [&>svg]:size-3.5'
18
+ >
19
+ <CheckIcon />
20
+ </CheckboxPrimitive.Indicator>
21
+ </CheckboxPrimitive.Root>
22
+ );
23
+ }
24
+
25
+ export { Checkbox };
@@ -25,8 +25,6 @@ import {
25
25
  } from '#components/ui/tooltip';
26
26
  import { PanelLeftIcon } from 'lucide-react';
27
27
 
28
- const SIDEBAR_COOKIE_NAME = 'sidebar_state';
29
- const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
30
28
  const SIDEBAR_WIDTH = '16rem';
31
29
  const SIDEBAR_WIDTH_MOBILE = '18rem';
32
30
  const SIDEBAR_WIDTH_ICON = '3rem';
@@ -82,8 +80,7 @@ function SidebarProvider({
82
80
  _setOpen(openState);
83
81
  }
84
82
 
85
- // This sets the cookie to keep the sidebar state.
86
- document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
83
+ // Persistence belongs to the application-scoped preference, not an origin-wide cookie.
87
84
  },
88
85
  [setOpenProp, open],
89
86
  );
@@ -0,0 +1,30 @@
1
+ import { Switch as SwitchPrimitive } from '@base-ui/react/switch';
2
+ import { cn } from 'cn';
3
+ import type { ReactElement } from 'react';
4
+
5
+ function Switch({
6
+ className,
7
+ size = 'default',
8
+ ...props
9
+ }: SwitchPrimitive.Root.Props & {
10
+ size?: 'sm' | 'default';
11
+ }): ReactElement {
12
+ return (
13
+ <SwitchPrimitive.Root
14
+ data-slot='switch'
15
+ data-size={size}
16
+ className={cn(
17
+ 'peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50',
18
+ className,
19
+ )}
20
+ {...props}
21
+ >
22
+ <SwitchPrimitive.Thumb
23
+ data-slot='switch-thumb'
24
+ className='pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground'
25
+ />
26
+ </SwitchPrimitive.Root>
27
+ );
28
+ }
29
+
30
+ export { Switch };