@poodle64/ui 2026.8.13 → 2026.8.15

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
@@ -751,7 +751,11 @@ node harness/additivity.mjs ui-v2026.8.3
751
751
  Tailwind chain, exactly as a consuming app wires it — a class that generates no
752
752
  rule, a colour that resolves to nothing, a registered theme key nothing reads, a
753
753
  variant whose selector cannot match the DOM, and a sizing utility that has
754
- silently changed meaning all fail there rather than shipping.
754
+ silently changed meaning all fail there rather than shipping. It also sweeps the
755
+ source for the one craft defect none of those can see — a content container that
756
+ draws a border and declares no surface, so it computes transparent and the page
757
+ shows through it (`src/test/bordered-surface.test.ts`, which carries its own
758
+ limits at the foot of the file).
755
759
 
756
760
  `pnpm run test:browser` is the leg nothing else can stand in for: jsdom applies
757
761
  no stylesheet and returns unresolved `var(…)` literals, so it passes just as
@@ -69,17 +69,28 @@
69
69
  <EmptyState title={notFoundTitle} description={notFoundDescription} />
70
70
  {:else}
71
71
  <DetailPanel eyebrow="Document" title={document.title} titleFace="display" footer={actions}>
72
- <div class="grid gap-4 sm:grid-cols-2">
72
+ <!-- min-w-0 on the grid AND on every cell and value below: a grid or
73
+ flex item defaults to min-width:auto, so it grows to its widest
74
+ child and `truncate` can never shrink it. A 64-character content
75
+ hash and a filesystem path therefore took this card to 1,124px
76
+ inside a 390px screen and pushed every VALUE off the side, leaving
77
+ the labels alone on it. -->
78
+ <div class="grid min-w-0 gap-4 sm:grid-cols-2">
73
79
  {#if document.fields?.length}
74
- <div>
80
+ <div class="min-w-0">
75
81
  <h3 class="text-muted-foreground text-2xs mb-2 font-semibold tracking-wide uppercase">
76
82
  Details
77
83
  </h3>
78
84
  <dl class="space-y-1 text-sm">
79
85
  {#each document.fields as field (field.label)}
80
86
  <div class="flex justify-between gap-4">
81
- <dt class="text-muted-foreground">{field.label}</dt>
82
- <dd class={field.mono ? 'truncate font-mono text-xs' : 'text-right'}>
87
+ <dt class="text-muted-foreground shrink-0">{field.label}</dt>
88
+ <dd
89
+ class={field.mono
90
+ ? 'min-w-0 truncate font-mono text-xs'
91
+ : 'min-w-0 truncate text-right'}
92
+ title={field.value}
93
+ >
83
94
  {field.value}
84
95
  </dd>
85
96
  </div>
@@ -89,14 +100,16 @@
89
100
  {/if}
90
101
 
91
102
  {#if document.locations?.length}
92
- <div>
103
+ <div class="min-w-0">
93
104
  <h3 class="text-muted-foreground text-2xs mb-2 font-semibold tracking-wide uppercase">
94
105
  Locations
95
106
  </h3>
96
107
  <ul class="space-y-1 text-sm">
97
108
  {#each document.locations as location (location.path)}
98
109
  <li class="flex items-center justify-between gap-2">
99
- <span class="truncate font-mono text-xs">{location.path}</span>
110
+ <span class="min-w-0 truncate font-mono text-xs" title={location.path}
111
+ >{location.path}</span
112
+ >
100
113
  <div class="flex shrink-0 items-center gap-1">
101
114
  {#if location.primary}
102
115
  <Badge variant="secondary">primary</Badge>
@@ -112,7 +125,7 @@
112
125
  {/if}
113
126
 
114
127
  {#if document.tags?.length}
115
- <div>
128
+ <div class="min-w-0">
116
129
  <h3 class="text-muted-foreground text-2xs mb-2 font-semibold tracking-wide uppercase">
117
130
  Tags
118
131
  </h3>
@@ -125,7 +138,7 @@
125
138
  {/if}
126
139
 
127
140
  {#if document.memberships?.length}
128
- <div>
141
+ <div class="min-w-0">
129
142
  <h3 class="text-muted-foreground text-2xs mb-2 font-semibold tracking-wide uppercase">
130
143
  Collections
131
144
  </h3>
@@ -4,6 +4,13 @@
4
4
  // a consumer that passes no `collections` never sees an empty column —
5
5
  // which is what lets one table serve both the whole-catalogue view and a
6
6
  // single collection's slice.
7
+ //
8
+ // Columns also come and go with the VIEWPORT. The title is the only column
9
+ // a phone has room for, so the secondary ones drop by breakpoint rather
10
+ // than pushing the table into its own horizontal scroller — measured on a
11
+ // real catalogue at 390px, where four columns of full-length titles, tag
12
+ // lists and collection lists took the table to 1,612px and every row
13
+ // scrolled sideways.
7
14
  import {
8
15
  Table as TableRoot,
9
16
  TableBody,
@@ -31,16 +38,28 @@
31
38
  const hasBadges = $derived(documents.some((d) => d.badges?.length));
32
39
  </script>
33
40
 
34
- <div class="rounded-md border">
41
+ <!-- The card surface, not a bare border. This wrapper holds CONTENT, so it has
42
+ to paint one: with `border` alone the table and every ancestor computed
43
+ rgba(0,0,0,0) at 1440x900 in both themes, so the page background showed
44
+ straight through a container sitting beside opaque cards. `bg-card
45
+ border-border ds-edge` is the same trio Panel, StatCard, StatList,
46
+ DetailPanel, EmptyState and DataTableTanstack use — and `border-border`
47
+ matters on its own: a bare `border` resolves to currentColor, which was
48
+ drawing this hairline in full-strength foreground rather than the border
49
+ token. `overflow-hidden` comes with the surface, as it does on every
50
+ sibling: a row's `hover:bg-muted/50` otherwise paints square corners over
51
+ the wrapper's radius now that there is a surface to paint over.
52
+ Gated by src/test/bordered-surface.test.ts. -->
53
+ <div class="bg-card border-border ds-edge overflow-hidden rounded-md border">
35
54
  <TableRoot>
36
55
  <TableHeader>
37
56
  <TableRow>
38
57
  <TableHead>Title</TableHead>
39
58
  {#if hasTags}
40
- <TableHead class="w-40">Tags</TableHead>
59
+ <TableHead class="hidden w-40 xl:table-cell">Tags</TableHead>
41
60
  {/if}
42
61
  {#if hasCollections}
43
- <TableHead class="w-40">Collections</TableHead>
62
+ <TableHead class="hidden w-40 lg:table-cell">Collections</TableHead>
44
63
  {/if}
45
64
  {#if hasBadges}
46
65
  <TableHead class="w-40 text-center">Status</TableHead>
@@ -50,11 +69,16 @@
50
69
  <TableBody>
51
70
  {#each documents as doc (doc.id)}
52
71
  <TableRow>
53
- <TableCell>
72
+ <!-- max-w-0 + truncate, together: TableCell sets whitespace-nowrap, so
73
+ without a width bound this column stretches the table to its widest
74
+ title, and with a bound but no truncate the text overflows UNDER the
75
+ next cell — which then swallows the link's clicks. -->
76
+ <TableCell class="max-w-0">
54
77
  {#if documentHref}
55
78
  <a
56
79
  href={documentHref(doc)}
57
- class="font-medium hover:underline"
80
+ title={doc.title}
81
+ class="block truncate font-medium hover:underline"
58
82
  onclick={() => onOpen?.(doc)}
59
83
  >
60
84
  {doc.title}
@@ -62,22 +86,29 @@
62
86
  {:else if onOpen}
63
87
  <button
64
88
  type="button"
65
- class="text-left font-medium hover:underline"
89
+ title={doc.title}
90
+ class="block w-full truncate text-left font-medium hover:underline"
66
91
  onclick={() => onOpen(doc)}
67
92
  >
68
93
  {doc.title}
69
94
  </button>
70
95
  {:else}
71
- <span class="font-medium">{doc.title}</span>
96
+ <span class="block truncate font-medium" title={doc.title}>{doc.title}</span>
72
97
  {/if}
73
98
  </TableCell>
74
99
  {#if hasTags}
75
- <TableCell class="text-muted-foreground text-xs">
100
+ <TableCell
101
+ class="text-muted-foreground hidden max-w-40 truncate text-xs xl:table-cell"
102
+ title={doc.tags?.join(', ')}
103
+ >
76
104
  {doc.tags?.join(', ') || '—'}
77
105
  </TableCell>
78
106
  {/if}
79
107
  {#if hasCollections}
80
- <TableCell class="text-muted-foreground text-xs">
108
+ <TableCell
109
+ class="text-muted-foreground hidden max-w-40 truncate text-xs lg:table-cell"
110
+ title={doc.collections?.join(', ')}
111
+ >
81
112
  {doc.collections?.join(', ') || '—'}
82
113
  </TableCell>
83
114
  {/if}
@@ -15,6 +15,7 @@
15
15
  import { cn, type WithElementRef } from '../../../utils.js';
16
16
  import Search from '@lucide/svelte/icons/search';
17
17
  import LibraryIcon from '@lucide/svelte/icons/library';
18
+ import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
18
19
  import X from '@lucide/svelte/icons/x';
19
20
  import { Input } from '../input/index.js';
20
21
  import Button from '../button/button.svelte';
@@ -110,6 +111,15 @@
110
111
  }
111
112
  }
112
113
 
114
+ /**
115
+ * Whether the facet rail is revealed below `md`. The only state this
116
+ * component owns, and deliberately so: it is a disclosure, not a filter.
117
+ * The rail used to be `hidden md:block` with nothing to reveal it, so on a
118
+ * phone the whole of filtering was simply absent — no drawer, no
119
+ * disclosure, no way to reach it at all.
120
+ */
121
+ let facetsOpen = $state(false);
122
+
113
123
  const shownTotal = $derived(total ?? documents.length);
114
124
  // Floored at 1 so a consumer's limit: 0 cannot put Infinity in the pager.
115
125
  const pageSize = $derived(Math.max(1, limit));
@@ -161,11 +171,24 @@
161
171
  </div>
162
172
  {/if}
163
173
 
174
+ {#if facets.length > 0}
175
+ <Button
176
+ variant="outline"
177
+ size="sm"
178
+ class="w-fit md:hidden"
179
+ aria-expanded={facetsOpen}
180
+ onclick={() => (facetsOpen = !facetsOpen)}
181
+ >
182
+ <SlidersHorizontal class="size-4" />
183
+ {facetsOpen ? 'Hide filters' : 'Filters'}
184
+ </Button>
185
+ {/if}
186
+
164
187
  <div
165
188
  class={facets.length > 0 ? 'grid gap-6 md:grid-cols-[220px_minmax(0,1fr)]' : 'grid gap-6'}
166
189
  >
167
190
  {#if facets.length > 0}
168
- <aside class="hidden md:block" aria-label="Filters">
191
+ <aside class={facetsOpen ? 'block' : 'hidden md:block'} aria-label="Filters">
169
192
  <FacetRail {facets} onChange={onFacetChange} />
170
193
  </aside>
171
194
  {/if}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poodle64/ui",
3
- "version": "2026.8.13",
3
+ "version": "2026.8.15",
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,7 +1,7 @@
1
1
  {
2
2
  "meta": {
3
3
  "package": "@poodle64/ui",
4
- "version": "2026.8.13",
4
+ "version": "2026.8.15",
5
5
  "generatedBy": "scripts/generate-registry.mjs",
6
6
  "source": "scripts/situations.json + package source (DO NOT EDIT the outputs by hand)",
7
7
  "componentCount": 54,
@@ -1,7 +1,7 @@
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.8.13`. 54 components, 14 situations.
4
+ Generated from `@poodle64/ui@2026.8.15`. 54 components, 14 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