@orochibraru/ui 1.0.0 → 1.0.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 (112) hide show
  1. package/README.md +84 -3
  2. package/dist/components/blocks/accent-toggle.svelte +7 -3
  3. package/dist/components/blocks/accent-toggle.svelte.d.ts +1 -1
  4. package/dist/components/blocks/bottom-nav.svelte +1 -1
  5. package/dist/components/blocks/command-palette.svelte +268 -0
  6. package/dist/components/blocks/command-palette.svelte.d.ts +37 -0
  7. package/dist/components/blocks/error-page.svelte +23 -7
  8. package/dist/components/blocks/error-page.svelte.d.ts +4 -0
  9. package/dist/components/blocks/fab.svelte +1 -1
  10. package/dist/components/blocks/install-prompt.svelte +14 -8
  11. package/dist/components/blocks/install-prompt.svelte.d.ts +2 -1
  12. package/dist/components/blocks/music-player/labelled-slider.svelte +3 -12
  13. package/dist/components/blocks/music-player/labelled-slider.svelte.d.ts +1 -2
  14. package/dist/components/blocks/music-player.svelte +13 -12
  15. package/dist/components/blocks/otp-field.svelte +77 -33
  16. package/dist/components/blocks/otp-field.svelte.d.ts +7 -0
  17. package/dist/components/blocks/password-input.svelte +56 -16
  18. package/dist/components/blocks/password-input.svelte.d.ts +3 -0
  19. package/dist/components/blocks/stepper/stepper-phone-header.svelte +93 -0
  20. package/dist/components/blocks/stepper/stepper-phone-header.svelte.d.ts +13 -0
  21. package/dist/components/blocks/stepper.svelte +175 -0
  22. package/dist/components/blocks/stepper.svelte.d.ts +30 -0
  23. package/dist/components/blocks/time-range-picker.svelte +229 -0
  24. package/dist/components/blocks/time-range-picker.svelte.d.ts +33 -0
  25. package/dist/components/blocks/time-series-chart/data-table.svelte +43 -0
  26. package/dist/components/blocks/time-series-chart/data-table.svelte.d.ts +15 -0
  27. package/dist/components/blocks/time-series-chart/legend.svelte +23 -0
  28. package/dist/components/blocks/time-series-chart/legend.svelte.d.ts +10 -0
  29. package/dist/components/blocks/time-series-chart/marks.svelte +118 -0
  30. package/dist/components/blocks/time-series-chart/marks.svelte.d.ts +29 -0
  31. package/dist/components/blocks/time-series-chart/messages.d.ts +13 -0
  32. package/dist/components/blocks/time-series-chart/messages.js +12 -0
  33. package/dist/components/blocks/time-series-chart/readout.svelte +44 -0
  34. package/dist/components/blocks/time-series-chart/readout.svelte.d.ts +15 -0
  35. package/dist/components/blocks/time-series-chart/scale.d.ts +24 -0
  36. package/dist/components/blocks/time-series-chart/scale.js +136 -0
  37. package/dist/components/blocks/time-series-chart/time.d.ts +7 -0
  38. package/dist/components/blocks/time-series-chart/time.js +96 -0
  39. package/dist/components/blocks/time-series-chart.svelte +492 -0
  40. package/dist/components/blocks/time-series-chart.svelte.d.ts +18 -0
  41. package/dist/components/blocks/top-loading-bar.svelte +1 -1
  42. package/dist/components/blocks/video-player/center-controls.svelte +7 -7
  43. package/dist/components/blocks/video-player/control-bar.svelte +41 -35
  44. package/dist/components/blocks/video-player/control-button.svelte +1 -1
  45. package/dist/components/blocks/video-player/option-menu.svelte +1 -1
  46. package/dist/components/blocks/video-player/scrub-bar.svelte +39 -12
  47. package/dist/components/blocks/video-player/scrub-bar.svelte.d.ts +2 -0
  48. package/dist/components/blocks/video-player.svelte +2 -2
  49. package/dist/components/kit/accordion.svelte +70 -0
  50. package/dist/components/kit/accordion.svelte.d.ts +37 -0
  51. package/dist/components/kit/avatar-group.svelte +84 -0
  52. package/dist/components/kit/avatar-group.svelte.d.ts +14 -0
  53. package/dist/components/kit/bar-list.svelte +104 -0
  54. package/dist/components/kit/bar-list.svelte.d.ts +22 -0
  55. package/dist/components/kit/breadcrumbs.svelte +102 -0
  56. package/dist/components/kit/breadcrumbs.svelte.d.ts +14 -0
  57. package/dist/components/kit/checkbox-list.svelte +98 -0
  58. package/dist/components/kit/checkbox-list.svelte.d.ts +15 -0
  59. package/dist/components/kit/checkbox.svelte +84 -0
  60. package/dist/components/kit/checkbox.svelte.d.ts +8 -0
  61. package/dist/components/kit/confirm-dialog.svelte +127 -0
  62. package/dist/components/kit/confirm-dialog.svelte.d.ts +19 -0
  63. package/dist/components/kit/data-table.svelte +280 -0
  64. package/dist/components/kit/data-table.svelte.d.ts +46 -0
  65. package/dist/components/kit/date-field.svelte +210 -0
  66. package/dist/components/kit/date-field.svelte.d.ts +14 -0
  67. package/dist/components/kit/field-frame.svelte +71 -0
  68. package/dist/components/kit/field-frame.svelte.d.ts +26 -0
  69. package/dist/components/kit/fieldset-frame.svelte +69 -0
  70. package/dist/components/kit/fieldset-frame.svelte.d.ts +14 -0
  71. package/dist/components/kit/file-dropzone/files.d.ts +14 -0
  72. package/dist/components/kit/file-dropzone/files.js +69 -0
  73. package/dist/components/kit/file-dropzone.svelte +253 -0
  74. package/dist/components/kit/file-dropzone.svelte.d.ts +25 -0
  75. package/dist/components/kit/form.svelte +335 -0
  76. package/dist/components/kit/form.svelte.d.ts +61 -0
  77. package/dist/components/kit/input.svelte +73 -0
  78. package/dist/components/kit/input.svelte.d.ts +9 -0
  79. package/dist/components/kit/menu.svelte +163 -0
  80. package/dist/components/kit/menu.svelte.d.ts +28 -0
  81. package/dist/components/kit/pagination.svelte +134 -0
  82. package/dist/components/kit/pagination.svelte.d.ts +17 -0
  83. package/dist/components/kit/radio-list.svelte +131 -0
  84. package/dist/components/kit/radio-list.svelte.d.ts +15 -0
  85. package/dist/components/kit/segmented-control/segment.svelte +47 -0
  86. package/dist/components/kit/segmented-control/segment.svelte.d.ts +11 -0
  87. package/dist/components/kit/segmented-control.svelte +70 -0
  88. package/dist/components/kit/segmented-control.svelte.d.ts +16 -0
  89. package/dist/components/kit/select.svelte +244 -0
  90. package/dist/components/kit/select.svelte.d.ts +26 -0
  91. package/dist/components/kit/status-badge.svelte +54 -0
  92. package/dist/components/kit/status-badge.svelte.d.ts +31 -0
  93. package/dist/components/kit/switch.svelte +84 -0
  94. package/dist/components/kit/switch.svelte.d.ts +8 -0
  95. package/dist/components/kit/tabs.svelte +86 -0
  96. package/dist/components/kit/tabs.svelte.d.ts +20 -0
  97. package/dist/components/kit/textarea.svelte +92 -0
  98. package/dist/components/kit/textarea.svelte.d.ts +11 -0
  99. package/dist/components/ui/drawer/drawer-content.svelte +1 -1
  100. package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
  101. package/dist/components/ui/sidebar/sidebar-input.svelte.d.ts +1 -1
  102. package/dist/components/ui/sonner/sonner.svelte +2 -4
  103. package/dist/mcp-content.json +1 -1
  104. package/dist/mcp.js +11 -11
  105. package/dist/server/hooks.d.ts +9 -0
  106. package/dist/server/hooks.js +41 -0
  107. package/dist/server/logger.d.ts +39 -0
  108. package/dist/server/logger.js +144 -0
  109. package/dist/server/telemetry.d.ts +23 -0
  110. package/dist/server/telemetry.js +82 -0
  111. package/dist/theme.css +18 -6
  112. package/package.json +40 -1
