terminoir 0.4.0 → 0.5.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.
- package/README.md +38 -28
- package/css/components.css +62 -0
- package/css/shell.css +32 -1
- package/dist/components/AccentSwatches.svelte +1 -1
- package/dist/components/AppShell.svelte +33 -5
- package/dist/components/AppShell.svelte.d.ts +10 -1
- package/dist/components/AppShellNavLink.svelte +2 -4
- package/dist/components/Button.svelte +32 -14
- package/dist/components/Button.svelte.d.ts +2 -0
- package/dist/components/CalendarGrid.svelte +245 -0
- package/dist/components/CalendarGrid.svelte.d.ts +12 -0
- package/dist/components/CanvasStage.svelte +81 -0
- package/dist/components/CanvasStage.svelte.d.ts +19 -0
- package/dist/components/Centered.svelte +36 -0
- package/dist/components/Centered.svelte.d.ts +10 -0
- package/dist/components/Checkbox.svelte +64 -12
- package/dist/components/Checkbox.svelte.d.ts +5 -0
- package/dist/components/Chip.svelte +90 -2
- package/dist/components/Chip.svelte.d.ts +8 -0
- package/dist/components/CommandPalette.svelte +178 -97
- package/dist/components/CommandPalette.svelte.d.ts +15 -8
- package/dist/components/ContextMenu.svelte +68 -0
- package/dist/components/ContextMenu.svelte.d.ts +12 -0
- package/dist/components/DataTable.svelte +139 -0
- package/dist/components/DataTable.svelte.d.ts +39 -0
- package/dist/components/DescriptionList.svelte +41 -0
- package/dist/components/DescriptionList.svelte.d.ts +8 -0
- package/dist/components/Dialog.svelte +2 -1
- package/dist/components/Disclosure.svelte +55 -0
- package/dist/components/Disclosure.svelte.d.ts +10 -0
- package/dist/components/DocumentViewer.svelte +105 -0
- package/dist/components/DocumentViewer.svelte.d.ts +23 -0
- package/dist/components/Drawer.svelte +119 -0
- package/dist/components/Drawer.svelte.d.ts +16 -0
- package/dist/components/DropdownMenu.svelte +46 -62
- package/dist/components/DropdownMenu.svelte.d.ts +7 -6
- package/dist/components/FieldMessage.svelte +9 -0
- package/dist/components/FieldMessage.svelte.d.ts +8 -0
- package/dist/components/FileButton.svelte +44 -0
- package/dist/components/FileButton.svelte.d.ts +16 -0
- package/dist/components/FormRow.svelte +62 -0
- package/dist/components/FormRow.svelte.d.ts +13 -0
- package/dist/components/IconField.svelte +80 -5
- package/dist/components/IconField.svelte.d.ts +2 -0
- package/dist/components/Inline.svelte +52 -0
- package/dist/components/Inline.svelte.d.ts +16 -0
- package/dist/components/Input.svelte +23 -3
- package/dist/components/Input.svelte.d.ts +9 -0
- package/dist/components/List.svelte +42 -0
- package/dist/components/List.svelte.d.ts +13 -0
- package/dist/components/ListItem.svelte +114 -0
- package/dist/components/ListItem.svelte.d.ts +17 -0
- package/dist/components/MenuItems.svelte +44 -0
- package/dist/components/MenuItems.svelte.d.ts +8 -0
- package/dist/components/NavSection.svelte +45 -0
- package/dist/components/NavSection.svelte.d.ts +12 -0
- package/dist/components/Page.svelte +53 -0
- package/dist/components/Page.svelte.d.ts +12 -0
- package/dist/components/PageHeader.svelte +91 -0
- package/dist/components/PageHeader.svelte.d.ts +15 -0
- package/dist/components/Prose.svelte +33 -0
- package/dist/components/Prose.svelte.d.ts +8 -0
- package/dist/components/RadioCards.svelte +68 -0
- package/dist/components/RadioCards.svelte.d.ts +11 -0
- package/dist/components/SearchPicker.svelte +171 -0
- package/dist/components/SearchPicker.svelte.d.ts +19 -0
- package/dist/components/Section.svelte +71 -0
- package/dist/components/Section.svelte.d.ts +12 -0
- package/dist/components/SegmentedControl.svelte +63 -0
- package/dist/components/SegmentedControl.svelte.d.ts +12 -0
- package/dist/components/Select.svelte +22 -4
- package/dist/components/Select.svelte.d.ts +9 -1
- package/dist/components/Spacer.svelte +6 -0
- package/dist/components/Spacer.svelte.d.ts +26 -0
- package/dist/components/Stack.svelte +37 -0
- package/dist/components/Stack.svelte.d.ts +12 -0
- package/dist/components/Switch.svelte +6 -2
- package/dist/components/Switch.svelte.d.ts +2 -0
- package/dist/components/Tabs.svelte +6 -4
- package/dist/components/Text.svelte +68 -0
- package/dist/components/Text.svelte.d.ts +15 -0
- package/dist/components/Textarea.svelte +19 -3
- package/dist/components/Textarea.svelte.d.ts +6 -0
- package/dist/components/TitleInput.svelte +62 -0
- package/dist/components/TitleInput.svelte.d.ts +10 -0
- package/dist/index.d.ts +35 -2
- package/dist/index.js +31 -0
- package/dist/utils/calendar.d.ts +35 -0
- package/dist/utils/calendar.js +61 -0
- package/dist/utils/commands.d.ts +22 -0
- package/dist/utils/commands.js +17 -0
- package/dist/utils/componentTypes.d.ts +41 -0
- package/dist/utils/componentTypes.js +1 -0
- package/dist/utils/listNav.d.ts +2 -0
- package/dist/utils/listNav.js +8 -0
- package/dist/utils/menu.d.ts +10 -0
- package/dist/utils/menu.js +1 -0
- 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 (
|
|
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
|
-
|
|
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
|
|
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` |
|
|
134
|
-
| `Chip`, `Badge` | Status tones
|
|
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
|
|
139
|
-
| `
|
|
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` |
|
|
142
|
-
| `AppShell` | `header` + `sidebar` snippets;
|
|
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)
|
package/css/components.css
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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={
|
|
132
|
-
aria-expanded={
|
|
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 =
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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';
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn } from '../utils/cn.js';
|
|
3
|
+
import {
|
|
4
|
+
addDays,
|
|
5
|
+
calendarRange,
|
|
6
|
+
dayLayout,
|
|
7
|
+
eventsOn,
|
|
8
|
+
monthGrid,
|
|
9
|
+
sameDay,
|
|
10
|
+
startOfWeek,
|
|
11
|
+
type CalendarEvent,
|
|
12
|
+
type CalendarMode,
|
|
13
|
+
} from '../utils/calendar.js';
|
|
14
|
+
|
|
15
|
+
const HOURS = Array.from({ length: 24 }, (_, h) => h);
|
|
16
|
+
const MONTH_CELL_LIMIT = 3;
|
|
17
|
+
const SCROLL_TO_HOUR = 7;
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
mode = 'week',
|
|
21
|
+
anchor,
|
|
22
|
+
events,
|
|
23
|
+
onEventClick,
|
|
24
|
+
class: className = '',
|
|
25
|
+
}: {
|
|
26
|
+
mode?: CalendarMode;
|
|
27
|
+
/** Any date inside the week or month to show. */
|
|
28
|
+
anchor: Date;
|
|
29
|
+
events: CalendarEvent[];
|
|
30
|
+
onEventClick?: (event: CalendarEvent) => void;
|
|
31
|
+
class?: string;
|
|
32
|
+
} = $props();
|
|
33
|
+
|
|
34
|
+
const today = new Date();
|
|
35
|
+
const range = $derived(calendarRange(anchor, mode));
|
|
36
|
+
/** Seven days from the Monday that starts the visible week (or the first row of the month). */
|
|
37
|
+
const weekDays = $derived(Array.from({ length: 7 }, (_, i) => addDays(startOfWeek(range.start), i)));
|
|
38
|
+
const monthDays = $derived(monthGrid(range.start));
|
|
39
|
+
|
|
40
|
+
let scroller: HTMLDivElement | undefined = $state();
|
|
41
|
+
|
|
42
|
+
$effect(() => {
|
|
43
|
+
if (mode === 'week' && scroller) scroller.scrollTop = (scroller.scrollHeight / 24) * SCROLL_TO_HOUR;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function time(d: Date): string {
|
|
47
|
+
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hourLabel(h: number): string {
|
|
51
|
+
return new Date(2000, 0, 1, h).toLocaleTimeString(undefined, { hour: 'numeric' });
|
|
52
|
+
}
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
{#snippet chip(event: CalendarEvent, showTime: boolean)}
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
class="terminoir-cal__event terminoir-focusable"
|
|
59
|
+
class:terminoir-cal__event--muted={event.muted}
|
|
60
|
+
style:--event-color={event.color}
|
|
61
|
+
title={event.title}
|
|
62
|
+
disabled={!onEventClick}
|
|
63
|
+
onclick={() => onEventClick?.(event)}
|
|
64
|
+
>
|
|
65
|
+
{#if showTime && !event.allDay}<span class="terminoir-cal__time">{time(event.start)}</span>{/if}
|
|
66
|
+
<span class="terminoir-cal__title">{event.title}</span>
|
|
67
|
+
</button>
|
|
68
|
+
{/snippet}
|
|
69
|
+
|
|
70
|
+
{#if mode === 'week'}
|
|
71
|
+
<div class={cn('terminoir-cal terminoir-cal--week', className)}>
|
|
72
|
+
<div class="terminoir-cal__week-head">
|
|
73
|
+
<div></div>
|
|
74
|
+
{#each weekDays as day (day.getTime())}
|
|
75
|
+
<div class="terminoir-cal__day-head" class:terminoir-cal__day-head--today={sameDay(day, today)}>
|
|
76
|
+
<span>{day.toLocaleDateString(undefined, { weekday: 'short' })}</span>
|
|
77
|
+
<strong>{day.getDate()}</strong>
|
|
78
|
+
</div>
|
|
79
|
+
{/each}
|
|
80
|
+
<div class="terminoir-cal__gutter-label">all-day</div>
|
|
81
|
+
{#each weekDays as day (day.getTime())}
|
|
82
|
+
<div class="terminoir-cal__allday">
|
|
83
|
+
{#each eventsOn(events, day).filter((e) => e.allDay) as event (event.id)}{@render chip(event, false)}{/each}
|
|
84
|
+
</div>
|
|
85
|
+
{/each}
|
|
86
|
+
</div>
|
|
87
|
+
<div class="terminoir-cal__scroller" bind:this={scroller}>
|
|
88
|
+
<div class="terminoir-cal__week-body">
|
|
89
|
+
<div class="terminoir-cal__gutter">
|
|
90
|
+
{#each HOURS as h (h)}<div class="terminoir-cal__hour-label">{h === 0 ? '' : hourLabel(h)}</div>{/each}
|
|
91
|
+
</div>
|
|
92
|
+
{#each weekDays as day (day.getTime())}
|
|
93
|
+
<div class="terminoir-cal__day-col" class:terminoir-cal__day-col--today={sameDay(day, today)}>
|
|
94
|
+
{#each eventsOn(events, day).filter((e) => !e.allDay) as event (event.id)}
|
|
95
|
+
{@const layout = dayLayout(event)}
|
|
96
|
+
<div class="terminoir-cal__timed" style:top={`${layout.top}%`} style:height={`${layout.height}%`}>
|
|
97
|
+
{@render chip(event, true)}
|
|
98
|
+
</div>
|
|
99
|
+
{/each}
|
|
100
|
+
</div>
|
|
101
|
+
{/each}
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
{:else}
|
|
106
|
+
<div class={cn('terminoir-cal terminoir-cal--month', className)}>
|
|
107
|
+
{#each weekDays as day (day.getTime())}
|
|
108
|
+
<div class="terminoir-cal__dow">{day.toLocaleDateString(undefined, { weekday: 'short' })}</div>
|
|
109
|
+
{/each}
|
|
110
|
+
{#each monthDays as day (day.getTime())}
|
|
111
|
+
{@const dayEvents = eventsOn(events, day)}
|
|
112
|
+
<div class="terminoir-cal__cell" class:terminoir-cal__cell--outside={day.getMonth() !== range.start.getMonth()}>
|
|
113
|
+
<span class="terminoir-cal__daynum" class:terminoir-cal__daynum--today={sameDay(day, today)}>{day.getDate()}</span>
|
|
114
|
+
{#each dayEvents.slice(0, MONTH_CELL_LIMIT) as event (event.id)}{@render chip(event, true)}{/each}
|
|
115
|
+
{#if dayEvents.length > MONTH_CELL_LIMIT}
|
|
116
|
+
<span class="terminoir-cal__more">+{dayEvents.length - MONTH_CELL_LIMIT} more</span>
|
|
117
|
+
{/if}
|
|
118
|
+
</div>
|
|
119
|
+
{/each}
|
|
120
|
+
</div>
|
|
121
|
+
{/if}
|
|
122
|
+
|
|
123
|
+
<style>
|
|
124
|
+
.terminoir-cal {
|
|
125
|
+
--cal-line: color-mix(in srgb, var(--border) 70%, transparent);
|
|
126
|
+
--cal-hour: 3rem;
|
|
127
|
+
font-size: 0.8125rem;
|
|
128
|
+
border: 1px solid var(--border);
|
|
129
|
+
border-radius: var(--radius);
|
|
130
|
+
overflow: hidden;
|
|
131
|
+
min-width: 0;
|
|
132
|
+
}
|
|
133
|
+
/* Fixed viewport-relative height so the hours scroll inside the grid (it opens near 7am). */
|
|
134
|
+
.terminoir-cal--week { display: flex; flex-direction: column; height: min(72dvh, 52rem); min-height: 24rem; }
|
|
135
|
+
.terminoir-cal__week-head,
|
|
136
|
+
.terminoir-cal__week-body {
|
|
137
|
+
display: grid;
|
|
138
|
+
grid-template-columns: 3.5rem repeat(7, minmax(0, 1fr));
|
|
139
|
+
}
|
|
140
|
+
.terminoir-cal__week-head { border-bottom: 1px solid var(--border); }
|
|
141
|
+
.terminoir-cal__day-head {
|
|
142
|
+
display: flex;
|
|
143
|
+
align-items: baseline;
|
|
144
|
+
justify-content: center;
|
|
145
|
+
gap: var(--space-1);
|
|
146
|
+
padding: var(--space-2) 0;
|
|
147
|
+
color: var(--muted-foreground);
|
|
148
|
+
}
|
|
149
|
+
.terminoir-cal__day-head strong { font-size: 1rem; color: var(--foreground); font-weight: 600; }
|
|
150
|
+
.terminoir-cal__day-head--today strong { color: var(--primary); }
|
|
151
|
+
.terminoir-cal__gutter-label {
|
|
152
|
+
font-size: 0.6875rem;
|
|
153
|
+
color: var(--muted-foreground);
|
|
154
|
+
text-align: right;
|
|
155
|
+
padding: var(--space-1) var(--space-2);
|
|
156
|
+
}
|
|
157
|
+
.terminoir-cal__allday {
|
|
158
|
+
display: flex;
|
|
159
|
+
flex-direction: column;
|
|
160
|
+
gap: 2px;
|
|
161
|
+
padding: 2px;
|
|
162
|
+
min-height: 1.75rem;
|
|
163
|
+
border-left: 1px solid var(--cal-line);
|
|
164
|
+
}
|
|
165
|
+
.terminoir-cal__scroller { flex: 1; overflow-y: auto; min-height: 0; }
|
|
166
|
+
.terminoir-cal__week-body { height: calc(var(--cal-hour) * 24); }
|
|
167
|
+
.terminoir-cal__gutter { display: grid; grid-template-rows: repeat(24, var(--cal-hour)); }
|
|
168
|
+
.terminoir-cal__hour-label {
|
|
169
|
+
font-size: 0.6875rem;
|
|
170
|
+
color: var(--muted-foreground);
|
|
171
|
+
text-align: right;
|
|
172
|
+
padding-right: var(--space-2);
|
|
173
|
+
transform: translateY(-0.5em);
|
|
174
|
+
}
|
|
175
|
+
.terminoir-cal__day-col {
|
|
176
|
+
position: relative;
|
|
177
|
+
border-left: 1px solid var(--cal-line);
|
|
178
|
+
background-image: linear-gradient(var(--cal-line) 1px, transparent 1px);
|
|
179
|
+
background-size: 100% var(--cal-hour);
|
|
180
|
+
}
|
|
181
|
+
.terminoir-cal__day-col--today { background-color: color-mix(in srgb, var(--primary) 4%, transparent); }
|
|
182
|
+
.terminoir-cal__timed { position: absolute; left: 2px; right: 2px; display: flex; }
|
|
183
|
+
.terminoir-cal__timed .terminoir-cal__event { align-items: flex-start; height: 100%; flex-direction: column; gap: 0; }
|
|
184
|
+
.terminoir-cal__event {
|
|
185
|
+
--event-color: var(--primary);
|
|
186
|
+
display: flex;
|
|
187
|
+
align-items: center;
|
|
188
|
+
gap: var(--space-1);
|
|
189
|
+
width: 100%;
|
|
190
|
+
min-width: 0;
|
|
191
|
+
padding: 0.15rem 0.35rem;
|
|
192
|
+
border: 0;
|
|
193
|
+
border-left: 3px solid var(--event-color);
|
|
194
|
+
border-radius: calc(var(--radius-control) - 0.3rem);
|
|
195
|
+
background: color-mix(in srgb, var(--event-color) 16%, var(--background));
|
|
196
|
+
color: var(--foreground);
|
|
197
|
+
font: inherit;
|
|
198
|
+
font-size: 0.75rem;
|
|
199
|
+
text-align: left;
|
|
200
|
+
overflow: hidden;
|
|
201
|
+
cursor: pointer;
|
|
202
|
+
}
|
|
203
|
+
.terminoir-cal__event:disabled { cursor: default; }
|
|
204
|
+
.terminoir-cal__event--muted { opacity: 0.6; background: transparent; }
|
|
205
|
+
.terminoir-cal__time { color: var(--muted-foreground); flex-shrink: 0; }
|
|
206
|
+
.terminoir-cal__title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
207
|
+
.terminoir-cal--month { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
|
208
|
+
.terminoir-cal__dow {
|
|
209
|
+
padding: var(--space-2);
|
|
210
|
+
font-size: 0.6875rem;
|
|
211
|
+
font-weight: 600;
|
|
212
|
+
letter-spacing: 0.06em;
|
|
213
|
+
text-transform: uppercase;
|
|
214
|
+
color: var(--muted-foreground);
|
|
215
|
+
border-bottom: 1px solid var(--border);
|
|
216
|
+
}
|
|
217
|
+
.terminoir-cal__cell {
|
|
218
|
+
display: flex;
|
|
219
|
+
flex-direction: column;
|
|
220
|
+
gap: 2px;
|
|
221
|
+
min-height: 6rem;
|
|
222
|
+
padding: var(--space-1);
|
|
223
|
+
border-top: 1px solid var(--cal-line);
|
|
224
|
+
border-left: 1px solid var(--cal-line);
|
|
225
|
+
min-width: 0;
|
|
226
|
+
}
|
|
227
|
+
.terminoir-cal__cell:nth-child(7n + 1) { border-left: 0; }
|
|
228
|
+
.terminoir-cal__cell--outside { background: color-mix(in srgb, var(--foreground) 2%, transparent); }
|
|
229
|
+
.terminoir-cal__cell--outside .terminoir-cal__daynum { color: var(--muted-foreground); }
|
|
230
|
+
.terminoir-cal__daynum {
|
|
231
|
+
align-self: flex-end;
|
|
232
|
+
min-width: 1.5rem;
|
|
233
|
+
padding: 0 0.3rem;
|
|
234
|
+
border-radius: 999px;
|
|
235
|
+
font-size: 0.75rem;
|
|
236
|
+
text-align: center;
|
|
237
|
+
}
|
|
238
|
+
.terminoir-cal__daynum--today { background: var(--primary); color: var(--primary-foreground); }
|
|
239
|
+
.terminoir-cal__more { font-size: 0.6875rem; color: var(--muted-foreground); padding-left: 0.35rem; }
|
|
240
|
+
@media (max-width: 700px) {
|
|
241
|
+
.terminoir-cal__day-head span { display: none; }
|
|
242
|
+
.terminoir-cal__cell { min-height: 4rem; }
|
|
243
|
+
.terminoir-cal__cell .terminoir-cal__event .terminoir-cal__time { display: none; }
|
|
244
|
+
}
|
|
245
|
+
</style>
|