@poodle64/ui 2026.10.8 → 2026.10.9

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 CHANGED
@@ -118,6 +118,7 @@ rather than rebuilding it:
118
118
  | `document-detail` | One document's surface: identity fields, locations, tags and collection memberships, each section present exactly when its data is. Membership links are the app's own via `collectionHref`. |
119
119
  | `search-results` | A ranked retrieval answer: title, the matched passage with accent-tinted highlights, source chip, mapped state and a mono relevance figure per hit; plus the before-any-search and matched-nothing empties. |
120
120
  | `ledger` | The one ledger every module draws: dated rows of money, coins or units, two lines a row, grouped by the viewer's period, the viewer's columns and running balance. See [The ledger](#the-ledger). |
121
+ | `resize-handle` | The drag edge of a side panel: pointer, arrow keys, double-click reset, remembered width, ceiling measured against the content beside it. AppShell's rail uses it; an app with its own `<aside>` renders `<ResizeHandle bind:width storageKey="…" />` as the aside's last child (positioned) and sets the aside's width from `width`. |
121
122
 
122
123
  **The application shell** (`app-shell`, `command-palette`). Page chrome is not
123
124
  what makes an app feel like an app either — the shell is. Five household
@@ -94,6 +94,7 @@
94
94
  import { activeNavLabel, matchesPrefix, toItems, type NavSource } from './types.js';
95
95
  import { provideShellControls, type ShellControlsSlot } from './controls.js';
96
96
  import { cn } from '../../../utils.js';
97
+ import ResizeHandle from '../resize-handle/resize-handle.svelte';
97
98
 
98
99
  let {
99
100
  nav,
@@ -254,81 +255,8 @@
254
255
 
255
256
  // Rail width is a personal preference: localStorage, one key across apps.
256
257
  // A drag below RAIL_SNAP collapses; the width survives and returns on expand.
257
- const RAIL_KEY = 'ds-shell-rail-width';
258
- const RAIL_MIN = 200;
259
- const RAIL_MAX = 420;
260
- const RAIL_SNAP = 170;
261
- const RAIL_STEP = 16;
262
258
  let railWidth = $state<number | null>(null);
263
259
  let resizing = $state(false);
264
- $effect(() => {
265
- try {
266
- const stored = Number(localStorage.getItem(RAIL_KEY));
267
- if (stored >= RAIL_MIN && stored <= RAIL_MAX) railWidth = stored;
268
- } catch {
269
- /* storage unavailable: the default width stands */
270
- }
271
- });
272
- function clampRail(next: number) {
273
- return Math.min(RAIL_MAX, Math.max(RAIL_MIN, Math.round(next)));
274
- }
275
- function setRailWidth(next: number) {
276
- railWidth = clampRail(next);
277
- try {
278
- localStorage.setItem(RAIL_KEY, String(railWidth));
279
- } catch {
280
- /* storage unavailable: the width lasts the session */
281
- }
282
- }
283
- function startResize(event: PointerEvent) {
284
- const handle = event.currentTarget as HTMLElement;
285
- const rail = handle.parentElement as HTMLElement;
286
- const startX = event.clientX;
287
- const startWidth = rail.getBoundingClientRect().width;
288
- const before = railWidth;
289
- resizing = true;
290
- handle.setPointerCapture(event.pointerId);
291
- const move = (e: PointerEvent) => {
292
- const next = startWidth + (e.clientX - startX);
293
- if (next < RAIL_SNAP) {
294
- // A collapse keeps the width it had; only a resize stores one.
295
- railWidth = before;
296
- collapsed = true;
297
- stop();
298
- } else {
299
- railWidth = clampRail(next);
300
- }
301
- };
302
- const stop = () => {
303
- resizing = false;
304
- if (!collapsed && railWidth !== null) setRailWidth(railWidth);
305
- handle.removeEventListener('pointermove', move);
306
- handle.removeEventListener('pointerup', stop);
307
- handle.removeEventListener('pointercancel', stop);
308
- if (handle.hasPointerCapture(event.pointerId)) handle.releasePointerCapture(event.pointerId);
309
- };
310
- handle.addEventListener('pointermove', move);
311
- handle.addEventListener('pointerup', stop);
312
- handle.addEventListener('pointercancel', stop);
313
- }
314
- function resizeByKey(event: KeyboardEvent) {
315
- const current = railWidth ?? RAIL_MIN + (RAIL_MAX - RAIL_MIN) / 4;
316
- if (event.key === 'ArrowLeft') setRailWidth(current - RAIL_STEP);
317
- else if (event.key === 'ArrowRight') setRailWidth(current + RAIL_STEP);
318
- else if (event.key === 'Home') resetRailWidth();
319
- else if (event.key === 'End') setRailWidth(RAIL_MAX);
320
- else return;
321
- event.preventDefault();
322
- }
323
- /** Back to the package default (Home, or a double-click on the handle). */
324
- function resetRailWidth() {
325
- railWidth = null;
326
- try {
327
- localStorage.removeItem(RAIL_KEY);
328
- } catch {
329
- /* storage unavailable */
330
- }
331
- }
332
260
 
333
261
  // The measure is resolved ONCE, and the class and the attribute both read
334
262
  // that one answer. Two independently-worded conditionals looked symmetric
@@ -696,25 +624,14 @@
696
624
  </div>
697
625
  {/if}
698
626
  {#if collapsible && !railCollapsed && !mobileNavOpen}
699
- <!-- The rail's right edge: drag to resize, drag past the snap to
700
- collapse, arrows to nudge, Home to reset, double-click to reset.
701
- A focusable separator is the ARIA window-splitter pattern. -->
702
- <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
703
- <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
704
- <div
705
- role="separator"
706
- aria-orientation="vertical"
707
- aria-label="Resize sidebar"
708
- aria-valuenow={railWidth ?? undefined}
709
- aria-valuemin={RAIL_MIN}
710
- aria-valuemax={RAIL_MAX}
711
- tabindex="0"
712
- class="ds-shell-rail-handle"
713
- onpointerdown={startResize}
714
- onkeydown={resizeByKey}
715
- ondblclick={resetRailWidth}
627
+ <ResizeHandle
628
+ bind:width={railWidth}
629
+ bind:dragging={resizing}
630
+ storageKey="ds-shell-rail-width"
631
+ snap={170}
632
+ onsnap={() => (collapsed = true)}
716
633
  data-testid="ds-rail-resize"
717
- ></div>
634
+ />
718
635
  {/if}
719
636
  </aside>
720
637
  {/if}
@@ -0,0 +1,2 @@
1
+ import Root from './resize-handle.svelte';
2
+ export { Root, Root as ResizeHandle };
@@ -0,0 +1,4 @@
1
+ import Root from './resize-handle.svelte';
2
+ export { Root,
3
+ //
4
+ Root as ResizeHandle };
@@ -0,0 +1,140 @@
1
+ <script lang="ts">
2
+ /**
3
+ * The drag edge of a resizable side panel: the one implementation behind
4
+ * AppShell's rail, for an app whose sidebar is its own element.
5
+ *
6
+ * Render it as the LAST child of the panel, and give the panel
7
+ * `position: relative` (or sticky) and `width: <width>px` while `width` is
8
+ * set. It owns the pointer drag, the keyboard (arrows nudge, Home resets,
9
+ * End takes the maximum), the double-click reset and persistence; the panel
10
+ * owns how the number is applied.
11
+ *
12
+ * The ceiling is measured against the CONTENT beside the panel, not the
13
+ * window: the panel's parent is the row, so the panel can never grow past
14
+ * `row width - minContent`, whatever the screen.
15
+ */
16
+ import type { HTMLAttributes } from 'svelte/elements';
17
+ import { cn } from '../../../utils.js';
18
+
19
+ let {
20
+ width = $bindable(null),
21
+ dragging = $bindable(false),
22
+ min = 200,
23
+ max = 420,
24
+ minContent = 480,
25
+ step = 16,
26
+ snap,
27
+ onsnap,
28
+ storageKey,
29
+ label = 'Resize sidebar',
30
+ class: className,
31
+ ...rest
32
+ }: Omit<HTMLAttributes<HTMLDivElement>, 'role'> & {
33
+ /** Panel width in px; `null` is the panel's own default. */
34
+ width?: number | null;
35
+ /** True during a pointer drag: switch the panel's width transition off. */
36
+ dragging?: boolean;
37
+ min?: number;
38
+ max?: number;
39
+ /** Least width the content beside the panel keeps. */
40
+ minContent?: number;
41
+ /** Arrow-key nudge in px. */
42
+ step?: number;
43
+ /** A drag below this calls `onsnap` (collapse) and keeps the old width. */
44
+ snap?: number;
45
+ onsnap?: () => void;
46
+ /** localStorage key; one per app. Omitted, nothing is stored. */
47
+ storageKey?: string;
48
+ label?: string;
49
+ } = $props();
50
+
51
+ $effect(() => {
52
+ if (!storageKey) return;
53
+ try {
54
+ const stored = Number(localStorage.getItem(storageKey));
55
+ if (stored >= min && stored <= max) width = stored;
56
+ } catch {
57
+ /* storage unavailable: the default width stands */
58
+ }
59
+ });
60
+
61
+ let handle: HTMLElement;
62
+ const panel = () => handle.parentElement as HTMLElement;
63
+ const clamp = (next: number) => {
64
+ const row = panel().parentElement?.getBoundingClientRect().width || Infinity;
65
+ const ceiling = Math.max(min, Math.min(max, row - minContent));
66
+ return Math.min(ceiling, Math.max(min, Math.round(next)));
67
+ };
68
+ const store = (value: number | null) => {
69
+ if (!storageKey) return;
70
+ try {
71
+ if (value === null) localStorage.removeItem(storageKey);
72
+ else localStorage.setItem(storageKey, String(value));
73
+ } catch {
74
+ /* storage unavailable: the width lasts the session */
75
+ }
76
+ };
77
+ const set = (next: number) => {
78
+ width = clamp(next);
79
+ store(width);
80
+ };
81
+ const reset = () => {
82
+ width = null;
83
+ store(null);
84
+ };
85
+
86
+ let drag: { x: number; start: number; before: number | null } | null = null;
87
+ function down(event: PointerEvent) {
88
+ drag = { x: event.clientX, start: panel().getBoundingClientRect().width, before: width };
89
+ dragging = true;
90
+ handle.setPointerCapture(event.pointerId);
91
+ }
92
+ function move(event: PointerEvent) {
93
+ if (!drag) return;
94
+ const next = drag.start + event.clientX - drag.x;
95
+ if (snap !== undefined && next < snap) {
96
+ width = drag.before;
97
+ end(event);
98
+ onsnap?.();
99
+ } else width = clamp(next);
100
+ }
101
+ function end(event: PointerEvent) {
102
+ if (!drag) return;
103
+ const moved = width !== drag.before;
104
+ drag = null;
105
+ dragging = false;
106
+ if (handle.hasPointerCapture(event.pointerId)) handle.releasePointerCapture(event.pointerId);
107
+ if (moved && width !== null) store(width);
108
+ }
109
+ function key(event: KeyboardEvent) {
110
+ const current = width ?? panel().getBoundingClientRect().width;
111
+ if (event.key === 'ArrowLeft') set(current - step);
112
+ else if (event.key === 'ArrowRight') set(current + step);
113
+ else if (event.key === 'Home') reset();
114
+ else if (event.key === 'End') set(max);
115
+ else return;
116
+ event.preventDefault();
117
+ }
118
+ </script>
119
+
120
+ <!-- A focusable separator is the ARIA window-splitter pattern. -->
121
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
122
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
123
+ <div
124
+ bind:this={handle}
125
+ role="separator"
126
+ aria-orientation="vertical"
127
+ aria-label={label}
128
+ aria-valuenow={width ?? undefined}
129
+ aria-valuemin={min}
130
+ aria-valuemax={max}
131
+ tabindex="0"
132
+ class={cn('ds-resize-handle', className)}
133
+ onpointerdown={down}
134
+ onpointermove={move}
135
+ onpointerup={end}
136
+ onpointercancel={end}
137
+ onkeydown={key}
138
+ ondblclick={reset}
139
+ {...rest}
140
+ ></div>
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The drag edge of a resizable side panel: the one implementation behind
3
+ * AppShell's rail, for an app whose sidebar is its own element.
4
+ *
5
+ * Render it as the LAST child of the panel, and give the panel
6
+ * `position: relative` (or sticky) and `width: <width>px` while `width` is
7
+ * set. It owns the pointer drag, the keyboard (arrows nudge, Home resets,
8
+ * End takes the maximum), the double-click reset and persistence; the panel
9
+ * owns how the number is applied.
10
+ *
11
+ * The ceiling is measured against the CONTENT beside the panel, not the
12
+ * window: the panel's parent is the row, so the panel can never grow past
13
+ * `row width - minContent`, whatever the screen.
14
+ */
15
+ import type { HTMLAttributes } from 'svelte/elements';
16
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, 'role'> & {
17
+ /** Panel width in px; `null` is the panel's own default. */
18
+ width?: number | null;
19
+ /** True during a pointer drag: switch the panel's width transition off. */
20
+ dragging?: boolean;
21
+ min?: number;
22
+ max?: number;
23
+ /** Least width the content beside the panel keeps. */
24
+ minContent?: number;
25
+ /** Arrow-key nudge in px. */
26
+ step?: number;
27
+ /** A drag below this calls `onsnap` (collapse) and keeps the old width. */
28
+ snap?: number;
29
+ onsnap?: () => void;
30
+ /** localStorage key; one per app. Omitted, nothing is stored. */
31
+ storageKey?: string;
32
+ label?: string;
33
+ };
34
+ declare const ResizeHandle: import("svelte").Component<$$ComponentProps, {}, "width" | "dragging">;
35
+ type ResizeHandle = ReturnType<typeof ResizeHandle>;
36
+ export default ResizeHandle;
package/dist/styles.css CHANGED
@@ -968,6 +968,7 @@
968
968
  top: 0;
969
969
  height: 100dvh;
970
970
  width: var(--ds-shell-rail-width);
971
+ max-width: 50%;
971
972
  transition: width 200ms ease;
972
973
  }
973
974
 
@@ -1038,28 +1039,23 @@
1038
1039
  transition: none;
1039
1040
  }
1040
1041
 
1041
- /* The resize handle: a 6px grab strip astride the rail's right edge, md+
1042
- only, showing the border colour under the pointer and keyboard focus. */
1043
- .ds-shell-rail-handle {
1044
- display: none;
1045
- }
1046
- @media (min-width: 48rem) {
1047
- .ds-shell-rail-handle {
1048
- display: block;
1049
- position: absolute;
1050
- inset-block: 0;
1051
- right: -3px;
1052
- width: 6px;
1053
- cursor: col-resize;
1054
- z-index: 1;
1055
- touch-action: none;
1056
- }
1057
- .ds-shell-rail-handle:hover,
1058
- .ds-shell-rail-handle:focus-visible {
1059
- background: var(--ds-color-primary);
1060
- opacity: 0.4;
1061
- outline: none;
1062
- }
1042
+ /* The resize handle (<ResizeHandle>): a 6px grab strip astride the right
1043
+ edge of the panel it is the last child of, showing the primary colour
1044
+ under the pointer and keyboard focus. The panel must be positioned. */
1045
+ .ds-resize-handle {
1046
+ position: absolute;
1047
+ inset-block: 0;
1048
+ right: -3px;
1049
+ width: 6px;
1050
+ cursor: col-resize;
1051
+ z-index: 1;
1052
+ touch-action: none;
1053
+ }
1054
+ .ds-resize-handle:hover,
1055
+ .ds-resize-handle:focus-visible {
1056
+ background: var(--ds-color-primary);
1057
+ opacity: 0.4;
1058
+ outline: none;
1063
1059
  }
1064
1060
 
1065
1061
  /* The content measure: the shell's content box is ONE element in four
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poodle64/ui",
3
- "version": "2026.10.8",
3
+ "version": "2026.10.9",
4
4
  "description": "Household shared component layer: shadcn-svelte primitives (bits-ui) plus the composed page chrome every app builds its routes from, restyled by each app's @poodle64/design-tokens alias layer. One fix reaches every app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,13 +1,27 @@
1
1
  {
2
2
  "meta": {
3
3
  "package": "@poodle64/ui",
4
- "version": "2026.10.8",
4
+ "version": "2026.10.9",
5
5
  "generatedBy": "scripts/generate-registry.mjs",
6
6
  "source": "scripts/situations.json + package source (DO NOT EDIT the outputs by hand)",
7
- "componentCount": 67,
8
- "situationCount": 18
7
+ "componentCount": 68,
8
+ "situationCount": 19
9
9
  },
10
10
  "situations": [
11
+ {
12
+ "key": "resizable-panel",
13
+ "title": "A side panel the user can widen or narrow",
14
+ "description": "An app's own sidebar or list pane that needs a drag edge: pointer, arrow keys, double-click reset, remembered width. AppShell's rail already has it.",
15
+ "components": [
16
+ {
17
+ "name": "ResizeHandle",
18
+ "dir": "resize-handle",
19
+ "import": "@poodle64/ui/resize-handle",
20
+ "props": "width?, dragging?, min?, max?, minContent?, step?, snap?, onsnap?, storageKey?, label?",
21
+ "insteadOf": "a hand-written pointer-drag and localStorage width on your own <aside>"
22
+ }
23
+ ]
24
+ },
11
25
  {
12
26
  "key": "single-figure",
13
27
  "title": "A single labelled figure",
@@ -1,13 +1,21 @@
1
1
  # @poodle64/ui — situation → component map
2
2
 
3
3
  <!-- GENERATED by scripts/generate-registry.mjs from scripts/situations.json + package source. DO NOT EDIT. -->
4
- Generated from `@poodle64/ui@2026.10.8`. 67 components, 18 situations.
4
+ Generated from `@poodle64/ui@2026.10.9`. 68 components, 19 situations.
5
5
 
6
6
  **Read this before writing a `<div>`.** Find the SITUATION you are in below, then compose the component named for it — do not hand-build it from raw `Card` or utility classes. Import is `import { Name } from '<import path>'`. Props marked `?` are optional. This map is the retrieval step the [`frontend-design` skill] makes mandatory; the [CHI 2026 study] measured composing-from-a-registry at 95% design-system compliance against 71% for writing the CSS from a prose style guide.
7
7
 
8
8
  [`frontend-design` skill]: the household rule that loads before any UI code.
9
9
  [CHI 2026 study]: 10.1145/3772363.3798616
10
10
 
11
+ ## A side panel the user can widen or narrow
12
+
13
+ _An app's own sidebar or list pane that needs a drag edge: pointer, arrow keys, double-click reset, remembered width. AppShell's rail already has it._
14
+
15
+ | Component | Import | Key props | Reach for it instead of |
16
+ | --- | --- | --- | --- |
17
+ | `ResizeHandle` | `@poodle64/ui/resize-handle` | `width?, dragging?, min?, max?, minContent?, step?, snap?, onsnap?, storageKey?, label?` | a hand-written pointer-drag and localStorage width on your own <aside> |
18
+
11
19
  ## A single labelled figure
12
20
 
13
21
  _One headline metric that earns a tile — a figure with a label, an optional unit, and the state of the thing it measures._