package/README.md CHANGED
@@ -55,6 +55,66 @@ spinner, switch, table, tabs, textarea, toggle, toggle-group, tooltip.
55
55
  `input` turns password managers off on fields that don't set `autocomplete`, so
56
56
  a search box never gets a login suggestion.
57
57
 
58
+ ## Components
59
+
60
+ Under `@orochibraru/ui/components/<name>`: one import, data in. Where a
61
+ primitive is assembled from parts, a component takes an array or an object and
62
+ renders the whole thing, phones included.
63
+
64
+ ```svelte
65
+ <script lang="ts">
66
+ import Input from "@orochibraru/ui/components/input";
67
+ </script>
68
+
69
+ <Input label="Email" name="email" type="email" required bind:value={email} />
70
+ ```
71
+
72
+ Every form component takes the same field props: `name`, `label`, `description`,
73
+ `error` (a string or the array a form action returns), `required`, `disabled`,
74
+ `id` and a bindable `value` (`checked` for `checkbox` and `switch`, `files` for
75
+ `file-dropzone`). The label, the description or the error, and the ARIA wiring
76
+ between them come with it.
77
+
78
+ `form` renders them from an array and posts to a form action. A failed action
79
+ that returns `fail(400, { errors: { field: ["message"] } })` puts each message
80
+ under its field; a toast narrates the submit:
81
+
82
+ ```svelte
83
+ <Form
84
+ action="?/create"
85
+ fields={[
86
+ { name: "name", label: "Project name", required: true },
87
+ { name: "role", type: "select", label: "Role", options: roles },
88
+ { name: "notify", type: "switch", label: "Notify the team" },
89
+ ]}
90
+ messages={{ submit: "Create project", success: "Project created." }}
91
+ />
92
+ ```
93
+
94
+ | Component | What it does |
95
+ | ------------------- | ---------------------------------------------------------------------------------------------- |
96
+ | `accordion` | Questions and answers from an array: plain text or a snippet per item. |
97
+ | `avatar-group` | Overlapping avatars with initials, a +N count and names in tooltips. |
98
+ | `bar-list` | A ranked list with proportional bars, links and a show-more toggle. |
99
+ | `breadcrumbs` | A trail of pages from an array, collapsing the middle into a menu on phones. |
100
+ | `checkbox` | One checkbox with its label and description, the whole row tappable. |
101
+ | `checkbox-list` | Options in, a labelled group of checkboxes out, posting every checked value. |
102
+ | `confirm-dialog` | Ask before acting: waits on an async confirm, a drawer on phones. |
103
+ | `data-table` | Columns and rows in, a sortable table out: cards on phones, links per row. |
104
+ | `date-field` | A date with its label: calendar in a popover, a drawer on phones, ISO value. |
105
+ | `file-dropzone` | Drop or pick files, folders too: type and size limits, a list to review, posted with the form. |
106
+ | `form` | Fields from an array, laid out and posted to a form action, with errors under each field. |
107
+ | `input` | A text field with its label, description and error in one: passwords get a reveal button. |
108
+ | `menu` | Actions from an array: a dropdown on desktop, a drawer of big rows on phones. |
109
+ | `pagination` | Page numbers from a total and a page size, prev and next on phones. |
110
+ | `radio-list` | Pick one option from a list or from cards, with a legend and errors. |
111
+ | `segmented-control` | A single choice as equal segments that fold to icons when space runs out. |
112
+ | `select` | Options in, a labelled select out: searchable, and a drawer on phones. |
113
+ | `status-badge` | A status pill with a tone, a dot and an optional pulse for live states. |
114
+ | `switch` | A labelled switch row that posts true when on. |
115
+ | `tabs` | Tabs from an array, scrolling sideways when they run out of room. |
116
+ | `textarea` | A multi-line field with its label, error and an optional character counter. |
117
+
58
118
  ## Blocks
