@saena-io/create 0.1.0 → 0.2.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 (100) hide show
  1. package/dist/index.js +9 -9
  2. package/package.json +1 -1
  3. package/template/base/package.json +44 -2
  4. package/template/base/scripts/ui-update.ts +83 -0
  5. package/template/base/src/components/ui/accordion.tsx +75 -0
  6. package/template/base/src/components/ui/alert-dialog.tsx +162 -0
  7. package/template/base/src/components/ui/alert.tsx +73 -0
  8. package/template/base/src/components/ui/app-sidebar.tsx +183 -0
  9. package/template/base/src/components/ui/aspect-ratio.tsx +22 -0
  10. package/template/base/src/components/ui/asset-input.tsx +211 -0
  11. package/template/base/src/components/ui/avatar.tsx +91 -0
  12. package/template/base/src/components/ui/badge.tsx +50 -0
  13. package/template/base/src/components/ui/breadcrumb.tsx +104 -0
  14. package/template/base/src/components/ui/button-group.tsx +78 -0
  15. package/template/base/src/components/ui/button.tsx +56 -0
  16. package/template/base/src/components/ui/calendar.tsx +205 -0
  17. package/template/base/src/components/ui/card.tsx +85 -0
  18. package/template/base/src/components/ui/carousel.tsx +232 -0
  19. package/template/base/src/components/ui/chart.tsx +337 -0
  20. package/template/base/src/components/ui/checkbox.tsx +29 -0
  21. package/template/base/src/components/ui/collapsible.tsx +15 -0
  22. package/template/base/src/components/ui/combobox.tsx +276 -0
  23. package/template/base/src/components/ui/command.tsx +190 -0
  24. package/template/base/src/components/ui/context-menu.tsx +243 -0
  25. package/template/base/src/components/ui/dialog.tsx +134 -0
  26. package/template/base/src/components/ui/direction.tsx +4 -0
  27. package/template/base/src/components/ui/drawer.tsx +120 -0
  28. package/template/base/src/components/ui/dropdown-menu.tsx +254 -0
  29. package/template/base/src/components/ui/empty.tsx +94 -0
  30. package/template/base/src/components/ui/field.tsx +222 -0
  31. package/template/base/src/components/ui/focal-point-picker.tsx +175 -0
  32. package/template/base/src/components/ui/hover-card.tsx +46 -0
  33. package/template/base/src/components/ui/input-group.tsx +149 -0
  34. package/template/base/src/components/ui/input-otp.tsx +85 -0
  35. package/template/base/src/components/ui/input.tsx +20 -0
  36. package/template/base/src/components/ui/item.tsx +188 -0
  37. package/template/base/src/components/ui/kbd.tsx +26 -0
  38. package/template/base/src/components/ui/label.tsx +20 -0
  39. package/template/base/src/components/ui/menubar.tsx +268 -0
  40. package/template/base/src/components/ui/native-select.tsx +58 -0
  41. package/template/base/src/components/ui/nav-main.tsx +70 -0
  42. package/template/base/src/components/ui/nav-projects.tsx +97 -0
  43. package/template/base/src/components/ui/nav-secondary.tsx +37 -0
  44. package/template/base/src/components/ui/nav-user.tsx +108 -0
  45. package/template/base/src/components/ui/navigation-menu.tsx +164 -0
  46. package/template/base/src/components/ui/pagination.tsx +123 -0
  47. package/template/base/src/components/ui/popover.tsx +80 -0
  48. package/template/base/src/components/ui/progress.tsx +66 -0
  49. package/template/base/src/components/ui/radio-group.tsx +36 -0
  50. package/template/base/src/components/ui/resizable.tsx +42 -0
  51. package/template/base/src/components/ui/rich-text/ai-chat-editor.tsx +20 -0
  52. package/template/base/src/components/ui/rich-text/ai-command.tsx +90 -0
  53. package/template/base/src/components/ui/rich-text/ai-copilot.tsx +67 -0
  54. package/template/base/src/components/ui/rich-text/ai-menu.tsx +456 -0
  55. package/template/base/src/components/ui/rich-text/ai-node.tsx +42 -0
  56. package/template/base/src/components/ui/rich-text/ai-toolbar-button.tsx +29 -0
  57. package/template/base/src/components/ui/rich-text/block-draggable.tsx +187 -0
  58. package/template/base/src/components/ui/rich-text/block-selection.tsx +17 -0
  59. package/template/base/src/components/ui/rich-text/code-block-node.tsx +204 -0
  60. package/template/base/src/components/ui/rich-text/codec.ts +63 -0
  61. package/template/base/src/components/ui/rich-text/extension.ts +53 -0
  62. package/template/base/src/components/ui/rich-text/ghost-text.tsx +23 -0
  63. package/template/base/src/components/ui/rich-text/import-export-toolbar.tsx +103 -0
  64. package/template/base/src/components/ui/rich-text/link.tsx +18 -0
  65. package/template/base/src/components/ui/rich-text/list-node.tsx +65 -0
  66. package/template/base/src/components/ui/rich-text/nodes.tsx +44 -0
  67. package/template/base/src/components/ui/rich-text/plugins.ts +233 -0
  68. package/template/base/src/components/ui/rich-text/rich-text-editor.tsx +82 -0
  69. package/template/base/src/components/ui/rich-text/static.tsx +117 -0
  70. package/template/base/src/components/ui/rich-text/table-node.tsx +934 -0
  71. package/template/base/src/components/ui/rich-text/table-toolbar.tsx +232 -0
  72. package/template/base/src/components/ui/rich-text/toggle-node.tsx +36 -0
  73. package/template/base/src/components/ui/rich-text/toolbar-slots.ts +41 -0
  74. package/template/base/src/components/ui/rich-text/toolbar.tsx +668 -0
  75. package/template/base/src/components/ui/rich-text/use-ai-chat.ts +35 -0
  76. package/template/base/src/components/ui/rich-text/variable-type.ts +4 -0
  77. package/template/base/src/components/ui/rich-text/variable.tsx +97 -0
  78. package/template/base/src/components/ui/scroll-area.tsx +49 -0
  79. package/template/base/src/components/ui/select.tsx +202 -0
  80. package/template/base/src/components/ui/separator.tsx +19 -0
  81. package/template/base/src/components/ui/sheet.tsx +126 -0
  82. package/template/base/src/components/ui/sidebar.tsx +695 -0
  83. package/template/base/src/components/ui/skeleton.tsx +13 -0
  84. package/template/base/src/components/ui/slider.tsx +52 -0
  85. package/template/base/src/components/ui/sonner.tsx +50 -0
  86. package/template/base/src/components/ui/spinner.tsx +18 -0
  87. package/template/base/src/components/ui/switch.tsx +30 -0
  88. package/template/base/src/components/ui/table.tsx +89 -0
  89. package/template/base/src/components/ui/tabs.tsx +73 -0
  90. package/template/base/src/components/ui/textarea.tsx +18 -0
  91. package/template/base/src/components/ui/toggle-group.tsx +85 -0
  92. package/template/base/src/components/ui/toggle.tsx +45 -0
  93. package/template/base/src/components/ui/toolbar.tsx +451 -0
  94. package/template/base/src/components/ui/tooltip.tsx +52 -0
  95. package/template/base/src/hooks/use-mobile.ts +19 -0
  96. package/template/base/src/lib/utils.ts +6 -0
  97. package/template/base/src/routes/__root.tsx +1 -1
  98. package/template/base/src/server/auth.ts +2 -2
  99. package/template/base/src/styles/globals.css +230 -0
  100. package/template/base/vite.config.ts +15 -1
