terminoir 0.4.0 → 0.5.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.
Files changed (98) hide show
  1. package/README.md +38 -28
  2. package/css/components.css +62 -0
  3. package/css/shell.css +32 -1
  4. package/dist/components/AccentSwatches.svelte +1 -1
  5. package/dist/components/AppShell.svelte +33 -5
  6. package/dist/components/AppShell.svelte.d.ts +10 -1
  7. package/dist/components/AppShellNavLink.svelte +2 -4
  8. package/dist/components/Button.svelte +32 -14
  9. package/dist/components/Button.svelte.d.ts +2 -0
  10. package/dist/components/CalendarGrid.svelte +260 -0
  11. package/dist/components/CalendarGrid.svelte.d.ts +12 -0
  12. package/dist/components/CanvasStage.svelte +81 -0
  13. package/dist/components/CanvasStage.svelte.d.ts +19 -0
  14. package/dist/components/Centered.svelte +36 -0
  15. package/dist/components/Centered.svelte.d.ts +10 -0
  16. package/dist/components/Checkbox.svelte +64 -12
  17. package/dist/components/Checkbox.svelte.d.ts +5 -0
  18. package/dist/components/Chip.svelte +90 -2
  19. package/dist/components/Chip.svelte.d.ts +8 -0
  20. package/dist/components/CommandPalette.svelte +178 -97
  21. package/dist/components/CommandPalette.svelte.d.ts +15 -8
  22. package/dist/components/ContextMenu.svelte +68 -0
  23. package/dist/components/ContextMenu.svelte.d.ts +12 -0
  24. package/dist/components/DataTable.svelte +139 -0
  25. package/dist/components/DataTable.svelte.d.ts +39 -0
  26. package/dist/components/DescriptionList.svelte +41 -0
  27. package/dist/components/DescriptionList.svelte.d.ts +8 -0
  28. package/dist/components/Dialog.svelte +17 -8
  29. package/dist/components/Disclosure.svelte +55 -0
  30. package/dist/components/Disclosure.svelte.d.ts +10 -0
  31. package/dist/components/DocumentViewer.svelte +105 -0
  32. package/dist/components/DocumentViewer.svelte.d.ts +23 -0
  33. package/dist/components/Drawer.svelte +119 -0
  34. package/dist/components/Drawer.svelte.d.ts +16 -0
  35. package/dist/components/DropdownMenu.svelte +46 -62
  36. package/dist/components/DropdownMenu.svelte.d.ts +7 -6
  37. package/dist/components/FieldMessage.svelte +9 -0
  38. package/dist/components/FieldMessage.svelte.d.ts +8 -0
  39. package/dist/components/FileButton.svelte +44 -0
  40. package/dist/components/FileButton.svelte.d.ts +16 -0
  41. package/dist/components/FormRow.svelte +62 -0
  42. package/dist/components/FormRow.svelte.d.ts +13 -0
  43. package/dist/components/IconField.svelte +80 -5
  44. package/dist/components/IconField.svelte.d.ts +2 -0
  45. package/dist/components/Inline.svelte +52 -0
  46. package/dist/components/Inline.svelte.d.ts +16 -0
  47. package/dist/components/Input.svelte +23 -3
  48. package/dist/components/Input.svelte.d.ts +9 -0
  49. package/dist/components/List.svelte +42 -0
  50. package/dist/components/List.svelte.d.ts +13 -0
  51. package/dist/components/ListItem.svelte +114 -0
  52. package/dist/components/ListItem.svelte.d.ts +17 -0
  53. package/dist/components/MenuItems.svelte +44 -0
  54. package/dist/components/MenuItems.svelte.d.ts +8 -0
  55. package/dist/components/NavSection.svelte +45 -0
  56. package/dist/components/NavSection.svelte.d.ts +12 -0
  57. package/dist/components/Page.svelte +53 -0
  58. package/dist/components/Page.svelte.d.ts +12 -0
  59. package/dist/components/PageHeader.svelte +91 -0
  60. package/dist/components/PageHeader.svelte.d.ts +15 -0
  61. package/dist/components/Prose.svelte +33 -0
  62. package/dist/components/Prose.svelte.d.ts +8 -0
  63. package/dist/components/RadioCards.svelte +68 -0
  64. package/dist/components/RadioCards.svelte.d.ts +11 -0
  65. package/dist/components/SearchPicker.svelte +171 -0
  66. package/dist/components/SearchPicker.svelte.d.ts +19 -0
  67. package/dist/components/Section.svelte +71 -0
  68. package/dist/components/Section.svelte.d.ts +12 -0
  69. package/dist/components/SegmentedControl.svelte +63 -0
  70. package/dist/components/SegmentedControl.svelte.d.ts +12 -0
  71. package/dist/components/Select.svelte +22 -4
  72. package/dist/components/Select.svelte.d.ts +9 -1
  73. package/dist/components/Spacer.svelte +6 -0
  74. package/dist/components/Spacer.svelte.d.ts +26 -0
  75. package/dist/components/Stack.svelte +37 -0
  76. package/dist/components/Stack.svelte.d.ts +12 -0
  77. package/dist/components/Switch.svelte +6 -2
  78. package/dist/components/Switch.svelte.d.ts +2 -0
  79. package/dist/components/Tabs.svelte +6 -4
  80. package/dist/components/Text.svelte +68 -0
  81. package/dist/components/Text.svelte.d.ts +15 -0
  82. package/dist/components/Textarea.svelte +19 -3
  83. package/dist/components/Textarea.svelte.d.ts +6 -0
  84. package/dist/components/TitleInput.svelte +62 -0
  85. package/dist/components/TitleInput.svelte.d.ts +10 -0
  86. package/dist/index.d.ts +35 -2
  87. package/dist/index.js +31 -0
  88. package/dist/utils/calendar.d.ts +48 -0
  89. package/dist/utils/calendar.js +105 -0
  90. package/dist/utils/commands.d.ts +22 -0
  91. package/dist/utils/commands.js +17 -0
  92. package/dist/utils/componentTypes.d.ts +41 -0
  93. package/dist/utils/componentTypes.js +1 -0
  94. package/dist/utils/listNav.d.ts +2 -0
  95. package/dist/utils/listNav.js +8 -0
  96. package/dist/utils/menu.d.ts +10 -0
  97. package/dist/utils/menu.js +1 -0
  98. package/package.json +5 -3