59
119
 
60
120
  Under `@orochibraru/ui/blocks/<name>`:
@@ -64,6 +124,7 @@ Under `@orochibraru/ui/blocks/<name>`:
64
124
  | `accent-toggle` | The accent presets: swatch strip on desktop, labelled rows on phones. |
65
125
  | `auth-shell` | Centered sign-in layout: heading, optional `brand` and `footer` snippets. |
66
126
  | `bottom-nav` | Phone tab bar of top-level destinations, the current one lit. |
127
+ | `command-palette` | ⌘K search over pages and actions: dialog on desktop, tall drawer on phones. |
67
128
  | `copy-field` | Monospace value with a copy button and a promise toast. |
68
129
  | `document-title` | `<title>` from the page title, suffixed with `appName`. |
69
130
  | `empty-state` | Icon, title, subtitle and an action slot for an empty list. |
@@ -80,6 +141,7 @@ Under `@orochibraru/ui/blocks/<name>`:
80
141
  | `responsive-sheet` | A dialog on desktop, a bottom drawer under 768px. |
81
142
  | `setting-switch` | Labelled switch row for settings forms, posting `name=true` when on. |
82
143
  | `stat-card` | Glass card with an icon, a value and a hint, optionally a link. |
144
+ | `stepper` | Multi-step flow: markers on desktop, progress bar and current step on phones. |
83
145
  | `tab-nav` | Pill tab bar of links, the current page's tab highlighted. |
84
146
  | `tab-section` | Layout for a tabbed section: tabs on desktop, nav cards on phones. |
85
147
  | `theme-picker` | System, light and dark as radio cards, for an appearance page. |
@@ -129,6 +191,16 @@ them, which apps put in their root layout.
129
191
  - `@orochibraru/ui/password`: `passwordRules` and `passwordScore`, the rules a
130
192
  new password must pass and a 0 to 4 strength, the same on the form and the
131
193
  server. `new-password-fields` renders them; `ruleLabels` translates them.