@@ -0,0 +1,451 @@
1
+ 'use client';
2
+
3
+ import { ArrowDown01Icon } from '@hugeicons/core-free-icons';
4
+ import { HugeiconsIcon } from '@hugeicons/react';
5
+ import * as React from 'react';
6
+
7
+ import { Button, buttonVariants } from '@saena-io/ui/components/button';
8
+ import {
9
+ DropdownMenu,
10
+ DropdownMenuCheckboxItem,
11
+ DropdownMenuContent,
12
+ DropdownMenuItem,
13
+ DropdownMenuRadioGroup,
14
+ DropdownMenuRadioItem,
15
+ DropdownMenuSeparator,
16
+ DropdownMenuSub,
17
+ DropdownMenuSubContent,
18
+ DropdownMenuSubTrigger,
19
+ DropdownMenuTrigger,
20
+ } from '@saena-io/ui/components/dropdown-menu';
21
+ import { cn } from '@saena-io/ui/lib/utils';
22
+
23
+ // A config-driven, responsive toolbar primitive. The host describes groups of items; this component
24
+ // renders each item as a button/menu when there's room, and folds whole groups into a single dropdown
25
+ // (items become menu entries, nested menus become submenus) when the container gets narrow. Folding is
26
+ // pure CSS via container queries — no measurement, no flicker, SSR-safe. Built on Button + DropdownMenu.
27
+ // Editor-agnostic: a rich-text toolbar, a table toolbar, etc. all build the same `ToolbarGroup[]`.
28
+
29
+ type IconSvg = React.ComponentProps<typeof HugeiconsIcon>['icon'];
30
+
31
+ /** Container-query breakpoint at which a group folds into one dropdown (Tailwind `@sm`…`@2xl`). */
32
+ export type ToolbarBreakpoint = 'sm' | 'md' | 'lg' | 'xl' | '2xl';
33
+
34
+ /** A single control: a toggle (carries `active`) or an action (no `active`). */
35
+ export interface ToolbarButtonNode {
36
+ kind: 'button';
37
+ id: string;
38
+ label: string;
39
+ icon?: IconSvg;
40
+ /** Custom glyph (e.g. a bullet shape) when a hugeicon doesn't fit. Wins over `icon`. */
41
+ iconNode?: React.ReactNode;
42
+ /** Present ⇒ a toggle (pressed when expanded, checkable when collapsed). Absent ⇒ a one-shot action. */
43
+ active?: boolean;
44
+ disabled?: boolean;
45
+ /** Render as a destructive entry when collapsed (e.g. "Delete table"). */
46
+ destructive?: boolean;
47
+ onSelect: () => void;
48
+ }
49
+
50
+ /** A dropdown of child nodes. Expanded → a trigger + menu; collapsed → a submenu. Nests arbitrarily. */
51
+ export interface ToolbarMenuNode {
52
+ kind: 'menu';
53
+ id: string;
54
+ /** The control's stable name — used for the tooltip / accessible name and the folded submenu label. */
55
+ label: string;
56
+ /** `labeled` variant only: the text shown in the trigger (e.g. the current block type). Falls back to
57
+ * `label`. Keeps the accessible name stable ("Turn into") while the trigger displays the selection. */
58
+ valueLabel?: string;
59
+ icon?: IconSvg;
60
+ active?: boolean;
61
+ /** Expanded presentation: icon-only, icon+label, a split button (quick action + chevron), or plain. */
62
+ variant?: 'icon' | 'labeled' | 'split' | 'plain';
63
+ /** Split variant only: the primary (left half) action. */
64
+ onPrimary?: () => void;
65
+ /** Items are mutually exclusive — render as a radio group with a check on the active one. */
66
+ radio?: boolean;
67
+ /** Disable the trigger / submenu (e.g. Cell / Row / Column when the caret isn't in a table). */
68
+ disabled?: boolean;
69
+ triggerClassName?: string;
70
+ items: ToolbarNode[];
71
+ }
72
+
73
+ /** Escape hatch for genuinely custom UI (a link popover, a grid picker). Supplies either/both forms. */
74
+ export interface ToolbarCustomNode {
75
+ kind: 'custom';
76
+ id: string;
77
+ /** Rendered inline in the expanded toolbar. Omit for content that only ever lives inside a menu. */
78
+ expanded?: React.ReactNode;
79
+ /** Rendered as a menu entry when the group folds. Falls back to `expanded` (fine for click-only UI). */
80
+ collapsed?: React.ReactNode;
81
+ }
82
+
83
+ /** A divider between items inside a menu. */
84
+ export interface ToolbarSeparatorNode {
85
+ kind: 'separator';
86
+ id: string;
87
+ }
88
+
89
+ export type ToolbarNode =
90
+ | ToolbarButtonNode
91
+ | ToolbarMenuNode
92
+ | ToolbarCustomNode
93
+ | ToolbarSeparatorNode;
94
+
95
+ export interface ToolbarGroup {
96
+ id: string;
97
+ label: string;
98
+ icon?: IconSvg;
99
+ /** Fold into one dropdown below this container width; omit / "never" to always stay expanded. */
100
+ collapse?: ToolbarBreakpoint | 'never';
101
+ /** Hide the group entirely (display:none) below this container width — for controls that are redundant
102
+ * on small screens (e.g. text styling, which is also in the floating selection toolbar). */
103
+ hideBelow?: ToolbarBreakpoint;
104
+ items: ToolbarNode[];
105
+ }
106
+
107
+ // Static class maps so Tailwind's JIT sees the literal classes (it can't read dynamic `@${bp}:` strings).
108
+ const SHOW_EXPANDED: Record<ToolbarBreakpoint, string> = {
109
+ sm: 'hidden @sm:flex',
110
+ md: 'hidden @md:flex',
111
+ lg: 'hidden @lg:flex',
112
+ xl: 'hidden @xl:flex',
113
+ '2xl': 'hidden @2xl:flex',
114
+ };
115
+ const SHOW_COLLAPSED: Record<ToolbarBreakpoint, string> = {
116
+ sm: 'flex @sm:hidden',
117
+ md: 'flex @md:hidden',
118
+ lg: 'flex @lg:hidden',
119
+ xl: 'flex @xl:hidden',
120
+ '2xl': 'flex @2xl:hidden',
121
+ };
122
+ // A separator shows only where BOTH neighbouring groups are still expanded (container ≥ the larger of
123
+ // their two breakpoints), so the dividers vanish as soon as groups fold into dropdowns.
124
+ const SEP_SHOW: Record<ToolbarBreakpoint, string> = {
125
+ sm: 'hidden @sm:block',
126
+ md: 'hidden @md:block',
127
+ lg: 'hidden @lg:block',
128
+ xl: 'hidden @xl:block',
129
+ '2xl': 'hidden @2xl:block',
130
+ };
131
+ // Hide a whole group below a breakpoint; `contents` at/above it so the group's content rejoins the flow.
132
+ const HIDE_BELOW: Record<ToolbarBreakpoint, string> = {
133
+ sm: 'hidden @sm:contents',
134
+ md: 'hidden @md:contents',
135
+ lg: 'hidden @lg:contents',
136
+ xl: 'hidden @xl:contents',
137
+ '2xl': 'hidden @2xl:contents',
138
+ };
139
+ const BP_RANK: Record<string, number> = { never: 0, sm: 1, md: 2, lg: 3, xl: 4, '2xl': 5 };
140
+ const RANK_BP: (ToolbarBreakpoint | 'never')[] = ['never', 'sm', 'md', 'lg', 'xl', '2xl'];
141
+ // The separator shows only where both neighbours are fully present AND expanded — at/above the largest of
142
+ // their collapse + hideBelow breakpoints — so it vanishes when a neighbour folds OR hides.
143
+ function separatorShowBp(
144
+ prev: ToolbarGroup | undefined,
145
+ group: ToolbarGroup,
146
+ ): ToolbarBreakpoint | 'never' {
147
+ const ranks = [prev?.collapse, group.collapse, prev?.hideBelow, group.hideBelow].map(
148
+ (c) => BP_RANK[c ?? 'never'] ?? 0,
149
+ );
150
+ return RANK_BP[Math.max(...ranks)] ?? 'never';
151
+ }
152
+
153
+ function Glyph({
154
+ icon,
155
+ node,
156
+ className,
157
+ }: { icon?: IconSvg; node?: React.ReactNode; className?: string }) {
158
+ if (node) return <>{node}</>;
159
+ if (!icon) return null;
160
+ return <HugeiconsIcon icon={icon} strokeWidth={2} className={className} />;
161
+ }
162
+
163
+ function Chevron() {
164
+ return (
165
+ <HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2} className="size-3 shrink-0 opacity-60" />
166
+ );
167
+ }
168
+
169
+ export function Toolbar({
170
+ groups,
171
+ className,
172
+ }: {
173
+ groups: ToolbarGroup[];
174
+ className?: string;
175
+ }) {
176
+ const visible = groups.filter((g) => g.items.length > 0);
177
+ return (
178
+ <div className={cn('@container flex flex-wrap items-center border-b px-2 py-1', className)}>
179
+ {visible.map((group, i) => (
180
+ <React.Fragment key={group.id}>
181
+ {i > 0 ? <ToolbarSeparator bp={separatorShowBp(visible[i - 1], group)} /> : null}
182
+ <ToolbarGroupView group={group} />
183
+ </React.Fragment>
184
+ ))}
185
+ </div>
186
+ );
187
+ }
188
+
189
+ function ToolbarSeparator({ bp }: { bp: ToolbarBreakpoint | 'never' }) {
190
+ return (
191
+ <span
192
+ className={cn('mx-1.5 h-6 w-px shrink-0 bg-border', bp === 'never' ? 'block' : SEP_SHOW[bp])}
193
+ aria-hidden
194
+ />
195
+ );
196
+ }
197
+
198
+ function ToolbarGroupView({ group }: { group: ToolbarGroup }) {
199
+ const inner =
200
+ !group.collapse || group.collapse === 'never' ? (
201
+ <div className="flex items-center">
202
+ {group.items.map((n) => (
203
+ <ExpandedNode key={n.id} node={n} />
204
+ ))}
205
+ </div>
206
+ ) : (
207
+ // Render both forms; container queries show exactly one based on the toolbar's own width.
208
+ <>
209
+ <div className={cn('items-center', SHOW_EXPANDED[group.collapse])}>
210
+ {group.items.map((n) => (
211
+ <ExpandedNode key={n.id} node={n} />
212
+ ))}
213
+ </div>
214
+ <div className={cn('items-center', SHOW_COLLAPSED[group.collapse])}>
215
+ <CollapsedGroup group={group} />
216
+ </div>
217
+ </>
218
+ );
219
+ // A hidden group is display:none below its breakpoint, `contents` above so its content rejoins the row.
220
+ if (group.hideBelow) return <div className={HIDE_BELOW[group.hideBelow]}>{inner}</div>;
221
+ return inner;
222
+ }
223
+
224
+ /** The whole group folded into one dropdown. */
225
+ function CollapsedGroup({ group }: { group: ToolbarGroup }) {
226
+ return (
227
+ <DropdownMenu>
228
+ <DropdownMenuTrigger
229
+ className={cn(buttonVariants({ variant: 'ghost', size: 'default' }))}
230
+ title={group.label}
231
+ aria-label={group.label}
232
+ onMouseDown={(e) => e.preventDefault()}
233
+ >
234
+ {group.icon ? (
235
+ <Glyph icon={group.icon} className="size-3.5 shrink-0" />
236
+ ) : (
237
+ <span className="text-xs">{group.label}</span>
238
+ )}
239
+ <Chevron />
240
+ </DropdownMenuTrigger>
241
+ <DropdownMenuContent align="start" className="w-auto">
242
+ {group.items.map((n) => (
243
+ <CollapsedNode key={n.id} node={n} />
244
+ ))}
245
+ </DropdownMenuContent>
246
+ </DropdownMenu>
247
+ );
248
+ }
249
+
250
+ /** A node rendered inline in the expanded toolbar. */
251
+ function ExpandedNode({ node }: { node: ToolbarNode }) {
252
+ if (node.kind === 'separator') return null; // separators only divide items inside a menu
253
+ if (node.kind === 'custom') return <>{node.expanded}</>;
254
+ if (node.kind === 'button') {
255
+ return (
256
+ <Button
257
+ type="button"
258
+ size="icon"
259
+ variant={node.active ? 'secondary' : 'ghost'}
260
+ aria-pressed={node.active}
261
+ disabled={node.disabled}
262
+ title={node.label}
263
+ aria-label={node.label}
264
+ onMouseDown={(e) => e.preventDefault()}
265
+ onClick={node.onSelect}
266
+ >
267
+ {node.icon || node.iconNode ? (
268
+ <Glyph icon={node.icon} node={node.iconNode} className="size-3.5" />
269
+ ) : (
270
+ <span className="text-xs">{node.label}</span>
271
+ )}
272
+ </Button>
273
+ );
274
+ }
275
+ return <ExpandedMenu node={node} />;
276
+ }
277
+
278
+ function ExpandedMenu({ node }: { node: ToolbarMenuNode }) {
279
+ const variant = node.variant ?? 'plain';
280
+ const triggerVariant = node.active ? 'secondary' : 'ghost';
281
+
282
+ if (variant === 'split') {
283
+ return (
284
+ <div className="inline-flex items-stretch">
285
+ <Button
286
+ type="button"
287
+ size="icon"
288
+ variant={triggerVariant}
289
+ aria-pressed={node.active}
290
+ disabled={node.disabled}
291
+ className="rounded-r-none"
292
+ title={node.label}
293
+ aria-label={node.label}
294
+ onMouseDown={(e) => e.preventDefault()}
295
+ onClick={node.onPrimary}
296
+ >
297
+ <Glyph icon={node.icon} node={undefined} className="size-3.5" />
298
+ </Button>
299
+ <DropdownMenu>
300
+ <DropdownMenuTrigger
301
+ className={cn(
302
+ buttonVariants({ variant: triggerVariant, size: 'icon' }),
303
+ 'w-5 rounded-l-none px-0',
304
+ )}
305
+ title={`${node.label} options`}
306
+ aria-label={`${node.label} options`}
307
+ onMouseDown={(e) => e.preventDefault()}
308
+ >
309
+ <Chevron />
310
+ </DropdownMenuTrigger>
311
+ <DropdownMenuContent align="start" className="w-auto">
312
+ <MenuItems node={node} />
313
+ </DropdownMenuContent>
314
+ </DropdownMenu>
315
+ </div>
316
+ );
317
+ }
318
+
319
+ return (
320
+ <DropdownMenu>
321
+ <DropdownMenuTrigger
322
+ className={cn(
323
+ buttonVariants({ variant: triggerVariant, size: 'default' }),
324
+ node.triggerClassName,
325
+ )}
326
+ title={node.label}
327
+ aria-label={node.label}
328
+ onMouseDown={(e) => e.preventDefault()}
329
+ >
330
+ <Glyph icon={node.icon} className="size-3.5 shrink-0" />
331
+ {variant === 'labeled' ? (
332
+ <span className="flex-1 truncate text-left text-xs">{node.valueLabel ?? node.label}</span>
333
+ ) : null}
334
+ <Chevron />
335
+ </DropdownMenuTrigger>
336
+ <DropdownMenuContent align="start" className="w-auto">
337
+ <MenuItems node={node} />
338
+ </DropdownMenuContent>
339
+ </DropdownMenu>
340
+ );
341
+ }
342
+
343
+ /** A menu's children, rendered as dropdown content (radio group when the menu is mutually exclusive). */
344
+ function MenuItems({ node }: { node: ToolbarMenuNode }) {
345
+ if (node.radio) {
346
+ const activeId =
347
+ node.items.find((it) => (it.kind === 'button' || it.kind === 'menu') && it.active)?.id ?? '';
348
+ return (
349
+ <DropdownMenuRadioGroup
350
+ value={activeId}
351
+ onValueChange={(val) => {
352
+ const it = node.items.find((x) => x.id === val);
353
+ if (it && it.kind === 'button') it.onSelect();
354
+ else if (it && it.kind === 'menu') it.onPrimary?.();
355
+ }}
356
+ >
357
+ {node.items.map((it) =>
358
+ it.kind === 'button' ? (
359
+ <DropdownMenuRadioItem
360
+ key={it.id}
361
+ value={it.id}
362
+ onMouseDown={(e) => e.preventDefault()}
363
+ >
364
+ {it.icon || it.iconNode ? (
365
+ <Glyph icon={it.icon} node={it.iconNode} className="size-4" />
366
+ ) : null}
367
+ {it.label}
368
+ </DropdownMenuRadioItem>
369
+ ) : (
370
+ <CollapsedNode key={it.id} node={it} />
371
+ ),
372
+ )}
373
+ </DropdownMenuRadioGroup>
374
+ );
375
+ }
376
+ return (
377
+ <>
378
+ {node.items.map((it) => (
379
+ <CollapsedNode key={it.id} node={it} />
380
+ ))}
381
+ </>
382
+ );
383
+ }
384
+
385
+ /** A node rendered as a menu entry — inside a collapsed group or inside another menu. */
386
+ function CollapsedNode({ node }: { node: ToolbarNode }) {
387
+ if (node.kind === 'separator') return <DropdownMenuSeparator />;
388
+ if (node.kind === 'custom') return <>{node.collapsed ?? node.expanded}</>;
389
+
390
+ if (node.kind === 'button') {
391
+ if (node.active !== undefined) {
392
+ return (
393
+ <DropdownMenuCheckboxItem
394
+ checked={node.active}
395
+ disabled={node.disabled}
396
+ onCheckedChange={() => node.onSelect()}
397
+ onMouseDown={(e) => e.preventDefault()}
398
+ >
399
+ {node.icon || node.iconNode ? (
400
+ <Glyph icon={node.icon} node={node.iconNode} className="size-4" />
401
+ ) : null}
402
+ {node.label}
403
+ </DropdownMenuCheckboxItem>
404
+ );
405
+ }
406
+ return (
407
+ <DropdownMenuItem
408
+ variant={node.destructive ? 'destructive' : 'default'}
409
+ disabled={node.disabled}
410
+ onClick={node.onSelect}
411
+ onMouseDown={(e) => e.preventDefault()}
412
+ >
413
+ {node.icon || node.iconNode ? (
414
+ <Glyph icon={node.icon} node={node.iconNode} className="size-4" />
415
+ ) : null}
416
+ {node.label}
417
+ </DropdownMenuItem>
418
+ );
419
+ }
420
+
421
+ // menu → submenu (this is the arbitrary nesting: a folded group can hold submenus of submenus)
422
+ return (
423
+ <DropdownMenuSub>
424
+ <DropdownMenuSubTrigger disabled={node.disabled}>
425
+ {node.icon ? <Glyph icon={node.icon} className="size-4" /> : null}
426
+ {node.label}
427
+ </DropdownMenuSubTrigger>
428
+ <DropdownMenuSubContent className="w-auto">
429
+ <MenuItems node={node} />
430
+ </DropdownMenuSubContent>
431
+ </DropdownMenuSub>
432
+ );
433
+ }
434
+
435
+ /** Render a flat list of nodes as expanded controls — no group chrome, collapse, or container query. For
436
+ * compact surfaces like a floating selection toolbar (where an `@container` would break shrink-to-fit). */
437
+ export function ToolbarItems({
438
+ items,
439
+ className,
440
+ }: {
441
+ items: ToolbarNode[];
442
+ className?: string;
443
+ }) {
444
+ return (
445
+ <div className={cn('flex items-center', className)}>
446
+ {items.map((n) => (
447
+ <ExpandedNode key={n.id} node={n} />
448
+ ))}
449
+ </div>
450
+ );
451
+ }
@@ -0,0 +1,52 @@
1
+ import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip';
2
+
3
+ import { cn } from '@saena-io/ui/lib/utils';
4
+
5
+ function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) {
6
+ return <TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} />;
7
+ }
8
+
9
+ function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
10
+ return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
11
+ }
12
+
13
+ function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
14
+ return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
15
+ }
16
+
17
+ function TooltipContent({
18
+ className,
19
+ side = 'top',
20
+ sideOffset = 4,
21
+ align = 'center',
22
+ alignOffset = 0,
23
+ children,
24
+ ...props
25
+ }: TooltipPrimitive.Popup.Props &
26
+ Pick<TooltipPrimitive.Positioner.Props, 'align' | 'alignOffset' | 'side' | 'sideOffset'>) {
27
+ return (
28
+ <TooltipPrimitive.Portal>
29
+ <TooltipPrimitive.Positioner
30
+ align={align}
31
+ alignOffset={alignOffset}
32
+ side={side}
33
+ sideOffset={sideOffset}
34
+ className="isolate z-50"
35
+ >
36
+ <TooltipPrimitive.Popup
37
+ data-slot="tooltip-content"
38
+ className={cn(
39
+ 'z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
40
+ className,
41
+ )}
42
+ {...props}
43
+ >
44
+ {children}
45
+ <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
46
+ </TooltipPrimitive.Popup>
47
+ </TooltipPrimitive.Positioner>
48
+ </TooltipPrimitive.Portal>
49
+ );
50
+ }
51
+
52
+ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -0,0 +1,19 @@
1
+ import * as React from 'react';
2
+
3
+ const MOBILE_BREAKPOINT = 768;
4
+
5
+ export function useIsMobile() {
6
+ const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
7
+
8
+ React.useEffect(() => {
9
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
10
+ const onChange = () => {
11
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
12
+ };
13
+ mql.addEventListener('change', onChange);
14
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
15
+ return () => mql.removeEventListener('change', onChange);
16
+ }, []);
17
+
18
+ return !!isMobile;
19
+ }
@@ -0,0 +1,6 @@
1
+ import { type ClassValue, clsx } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -1,4 +1,4 @@
1
- import appCss from '@saena-io/ui/globals.css?url';
1
+ import appCss from '../styles/globals.css?url';
2
2
  import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router';
3
3
 
4
4
  export const Route = createRootRoute({
@@ -3,8 +3,8 @@ import type { Actor } from '@saena-io/plugin-sdk';
3
3
  import { getCoreDb } from './core';
4
4
 
5
5
  const SECRET = process.env.BETTER_AUTH_SECRET ?? 'dev-secret-change-me-0123456789abcdef';
6
- // Must match the dev server origin (WEB_PORT) so better-auth's origin/CSRF check passes.
7
- const BASE_URL = process.env.BETTER_AUTH_URL ?? 'http://localhost:3100';
6
+ // Must match the app's origin (PORT) so better-auth's origin/CSRF check passes.
7
+ const BASE_URL = process.env.BETTER_AUTH_URL ?? 'http://localhost:3000';
8
8
 
9
9
  // Server-only: the staff (admin) better-auth audience (§10). The customer audience moved to @saena-io/customers
10
10
  // (roadmap M0); a logged-in storefront wires its instance (createCustomerAuth) post-launch. Lazy so a build