package/README.md CHANGED
@@ -1,24 +1,26 @@
1
1
  # Terminoir
2
2
 
3
- Shared **Liquid Glass** design tokens and **Svelte 5** UI components for Mauricio's apps (oyster-web, Bibliothek, mauted.com-style surfaces). CSS remains framework-agnostic; interactive primitives ship as tree-shakeable Svelte components with typed props.
3
+ Shared **Liquid Glass** design tokens and **Svelte 5** UI components for Mauricio's apps (Oyster and future apps). CSS remains framework-agnostic; interactive primitives ship as tree-shakeable Svelte components with typed props.
4
+
5
+ **Terminoir is the source of truth for UI and design.** Apps compose its components and keep only their own domain UI, with no generic controls or CSS of their own. If another app could use a component with different data, it belongs here. Oyster enforces this with a ratchet check; see [`docs/ui-boundary.md` in mauted/oyster](https://github.com/mauted/oyster/blob/main/docs/ui-boundary.md).
4
6
 
5
7
  ## Install
6
8
 
7
9
  ```bash
8
- # Sibling checkout (oyster-web sync:terminoir → vendor/terminoir)
9
- pnpm add ../terminoir
10
- # or
11
- npm install file:../terminoir
12
-
13
- # Git dependency
14
- pnpm add github:mauted/terminoir
10
+ pnpm add terminoir
15
11
  ```
16
12
 
13
+ To try unreleased changes in an app, `pnpm link /path/to/terminoir` from the app, then `pnpm unlink terminoir && pnpm install` when done.
14
+
15
+ ### Releasing
16
+
17
+ Bump `version` (semver: new component or prop → minor; breaking prop/CSS change → major), then `npm publish` (`prepublishOnly` builds and tests). Apps bump their `terminoir` range to adopt.
18
+
17
19
  Peer dependency: **Svelte 5** (for components). Optional **React 18–19** for `terminoir/react`. CSS-only consumers can import styles without JS.
18
20
 
19
21
  **LiquidGlassJS:** Glass primitives use [`@surdeddd/liquidglass`](https://github.com/Surdeddd/liquidglassjs) (MIT — see `THIRD-PARTY-NOTICES.md`). Terminoir presets follow **macOS Tahoe / iOS 26–style Liquid Glass**: soft rounded corners (`--radius-glass`), flat frosted surfaces with thin hairline borders, minimal shadow, no gloss bevels; physics off on large panels, with automatic **css-fallback** blur when refraction is unavailable and respect for `prefers-reduced-motion` / `prefers-reduced-transparency`.
20
22
 
21
- **CSS entry:** `@import "terminoir"` (bare) resolves to the full CSS bundle via the package `style` field and export conditions—no `dist/` build required when vendoring raw source (oyster-web Docker flow).
23
+ **CSS entry:** `@import "terminoir"` (bare) resolves to the full CSS bundle via the package `style` field and export conditions.
22
24
 
23
25
  ### Glass components (Svelte)
24
26
 
@@ -75,17 +77,6 @@ Demo: `pnpm demo:dev` → `/glass` (light/dark toggle in header).
75
77
  </div>
76
78
  ```
77
79
 
78
- ```ts
79
- // vite.config.ts — during local sibling dev (optional)
80
- import path from 'node:path';
81
-
82
- export default defineConfig({
83
- resolve: {
84
- alias: { terminoir: path.resolve('../vendor/terminoir/src/lib/index.ts') },
85
- },
86
- ssr: { noExternal: ['terminoir'] },
87
- });
88
- ```
89
80
 
90
81
  ### React (Bibliothek)
91
82
 
@@ -129,23 +120,42 @@ Import from **`terminoir`**:
129
120
 
130
121
  | Component | Notes |
131
122
  |-----------|--------|
132
- | `Button`, `IconButton` | Variants: primary, secondary, ghost, destructive, outline · sizes sm/md/lg |
133
- | `Input`, `Textarea`, `Select`, `Checkbox`, `Switch` | Labels, focus rings |
134
- | `Chip`, `Badge` | Status tones on chips |
123
+ | `Button`, `IconButton` | Variants: primary, secondary, ghost, destructive, outline · sizes sm/md/lg · `Button href` renders a link |
124
+ | `Input`, `Textarea`, `Select`, `Checkbox`, `Switch` | `hideLabel` (screen-reader only), `hint`, `error`, `quiet` (borderless until hover/focus, for inline property editing); `Input` `width` for toolbar fields |
125
+ | `Chip`, `Badge` | Status tones; `Chip` `color` dot, `onclick`, removable `onremove` |
135
126
  | `Card`, `CardGrid` | Accent tint, badge, chips, progress, meta, hover lift |
136
127
  | `Panel` | Glass panel surface |
137
128
  | `Dialog` | Modal with focus trap-ish behavior + Escape |
138
- | `Popover`, `Tooltip`, `DropdownMenu` | Lightweight overlays |
139
- | `CommandPalette` | ⌘/Ctrl+K, filter, arrow keys |
129
+ | `Popover`, `Tooltip` | Lightweight overlays |
130
+ | `DropdownMenu`, `ContextMenu` | Items with `icon` and `tone: 'danger'`; icon-only trigger; close on outside click / Escape; `ContextMenu` opens at x/y |
131
+ | `Drawer` | Side sheet with title, close button and optional footer |
132
+ | `CommandPalette` | ⌘/Ctrl+K (`hotkey`), grouped rows with icons and hints; `bind:query` + `filter={false}` + `loading` for async search; `footer` snippet |
140
133
  | `Toaster` + `toast()` | Action / undo via `action: { label, onClick }` |
141
- | `Tabs` | Keyboard-focusable tablist |
142
- | `AppShell` | `header` + `sidebar` snippets; in-flow menu button; mobile drawer with ✕ close; `AppShellNavLink` active style; resize without hairline divider |
134
+ | `Tabs`, `SegmentedControl` | Tab panels (unique ids per instance); compact radio-group toggle |
135
+ | `AppShell` | `header` + `sidebar` snippets; mobile drawer; `fitViewport` (main scrolls, for canvases), `flush`, `headerMode="mobile"`; `toggleSidebar()` / `closeMobileDrawer()` via `bind:this` |
143
136
  | `AppShellNavLink` | `href?`, `active`, nav row with active highlight |
144
137
  | `SettingsPage`, `SettingsSection` | Settings layout — intro + stacked glass section cards |
145
138
  | `EmptyState`, `Skeleton`, `Avatar`, `Kbd` | Presentation |
146
139
  | `FontPicker` | UI + code font lists, lazy `@fontsource`, persisted per `appId` |
147
140
  | `NavIcon` | `name`, optional `color` — Font Awesome 6 (`fa-*` or plain ids like `inbox`); host app loads FA CSS |
148
- | `IconField` | `bind:value` icon id picker (`ICON_PICKER_OPTIONS`) |
141
+ | `IconField` | `bind:value` icon id picker (`ICON_PICKER_OPTIONS`); `compact` opens the grid in a popover |
142
+
143
+ ### App kit (0.5)
144
+
145
+ Building blocks so apps compose screens without CSS of their own. See the demo's **App kit** page.
146
+
147
+ | Component | Notes |
148
+ |-----------|--------|
149
+ | `Page`, `PageHeader`, `Section` | Readable page column (`narrow`/`default`/`wide`/`full`, `fill` for canvases); title + meta + actions; titled section with actions |
150
+ | `Stack`, `Inline`, `Spacer`, `Centered` | Vertical / horizontal layout with `--space-*` gaps (`Inline fill` splits evenly); fill space; full-viewport centred column |
151
+ | `Text`, `Prose`, `Disclosure` | Tone/size/weight text; long-form typography; collapsible details |
152
+ | `NavSection` | Sidebar section with title and action |
153
+ | `DataTable` | Columns, `cell` snippet, clickable rows, `hideOnMobile`, empty state |
154
+ | `List`, `ListItem`, `DescriptionList`, `FormRow` | Rows with leading/trailing slots; key–value pairs; label-left form rows |
155
+ | `SearchPicker` | Debounced async combobox (`search(q)`), keyboard navigation |
156
+ | `TitleInput`, `FileButton`, `RadioCards` | Borderless heading input; button that opens a file picker; card-style radio group |
157
+ | `CalendarGrid` | Week (scrolling hours) and month grids; `CalendarEvent` with `muted`/`color`; helpers `calendarRange`, `shiftCalendar`, `eventsOn` |
158
+ | `CanvasStage`, `DocumentViewer` | Full-bleed canvas host with floating overlay and side panel; paged viewer with toolbar and highlight marks |
149
159
  | `AccentSwatches` | `bind:value` accent hex from `ACCENT_PALETTE` |
150
160
 
151
161
  ### Utilities (typed, tree-shakeable)
@@ -8,6 +8,7 @@
8
8
  }
9
9
 
10
10
  .terminoir-btn {
11
+ white-space: nowrap;
11
12
  display: inline-flex;
12
13
  align-items: center;
13
14
  justify-content: center;
@@ -282,3 +283,64 @@
282
283
  .terminoir-accent-swatches__swatch:focus-visible {
283
284
  box-shadow: var(--focus-ring);
284
285
  }
286
+
287
+ /* Visually hidden but announced by screen readers (labels of compact controls). */
288
+ .terminoir-sr-only {
289
+ position: absolute;
290
+ width: 1px;
291
+ height: 1px;
292
+ padding: 0;
293
+ margin: -1px;
294
+ overflow: hidden;
295
+ clip: rect(0, 0, 0, 0);
296
+ white-space: nowrap;
297
+ border: 0;
298
+ }
299
+
300
+ .terminoir-field__hint {
301
+ margin: 0;
302
+ font-size: 0.75rem;
303
+ color: var(--muted-foreground);
304
+ }
305
+
306
+ .terminoir-field__hint--error {
307
+ color: var(--destructive);
308
+ }
309
+
310
+ .terminoir-input[aria-invalid='true'],
311
+ .terminoir-textarea[aria-invalid='true'],
312
+ .terminoir-select[aria-invalid='true'] {
313
+ border-color: var(--destructive);
314
+ }
315
+
316
+ a.terminoir-btn {
317
+ text-decoration: none;
318
+ }
319
+
320
+ /* Quiet fields: read like plain values until hovered or focused (inline property editors). */
321
+ .terminoir-field--quiet .terminoir-input,
322
+ .terminoir-field--quiet .terminoir-textarea,
323
+ .terminoir-field--quiet .terminoir-select {
324
+ border-color: transparent;
325
+ background-color: transparent;
326
+ box-shadow: none;
327
+ padding-left: 0.5rem;
328
+ }
329
+
330
+ .terminoir-field--quiet .terminoir-select {
331
+ background-image: none;
332
+ }
333
+
334
+ .terminoir-field--quiet .terminoir-input:hover,
335
+ .terminoir-field--quiet .terminoir-textarea:hover,
336
+ .terminoir-field--quiet .terminoir-select:hover {
337
+ border-color: color-mix(in srgb, var(--foreground) 12%, transparent);
338
+ background-color: color-mix(in srgb, var(--foreground) 3%, transparent);
339
+ }
340
+
341
+ .terminoir-field--quiet .terminoir-input:focus-visible,
342
+ .terminoir-field--quiet .terminoir-textarea:focus-visible,
343
+ .terminoir-field--quiet .terminoir-select:focus-visible {
344
+ border-color: var(--ring);
345
+ background-color: color-mix(in srgb, var(--card) 38%, var(--background));
346
+ }
package/css/shell.css CHANGED
@@ -151,6 +151,37 @@
151
151
  display: none;
152
152
  }
153
153
 
154
+ .terminoir-shell--fit {
155
+ height: 100dvh;
156
+ min-height: 0;
157
+ overflow: hidden;
158
+ }
159
+
160
+ .terminoir-shell--fit .terminoir-shell__stage {
161
+ min-height: 0;
162
+ height: 100dvh;
163
+ }
164
+
165
+ .terminoir-shell--fit .terminoir-shell__main {
166
+ overflow-y: auto;
167
+ min-height: 0;
168
+ }
169
+
170
+ .terminoir-shell--fit .terminoir-shell__sidebar-inner {
171
+ min-height: 0;
172
+ overflow-y: auto;
173
+ }
174
+
175
+ .terminoir-shell--flush .terminoir-shell__main {
176
+ padding: 0;
177
+ }
178
+
179
+ @media (min-width: 769px) {
180
+ .terminoir-shell--header-mobile .terminoir-shell__main-header {
181
+ display: none;
182
+ }
183
+ }
184
+
154
185
  @media (max-width: 768px) {
155
186
  .terminoir-shell {
156
187
  grid-template-columns: minmax(0, 1fr);
@@ -210,7 +241,7 @@
210
241
  padding-right: var(--space-3);
211
242
  }
212
243
 
213
- .terminoir-shell__main {
244
+ .terminoir-shell:not(.terminoir-shell--flush) .terminoir-shell__main {
214
245
  padding-left: var(--space-3);
215
246
  padding-right: var(--space-3);
216
247
  }
@@ -14,7 +14,7 @@
14
14
  class?: string;
15
15
  } = $props();
16
16
 
17
- const groupId = `terminoir-accent-swatches-${Math.random().toString(36).slice(2, 8)}`;
17
+ const groupId = $props.id();
18
18
  </script>
19
19
 
20
20
  <fieldset class={cn('terminoir-accent-swatches glass-palette', className)} aria-labelledby={`${groupId}-label`}>
@@ -9,6 +9,9 @@
9
9
  defaultWidth = 260,
10
10
  minWidth = 180,
11
11
  maxWidth = 420,
12
+ fitViewport = false,
13
+ flush = false,
14
+ headerMode = 'always',
12
15
  class: className = '',
13
16
  header,
14
17
  sidebar,
@@ -18,6 +21,12 @@
18
21
  defaultWidth?: number;
19
22
  minWidth?: number;
20
23
  maxWidth?: number;
24
+ /** Exactly one viewport tall; the main area scrolls (needed for full-height canvases). */
25
+ fitViewport?: boolean;
26
+ /** No padding around the main area (pages bring their own, e.g. `Page`). */
27
+ flush?: boolean;
28
+ /** `mobile` hides the header bar (and its menu button) on wide screens. */
29
+ headerMode?: 'always' | 'mobile';
21
30
  class?: string;
22
31
  header?: Snippet;
23
32
  sidebar?: Snippet;
@@ -32,6 +41,20 @@
32
41
  let mobileOpen = $state(false);
33
42
  let shellEl: HTMLElement | undefined = $state();
34
43
  let dragging = $state(false);
44
+ let isMobile = $state(false);
45
+ const expanded = $derived(isMobile ? mobileOpen : desktopOpen);
46
+ const MOBILE_QUERY = '(max-width: 768px)';
47
+
48
+ $effect(() => {
49
+ const mq = window.matchMedia(MOBILE_QUERY);
50
+ const sync = () => {
51
+ isMobile = mq.matches;
52
+ if (!mq.matches) mobileOpen = false;
53
+ };
54
+ sync();
55
+ mq.addEventListener('change', sync);
56
+ return () => mq.removeEventListener('change', sync);
57
+ });
35
58
 
36
59
  $effect.pre(() => {
37
60
  if (typeof window === 'undefined') return;
@@ -48,8 +71,9 @@
48
71
  shellEl?.style.setProperty('--terminoir-sidebar-width', `${width}px`);
49
72
  }
50
73
 
51
- function toggleSidebar() {
52
- if (typeof window !== 'undefined' && window.matchMedia('(max-width: 768px)').matches) {
74
+ /** Open/close the sidebar (drawer on phones). Call through `bind:this` for keyboard shortcuts. */
75
+ export function toggleSidebar() {
76
+ if (isMobile) {
53
77
  mobileOpen = !mobileOpen;
54
78
  return;
55
79
  }
@@ -57,7 +81,8 @@
57
81
  writeStorage(appId, openKey, String(desktopOpen));
58
82
  }
59
83
 
60
- function closeMobileDrawer() {
84
+ /** Close the phone drawer, e.g. after a navigation link is chosen. */
85
+ export function closeMobileDrawer() {
61
86
  mobileOpen = false;
62
87
  }
63
88
 
@@ -87,6 +112,9 @@
87
112
  class={cn(
88
113
  'terminoir-shell',
89
114
  !desktopOpen && 'terminoir-shell--collapsed',
115
+ fitViewport && 'terminoir-shell--fit',
116
+ flush && 'terminoir-shell--flush',
117
+ headerMode === 'mobile' && 'terminoir-shell--header-mobile',
90
118
  mobileOpen && 'terminoir-shell--mobile-open',
91
119
  className,
92
120
  )}
@@ -128,8 +156,8 @@
128
156
  <button
129
157
  type="button"
130
158
  class="terminoir-shell__icon-btn terminoir-shell__menu-btn terminoir-focusable glass-chrome"
131
- aria-label={desktopOpen || mobileOpen ? 'Hide sidebar' : 'Show sidebar'}
132
- aria-expanded={desktopOpen || mobileOpen}
159
+ aria-label={isMobile ? (expanded ? 'Close menu' : 'Open menu') : expanded ? 'Hide sidebar' : 'Show sidebar'}
160
+ aria-expanded={expanded}
133
161
  onclick={toggleSidebar}
134
162
  >
135
163
  <NavIcon name="bars" />
@@ -4,11 +4,20 @@ type $$ComponentProps = {
4
4
  defaultWidth?: number;
5
5
  minWidth?: number;
6
6
  maxWidth?: number;
7
+ /** Exactly one viewport tall; the main area scrolls (needed for full-height canvases). */
8
+ fitViewport?: boolean;
9
+ /** No padding around the main area (pages bring their own, e.g. `Page`). */
10
+ flush?: boolean;
11
+ /** `mobile` hides the header bar (and its menu button) on wide screens. */
12
+ headerMode?: 'always' | 'mobile';
7
13
  class?: string;
8
14
  header?: Snippet;
9
15
  sidebar?: Snippet;
10
16
  children?: Snippet;
11
17
  };
12
- declare const AppShell: import("svelte").Component<$$ComponentProps, {}, "">;
18
+ declare const AppShell: import("svelte").Component<$$ComponentProps, {
19
+ toggleSidebar: () => void;
20
+ closeMobileDrawer: () => void;
21
+ }, "">;
13
22
  type AppShell = ReturnType<typeof AppShell>;
14
23
  export default AppShell;
@@ -17,10 +17,8 @@
17
17
  children?: Snippet;
18
18
  } & Record<string, unknown> = $props();
19
19
 
20
- const classes = cn(
21
- 'terminoir-shell__nav-link terminoir-focusable',
22
- active && 'terminoir-shell__nav-link--active',
23
- className,
20
+ const classes = $derived(
21
+ cn('terminoir-shell__nav-link terminoir-focusable', active && 'terminoir-shell__nav-link--active', className),
24
22
  );
25
23
  </script>
26
24
 
@@ -6,6 +6,7 @@
6
6
  type Size = 'sm' | 'md' | 'lg';
7
7
 
8
8
  let {
9
+ href,
9
10
  variant = 'primary',
10
11
  size = 'md',
11
12
  type = 'button',
@@ -15,6 +16,8 @@
15
16
  children,
16
17
  ...rest
17
18
  }: {
19
+ /** Render as a link styled like a button (navigation, OAuth flows). */
20
+ href?: string;
18
21
  variant?: Variant;
19
22
  size?: Size;
20
23
  type?: 'button' | 'submit' | 'reset';
@@ -25,17 +28,32 @@
25
28
  } & Record<string, unknown> = $props();
26
29
  </script>
27
30
 
28
- <button
29
- {type}
30
- {disabled}
31
- class={cn(
32
- 'terminoir-btn terminoir-focusable glass-interactive',
33
- `terminoir-btn--${variant}`,
34
- `terminoir-btn--${size}`,
35
- className,
36
- )}
37
- {onclick}
38
- {...rest}
39
- >
40
- {@render children?.()}
41
- </button>
31
+ {#if href}
32
+ <a
33
+ {href}
34
+ class={cn(
35
+ 'terminoir-btn terminoir-focusable glass-interactive',
36
+ `terminoir-btn--${variant}`,
37
+ `terminoir-btn--${size}`,
38
+ className,
39
+ )}
40
+ {...rest}
41
+ >
42
+ {@render children?.()}
43
+ </a>
44
+ {:else}
45
+ <button
46
+ {type}
47
+ {disabled}
48
+ class={cn(
49
+ 'terminoir-btn terminoir-focusable glass-interactive',
50
+ `terminoir-btn--${variant}`,
51
+ `terminoir-btn--${size}`,
52
+ className,
53
+ )}
54
+ {onclick}
55
+ {...rest}
56
+ >
57
+ {@render children?.()}
58
+ </button>
59
+ {/if}
@@ -2,6 +2,8 @@ import type { Snippet } from 'svelte';
2
2
  type Variant = 'primary' | 'secondary' | 'ghost' | 'destructive' | 'outline';
3
3
  type Size = 'sm' | 'md' | 'lg';
4
4
  type $$ComponentProps = {
5
+ /** Render as a link styled like a button (navigation, OAuth flows). */
6
+ href?: string;
5
7
  variant?: Variant;
6
8
  size?: Size;
7
9
  type?: 'button' | 'submit' | 'reset';