194
+ - `@orochibraru/ui/server/logger`: `Logger` writes scoped lines as coloured text
195
+ or JSON lines (`LOG_FORMAT=json`) at `LOG_LEVEL` or above; `configureLogging`
196
+ sets both in code. JSON lines carry the active trace and span ids.
197
+ - `@orochibraru/ui/server/telemetry`: `Telemetry` starts OpenTelemetry tracing
198
+ over OTLP from `instrumentation.server.ts`; with `tracing: { server: true }`
199
+ in the kit options, SvelteKit's own spans go with it. Its SDK packages are
200
+ optional peer dependencies, installed only by apps that trace.
201
+ - `@orochibraru/ui/server/hooks`: `requestLog` logs one line per request and
202
+ `unexpectedErrors` logs a crash, records it on the span and returns its trace
203
+ id as the error id.
132
204
  - `@orochibraru/ui/haptics`: `haptic("tap" | "success" | "error")`, a no-op
133
205
  where vibration isn't supported.
134
206
 
@@ -148,8 +220,8 @@ On `<html>`:
148
220
 
149
221
  - `class="dark"` switches to the dark palette, `data-dark-variant="amoled"`
150
222
  makes it pure black.
151
- - `data-accent` picks a preset: `bordeaux`, `green`, `blue`, `violet`, `rose`,
152
- `orange`, `amber`, `cyan` or `neutral`.
223
+ - Bordeaux is the default. `data-accent` picks a preset: `bordeaux`, `green`,
224
+ `blue`, `violet`, `rose`, `orange`, `amber`, `cyan` or `neutral`.
153
225
  - `data-font` (`system`, `serif`, `mono`) and `data-corners="square"` change the
154
226
  type and the radius.
155
227
 
@@ -168,13 +240,22 @@ bun install
168
240
  bun run dev
169
241
  ```
170
242
 
171
- The dev server is the docs site: a page per block, primitive and module, with
243
+ The dev server is the docs site: a page per block, component and module, with
172
244
  the component live and the code that renders it. That code is the example file
173
245
  itself, its `#lib` imports rewritten to the package subpaths, so the two can't
174
246
  drift apart. A file named `<slug>.<name>` next to an example is a companion,
175
247
  shown after it: the layout, action or page an app needs for what the preview
176
248
  can't hold. An example without a script is a note for the preview only.
177
249
 
250
+ Components built mostly for phones (drawers, steppers, cards, the media players)
251
+ also show on their page inside an iPhone or Android frame: an iframe of a bare
252
+ preview route at the device's real width, so phone layouts and drawers behave as
253
+ they would on the device.
254
+
255
+ The primitives are shadcn-svelte's components, so the site links to its docs
256
+ instead of repeating them; llms.txt and the MCP server still carry their usage
257
+ code. The privacy page lists what the site stores and loads, and the credits.
258
+
178
259
  Every component page can be copied as Markdown, viewed as Markdown, exported as
