@nqmcreative/ui 0.2.2 → 0.2.3

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 (46) hide show
  1. package/dist/core/index.d.ts +1 -1
  2. package/dist/core/index.js +1 -1
  3. package/dist/core/trigger.d.ts +6 -0
  4. package/dist/core/trigger.js +13 -0
  5. package/dist/styles/matte/Button.svelte +4 -4
  6. package/dist/styles/matte/Card.svelte +3 -3
  7. package/dist/styles/matte/Combobox.svelte +2 -2
  8. package/dist/styles/matte/CommandPalette.svelte +32 -7
  9. package/dist/styles/matte/CommandPalette.svelte.d.ts +5 -0
  10. package/dist/styles/matte/DatePicker.svelte +2 -2
  11. package/dist/styles/matte/Drawer.svelte +2 -2
  12. package/dist/styles/matte/Dropzone.svelte +1 -1
  13. package/dist/styles/matte/FeatureCard.svelte +1 -1
  14. package/dist/styles/matte/Input.svelte +4 -4
  15. package/dist/styles/matte/MenuItem.svelte +1 -1
  16. package/dist/styles/matte/Modal.svelte +3 -3
  17. package/dist/styles/matte/NumberInput.svelte +2 -2
  18. package/dist/styles/matte/PasswordInput.svelte +2 -2
  19. package/dist/styles/matte/PricingCard.svelte +3 -3
  20. package/dist/styles/matte/SegmentedControl.svelte +1 -1
  21. package/dist/styles/matte/Select.svelte +1 -1
  22. package/dist/styles/matte/Sidebar.svelte +75 -26
  23. package/dist/styles/matte/Sidebar.svelte.d.ts +15 -1
  24. package/dist/styles/matte/StatsBand.svelte +1 -1
  25. package/dist/styles/matte/Table.svelte +3 -3
  26. package/dist/styles/matte/Testimonial.svelte +2 -2
  27. package/dist/styles/matte/Textarea.svelte +1 -1
  28. package/dist/styles/matte/index.d.ts +1 -1
  29. package/dist/styles/paper/CommandPalette.svelte +25 -2
  30. package/dist/styles/paper/CommandPalette.svelte.d.ts +5 -0
  31. package/dist/styles/paper/Modal.svelte +9 -1
  32. package/dist/styles/paper/Sidebar.svelte +75 -26
  33. package/dist/styles/paper/Sidebar.svelte.d.ts +15 -1
  34. package/dist/styles/paper/fonts.css +34 -7
  35. package/dist/styles/paper/index.d.ts +1 -1
  36. package/dist/styles/paper/theme.css +11 -8
  37. package/dist/styles/sprout/CommandPalette.svelte +25 -2
  38. package/dist/styles/sprout/CommandPalette.svelte.d.ts +5 -0
  39. package/dist/styles/sprout/Modal.svelte +9 -1
  40. package/dist/styles/sprout/Sidebar.svelte +75 -26
  41. package/dist/styles/sprout/Sidebar.svelte.d.ts +15 -1
  42. package/dist/styles/sprout/fonts.css +7 -8
  43. package/dist/styles/sprout/index.d.ts +1 -1
  44. package/dist/styles/sprout/theme.css +6 -6
  45. package/package.json +1 -1
  46. package/registry.json +2 -1
@@ -41,7 +41,7 @@
41
41
 
42
42
  <div class="grid {cols} {bordered ? 'gap-px border border-hairline bg-hairline' : 'gap-10'}">
43
43
  {#each stats as stat (stat.label)}
44
- <div class={bordered ? 'bg-bg p-6' : ''}>
44
+ <div class={bordered ? 'bg-bg p-5' : ''}>
45
45
  <Stat
46
46
  label={stat.label}
47
47
  value={stat.value}
@@ -77,7 +77,7 @@
77
77
  right: 'text-right'
78
78
  };
79
79
 
80
- const pad = $derived(compact ? 'px-4 py-2' : 'px-4 py-3');
80
+ const pad = $derived(compact ? 'px-3.5 py-1.5' : 'px-3.5 py-2.5');
81
81
 
82
82
  const sorted = $derived(sortLocally ? sortRows(rows, sort) : rows);
83
83
 
@@ -112,7 +112,7 @@
112
112
  <table class="w-full border-collapse font-sans text-[15px]">
