@poodle64/ui 2026.10.1 → 2026.10.2

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
@@ -886,8 +886,8 @@ no module rebuilds them and a need one module finds becomes a toggle for all.
886
886
 
887
887
  | Prop | Purpose |
888
888
  | --- | --- |
889
- | `rows` | `LedgerRow`s, extended with whatever the module's columns read: `id`, `date` (ISO), `title`, `note`, `amount` (signed), `balance`, `review` (`true` or a `Status`), `attachment` (its name), `origins` (a synthetic row's statement lines). Pass every row the view covers, filtered: the ledger sorts newest first, keeps a day's rows in the order given, and mounts only those near the view. |
890
- | `columns` | The module's columns, in order after the amount. The keys `date`, `title`, `amount` and `balance` are the ledger's own cells: list one to rename or resize it (the title reads "Description" until the module names it), and list `balance` to offer the running balance at all, with `text` saying what it counts (money, a coin, units). A `cell` snippet's links and buttons take their own clicks; the rest of the cell opens the row. |
889
+ | `rows` | `LedgerRow`s, extended with whatever the module's columns read: `id`, `date` (ISO), `title`, `note`, `amount` (signed), `balance`, `review` (`true` or a `Status`) with `reviewLabel` (the chip's words, "Review" by default), `conversion` (a swap inside the account: plain amount, in no total), `attachment` (its name), `origins` (a synthetic row's statement lines). Pass every row the view covers, filtered: the ledger sorts newest first, keeps a day's rows in the order given, and mounts only those near the view. |
890
+ | `columns` | The module's columns, in order after the amount. The keys `date`, `title`, `amount` and `balance` are the ledger's own cells: list one to rename or resize it (the title reads "Description" until the module names it), and list `balance` to offer the running balance at all, with `text` saying what it counts (money, a coin, units). A `cell` snippet's links and buttons take their own clicks; the rest of the cell opens the row. A column's `sort` gives the real value its head sorts by (a number, not its text); a `text` column sorts by its text without one, a `cell`-only column does not sort, and the balance never does. |
891
891
  | `preferences`, `onPreferencesChange` | The viewer's `{ columns, period }`. Missing fields take the module's defaults (each column's `on`, the balance off, grouped by month); an unknown column key is ignored. The ledger stores nothing. |
892
892
  | `fyStart` | The month a financial year starts, 1–12. July by default; anything else groups from July and logs an error in development. |
893
893
  | `locale`, `currency` | Every figure, amounts, nets and counts, in this locale (`AU_LOCALE` by default) and currency (AUD). Dates keep the drawn "15 Sep": the ledger's words are English. |
@@ -900,16 +900,22 @@ no module rebuilds them and a need one module finds becomes a toggle for all.
900
900
  Settled here, the same in every module, and not props:
901
901
 
902
902
  - **Two lines a row**, the note under the title, the pair centred on the row; a
903
- row with no note centres its title. The checkbox, the review dot, the date and
903
+ row with no note centres its title. The checkbox, the review chip, the date and
904
904
  every single-value cell centre too.
905
- - **One mark per kind.** A review dot at the row's left edge, never a status
906
- chip; a synthetic row's "Not as printed" badge leads its second line and
905
+ - **One mark per kind.** A review chip (StatusBadge's dot and label on a tint)
906
+ beside the title on line one, which truncates before the chip does; a synthetic row's "Not as printed" badge leads its second line and
907
907
  opens the row in place to the lines it was made from.
908
908
  - **PocketSmith's look.** No panel around the list; a head card exactly as wide
909
909
  as the group cards, its columns on theirs; one rounded card per period with
910
910
  hairlines between rows; the period's label, count and net on the page ground
911
911
  above its card; figures in the body face with tabular numerals, money out
912
912
  bracketed and money in green (`formatCurrency(v, { negative: 'brackets' })`).
913
+ - **Sorting and the groups' one control.** Click a column head to sort by it,
914
+ largest or newest first; click again to reverse. A sorted list is one flat
915
+ run with no group heads and shows the Date column even by day. Until the
916
+ viewer sorts, rows sit newest first in their groups. A chevron in the head's
917
+ gutter opens or closes every group at once; where rows tick, the gutter
918
+ widens to hold it beside the select-all checkbox.
913
919
  - **The Columns menu** holds every column as a tick, the three fixed ones
914
920
  locked and the running balance last, then the period: financial year,
915
921
  calendar year, month, week (from Monday), day (the date heads the group and
@@ -16,6 +16,11 @@ export declare function groupRows<R extends LedgerRow>(rows: readonly R[], perio
16
16
  key: string;
17
17
  rows: R[];
18
18
  }[];
19
+ /**
20
+ * The rows ordered by a column's value, `dir` 1 ascending and -1 descending;
21
+ * rows without a value come last either way, and equal values keep newest first.
22
+ */
23
+ export declare function sortRows<R extends LedgerRow>(rows: readonly R[], value: (row: R) => number | string | null | undefined, dir: 1 | -1): R[];
19
24
  /** Whole cents, so a group's sum carries no float error. */
20
25
  export declare function cents(value: number | string | null | undefined): number;
21
26
  /** Money as the ledger prints it: in plain, out in brackets. */
@@ -122,6 +122,23 @@ export function groupRows(rows, period, fyStart = 7) {
122
122
  }
123
123
  return out;
124
124
  }
125
+ /**
126
+ * The rows ordered by a column's value, `dir` 1 ascending and -1 descending;
127
+ * rows without a value come last either way, and equal values keep newest first.
128
+ */
129
+ export function sortRows(rows, value, dir) {
130
+ const keyed = (groupRows(rows, 'none')[0]?.rows ?? []).map((row) => ({ row, v: value(row) }));
131
+ const missing = (v) => v === null || v === undefined || v === '';
132
+ keyed.sort((a, b) => {
133
+ if (missing(a.v) || missing(b.v))
134
+ return Number(missing(a.v)) - Number(missing(b.v));
135
+ const cmp = typeof a.v === 'number' && typeof b.v === 'number'
136
+ ? a.v - b.v
137
+ : String(a.v).localeCompare(String(b.v), undefined, { numeric: true, sensitivity: 'base' });
138
+ return dir * cmp;
139
+ });
140
+ return keyed.map((k) => k.row);
141
+ }
125
142
  /** Whole cents, so a group's sum carries no float error. */
126
143
  export function cents(value) {
127
144
  const n = Number(value);
@@ -18,11 +18,13 @@
18
18
  import { DEV } from 'esm-env';
19
19
  import ChevronDown from '@lucide/svelte/icons/chevron-down';
20
20
  import ChevronRight from '@lucide/svelte/icons/chevron-right';
21
+ import ChevronUp from '@lucide/svelte/icons/chevron-up';
21
22
  import Paperclip from '@lucide/svelte/icons/paperclip';
22
23
  import X from '@lucide/svelte/icons/x';
23
24
  import Button from '../button/button.svelte';
24
25
  import { Checkbox } from '../checkbox/index.js';
25
26
  import EmptyState from '../empty-state/empty-state.svelte';
27
+ import StatusBadge from '../status-badge/status-badge.svelte';
26
28
  import LedgerColumns from './ledger-columns.svelte';
27
29
  import { AU_LOCALE } from '../../../format.js';
28
30
  import { cn } from '../../../utils.js';
@@ -36,6 +38,7 @@
36
38
  ledgerItems,
37
39
  ledgerMoney,
38
40
  periodLabel,
41
+ sortRows,
39
42
  windowOf
40
43
  } from './ledger.js';
41
44
  import type { LedgerColumn, LedgerOpenContext, LedgerPreferences, LedgerRow } from './types.js';
@@ -109,12 +112,18 @@
109
112
  } = $props();
110
113
 
111
114
  const BUILT_IN: Record<string, Omit<LedgerColumn<R>, 'key'>> = {
112
- date: { label: 'Date', width: { narrow: '4.25rem', wide: '7rem' } },
115
+ date: { label: 'Date', width: { narrow: '4.25rem', wide: '7rem' }, sort: (r) => r.date.slice(0, 10) },
113
116
  title: {
114
117
  label: 'Description',
118
+ sort: (r) => r.title,
115
119
  width: { narrow: 'minmax(0, 1fr)', wide: 'minmax(16rem, 1.5fr)' }
116
120
  },
117
- amount: { label: 'Amount', width: { narrow: '7.25rem', wide: '9.5rem' }, align: 'end' },
121
+ amount: {
122
+ label: 'Amount',
123
+ width: { narrow: '7.25rem', wide: '9.5rem' },
124
+ align: 'end',
125
+ sort: (r) => cents(r.amount)
126
+ },
118
127
  balance: {
119
128
  label: 'Running balance',
120
129
  head: 'Balance',
@@ -123,7 +132,9 @@
123
132
  on: false
124
133
  }
125
134
  };
135
+ /** Ticking adds a checkbox to the gutter, so it widens to hold the chevron beside it. */
126
136
  const GUTTER = '2.5rem';
137
+ const GUTTER_TICKS = '3.5rem';
127
138
  const CELL = 'min-w-0 truncate px-2';
128
139
  const END = 'pr-5 text-right';
129
140
  /** The row's height rides the package's density ramp: 3.5rem, 3rem compact. */
@@ -202,7 +213,27 @@
202
213
  );
203
214
  const WIDE = $derived(mode === 'wide');
204
215
  const PHONE = $derived(mode === 'phone');
205
- const BY_DAY = $derived(prefs.period === 'day');
216
+
217
+ const selectable = $derived(!!bulkActions && !PHONE);
218
+ // ── Sorting: the viewer's own, kept here until a head is clicked again ──
219
+ let sort = $state<{ key: string; dir: 1 | -1 } | null>(null);
220
+ /** What a column sorts by; the running balance depends on the order, so never it. */
221
+ const sorter = (c: { key: string; sort?: LedgerColumn<R>['sort']; text?: LedgerColumn<R>['text'] }) =>
222
+ c.key === 'balance' ? undefined : (c.sort ?? c.text);
223
+ /** Click a head to sort by it, largest or newest first; again to reverse. */
224
+ function sortBy(key: string) {
225
+ sort = sort?.key === key ? { key, dir: sort.dir === 1 ? -1 : 1 } : { key, dir: -1 };
226
+ }
227
+ /** The sort in force: a column the viewer has since unticked no longer sorts. */
228
+ const activeSort = $derived.by(() => {
229
+ const col = sort && resolved.find((c) => c.key === sort!.key);
230
+ return sort && col && sorter(col) && (col.locked || prefs.columns.includes(col.key))
231
+ ? sort
232
+ : null;
233
+ });
234
+ /** Until the viewer sorts, rows sit newest first in their period's groups. */
235
+ const grouped = $derived(prefs.period !== 'none' && !activeSort);
236
+ const BY_DAY = $derived(grouped && prefs.period === 'day');
206
237
 
207
238
  const shown = $derived(
208
239
  resolved.filter(
@@ -210,7 +241,7 @@
210
241
  )
211
242
  );
212
243
  const template = $derived(
213
- `${GUTTER} ${shown.map((c) => (WIDE ? c.track.wide : c.track.narrow)).join(' ')}`
244
+ `${selectable ? GUTTER_TICKS : GUTTER} ${shown.map((c) => (WIDE ? c.track.wide : c.track.narrow)).join(' ')}`
214
245
  );
215
246
  /** A column's grid line, the gutter being 1; 0 when it is not shown. */
216
247
  const column = (key: string) => {
@@ -229,7 +260,11 @@
229
260
  if (DEV && !validStart)
230
261
  console.error(`Ledger: fyStart is a month from 1 to 12, not ${fyStart}; grouping from July.`);
231
262
  });