179
260
  a PDF (through the browser's print dialog, in the light theme), or opened in
180
261
  Claude or ChatGPT. The list pages filter by category.
@@ -16,8 +16,8 @@
16
16
 
17
17
  const defaultMessages = {
18
18
  label: "Accent",
19
- blue: "Blue",
20
19
  bordeaux: "Bordeaux",
20
+ blue: "Blue",
21
21
  green: "Green",
22
22
  violet: "Violet",
23
23
  rose: "Rose",
@@ -33,11 +33,15 @@
33
33
  messages?: Partial<typeof defaultMessages>;
34
34
  }
35
35
 
36
- let { value = $bindable("blue"), onchange, messages = {} }: Props = $props();
36
+ let {
37
+ value = $bindable("bordeaux"),
38
+ onchange,
39
+ messages = {},
40
+ }: Props = $props();
37
41
 
38
42
  const accents: Accent[] = [
39
- "blue",
40
43
  "bordeaux",
44
+ "blue",
41
45
  "green",
42
46
  "violet",
43
47
  "rose",
@@ -3,8 +3,8 @@ declare const AccentToggle: import("svelte").Component<{
3
3
  onchange?: (accent: "blue" | "bordeaux" | "green" | "violet" | "rose" | "orange" | "amber" | "cyan" | "neutral") => void;
4
4
  messages?: Partial<{
5
5
  label: string;
6
- blue: string;
7
6
  bordeaux: string;
7
+ blue: string;
8
8
  green: string;
9
9
  violet: string;
10
10
  rose: string;
@@ -42,7 +42,7 @@
42
42
  <svelte:window {onscroll} />
43
43
 
44
44
  <nav
45
- class="group/nav fixed inset-x-0 bottom-0 z-40 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] md:hidden print:hidden"
45
+ class="group/nav fixed inset-x-0 bottom-0 z-40 px-4 pb-[max(0.75rem,var(--safe-bottom))] md:hidden print:hidden"
46
46
  style="view-transition-name: tab-bar"
47
47
  aria-label={label}
48
48
  data-compact={compact || undefined}
@@ -0,0 +1,268 @@
1
+ <script lang="ts" module>
2
+ import type { Component } from "svelte";
3
+
4
+ export interface CommandItem {
5
+ label: string;
6
+ href?: string;
7
+ onSelect?: () => void;
8
+ icon?: Component<{ class?: string }>;
9
+ description?: string;
10
+ shortcut?: string;
11
+ keywords?: string[];
12
+ }
13
+
14
+ export interface CommandGroup {
15
+ heading: string;
16
+ items: CommandItem[];
17
+ }
18
+ </script>
19
+
20
+ <script lang="ts">
21
+ import { goto } from "$app/navigation";
22
+ import SearchIcon from "@lucide/svelte/icons/search";
23
+ import SearchXIcon from "@lucide/svelte/icons/search-x";
24
+ import { onMount } from "svelte";
25
+ import { MediaQuery } from "svelte/reactivity";
26
+
27
+ import * as Command from "../ui/command/index.js";
28
+ import * as Drawer from "../ui/drawer/index.js";
29
+ import { haptic } from "../../haptics.js";
30
+
31
+ const defaultMessages = {
32
+ trigger: "Search…",
33
+ label: "Search",
34
+ title: "Command palette",
35
+ description: "Search for a page or run a command",
36
+ empty: "No results.",
37
+ navigate: "to navigate",
38
+ open: "to open",
39
+ close: "to close",
40
+ };
41
+
42
+ interface Props {
43
+ groups: CommandGroup[];
44
+ open?: boolean;
45
+ placeholder?: string;
46
+ shortcut?: boolean;
47
+ trigger?: boolean;
48
+ filter?: boolean;
49
+ onsearch?: (term: string) => void;
50
+ messages?: Partial<typeof defaultMessages>;
51
+ }
52
+
53
+ let {
54
+ groups,
55
+ open = $bindable(false),
56
+ placeholder = "Type a command or search…",
57
+ shortcut = true,
58
+ trigger = true,
59
+ filter = true,
60
+ onsearch,
61
+ messages = {},
62
+ }: Props = $props();
63
+
64
+ const desktop = new MediaQuery("(min-width: 768px)");
65
+ const text = $derived({ ...defaultMessages, ...messages });
66
+
67
+ let search = $state("");
68
+ let mac = $state(false);
69
+
70
+ onMount(() => {
71
+ mac = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
72
+ });
73
+
74
+ /** Every typed word has to appear in the label or a keyword; label matches rank first. */
75
+ function matchWords(value: string, term: string, keywords: string[] = []) {
76
+ const words = term.toLowerCase().split(/\s+/).filter(Boolean);
77
+ const label = value.toLowerCase();
78
+ const haystack = [label, ...keywords].join(" ").toLowerCase();
79
+ if (!words.every((word) => haystack.includes(word))) {
80
+ return 0;
81
+ }
82
+ return words.every((word) => label.includes(word)) ? 1 : 0.5;
83
+ }
84
+
85
+ function setSearch(term: string) {
86
+ search = term;
87
+ onsearch?.(term);
88
+ }
89
+
90
+ function isTyping(target: EventTarget | null): boolean {
91
+ return (
92
+ target instanceof HTMLElement &&
93
+ (target.isContentEditable || target.matches("input, textarea, select"))
94
+ );
95
+ }
96
+
97
+ function onkeydown(event: KeyboardEvent) {
98
+ if (
99
+ shortcut &&
100
+ event.key.toLowerCase() === "k" &&
101
+ (mac ? event.metaKey : event.ctrlKey) &&
102
+ (open || !isTyping(event.target))
103
+ ) {
104
+ event.preventDefault();
105
+ open = !open;
106
+ }
107
+ }
108
+
109
+ function select(item: CommandItem) {
110
+ haptic("tap");
111
+ open = false;
112
+ setSearch("");
113
+ if (item.onSelect) {
114
+ item.onSelect();
115
+ } else if (item.href) {
116
+ void goto(item.href);
117
+ }
118
+ }
119
+ </script>
120
+
121
+ <svelte:window {onkeydown} />
122
+
123
+ {#snippet results()}
124
+ <Command.Empty>
125
+ <div
126
+ class="text-muted-foreground flex flex-col items-center gap-2 py-10 text-sm"
127
+ >
128
+ <SearchXIcon class="size-6" />
129
+ {text.empty}
130
+ </div>
131
+ </Command.Empty>
132
+ {#each groups as group (group.heading)}
133
+ <Command.Group
134
+ heading={group.heading}
135
+ class="p-0 pb-2 **:data-command-group-heading:px-2.5 **:data-command-group-heading:pt-3 **:data-command-group-heading:pb-1.5 **:data-command-group-heading:text-[0.6875rem] **:data-command-group-heading:font-semibold **:data-command-group-heading:tracking-wider **:data-command-group-heading:uppercase"
136
+ >
137
+ {#each group.items as item (item.label)}
138
+ <Command.Item
139
+ value={item.label}
140
+ keywords={item.description
141
+ ? [...(item.keywords ?? []), item.description]
142
+ : item.keywords}
143
+ onSelect={() => select(item)}
144
+ class="group/item data-selected:bg-accent data-selected:**:[svg]:text-current min-h-11 gap-3 rounded-xl! px-2.5 py-1.5 max-md:min-h-13 max-md:text-base [&>svg:last-child]:hidden"
145
+ >
146
+ {#if item.icon}
147
+ <span
148
+ class="bg-muted text-muted-foreground group-data-selected/item:bg-primary group-data-selected/item:text-primary-foreground flex size-8 shrink-0 items-center justify-center rounded-lg transition-colors"
149
+ >
150
+ <item.icon class="size-4" />
151
+ </span>
152
+ {/if}
153
+ <span class="min-w-0 flex-1 truncate font-medium">{item.label}</span>
154
+ {#if item.description}
155
+ <span
156
+ class="text-muted-foreground bg-muted group-data-selected/item:bg-background/70 max-w-[40%] shrink-0 truncate rounded-full px-2 py-0.5 text-[0.6875rem]"
157
+ >
158
+ {item.description}
159
+ </span>
160
+ {/if}
161
+ {#if item.shortcut}
162
+ <kbd
163
+ class="bg-background text-muted-foreground shrink-0 rounded-md border px-1.5 py-0.5 font-sans text-[0.6875rem] font-medium tracking-widest max-md:hidden"
164
+ >
165
+ {item.shortcut}
166
+ </kbd>
167
+ {/if}
168
+ </Command.Item>
169
+ {/each}
170
+ </Command.Group>
171
+ {/each}
172
+ {/snippet}
173
+
174
+ {#snippet key(label: string)}
175
+ <kbd
176
+ class="bg-background text-foreground inline-flex h-5 min-w-5 items-center justify-center rounded-md border px-1 font-sans text-[0.6875rem] font-medium shadow-xs"
177
+ >{label}</kbd
178
+ >
179
+ {/snippet}
180
+
181
+ {#if trigger}
182
+ <button
183
+ type="button"
184
+ aria-label={text.label}
185
+ class="text-muted-foreground hover:text-foreground border-input focus-visible:ring-ring/50 dark:bg-input/30 flex h-11 w-full min-w-0 items-center gap-2 rounded-xl border bg-white px-3 text-base shadow-xs transition-colors outline-none focus-visible:ring-3 md:h-9 md:w-64 md:rounded-md md:px-2.5 md:text-sm"
186
+ onclick={() => {
187
+ open = true;
188
+ }}
189
+ >
190
+ <SearchIcon class="size-5 shrink-0 md:size-4" />
191
+ <span class="truncate">{text.trigger}</span>
192
+ {#if shortcut}
193
+ <kbd
194
+ class="bg-muted ml-auto hidden rounded px-1.5 font-sans text-[0.6875rem] font-medium tracking-widest md:inline"
195
+ >
196
+ {mac ? "⌘K" : "Ctrl K"}
197
+ </kbd>
198
+ {/if}
199
+ </button>
200
+ {/if}
201
+
202
+ {#if desktop.current}
203
+ <Command.Dialog
204
+ bind:open
205
+ class="rounded-2xl! shadow-2xl sm:max-w-2xl"
206
+ title={text.title}
207
+ description={text.description}
208
+ shouldFilter={filter}
209
+ filter={matchWords}
210
+ >
211
+ <div class="relative border-b">
212
+ <Command.Input
213
+ bind:value={() => search, setSearch}
214
+ {placeholder}
215
+ class="text-base"
216
+ wrapperClass="p-2"
217
+ groupClass="h-12! border-0! bg-transparent! *:data-[slot=input-group-addon]:pl-3!"
218
+ />
219
+ <span class="absolute top-1/2 right-4 -translate-y-1/2">
220
+ {@render key("esc")}
221
+ </span>
222
+ </div>
223
+ <Command.List class="max-h-[min(26rem,60dvh)] scroll-py-2 p-2">
224
+ {@render results()}
225
+ </Command.List>
226
+ <div
227
+ class="text-muted-foreground bg-muted/40 flex items-center gap-4 border-t px-4 py-2.5 text-xs"
228
+ >
229
+ <span class="flex items-center gap-1.5">
230
+ {@render key("↑")}{@render key("↓")}
231
+ {text.navigate}
232
+ </span>
233
+ <span class="flex items-center gap-1.5">
234
+ {@render key("↵")}
235
+ {text.open}
236
+ </span>
237
+ <span class="ml-auto flex items-center gap-1.5">
238
+ {@render key("esc")}
239
+ {text.close}
240
+ </span>
241
+ </div>
242
+ </Command.Dialog>
243
+ {:else}
244
+ <Drawer.Root bind:open autoFocus>
245
+ <Drawer.Content class="h-[85dvh]">
246
+ <Drawer.Header class="sr-only">
247
+ <Drawer.Title>{text.title}</Drawer.Title>
248
+ <Drawer.Description>{text.description}</Drawer.Description>
249
+ </Drawer.Header>
250
+ <Command.Root
251
+ shouldFilter={filter}
252
+ filter={matchWords}
253
+ class="min-h-0 flex-1 rounded-none! bg-transparent p-0"
254
+ >
255
+ <Command.Input
256
+ bind:value={() => search, setSearch}
257
+ {placeholder}
258
+ class="text-base"
259
+ wrapperClass="px-4 pb-3"
260
+ groupClass="h-12! rounded-xl! border-0! bg-muted! *:data-[slot=input-group-addon]:pl-3!"
261
+ />
262
+ <Command.List class="pb-safe-bottom max-h-none flex-1 px-2">
263
+ {@render results()}
264
+ </Command.List>
265
+ </Command.Root>
266
+ </Drawer.Content>
267
+ </Drawer.Root>
268
+ {/if}
@@ -0,0 +1,37 @@
1
+ import type { Component } from "svelte";
2
+ export interface CommandItem {
3
+ label: string;
4
+ href?: string;
5
+ onSelect?: () => void;
6
+ icon?: Component<{
7
+ class?: string;
8
+ }>;
9
+ description?: string;
10
+ shortcut?: string;
11
+ keywords?: string[];
12
+ }
13
+ export interface CommandGroup {
14
+ heading: string;
15
+ items: CommandItem[];
16
+ }
17
+ declare const CommandPalette: Component<{
18
+ groups: CommandGroup[];
19
+ open?: boolean;
20
+ placeholder?: string;
21
+ shortcut?: boolean;
22
+ trigger?: boolean;
23
+ filter?: boolean;
24
+ onsearch?: (term: string) => void;
25
+ messages?: Partial<{
26
+ trigger: string;
27
+ label: string;
28
+ title: string;
29
+ description: string;
30
+ empty: string;
31
+ navigate: string;
32
+ open: string;
33
+ close: string;
34
+ }>;
35
+ }, {}, "open">;
36
+ type CommandPalette = ReturnType<typeof CommandPalette>;
37
+ export default CommandPalette;
@@ -2,6 +2,7 @@
2
2
  import { page } from "$app/state";
3
3
 
4
4
  import { Button } from "../ui/button/index.js";
5
+ import { cn } from "../../utils.js";
5
6
 
6
7
  const defaultMessages = {
7
8
  notFoundTitle: "Nothing here",
@@ -13,31 +14,46 @@
13
14
 
14
15
  interface Props {
15
16
  homeHref: string;
17
+ /** The status to show instead of the page's, to render an error screen outside +error.svelte. */
18
+ status?: number;
19
+ error?: App.Error | null;
16
20
  messages?: Partial<typeof defaultMessages>;
21
+ class?: string;
17
22
  }
18
23
 
19
- const { homeHref, messages = {} }: Props = $props();
24
+ const {
25
+ homeHref,
26
+ status,
27
+ error,
28
+ messages = {},
29
+ class: className,
30
+ }: Props = $props();
20
31
 
21
32
  const text = $derived({ ...defaultMessages, ...messages });
22
- const notFound = $derived(page.status === 404);
33
+ const shownStatus = $derived(status ?? page.status);
34
+ const shownError = $derived(error === undefined ? page.error : error);
35
+ const notFound = $derived(shownStatus === 404);
23
36
  </script>
24
37
 
25
38
  <div
26
- class="pt-safe-top pb-safe-bottom flex min-h-dvh flex-col items-center justify-center gap-6 px-6 text-center"
39
+ class={cn(
40
+ "pt-safe-top pb-safe-bottom flex min-h-dvh flex-col items-center justify-center gap-6 px-6 text-center",
41
+ className,
42
+ )}
27
43
  >
28
44
  <p class="text-primary text-7xl font-bold tracking-tight tabular">
29
- {page.status}
45
+ {shownStatus}
30
46
  </p>
31
47
  <div>
32
48
  <h1 class="text-xl font-semibold">
33
49
  {notFound ? text.notFoundTitle : text.errorTitle}
34
50
  </h1>
35
51
  <p class="text-muted-foreground mt-2 text-sm">
36
- {notFound ? text.notFoundDescription : page.error?.message}
52
+ {notFound ? text.notFoundDescription : shownError?.message}
37
53
  </p>
38
- {#if page.error?.errorId}
54
+ {#if shownError?.errorId}
39
55
  <p class="text-muted-foreground mt-3 font-mono text-xs">
40
- {text.errorId}: {page.error.errorId}
56
+ {text.errorId}: {shownError.errorId}
41
57
  </p>
42
58
  {/if}
43
59
  </div>
@@ -1,5 +1,8 @@
1
1
  declare const ErrorPage: import("svelte").Component<{
2
2
  homeHref: string;
3
+ /** The status to show instead of the page's, to render an error screen outside +error.svelte. */
4
+ status?: number;
5
+ error?: App.Error | null;
3
6
  messages?: Partial<{
4
7
  notFoundTitle: string;
5
8
  notFoundDescription: string;
@@ -7,6 +10,7 @@ declare const ErrorPage: import("svelte").Component<{
7
10
  errorId: string;
8
11
  home: string;
9
12
  }>;
13
+ class?: string;
10
14
  }, {}, "">;
11
15
  type ErrorPage = ReturnType<typeof ErrorPage>;
12
16
  export default ErrorPage;
@@ -19,7 +19,7 @@
19
19
  haptic("tap");
20
20
  onclick();
21
21
  }}
22
- class="from-primary glow fixed right-5 bottom-[calc(max(0.75rem,env(safe-area-inset-bottom))+5.25rem)] z-40 flex size-15 items-center justify-center rounded-2xl bg-linear-to-br to-[oklch(0.62_calc(var(--primary-chroma)*1.1)_calc(var(--hue)+40))] text-white transition-transform duration-200 active:scale-90 md:hidden"
22
+ class="from-primary glow fixed right-5 bottom-[calc(max(0.75rem,var(--safe-bottom))+5.25rem)] z-40 flex size-15 items-center justify-center rounded-2xl bg-linear-to-br to-[oklch(0.62_calc(var(--primary-chroma)*1.1)_calc(var(--hue)+40))] text-white transition-transform duration-200 active:scale-90 md:hidden"
23
23
  style="view-transition-name: fab"
24
24
  >
25
25
  <Icon class="size-7" />
@@ -31,21 +31,27 @@
31
31
  brand?: Snippet;
32
32
  title?: string;
33
33
  messages?: Partial<typeof defaultMessages>;
34
+ open?: boolean;
34
35
  }
35
36
 
36
- const {
37
+ let {
37
38
  appName,
38
39
  storageKey,
39
40
  brand,
40
41
  title = `Install ${appName}`,
41
42
  messages = {},
43
+ open = $bindable(false),
42
44
  }: Props = $props();
43
45
 
44
46
  const text = $derived({ ...defaultMessages, ...messages });
45
47
 
46
- let open = $state(false);
47
48
  let mode = $state<"native" | "apple" | "menu">("native");
48
- let deferred: InstallPromptEvent | undefined;
49
+ let deferred = $state.raw<InstallPromptEvent>();
50
+ let apple = $state(false);
51
+
52
+ const shownMode = $derived(
53
+ mode === "native" && !deferred ? (apple ? "apple" : "menu") : mode,
54
+ );
49
55
 
50
56
  function alreadyPrompted(): boolean {
51
57
  try {
@@ -69,6 +75,9 @@
69
75
  }
70
76
 
71
77
  onMount(() => {
78
+ apple =
79
+ /iPhone|iPad/.test(navigator.userAgent) ||
80
+ (/Macintosh/.test(navigator.userAgent) && navigator.maxTouchPoints > 1);
72
81
  const standalone =
73
82
  matchMedia("(display-mode: standalone)").matches ||
74
83
  ("standalone" in navigator && navigator.standalone === true);
@@ -76,9 +85,6 @@
76
85
  if (standalone || !touch || alreadyPrompted()) {
77
86
  return;
78
87
  }
79
- const apple =
80
- /iPhone|iPad/.test(navigator.userAgent) ||
81
- (/Macintosh/.test(navigator.userAgent) && navigator.maxTouchPoints > 1);
82
88
  if (apple) {
83
89
  const timer = setTimeout(() => show("apple"), 1500);
84
90
  return () => clearTimeout(timer);
@@ -128,7 +134,7 @@
128
134
  {@render brand()}
129
135
  </div>
130
136
  {/if}
131
- {#if mode === "native"}
137
+ {#if shownMode === "native"}
132
138
  <Button class="glow h-12 rounded-xl text-base" onclick={install}>
133
139
  <DownloadIcon />
134
140
  {text.install}
@@ -140,7 +146,7 @@
140
146
  >
141
147
  {:else}
142
148
  <ol class="flex flex-col gap-3 text-sm">
143
- {#if mode === "apple"}
149
+ {#if shownMode === "apple"}
144
150
  {@render step(ShareIcon, text.appleShare)}
145
151
  {@render step(SquarePlusIcon, text.appleAdd)}
146
152
  {:else}
@@ -14,6 +14,7 @@ declare const InstallPrompt: import("svelte").Component<{
14
14
  install: string;
15
15
  notNow: string;
16
16
  }>;
17
- }, {}, "">;
17
+ open?: boolean;
18
+ }, {}, "open">;
18
19
  type InstallPrompt = ReturnType<typeof InstallPrompt>;
19
20
  export default InstallPrompt;