113
113
  {#if caption}
114
114
  <caption
115
- class="border-b border-hairline px-4 py-3 text-left font-sans text-sm text-text-muted"
115
+ class="border-b border-hairline px-3.5 py-2.5 text-left font-sans text-sm text-text-muted"
116
116
  >
117
117
  {caption}
118
118
  </caption>
@@ -170,7 +170,7 @@
170
170
  <tr>
171
171
  <td
172
172
  colspan={columns.length + (selectable ? 1 : 0)}
173
- class="px-4 py-10 text-center text-sm text-text-muted"
173
+ class="px-3.5 py-10 text-center text-sm text-text-muted"
174
174
  >
175
175
  {#if empty}{@render empty()}{:else}{t.current.noData}{/if}
176
176
  </td>
@@ -34,8 +34,8 @@
34
34
 
35
35
  const variants = $derived({
36
36
  plain: '',
37
- card: 'border border-hairline bg-bg p-6',
38
- accent: `${toneSurface[tone]} border-l-2 ${toneBorderLeft[tone]} p-6`
37
+ card: 'border border-hairline bg-bg p-5',
38
+ accent: `${toneSurface[tone]} border-l-2 ${toneBorderLeft[tone]} p-5`
39
39
  });
40
40
  </script>
41
41
 
@@ -48,7 +48,7 @@
48
48
  bind:value
49
49
  {rows}
50
50
  aria-invalid={invalid ? 'true' : undefined}
51
- class="w-full border bg-bg px-4 py-3 font-sans text-[15px] text-text transition-colors duration-150 ease-brand-out placeholder:text-text-muted focus:outline-none disabled:pointer-events-none disabled:opacity-50
51
+ class="w-full border bg-bg px-3.5 py-2.5 font-sans text-[15px] text-text transition-colors duration-150 ease-brand-out placeholder:text-text-muted focus:outline-none disabled:pointer-events-none disabled:opacity-50
52
52
  {autoresize ? 'resize-none overflow-hidden' : 'resize-y'}
53
53
  {invalid ? 'border-danger' : `border-hairline ${toneFocusBorder[tone]}`} {className}"
54
54
  {...rest}></textarea>
@@ -76,7 +76,7 @@ export type { StepItem } from './Steps.svelte';
76
76
  export { default as Navbar } from './Navbar.svelte';
77
77
  export type { NavItem } from './Navbar.svelte';
78
78
  export { default as Sidebar } from './Sidebar.svelte';
79
- export type { SidebarItem, SidebarSection } from './Sidebar.svelte';
79
+ export type { SidebarVariant, SidebarItem, SidebarSection } from './Sidebar.svelte';
80
80
  export { default as Footer } from './Footer.svelte';
81
81
  export type { FooterLink, FooterColumn } from './Footer.svelte';
82
82
  export { default as HeroSection } from './HeroSection.svelte';
@@ -21,6 +21,7 @@
21
21
  import { ListCursor, groupItems, matchQuery, revealIndex } from '../../core/list.svelte.js';
22
22
  import { useLocale } from '../../core/locale.svelte.js';
23
23
  import { toneSoft, type Tone } from '../../core/tones.js';
24
+ import { isTypingTarget } from '../../core/trigger.js';
24
25
  import Kbd from './Kbd.svelte';
25
26
 
26
27
  interface Props {
@@ -34,6 +35,11 @@
34
35
  * Pass `null` to wire your own trigger only.
35
36
  */
36
37
  hotkey?: string | null;
38
+ /**
39
+ * Key that opens the palette on its own, pressed anywhere the reader
40
+ * isn't typing. Default `'/'`. Pass `null` to turn it off.
41
+ */
42
+ quickKey?: string | null;
37
43
  onselect?: (item: CommandItem) => void;
38
44
  class?: string;
39
45
  }
@@ -45,6 +51,7 @@
45
51
  emptyText,
46
52
  tone = 'brand',
47
53
  hotkey = 'k',
54
+ quickKey = '/',
48
55
  onselect,
49
56
  class: className = ''
50
57
  }: Props = $props();
@@ -82,11 +89,27 @@
82
89
  });
83
90
 