232
- const groups = $derived(groupRows(rows, prefs.period, fy));
263
+ const groups = $derived.by(() => {
264
+ const by = activeSort && sorter(resolved.find((c) => c.key === activeSort.key)!);
265
+ if (activeSort && by) return [{ key: '', rows: sortRows(rows, by, activeSort.dir) }];
266
+ return groupRows(rows, prefs.period, fy);
267
+ });
233
268
  /** Closed groups, keyed with their period so a new period starts open. */
234
269
  const closed = new SvelteSet<string>();
235
270
  const groupId = (key: string) => `${prefs.period}:${key}`;
@@ -241,6 +276,17 @@
241
276
  else set.add(value);
242
277
  }
243
278
 
279
+ const allOpen = $derived(groups.every((g) => !closed.has(groupId(g.key))));
280
+ /** One control for every group: open them all, or close them all. */
281
+ function toggleAll() {
282
+ const closing = allOpen;
283
+ if (closing && open !== null) setOpen(null);
284
+ for (const g of groups) {
285
+ if (closing) closed.add(groupId(g.key));
286
+ else closed.delete(groupId(g.key));
287
+ }
288
+ }
289
+
244
290
  /** Closing a group closes the row open in it, and says so. */
245
291
  function toggleGroup(g: { key: string; rows: readonly R[] }) {
246
292
  const id = groupId(g.key);
@@ -249,7 +295,6 @@
249
295
  }