84
91
  $effect(() => {
85
- if (!hotkey) return;
92
+ if (!hotkey && !quickKey) return;
86
93
  const onKeydown = (event: KeyboardEvent) => {
87
- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === hotkey.toLowerCase()) {
94
+ const key = event.key.toLowerCase();
95
+
96
+ if ((event.metaKey || event.ctrlKey) && key === hotkey?.toLowerCase()) {
88
97
  event.preventDefault();
89
98
  open = !open;
99
+ return;
100
+ }
101
+
102
+ // A bare key can only open the palette, never close it: while it is
103
+ // open the keystroke belongs to the query field.
104
+ if (
105
+ key === quickKey?.toLowerCase() &&
106
+ !event.metaKey &&
107
+ !event.ctrlKey &&
108
+ !event.altKey &&
109
+ !isTypingTarget(event.target)
110
+ ) {
111
+ event.preventDefault();
112
+ open = true;
90
113
  }
91
114
  };
92
115
  window.addEventListener('keydown', onKeydown);
@@ -24,6 +24,11 @@ interface Props {
24
24
  * Pass `null` to wire your own trigger only.
25
25
  */
26
26
  hotkey?: string | null;
27
+ /**
28
+ * Key that opens the palette on its own, pressed anywhere the reader
29
+ * isn't typing. Default `'/'`. Pass `null` to turn it off.
30
+ */
31
+ quickKey?: string | null;
27
32
  onselect?: (item: CommandItem) => void;
28
33
  class?: string;
29
34
  }
@@ -103,7 +103,15 @@
103
103
  </div>
104
104
  {/if}
105
105
 
106
- <div class="px-6 py-2 pb-5 font-sans text-sm leading-relaxed text-text-secondary">
106
+ <!--
107
+ The body's top padding depends on what sits above it: 8px is the right
108
+ gap under a header, and far too tight when there is none — a titleless
109
+ modal, ConfirmDialog among them, would otherwise start against the edge.
110
+ -->
111
+ <div
112
+ class="px-6 pb-5 font-sans text-sm leading-relaxed text-text-secondary
113
+ {title || closable ? 'pt-2' : 'pt-5'}"
114
+ >
107
115
  {@render children()}
108
116
  </div>
109
117
 
@@ -1,4 +1,8 @@
1
1
  <script module lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ export type SidebarVariant = 'plain' | 'filled' | 'floating';
5
+
2
6
  export interface SidebarItem {
3
7
  /** Stable id used for the active state. Falls back to `href`. */
4
8
  id?: string;
@@ -6,10 +10,21 @@
6
10
  href?: string;
7
11
  disabled?: boolean;
8
12
  badge?: string | number;
13
+ /**
14
+ * Leading glyph, 16px. The one thing a collapsed rail still shows, so
15
+ * an item that has to survive `collapsed` needs one.
16
+ */
17
+ icon?: Snippet;
9
18
  /** Nested links — the parent becomes a collapsible group. */
10
19
  items?: SidebarItem[];
11
20
  /** Open the group on first render. */
12
21
  open?: boolean;
22
+ /**
23
+ * `false` pins a group open: the parent stops being a button and the
24
+ * children are always on show. For navigation a reader should not be
25
+ * able to lose.
26
+ */
27
+ collapsible?: boolean;
13
28
  }
14
29
 
15
30
  export interface SidebarSection {
@@ -20,7 +35,6 @@
20
35
  </script>
21
36
 
22
37
  <script lang="ts">
23
- import type { Snippet } from 'svelte';
24
38
  import { useLocale } from '../../core/locale.svelte.js';
25
39
  import { focusRing, toneRing, toneSoft, type Tone } from '../../core/tones.js';
26
40
 
@@ -32,6 +46,8 @@
32
46
  collapsed?: boolean;
33
47
  /** Show the collapse button. */
34
48
  collapsible?: boolean;
49
+ /** Surface treatment: bare, the framed default, or a detached card. */
50
+ variant?: SidebarVariant;
35
51
  tone?: Tone;
36
52
  header?: Snippet;
37
53
  footer?: Snippet;
@@ -44,6 +60,7 @@
44
60
  value = $bindable(''),
45
61
  collapsed = $bindable(false),
46
62
  collapsible = true,
63
+ variant = 'filled',
47
64
  tone = 'brand',
48
65
  header,
49
66
  footer,
@@ -59,7 +76,8 @@
59
76
  let opened = $state<Record<string, boolean>>({});
60
77
 
61
78
  const isOpen = (item: SidebarItem) =>
62
- opened[keyOf(item)] ?? (item.open || item.items!.some((child) => keyOf(child) === value));
79
+ item.collapsible === false ||
80
+ (opened[keyOf(item)] ?? (item.open || item.items!.some((child) => keyOf(child) === value)));
63
81
 
64
82
  function toggle(item: SidebarItem) {
65
83
  opened = { ...opened, [keyOf(item)]: !isOpen(item) };
@@ -71,13 +89,50 @@
71
89
  onnavigate?.(item);
72
90
  }
73
91
 
92
+ const surfaces: Record<SidebarVariant, string> = {
93
+ plain: 'h-full bg-transparent',
94
+ filled: 'h-full border-r border-hairline bg-bg-alt',
95
+ floating: 'm-3 h-[calc(100%-1.5rem)] rounded-lg border border-hairline bg-bg-alt shadow-sm'
96
+ };
97
+
74
98
  const row =
75
99
  'flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-left font-sans text-sm transition-colors duration-150 ease-brand-out disabled:pointer-events-none disabled:opacity-40';
76
100
  </script>
77
101
 
102
+ <!-- Nested links. One copy, rendered by both kinds of group. -->
103
+ {#snippet subitems(item: SidebarItem)}
104
+ <div class="ml-4 flex flex-col gap-0.5 border-l border-hairline pl-2">
105
+ {#each item.items as child (keyOf(child))}
106
+ <a
107
+ href={child.href}
108
+ onclick={() => go(child)}
109
+ aria-current={value === keyOf(child) ? 'page' : undefined}
110
+ class="{row} {focusRing} {toneRing[tone]} {value === keyOf(child)
111
+ ? `${toneSoft[tone]} font-medium`
112
+ : 'text-text-muted hover:bg-bg-inset hover:text-text'} {child.disabled
113
+ ? 'pointer-events-none opacity-40'
114
+ : ''}"
115
+ >
116
+ {@render glyph(child)}
117
+ <span class="min-w-0 flex-1 truncate">{child.label}</span>
118
+ {#if child.badge !== undefined}
119
+ <span class="shrink-0 text-[11px] tabular-nums">{child.badge}</span>
120
+ {/if}
121
+ </a>
122
+ {/each}
123
+ </div>
124
+ {/snippet}
125
+
126
+ <!-- One box for every glyph, so rows line up whether or not an item has one. -->
127
+ {#snippet glyph(item: SidebarItem)}
128
+ {#if item.icon}
129
+ <span class="grid size-4 shrink-0 place-items-center *:size-4">{@render item.icon()}</span>
130
+ {/if}
131
+ {/snippet}
132
+
78
133
  <aside
79
- class="flex h-full shrink-0 flex-col border-r border-hairline bg-bg-alt transition-[width] duration-200 ease-brand-out
80
- {collapsed ? 'w-16' : 'w-60'} {className}"
134
+ class="flex shrink-0 flex-col transition-[width] duration-200 ease-brand-out
135
+ {surfaces[variant]} {collapsed ? 'w-16' : 'w-60'} {className}"
81
136
  >
82
137
  {#if header}
83
138
  <div class="flex h-16 shrink-0 items-center gap-3 border-b border-hairline px-3">
@@ -97,16 +152,25 @@
97
152
  {/if}
98
153
 
99
154
  {#each section.items as item (keyOf(item))}
100
- {#if item.items?.length}
155
+ {#if item.items?.length && item.collapsible === false}
156
+ <div class="{row} {collapsed ? 'justify-center' : ''} font-medium text-text-muted">
157
+ {@render glyph(item)}
158
+ <span class="min-w-0 flex-1 truncate {collapsed ? 'sr-only' : ''}">{item.label}</span>
159
+ </div>
160
+ {#if isOpen(item) && !collapsed}
161
+ {@render subitems(item)}
162
+ {/if}
163
+ {:else if item.items?.length}
101
164
  <button
102
165
  type="button"
103
166
  onclick={() => toggle(item)}
104
167
  aria-expanded={isOpen(item)}
105
168
  title={collapsed ? item.label : undefined}
106
- class="{row} {focusRing} {toneRing[
169
+ class="{row} {collapsed ? 'justify-center' : ''} {focusRing} {toneRing[
107
170
  tone
108
171
  ]} text-text-secondary hover:bg-bg-inset hover:text-text"
109
172
  >
173
+ {@render glyph(item)}
110
174
  <span class="min-w-0 flex-1 truncate {collapsed ? 'sr-only' : ''}">{item.label}</span>
111
175
  {#if !collapsed}
112
176
  <svg
@@ -128,25 +192,7 @@
128
192
  </button>
129
193
 
130
194
  {#if isOpen(item) && !collapsed}
131
- <div class="ml-4 flex flex-col gap-0.5 border-l border-hairline pl-2">
132
- {#each item.items as child (keyOf(child))}
133
- <a
134
- href={child.href}
135
- onclick={() => go(child)}
136
- aria-current={value === keyOf(child) ? 'page' : undefined}
137
- class="{row} {focusRing} {toneRing[tone]} {value === keyOf(child)
138
- ? `${toneSoft[tone]} font-medium`
139
- : 'text-text-muted hover:bg-bg-inset hover:text-text'} {child.disabled
140
- ? 'pointer-events-none opacity-40'
141
- : ''}"
142
- >
143
- <span class="min-w-0 flex-1 truncate">{child.label}</span>
144
- {#if child.badge !== undefined}
145
- <span class="shrink-0 text-[11px] tabular-nums">{child.badge}</span>
146
- {/if}
147
- </a>
148
- {/each}
149
- </div>
195
+ {@render subitems(item)}
150
196
  {/if}
151
197
  {:else}
152
198
  <a
@@ -154,12 +200,15 @@
154
200
  onclick={() => go(item)}
155
201
  aria-current={value === keyOf(item) ? 'page' : undefined}
156
202
  title={collapsed ? item.label : undefined}
157
- class="{row} {focusRing} {toneRing[tone]} {value === keyOf(item)
203
+ class="{row} {collapsed ? 'justify-center' : ''} {focusRing} {toneRing[
204
+ tone
205
+ ]} {value === keyOf(item)
158
206
  ? `${toneSoft[tone]} font-medium`
159
207
  : 'text-text-secondary hover:bg-bg-inset hover:text-text'} {item.disabled
160
208
  ? 'pointer-events-none opacity-40'
161
209
  : ''}"
162
210
  >
211
+ {@render glyph(item)}
163
212
  <span class="min-w-0 flex-1 truncate {collapsed ? 'sr-only' : ''}">{item.label}</span>
164
213
  {#if item.badge !== undefined && !collapsed}
165
214
  <span class="shrink-0 text-[11px] text-text-muted tabular-nums">{item.badge}</span>
@@ -1,3 +1,5 @@
1
+ import type { Snippet } from 'svelte';
2
+ export type SidebarVariant = 'plain' | 'filled' | 'floating';
1
3
  export interface SidebarItem {
2
4
  /** Stable id used for the active state. Falls back to `href`. */
3
5
  id?: string;
@@ -5,17 +7,27 @@ export interface SidebarItem {
5
7
  href?: string;
6
8
  disabled?: boolean;
7
9
  badge?: string | number;
10
+ /**
11
+ * Leading glyph, 16px. The one thing a collapsed rail still shows, so
12
+ * an item that has to survive `collapsed` needs one.
13
+ */
14
+ icon?: Snippet;
8
15
  /** Nested links — the parent becomes a collapsible group. */
9
16
  items?: SidebarItem[];
10
17
  /** Open the group on first render. */
11
18
  open?: boolean;
19
+ /**
20
+ * `false` pins a group open: the parent stops being a button and the
21
+ * children are always on show. For navigation a reader should not be
22
+ * able to lose.
23
+ */
24
+ collapsible?: boolean;
12
25
  }
13
26
  export interface SidebarSection {
14
27
  /** Small heading above a run of items. */
15
28
  label?: string;
16
29
  items: SidebarItem[];
17
30
  }
18
- import type { Snippet } from 'svelte';
19
31
  import { type Tone } from '../../core/tones.js';
20
32
  interface Props {
21
33
  sections: SidebarSection[];
@@ -25,6 +37,8 @@ interface Props {
25
37
  collapsed?: boolean;
26
38
  /** Show the collapse button. */
27
39
  collapsible?: boolean;
40
+ /** Surface treatment: bare, the framed default, or a detached card. */
41
+ variant?: SidebarVariant;
28
42
  tone?: Tone;
29
43
  header?: Snippet;
30
44
  footer?: Snippet;
@@ -1,8 +1,8 @@
1
1
  /*
2
- * Optional: Plus Jakarta Sans, the face paper is designed around. Import this
3
- * only if your project doesn't already self-host or load it another way —
4
- * without it the tokens fall back to the system stack, which works but reads
5
- * harder and colder.
2
+ * Optional: the two faces paper is designed around Plus Jakarta Sans
3
+ * (headings) and Work Sans (body). Import this only if your project doesn't
4
+ * already self-host or load them another way without it the tokens fall back
5
+ * to the system stack, which works but reads harder and colder.
6
6
  *
7
7
  * These are plain `@font-face` rules rather than an `@import` of the Google
8
8
  * stylesheet on purpose: `@import` is only valid before every other rule, so a
@@ -11,13 +11,13 @@
11
11
  * you put it, and it skips the CSS-to-CSS round trip before the font files
12
12
  * start downloading.
13
13
  *
14
- * Two rules rather than matte's eight: this is a variable font, so one file per
15
- * subset covers every weight from 400 to 700. Latin and Latin Extended only
16
- * around 49 kB in total. Add
14
+ * Four rules for two families: both are variable fonts, so one file per subset
15
+ * covers every weight from 400 to 700. Latin and Latin Extended only. Add
17
16
  * `<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>` to
18
17
  * your document head to open the connection earlier.
19
18
  */
20
19
 
20
+ /* --- Plus Jakarta Sans --- */
21
21
  /* latin */
22
22
  @font-face {
23
23
  font-family: 'Plus Jakarta Sans';
@@ -44,3 +44,30 @@
44
44
  U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
45
45
  U+A720-A7FF;
46
46
  }
47
+
48
+ /* --- Work Sans --- */
49
+ /* latin */
50
+ @font-face {
51
+ font-family: 'Work Sans';
52
+ font-style: normal;
53
+ font-weight: 400 700;
54
+ font-display: swap;
55
+ src: url(https://fonts.gstatic.com/s/worksans/v24/QGYsz_wNahGAdqQ43Rh_fKDp.woff2) format('woff2');
56
+ unicode-range:
57
+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
58
+ U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
59
+ }
60
+
61
+ /* latin-ext */
62
+ @font-face {
63
+ font-family: 'Work Sans';
64
+ font-style: normal;
65
+ font-weight: 400 700;
66
+ font-display: swap;
67
+ src: url(https://fonts.gstatic.com/s/worksans/v24/QGYsz_wNahGAdqQ43Rh_cqDpp_k.woff2)
68
+ format('woff2');
69
+ unicode-range:
70
+ U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
71
+ U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
72
+ U+A720-A7FF;
73
+ }
@@ -76,7 +76,7 @@ export type { StepItem } from './Steps.svelte';
76
76
  export { default as Navbar } from './Navbar.svelte';
77
77
  export type { NavItem } from './Navbar.svelte';
78
78
  export { default as Sidebar } from './Sidebar.svelte';
79
- export type { SidebarItem, SidebarSection } from './Sidebar.svelte';
79
+ export type { SidebarVariant, SidebarItem, SidebarSection } from './Sidebar.svelte';
80
80
  export { default as Footer } from './Footer.svelte';
81
81
  export type { FooterLink, FooterColumn } from './Footer.svelte';
82
82
  export { default as HeroSection } from './HeroSection.svelte';
@@ -78,16 +78,19 @@
78
78
 
79
79
  /* --- type --- */
80
80
  /*
81
- * Plus Jakarta Sans: rounded terminals, open counters, low contrast. Softer
82
- * than a system stack, which lands on Segoe UI for most readers and gives
83
- * paper's generous spacing nothing warm to sit in.
81
+ * Work Sans sets running text in every style, so a project that swaps the
82
+ * word after `@nqmcreative/ui/` keeps the same reading rhythm and only the
83
+ * headings change voice.
84
84
  *
85
- * One variable file covers 400 to 700, so this costs a single request per
86
- * subsetsee `fonts.css`. Everything degrades to the system stack if that
87
- * file is not imported, so the webfont stays optional.
85
+ * Plus Jakarta Sans keeps the headings: rounded terminals, open counters,
86
+ * low contrast softer than a system stack, which lands on Segoe UI for
87
+ * most readers and gives paper's generous spacing nothing warm to sit in.
88
+ *
89
+ * Both are variable files, one request per subset — see `fonts.css`.
90
+ * Everything degrades to the system stack if that file is not imported, so
91
+ * the webfonts stay optional.
88
92
  */
89
- --font-sans:
90
- 'Plus Jakarta Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
93
+ --font-sans: 'Work Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
91
94
  --font-heading:
92
95
  'Plus Jakarta Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
93
96
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
@@ -21,6 +21,7 @@
21
21
  import { ListCursor, groupItems, matchQuery, revealIndex } from '../../core/list.svelte.js';
22
22
  import { useLocale } from '../../core/locale.svelte.js';
23
23
  import { toneSoft, type Tone } from '../../core/tones.js';
24
+ import { isTypingTarget } from '../../core/trigger.js';
24
25
  import Kbd from './Kbd.svelte';
25
26
  import { float } from './lift.js';
26
27
 
@@ -35,6 +36,11 @@
35
36
  * Pass `null` to wire your own trigger only.
36
37
  */
37
38
  hotkey?: string | null;
39
+ /**
40
+ * Key that opens the palette on its own, pressed anywhere the reader
41
+ * isn't typing. Default `'/'`. Pass `null` to turn it off.
42
+ */
43
+ quickKey?: string | null;
38
44
  onselect?: (item: CommandItem) => void;
39
45
  class?: string;
40
46
  }
@@ -46,6 +52,7 @@
46
52
  emptyText,
47
53
  tone = 'brand',
48
54
  hotkey = 'k',
55
+ quickKey = '/',
49
56
  onselect,
50
57
  class: className = ''
51
58
  }: Props = $props();
@@ -83,11 +90,27 @@
83
90
  });
84
91
 
85
92
  $effect(() => {
86
- if (!hotkey) return;
93
+ if (!hotkey && !quickKey) return;
87
94
  const onKeydown = (event: KeyboardEvent) => {
88
- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === hotkey.toLowerCase()) {
95
+ const key = event.key.toLowerCase();
96
+
97
+ if ((event.metaKey || event.ctrlKey) && key === hotkey?.toLowerCase()) {
89
98
  event.preventDefault();
90
99
  open = !open;
100
+ return;
101
+ }
102
+
103
+ // A bare key can only open the palette, never close it: while it is
104
+ // open the keystroke belongs to the query field.
105
+ if (
106
+ key === quickKey?.toLowerCase() &&
107
+ !event.metaKey &&
108
+ !event.ctrlKey &&
109
+ !event.altKey &&
110
+ !isTypingTarget(event.target)
111
+ ) {
112
+ event.preventDefault();
113
+ open = true;
91
114
  }
92
115
  };
93
116
  window.addEventListener('keydown', onKeydown);
@@ -24,6 +24,11 @@ interface Props {
24
24
  * Pass `null` to wire your own trigger only.
25
25
  */
26
26
  hotkey?: string | null;
27
+ /**
28
+ * Key that opens the palette on its own, pressed anywhere the reader
29
+ * isn't typing. Default `'/'`. Pass `null` to turn it off.
30
+ */
31
+ quickKey?: string | null;
27
32
  onselect?: (item: CommandItem) => void;
28
33
  class?: string;
29
34
  }
@@ -104,7 +104,15 @@
104
104
  </div>
105
105
  {/if}
106
106
 
107
- <div class="px-6 py-2 pb-5 font-sans text-sm leading-relaxed text-text-secondary">
107
+ <!--
108
+ The body's top padding depends on what sits above it: 8px is the right
109
+ gap under a header, and far too tight when there is none — a titleless
110
+ modal, ConfirmDialog among them, would otherwise start against the edge.
111
+ -->
112
+ <div
113
+ class="px-6 pb-5 font-sans text-sm leading-relaxed text-text-secondary
114
+ {title || closable ? 'pt-2' : 'pt-5'}"
115
+ >
108
116
  {@render children()}
109
117
  </div>
110
118