250
296
 
251
297
  // ── Selection and the opened row ──
252
- const selectable = $derived(!!bulkActions && !PHONE);
253
298
  const openable = $derived(!!editor);
254
299
  const ticked = $derived(new Set<Id>(selected));
255
300
  const tickedRows = $derived(groups.flatMap((g) => g.rows).filter((r) => ticked.has(r.id)));
@@ -287,7 +332,7 @@
287
332
  // bar, the cards and the head keep their true geometry.
288
333
  const items = $derived(
289
334
  ledgerItems(groups, {
290
- heads: prefs.period !== 'none',
335
+ heads: grouped,
291
336
  closed: (key) => closed.has(groupId(key)),
292
337
  open: openable ? open : null
293
338
  })
@@ -301,7 +346,7 @@
301
346
  let measured = $state(0);
302
347
  let rowEstimate = $state(REM * 3.5);
303
348
  let headEstimate = $state(REM * 3.125);
304
- const sizeKey = (key: string) => `${mode}|${prefs.period}|${key}`;
349
+ const sizeKey = (key: string) => `${mode}|${grouped ? prefs.period : 'flat'}|${key}`;
305
350
 
306
351
  const starts = $derived.by(() => {
307
352
  void measured;
@@ -367,6 +412,7 @@
367
412
  let inn = 0;
368
413
  let out = 0;
369
414
  for (const r of rs) {
415
+ if (r.conversion) continue;
370
416
  const c = cents(r.amount);
371
417
  if (c > 0) inn += c;
372
418
  else out += c;
@@ -378,6 +424,8 @@
378
424
  const number = (n: number) => n.toLocaleString(locale);
379
425
  const count = (n: number) => `${number(n)} ${n === 1 ? noun[0] : noun[1]}`;
380
426
  const income = (value: number | string | null | undefined) => cents(value) > 0;
427
+ /** Green for money in; a conversion is neither in nor out, so plain. */
428
+ const isIncome = (r: R) => !r.conversion && income(r.amount);
381
429
  const reviewOf = (r: R) => (r.review === true ? 'warning' : r.review || null);
382
430
  const balanceText = (r: R) =>
383
431
  balanceColumn?.text ? balanceColumn.text(r) : r.balance == null ? '' : money(r.balance);
@@ -431,19 +479,12 @@
431
479
  </button>
432
480
  {/snippet}
433
481
 
434
- {#snippet dot(r: R, beside: boolean)}
482
+ {#snippet chip(r: R)}
435
483
  {@const status = reviewOf(r)}
436
484
  {#if status}
437
- <span
438
- class={cn(
439
- 'ds-dot size-1.5',
440
- `ds-dot-${status}`,
441
- beside && 'absolute top-1/2 right-[calc(50%+0.75rem)] -translate-y-1/2'
442
- )}
443
- role="img"
444
- aria-label="Needs review"
445
- data-slot="ledger-review"
446
- ></span>
485
+ <span class="flex-none" data-slot="ledger-review">
486
+ <StatusBadge {status} label={r.reviewLabel ?? 'Review'} />
487
+ </span>
447
488
  {/if}
448
489
  {/snippet}
449
490
 
@@ -467,6 +508,7 @@
467
508
  <span class="flex min-w-0 items-center gap-1.5">
468
509
  <span class="min-w-0 truncate">{r.title}</span>
469
510
  {#if r.attachment}{@render clip(r)}{/if}
511
+ {@render chip(r)}
470
512
  </span>
471
513
  {#if r.note || r.origins}
472
514
  <span class="text-muted-foreground flex min-w-0 items-center gap-2 text-xs font-normal">
@@ -482,7 +524,7 @@
482
524
  END,
483
525
  through,
484
526
  'self-center tabular-nums',
485
- income(r.amount) && 'text-status-success'
527
+ isIncome(r) && 'text-status-success'
486
528
  )}
487
529
  {style}
488
530
  data-slot="ledger-amount"
@@ -611,9 +653,9 @@
611
653
  {/if}
612
654
  <span class="pointer-events-none flex min-w-0 flex-col justify-center gap-0.5">
613
655
  <span class="flex min-w-0 items-center gap-1.5">
614
- {@render dot(r, false)}
615
656
  <span class="min-w-0 truncate font-medium">{r.title}</span>
616
657
  {#if r.attachment}{@render clip(r)}{/if}
658
+ {@render chip(r)}
617
659
  </span>
618
660
  {#if two || r.origins}
619
661
  <span class="text-muted-foreground flex min-w-0 items-center gap-2 text-xs">
@@ -623,7 +665,7 @@
623
665
  {/if}
624
666
  </span>
625
667
  <span class="pointer-events-none flex flex-col items-end justify-center gap-0.5 tabular-nums">
626
- <span class={cn(income(r.amount) && 'text-status-success')} data-slot="ledger-amount"
668
+ <span class={cn(isIncome(r) && 'text-status-success')} data-slot="ledger-amount"
627
669
  >{money(r.amount)}</span
628
670
  >
629
671
  {#if balanceOn && balanceText(r)}<span class="text-muted-foreground text-xs"
@@ -643,7 +685,6 @@
643
685
  data-slot="ledger-row"
644
686
  >
645
687
  <span class="relative flex items-center justify-center" style="grid-column: 1; grid-row: 1;">
646
- {@render dot(r, selectable)}
647
688
  {#if selectable}
648
689
  <Checkbox
649
690
  checked={on}
@@ -732,7 +773,10 @@
732
773
  <span class="min-w-0 flex-1 truncate">{metaText}</span>
733
774
  <span class={cn('tabular-nums', netTone)}>{net}</span>
734
775
  {:else}
735
- <span class="grid place-items-center" style="grid-column: 1;">
776
+ <span
777
+ class={cn('grid place-items-center', selectable && 'justify-items-start pl-[5px]')}
778
+ style="grid-column: 1;"
779
+ >
736
780
  {#if !BY_DAY}<ChevronRight
737
781
  class={cn('size-3.5 transition-transform', isOpen && 'rotate-90')}
738
782
  />{/if}
@@ -776,7 +820,7 @@
776
820
  <span class="text-sm font-semibold whitespace-nowrap">{number(tickedRows.length)} ticked</span
777
821
  >
778
822
  <span class="text-muted-foreground mr-2 text-xs whitespace-nowrap tabular-nums">
779
- net {money(tickedRows.reduce((s, r) => s + cents(r.amount), 0) / 100)}
823
+ net {money(tickedRows.reduce((s, r) => s + (r.conversion ? 0 : cents(r.amount)), 0) / 100)}
780
824
  </span>
781
825
  {@render bulkActions?.(tickedRows)}
782
826
  <span class="flex-1"></span>
@@ -829,7 +873,23 @@
829
873
  style:grid-template-columns={template}
830
874
  data-slot="ledger-head"
831
875
  >
832
- <span class="grid place-items-center">
876
+ <span class="relative grid place-items-center">
877
+ {#if grouped && !BY_DAY && groups.length}
878
+ <button
879
+ type="button"
880
+ class={cn(
881
+ 'text-muted-foreground hover:text-foreground absolute top-1/2 z-10 grid size-4 -translate-y-1/2 cursor-pointer place-items-center rounded-md',
882
+ selectable ? 'left-1' : 'left-1/2 -translate-x-1/2',
883
+ RING
884
+ )}
885
+ aria-label={allOpen ? 'Collapse every group' : 'Expand every group'}
886
+ aria-expanded={allOpen}
887
+ onclick={toggleAll}
888
+ data-slot="ledger-all-groups"
889
+ >
890
+ <ChevronRight class={cn('size-3.5 transition-transform', allOpen && 'rotate-90')} />
891
+ </button>
892
+ {/if}
833
893
  {#if selectable}
834
894
  <Checkbox
835
895
  checked={allTicked}
@@ -844,15 +904,46 @@
844
904
  {/if}
845
905
  </span>
846
906
  {#each shown as c (c.key)}
847
- <span
848
- class={cn(
849
- CELL,
850
- 'text-muted-foreground text-2xs tracking-eyebrow font-semibold uppercase',
851
- c.align === 'end' && END
852
- )}
853
- >
854
- {c.head ?? c.label}
855
- </span>
907
+ {@const sortable = !!sorter(c)}
908
+ {@const mine = activeSort?.key === c.key ? activeSort : null}
909
+ {@const label = c.head ?? c.label}
910
+ {@const headClass = cn(
911
+ CELL,
912
+ 'text-muted-foreground text-2xs tracking-eyebrow font-semibold uppercase',
913
+ c.align === 'end' && END
914
+ )}
915
+ {#if sortable}
916
+ <button
917
+ type="button"
918
+ class={cn(
919
+ headClass,
920
+ 'group flex cursor-pointer items-center gap-0.5 rounded-md hover:text-foreground',
921
+ RING_INSET,
922
+ c.align === 'end' && 'justify-end',
923
+ mine && 'text-foreground'
924
+ )}
925
+ aria-label={mine
926
+ ? `Sorted by ${c.label}, ${mine.dir === -1 ? 'descending' : 'ascending'}. Click to reverse`
927
+ : `Sort by ${c.label}`}
928
+ onclick={() => sortBy(c.key)}
929
+ data-slot="ledger-sort"
930
+ data-sort={mine ? (mine.dir === -1 ? 'desc' : 'asc') : undefined}
931
+ >
932
+ <span class="truncate">{label}</span>
933
+ {#if mine?.dir === 1}
934
+ <ChevronUp class="size-3 flex-none" />
935
+ {:else}
936
+ <ChevronDown
937
+ class={cn(
938
+ 'size-3 flex-none',
939
+ !mine && 'opacity-0 group-hover:opacity-50 group-focus-visible:opacity-50'
940
+ )}
941
+ />
942
+ {/if}
943
+ </button>
944
+ {:else}
945
+ <span class={headClass}>{label}</span>
946
+ {/if}
856
947
  {/each}
857
948
  </div>
858
949
  </div>
@@ -25,8 +25,17 @@ export interface LedgerRow {
25
25
  amount: number | string;
26
26
  /** What the running balance counts after this row: money, a coin, units. */
27
27
  balance?: number | string | null;
28
- /** A dot at the row's left edge. `true` is a warning. */
28
+ /** A chip beside the title, on line one. `true` is a warning. */
29
29
  review?: boolean | Status;
30
+ /** The review chip's words, in the module's own voice. Defaults to "Review". */
31
+ reviewLabel?: string;
32
+ /**
33
+ * The amount is a conversion inside the account (dollars swapped for a
34
+ * coin, so what the account is worth does not change). It shows plain,
35
+ * neither income green nor the muted outgoing tone, and counts in no
36
+ * group's in, out or net, nor the ticked rows' net.
37
+ */
38
+ conversion?: boolean;
30
39
  /** The attachment's name: a paperclip after the title, the name on hover. */
31
40
  attachment?: string | null;
32
41
  /** A row not as printed: the statement lines it was made from. */
@@ -55,6 +64,13 @@ export interface LedgerColumn<R extends LedgerRow = LedgerRow> {
55
64
  align?: 'start' | 'end';
56
65
  /** Ticked until the viewer chooses. Defaults to true, and to false for `balance`. */
57
66
  on?: boolean;
67
+ /**
68
+ * What a click on the head sorts this column by: the real value, a number
69
+ * rather than its display text. Without it a column with `text` sorts by
70
+ * that text and a `cell`-only column does not sort. The running balance
71
+ * never sorts, since it depends on the order.
72
+ */
73
+ sort?: (row: R) => number | string | null | undefined;
58
74
  /** A plain cell. */
59
75
  text?: (row: R) => string;
60
76
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poodle64/ui",
3
- "version": "2026.10.1",
3
+ "version": "2026.10.2",
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.10.1",
4
+ "version": "2026.10.2",
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": 62,
@@ -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.10.1`. 62 components, 18 situations.
4
+ Generated from `@poodle64/ui@2026.10.2`. 62 components, 18 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