@poodle64/ui 2026.8.11 → 2026.8.12

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 (52) hide show
  1. package/README.md +164 -20
  2. package/dist/components/ui/collection-detail/collection-detail.svelte +169 -0
  3. package/dist/components/ui/collection-detail/collection-detail.svelte.d.ts +47 -0
  4. package/dist/components/ui/collection-detail/index.d.ts +3 -0
  5. package/dist/components/ui/collection-detail/index.js +2 -0
  6. package/dist/components/ui/document-detail/document-detail.svelte +184 -0
  7. package/dist/components/ui/document-detail/document-detail.svelte.d.ts +34 -0
  8. package/dist/components/ui/document-detail/index.d.ts +3 -0
  9. package/dist/components/ui/document-detail/index.js +2 -0
  10. package/dist/components/ui/library-browse/document-table.svelte +101 -0
  11. package/dist/components/ui/library-browse/document-table.svelte.d.ts +10 -0
  12. package/dist/components/ui/library-browse/facet-rail.svelte +62 -0
  13. package/dist/components/ui/library-browse/facet-rail.svelte.d.ts +8 -0
  14. package/dist/components/ui/library-browse/index.d.ts +3 -0
  15. package/dist/components/ui/library-browse/index.js +2 -0
  16. package/dist/components/ui/library-browse/library-browse.svelte +235 -0
  17. package/dist/components/ui/library-browse/library-browse.svelte.d.ts +44 -0
  18. package/dist/components/ui/library-browse/types.d.ts +101 -0
  19. package/dist/components/ui/library-browse/types.js +1 -0
  20. package/dist/components/ui/schema-form/context.d.ts +31 -0
  21. package/dist/components/ui/schema-form/context.js +10 -0
  22. package/dist/components/ui/schema-form/data.d.ts +42 -0
  23. package/dist/components/ui/schema-form/data.js +97 -0
  24. package/dist/components/ui/schema-form/dispatch.d.ts +35 -0
  25. package/dist/components/ui/schema-form/dispatch.js +139 -0
  26. package/dist/components/ui/schema-form/index.d.ts +5 -0
  27. package/dist/components/ui/schema-form/index.js +5 -0
  28. package/dist/components/ui/schema-form/schema-form-control.svelte +146 -0
  29. package/dist/components/ui/schema-form/schema-form-control.svelte.d.ts +7 -0
  30. package/dist/components/ui/schema-form/schema-form-element.svelte +105 -0
  31. package/dist/components/ui/schema-form/schema-form-element.svelte.d.ts +7 -0
  32. package/dist/components/ui/schema-form/schema-form-widget.svelte +169 -0
  33. package/dist/components/ui/schema-form/schema-form-widget.svelte.d.ts +18 -0
  34. package/dist/components/ui/schema-form/schema-form.svelte +153 -0
  35. package/dist/components/ui/schema-form/schema-form.svelte.d.ts +4 -0
  36. package/dist/components/ui/schema-form/types.d.ts +61 -0
  37. package/dist/components/ui/schema-form/types.js +21 -0
  38. package/dist/components/ui/schema-form/widgets/radio-field.svelte +48 -0
  39. package/dist/components/ui/schema-form/widgets/radio-field.svelte.d.ts +15 -0
  40. package/dist/components/ui/schema-form/widgets/slider-field.svelte +51 -0
  41. package/dist/components/ui/schema-form/widgets/slider-field.svelte.d.ts +13 -0
  42. package/dist/components/ui/schema-form/widgets/tags-field.svelte +98 -0
  43. package/dist/components/ui/schema-form/widgets/tags-field.svelte.d.ts +12 -0
  44. package/dist/components/ui/schema-form/widgets/unknown-field.svelte +123 -0
  45. package/dist/components/ui/schema-form/widgets/unknown-field.svelte.d.ts +20 -0
  46. package/dist/components/ui/search-results/index.d.ts +3 -0
  47. package/dist/components/ui/search-results/index.js +2 -0
  48. package/dist/components/ui/search-results/search-results.svelte +183 -0
  49. package/dist/components/ui/search-results/search-results.svelte.d.ts +40 -0
  50. package/package.json +2 -1
  51. package/registry/component-map.json +52 -3
  52. package/registry/component-map.md +20 -1
package/README.md CHANGED
@@ -74,30 +74,35 @@ the one-owner-per-key note below). It becomes a candidate the day two apps want
74
74
  the same chart palette; until then a shared chart component would be a shared
75
75
  disagreement.
76
76
 
77
- **Composed components** (20). Primitives are not what makes an app look like an
77
+ **Composed components** (24). Primitives are not what makes an app look like an
78
78
  app — the page chrome is. These are the cross-cutting surfaces every route
79
79
  composes from, so a household app gets its layout language from the package
80
80
  rather than rebuilding it:
81
81
 
82
- | Import | What it is |
83
- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
84
- | `page-header` | The only page-title pattern: optional `breadcrumbs` snippet, eyebrow, an optional title, one clamped subtitle, an `info` tooltip, an `actions` slot. Omit `title` for a header that is a breadcrumb bar. |
85
- | `panel` | The generic titled card: optional icon, subtitle and trailing actions over a body that can opt out of padding. |
86
- | `detail-panel` | The entity-detail surface: header with icon/eyebrow/title/`StatusBadge`/close, scrollable body, footer of actions. |
87
- | `context-column` | The persistent right-hand column: a standing `StatList` plus an optional detail that flows in on select. |
88
- | `app-dialog` | The dialogue frame: titled header, scrollable body, footer action bar, five sizes (`xs`…`xl`), and an `onOpenChange` for the dismissals the caller did not drive. |
89
- | `dialog-section` | One section of a dialogue body; adjacent sections are divided automatically. |
90
- | `stat-card` | A single metric that earns its space (label, value, unit, sub, status dot, and `valueTone` to colour the figure itself). |
91
- | `stat-list` | A route's low-context integers as a label→value list. Zero-aware: `muted` keeps a healthy zero quiet. |
92
- | `arc-gauge` | A radial capacity/percentage ring for a single 0–100 metric, in a footprint too compact for a `stat-card`. |
93
- | `bar-row` | A labelled horizontal bar with a trailing tabular value, for a ranked list (usage, rank, token burn). |
94
- | `scorecard` | A compact 0/1/2 dot-row health strip for several independent checks read at a glance. |
95
- | `sparkline` | An inline multi-series area+line trend for a row or card with room for a trend but not a full chart. |
96
- | `status` / `status-badge` | The fixed five-state vocabulary (`success \| warning \| error \| info \| neutral`) and the one state chip, with `pulse` for a state still in motion and `class` for placement. `status-badge` alone also accepts `'primary'`, a brand-emphasis extension outside the shared vocabulary — `stat-card`, `stat-list` and `data-table-toolbar` never see it. |
97
- | `empty-state` / `error-state` / `loading-state` | The shared blank, error and loading surfaces. Never hand-roll one. |
98
- | `info-tip` | One tooltip pattern: a small info trigger, or wrap an existing affordance as children. |
99
- | `data-table-toolbar` | Search field plus filter-chip groups for a TanStack table. Owns no state; fires callbacks. |
100
- | `data-table-tanstack` | The TanStack-backed table: global search, column filters, master-detail row select, opt-in bulk selection, responsive column hiding, a first-class empty branch. |
82
+ | Import | What it is |
83
+ | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
84
+ | `page-header` | The only page-title pattern: optional `breadcrumbs` snippet, eyebrow, an optional title, one clamped subtitle, an `info` tooltip, an `actions` slot. Omit `title` for a header that is a breadcrumb bar. |
85
+ | `panel` | The generic titled card: optional icon, subtitle and trailing actions over a body that can opt out of padding. |
86
+ | `detail-panel` | The entity-detail surface: header with icon/eyebrow/title/`StatusBadge`/close, scrollable body, footer of actions. |
87
+ | `context-column` | The persistent right-hand column: a standing `StatList` plus an optional detail that flows in on select. |
88
+ | `app-dialog` | The dialogue frame: titled header, scrollable body, footer action bar, five sizes (`xs`…`xl`), and an `onOpenChange` for the dismissals the caller did not drive. |
89
+ | `dialog-section` | One section of a dialogue body; adjacent sections are divided automatically. |
90
+ | `stat-card` | A single metric that earns its space (label, value, unit, sub, status dot, and `valueTone` to colour the figure itself). |
91
+ | `stat-list` | A route's low-context integers as a label→value list. Zero-aware: `muted` keeps a healthy zero quiet. |
92
+ | `arc-gauge` | A radial capacity/percentage ring for a single 0–100 metric, in a footprint too compact for a `stat-card`. |
93
+ | `bar-row` | A labelled horizontal bar with a trailing tabular value, for a ranked list (usage, rank, token burn). |
94
+ | `scorecard` | A compact 0/1/2 dot-row health strip for several independent checks read at a glance. |
95
+ | `sparkline` | An inline multi-series area+line trend for a row or card with room for a trend but not a full chart. |
96
+ | `status` / `status-badge` | The fixed five-state vocabulary (`success \| warning \| error \| info \| neutral`) and the one state chip, with `pulse` for a state still in motion and `class` for placement. `status-badge` alone also accepts `'primary'`, a brand-emphasis extension outside the shared vocabulary — `stat-card`, `stat-list` and `data-table-toolbar` never see it. |
97
+ | `empty-state` / `error-state` / `loading-state` | The shared blank, error and loading surfaces. Never hand-roll one. |
98
+ | `info-tip` | One tooltip pattern: a small info trigger, or wrap an existing affordance as children. |
99
+ | `data-table-toolbar` | Search field plus filter-chip groups for a TanStack table. Owns no state; fires callbacks. |
100
+ | `schema-form` | The renderer for a config object the server described: a JSON Schema plus a JSON Forms UI Schema in, a form out. Anything it cannot dispatch renders flagged, never blank; see [Server-described forms](#server-described-forms) below. |
101
+ | `data-table-tanstack` | The TanStack-backed table: global search, column filters, master-detail row select, opt-in bulk selection, responsive column hiding, a first-class empty branch. |
102
+ | `library-browse` | The faceted catalogue index: search, facet rail, active filter chips, the document table and a pager, all over plain props (`LibraryDocument[]`, `LibraryFacet[]`). The page fetches, maps and routes; the component renders. Library data is fixed components because its shape is stable; configuration is `schema-form` because its shape changes (#30). |
103
+ | `collection-detail` | One collection's surface: identity (`detail-panel`), an at-a-glance `stat-list`, and the documents it holds, with `actions` and `children` slots for the app-specific rest. |
104
+ | `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`. |
105
+ | `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. |
101
106
 
102
107
  **The application shell** (`app-shell`, `command-palette`). Page chrome is not
103
108
  what makes an app feel like an app either — the shell is. Five household
@@ -503,6 +508,145 @@ but the same class on the `<th>` collapses the heading over its neighbour:
503
508
  { accessorKey: 'filename', header: 'Document', meta: { class: 'w-full', cellClass: 'max-w-0' } }
504
509
  ```
505
510
 
511
+ ## Server-described forms
512
+
513
+ `<SchemaForm>` renders a config object whose shape arrives at runtime. It is the
514
+ estate's one mechanism for that, and this README is where its contract lives.
515
+
516
+ Two standard documents go in. A **JSON Schema** says what the value is; a **JSON
517
+ Forms UI Schema** says how it is laid out — a tree of layouts, Controls
518
+ addressed by JSON Pointer `scope`, and declarative `rule: { effect, condition }`
519
+ for conditional visibility. Both are published standards, which is the point:
520
+ the renderer is replaceable without a server changing anything.
521
+
522
+ ```svelte
523
+ <script lang="ts">
524
+ import SchemaForm from '@poodle64/ui/schema-form';
525
+
526
+ let { schema, uischema } = $props(); // fetched by the app, not by this package
527
+ let value = $state({});
528
+ </script>
529
+
530
+ <SchemaForm {schema} {uischema} {value} onChange={(next) => (value = next)} />
531
+ ```
532
+
533
+ | Prop | Purpose |
534
+ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------- |
535
+ | `schema` | The JSON Schema. Scopes resolve against it, `$ref` included, and it drives validation. |
536
+ | `uischema` | The JSON Forms UI Schema. Omit it and one is generated from the schema, the only mode in which no field can be missing from the layout. |
537
+ | `value` | The current value. The component is controlled and never mutates what it is given. |
538
+ | `onChange` | `(next, { path, value })`. `next` is a fresh object with every untouched branch structurally intact. |
539
+ | `disabled` | Disables every control. |
540
+ | `idPrefix` | Prefix for generated element ids, when two forms share a page. |
541
+
542
+ ### It knows nothing about any app
543
+
544
+ No HTTP client, no `fetch`, no endpoint string, no service name, no
545
+ app-specific type. Consumers fetch the two documents and map the result
546
+ themselves. That invariant is load-bearing: it is what lets the engine behind
547
+ the schema be swapped later without touching a consumer, and what stopped three
548
+ apps' renderers from being three different components.
549
+
550
+ ### An unrecognised control renders LOUDLY
551
+
552
+ **This is the non-negotiable rule.** The three hand-rolled renderers this
553
+ replaces failed silently: a `widget: "dropdown"` hint rendered no input at all,
554
+ three whole top-level config groups never rendered because their names were
555
+ absent from a hardcoded `GROUP_ORDER`, and nested hints were inert. Every one
556
+ of those made a field VANISH, and a form with a field missing looks exactly like
557
+ a form. They shipped that way for months.
558
+
559
+ So nothing here renders as nothing. Eight failure shapes each produce a
560
+ visibly-flagged block carrying `data-schema-form-unknown` and a
561
+ `data-unknown-reason`, which names what could not be done, quotes the pointer so
562
+ the fix is a copy-paste, shows the value that would otherwise have been lost,
563
+ and keeps it editable wherever a text box cannot destroy structure:
564
+
565
+ | `data-unknown-reason` | Raised when |
566
+ | --------------------- | --------------------------------------------------------------------------------- |
567
+ | `unknown-widget` | `options.format` / `options.widget` names a widget this package does not ship. |
568
+ | `unknown-element` | A UI schema element `type` outside the vocabulary below. |
569
+ | `unresolved-scope` | A Control's `scope` resolves to nothing in the JSON Schema. |
570
+ | `missing-scope` | A Control carries no `scope` at all. |
571
+ | `no-options` | A `select` or `radio` over a subschema that declares no `enum` or `oneOf`. |
572
+ | `object-control` | A Control points at an object, which needs a layout rather than one control. |
573
+ | `unsupported-array` | An array whose items are not primitives, so `tags` cannot represent it. |
574
+ | `not-in-layout` | The JSON Schema describes a property no Control anywhere in the layout addresses. |
575
+
576
+ A primitive value stays editable; an object or an array is shown read-only,
577
+ because a text box over structured data is a data-loss affordance rather than a
578
+ fallback. An unrecognised hint is never quietly swapped for the widget it
579
+ probably meant — `dropdown` almost certainly meant `select`, and guessing would
580
+ restore the field while hiding the fact that the two documents disagree.
581
+
582
+ `not-in-layout` is reported once per unaddressed subtree, at the subtree, so a
583
+ forgotten group is one loud entry rather than forty. There is no prop to silence
584
+ any of this. The way to stop a field being flagged is to put a Control for it in
585
+ the UI schema.
586
+
587
+ `src/test/schema-form-loud-unknown.test.ts` pins all eight reasons, and
588
+ `harness/drive.mjs` proves in a real engine that each flag has real size and a
589
+ resolved, visible warning border. "Renders loudly" is a claim about paint, and
590
+ a fallback styled into invisibility would satisfy every jsdom assertion while
591
+ reproducing the original defect exactly.
592
+
593
+ ### The widget dispatch table
594
+
595
+ An explicit hint wins; otherwise the widget is derived from the subschema.
596
+ `options.format` is the JSON Forms spelling and `options.widget` is accepted
597
+ alongside it, so a server migrating off a home-grown hint vocabulary does not
598
+ have to change both documents at once. Any hint outside this table is
599
+ `unknown-widget`.
600
+
601
+ | Widget | Chosen when | Rendered by |
602
+ | ---------- | ----------------------------------------------- | --------------------------------- |
603
+ | `text` | `string` (the default) | `input` |
604
+ | `textarea` | `options.multi: true`, or the hint | `textarea` |
605
+ | `password` | `format: "password"`, or the hint | `input[type=password]` |
606
+ | `date` | `format: "date"` | `input[type=date]` |
607
+ | `time` | `format: "time"` | `input[type=time]` |
608
+ | `datetime` | `format: "date-time"` | `input[type=datetime-local]` |
609
+ | `number` | `number` / `integer` | `input[type=number]` |
610
+ | `slider` | `options.slider: true` on a number, or the hint | composed here (native `range`) |
611
+ | `select` | `enum`, or `oneOf: [{ const, title }]` | `select` |
612
+ | `radio` | the hint, on the same closed value sets | composed here (native `radio`) |
613
+ | `switch` | `boolean` (the default) | `switch` |
614
+ | `checkbox` | the hint, on a boolean | `checkbox` |
615
+ | `tags` | `array` of `string` / `number` / `integer` | composed here (`badge` + `input`) |
616
+
617
+ Three of those — `slider`, `radio` and `tags` — had no primitive in this package
618
+ and are composed inside `schema-form/widgets/`. They are deliberately not
619
+ top-level exports: their only caller is the renderer, and a widget promoted to
620
+ the catalogue before a second consumer wants it is a shape nobody agreed to.
621
+
622
+ ### The layout vocabulary
623
+
624
+ `VerticalLayout`, `HorizontalLayout`, `Group` (a `Panel`), `Categorization` /
625
+ `Category` (a `Tabs` set), `Control` and `Label`. Anything else is
626
+ `unknown-element`.
627
+
628
+ `rule` is evaluated on every element, not only on Controls, so a rule on a Group
629
+ takes the whole group with it — which is what an author writing one means.
630
+ `SHOW` / `HIDE` decide whether the element renders at all; `ENABLE` / `DISABLE`
631
+ and `options.readonly` disable the control instead.
632
+
633
+ ### The engine
634
+
635
+ `@jsonforms/core` is used **headless**: JSON Pointer scope resolution, rule
636
+ evaluation and the Ajv instance, nothing else. The renderers are this package's
637
+ own and dispatch to this package's own widgets. There is no official Svelte
638
+ renderer for JSON Forms and the community one carries no external validation, so
639
+ neither is used.
640
+
641
+ JSON Forms was chosen over RJSF and `@sjsf/form` on one deciding fact: the
642
+ upstream config models carry 36 conditional-visibility rules, and JSON Forms'
643
+ `rule` model maps onto them 1:1, while RJSF has no standard conditional-
644
+ visibility directive. `@jsonforms/core@3.8.0` was verified working headless
645
+ under Svelte 5 in this package before it was adopted, on 21/08/2026 — scope
646
+ resolution through `$ref`, `SHOW`/`HIDE`/`DISABLE` evaluation, and
647
+ `Generate.uiSchema` all exercised live. It is the package's only runtime
648
+ dependency beyond what was already here.
649
+
506
650
  ## Consuming the package
507
651
 
508
652
  Published to public npm under the `@poodle64` scope, same as `@poodle64/design-tokens`
@@ -0,0 +1,169 @@
1
+ <script lang="ts">
2
+ /**
3
+ * CollectionDetail — one collection's surface: its identity (name, meta,
4
+ * state), an at-a-glance stat list, and the documents it holds.
5
+ *
6
+ * Fixed props over plain data (#30): the page fetches, maps its responses
7
+ * into the shared library vocabulary, and owns the pager offset. Anything
8
+ * app-specific — a config form, an operations tab, action buttons — comes
9
+ * in through `actions` (the identity panel's footer) and `children`
10
+ * (sections rendered after the documents), so the app extends the surface
11
+ * without forking it.
12
+ */
13
+ import type { Snippet } from 'svelte';
14
+ import type { HTMLAttributes } from 'svelte/elements';
15
+ import { cn, type WithElementRef } from '../../../utils.js';
16
+ import Button from '../button/button.svelte';
17
+ import DetailPanel from '../detail-panel/detail-panel.svelte';
18
+ import Panel from '../panel/panel.svelte';
19
+ import StatList from '../stat-list/stat-list.svelte';
20
+ import type { StatItem } from '../stat-list/stat-list.svelte';
21
+ import EmptyState from '../empty-state/empty-state.svelte';
22
+ import ErrorState from '../error-state/error-state.svelte';
23
+ import LoadingState from '../loading-state/loading-state.svelte';
24
+ import DocumentTable from '../library-browse/document-table.svelte';
25
+ import type { LibraryCollection, LibraryDocument } from '../library-browse/types.js';
26
+
27
+ let {
28
+ collection,
29
+ stats = [],
30
+ documents = [],
31
+ documentsTotal,
32
+ offset = 0,
33
+ limit = 25,
34
+ loading = false,
35
+ error = null,
36
+ documentsLoading = false,
37
+ documentsError = null,
38
+ emptyTitle = 'No documents in this collection yet',
39
+ emptyDescription,
40
+ notFoundTitle = 'Collection not found',
41
+ notFoundDescription,
42
+ onPageChange,
43
+ onRetry,
44
+ documentHref,
45
+ onOpenDocument,
46
+ actions,
47
+ children,
48
+ ref = $bindable(null),
49
+ class: className,
50
+ ...restProps
51
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
52
+ collection: LibraryCollection | null;
53
+ /** At-a-glance figures for the collection (document count, indexed, …). */
54
+ stats?: StatItem[];
55
+ documents?: LibraryDocument[];
56
+ /** Total documents across all pages; defaults to the rows given. */
57
+ documentsTotal?: number;
58
+ offset?: number;
59
+ limit?: number;
60
+ /** The collection itself is still loading. */
61
+ loading?: boolean;
62
+ error?: string | null;
63
+ /** The document list loads independently of the identity. */
64
+ documentsLoading?: boolean;
65
+ documentsError?: string | null;
66
+ emptyTitle?: string;
67
+ emptyDescription?: string;
68
+ notFoundTitle?: string;
69
+ notFoundDescription?: string;
70
+ onPageChange?: (offset: number) => void;
71
+ onRetry?: () => void;
72
+ documentHref?: (doc: LibraryDocument) => string;
73
+ onOpenDocument?: (doc: LibraryDocument) => void;
74
+ /** App-specific controls, rendered in the identity panel's footer. */
75
+ actions?: Snippet;
76
+ /** App-specific sections rendered after the documents. */
77
+ children?: Snippet;
78
+ } = $props();
79
+
80
+ const shownTotal = $derived(documentsTotal ?? documents.length);
81
+ // Floored at 1 so a consumer's limit: 0 cannot put Infinity in the pager.
82
+ const pageSize = $derived(Math.max(1, limit));
83
+ const pageCount = $derived(Math.max(1, Math.ceil(shownTotal / pageSize)));
84
+ const page = $derived(Math.floor(offset / pageSize) + 1);
85
+ </script>
86
+
87
+ <div bind:this={ref} class={cn('flex flex-col gap-4', className)} {...restProps}>
88
+ {#if loading}
89
+ <LoadingState message="Loading the collection…" />
90
+ {:else if error}
91
+ <ErrorState message={error}>
92
+ {#snippet action()}
93
+ {#if onRetry}
94
+ <Button variant="outline" onclick={onRetry}>Retry</Button>
95
+ {/if}
96
+ {/snippet}
97
+ </ErrorState>
98
+ {:else if !collection}
99
+ <EmptyState title={notFoundTitle} description={notFoundDescription} />
100
+ {:else}
101
+ <DetailPanel
102
+ eyebrow="Collection"
103
+ title={collection.name}
104
+ titleFace="display"
105
+ status={collection.badge?.status}
106
+ statusLabel={collection.badge?.label}
107
+ footer={actions}
108
+ >
109
+ {#if collection.subtitle}
110
+ <p class="text-muted-foreground text-sm">{collection.subtitle}</p>
111
+ {/if}
112
+ {#if collection.description}
113
+ <p class="mt-2 max-w-prose text-sm">{collection.description}</p>
114
+ {/if}
115
+ {#if !collection.subtitle && !collection.description}
116
+ <p class="text-muted-foreground text-sm">No description recorded.</p>
117
+ {/if}
118
+ </DetailPanel>
119
+
120
+ {#if stats.length > 0}
121
+ <StatList items={stats} />
122
+ {/if}
123
+
124
+ {#if documentsLoading}
125
+ <LoadingState message="Loading the documents…" />
126
+ {:else if documentsError}
127
+ <ErrorState message={documentsError}>
128
+ {#snippet action()}
129
+ {#if onRetry}
130
+ <Button variant="outline" onclick={onRetry}>Retry</Button>
131
+ {/if}
132
+ {/snippet}
133
+ </ErrorState>
134
+ {:else if documents.length === 0}
135
+ <EmptyState title={emptyTitle} description={emptyDescription} />
136
+ {:else}
137
+ <Panel title="Documents" subtitle="{shownTotal} document{shownTotal === 1 ? '' : 's'}">
138
+ <div class="flex flex-col gap-3">
139
+ <DocumentTable {documents} {documentHref} onOpen={onOpenDocument} />
140
+ {#if onPageChange && shownTotal > pageSize}
141
+ <div class="flex flex-wrap items-center justify-end gap-2">
142
+ <Button
143
+ variant="outline"
144
+ size="sm"
145
+ disabled={offset === 0}
146
+ onclick={() => onPageChange(Math.max(0, offset - pageSize))}
147
+ >
148
+ Previous
149
+ </Button>
150
+ <span class="text-muted-foreground text-xs">Page {page} of {pageCount}</span>
151
+ <Button
152
+ variant="outline"
153
+ size="sm"
154
+ disabled={offset + pageSize >= shownTotal}
155
+ onclick={() => onPageChange(offset + pageSize)}
156
+ >
157
+ Next
158
+ </Button>
159
+ </div>
160
+ {/if}
161
+ </div>
162
+ </Panel>
163
+ {/if}
164
+
165
+ {#if children}
166
+ {@render children()}
167
+ {/if}
168
+ {/if}
169
+ </div>
@@ -0,0 +1,47 @@
1
+ /**
2
+ * CollectionDetail — one collection's surface: its identity (name, meta,
3
+ * state), an at-a-glance stat list, and the documents it holds.
4
+ *
5
+ * Fixed props over plain data (#30): the page fetches, maps its responses
6
+ * into the shared library vocabulary, and owns the pager offset. Anything
7
+ * app-specific — a config form, an operations tab, action buttons — comes
8
+ * in through `actions` (the identity panel's footer) and `children`
9
+ * (sections rendered after the documents), so the app extends the surface
10
+ * without forking it.
11
+ */
12
+ import type { Snippet } from 'svelte';
13
+ import type { HTMLAttributes } from 'svelte/elements';
14
+ import { type WithElementRef } from '../../../utils.js';
15
+ import type { StatItem } from '../stat-list/stat-list.svelte';
16
+ import type { LibraryCollection, LibraryDocument } from '../library-browse/types.js';
17
+ type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLDivElement>> & {
18
+ collection: LibraryCollection | null;
19
+ /** At-a-glance figures for the collection (document count, indexed, …). */
20
+ stats?: StatItem[];
21
+ documents?: LibraryDocument[];
22
+ /** Total documents across all pages; defaults to the rows given. */
23
+ documentsTotal?: number;
24
+ offset?: number;
25
+ limit?: number;
26
+ /** The collection itself is still loading. */
27
+ loading?: boolean;
28
+ error?: string | null;
29
+ /** The document list loads independently of the identity. */
30
+ documentsLoading?: boolean;
31
+ documentsError?: string | null;
32
+ emptyTitle?: string;
33
+ emptyDescription?: string;
34
+ notFoundTitle?: string;
35
+ notFoundDescription?: string;
36
+ onPageChange?: (offset: number) => void;
37
+ onRetry?: () => void;
38
+ documentHref?: (doc: LibraryDocument) => string;
39
+ onOpenDocument?: (doc: LibraryDocument) => void;
40
+ /** App-specific controls, rendered in the identity panel's footer. */
41
+ actions?: Snippet;
42
+ /** App-specific sections rendered after the documents. */
43
+ children?: Snippet;
44
+ };
45
+ declare const CollectionDetail: import("svelte").Component<$$ComponentProps, {}, "ref">;
46
+ type CollectionDetail = ReturnType<typeof CollectionDetail>;
47
+ export default CollectionDetail;
@@ -0,0 +1,3 @@
1
+ export { default as CollectionDetail } from './collection-detail.svelte';
2
+ export { default } from './collection-detail.svelte';
3
+ export type { LibraryBadge, LibraryCollection, LibraryDocument } from '../library-browse/types.js';
@@ -0,0 +1,2 @@
1
+ export { default as CollectionDetail } from './collection-detail.svelte';
2
+ export { default } from './collection-detail.svelte';
@@ -0,0 +1,184 @@
1
+ <script lang="ts">
2
+ /**
3
+ * DocumentDetail — one document's surface: its identity fields, recorded
4
+ * locations, tags, and collection memberships.
5
+ *
6
+ * Fixed props over plain data (#30). Sections render only when their data
7
+ * is present, so a consumer whose documents carry no locations never shows
8
+ * an empty heading. App-specific work — retract/re-extract buttons, an
9
+ * operations history — comes in through `actions` (the panel's footer) and
10
+ * `children` (sections after the panel); membership links are the app's
11
+ * own routed links via `collectionHref`, because this package has no
12
+ * router.
13
+ */
14
+ import type { Snippet } from 'svelte';
15
+ import type { HTMLAttributes } from 'svelte/elements';
16
+ import { cn, type WithElementRef } from '../../../utils.js';
17
+ import Button from '../button/button.svelte';
18
+ import { Badge } from '../badge/index.js';
19
+ import DetailPanel from '../detail-panel/detail-panel.svelte';
20
+ import StatusBadge from '../status-badge/status-badge.svelte';
21
+ import EmptyState from '../empty-state/empty-state.svelte';
22
+ import ErrorState from '../error-state/error-state.svelte';
23
+ import LoadingState from '../loading-state/loading-state.svelte';
24
+ import type { LibraryDocumentDetail, LibraryMembership } from '../library-browse/types.js';
25
+
26
+ let {
27
+ document,
28
+ loading = false,
29
+ error = null,
30
+ notFoundTitle = 'Document not found',
31
+ notFoundDescription = 'This document is not in the catalogue.',
32
+ collectionHref,
33
+ onOpenCollection,
34
+ onRetry,
35
+ actions,
36
+ children,
37
+ ref = $bindable(null),
38
+ class: className,
39
+ ...restProps
40
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
41
+ document: LibraryDocumentDetail | null;
42
+ loading?: boolean;
43
+ error?: string | null;
44
+ notFoundTitle?: string;
45
+ notFoundDescription?: string;
46
+ /** The app's own routed link per membership. */
47
+ collectionHref?: (membership: LibraryMembership) => string;
48
+ onOpenCollection?: (membership: LibraryMembership) => void;
49
+ onRetry?: () => void;
50
+ /** App-specific controls, rendered in the panel's footer. */
51
+ actions?: Snippet;
52
+ /** App-specific sections rendered after the panel. */
53
+ children?: Snippet;
54
+ } = $props();
55
+ </script>
56
+
57
+ <div bind:this={ref} class={cn('flex flex-col gap-4', className)} {...restProps}>
58
+ {#if loading}
59
+ <LoadingState message="Loading the document…" />
60
+ {:else if error}
61
+ <ErrorState message={error}>
62
+ {#snippet action()}
63
+ {#if onRetry}
64
+ <Button variant="outline" onclick={onRetry}>Retry</Button>
65
+ {/if}
66
+ {/snippet}
67
+ </ErrorState>
68
+ {:else if !document}
69
+ <EmptyState title={notFoundTitle} description={notFoundDescription} />
70
+ {:else}
71
+ <DetailPanel eyebrow="Document" title={document.title} titleFace="display" footer={actions}>
72
+ <div class="grid gap-4 sm:grid-cols-2">
73
+ {#if document.fields?.length}
74
+ <div>
75
+ <h3 class="text-muted-foreground text-2xs mb-2 font-semibold tracking-wide uppercase">
76
+ Details
77
+ </h3>
78
+ <dl class="space-y-1 text-sm">
79
+ {#each document.fields as field (field.label)}
80
+ <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'}>
83
+ {field.value}
84
+ </dd>
85
+ </div>
86
+ {/each}
87
+ </dl>
88
+ </div>
89
+ {/if}
90
+
91
+ {#if document.locations?.length}
92
+ <div>
93
+ <h3 class="text-muted-foreground text-2xs mb-2 font-semibold tracking-wide uppercase">
94
+ Locations
95
+ </h3>
96
+ <ul class="space-y-1 text-sm">
97
+ {#each document.locations as location (location.path)}
98
+ <li class="flex items-center justify-between gap-2">
99
+ <span class="truncate font-mono text-xs">{location.path}</span>
100
+ <div class="flex shrink-0 items-center gap-1">
101
+ {#if location.primary}
102
+ <Badge variant="secondary">primary</Badge>
103
+ {/if}
104
+ {#if location.badge}
105
+ <StatusBadge status={location.badge.status} label={location.badge.label} />
106
+ {/if}
107
+ </div>
108
+ </li>
109
+ {/each}
110
+ </ul>
111
+ </div>
112
+ {/if}
113
+
114
+ {#if document.tags?.length}
115
+ <div>
116
+ <h3 class="text-muted-foreground text-2xs mb-2 font-semibold tracking-wide uppercase">
117
+ Tags
118
+ </h3>
119
+ <div class="flex flex-wrap gap-1.5">
120
+ {#each document.tags as tag (tag)}
121
+ <Badge variant="outline">{tag}</Badge>
122
+ {/each}
123
+ </div>
124
+ </div>
125
+ {/if}
126
+
127
+ {#if document.memberships?.length}
128
+ <div>
129
+ <h3 class="text-muted-foreground text-2xs mb-2 font-semibold tracking-wide uppercase">
130
+ Collections
131
+ </h3>
132
+ <div class="flex flex-wrap gap-1.5">
133
+ {#each document.memberships as membership (membership.id)}
134
+ {#if collectionHref}
135
+ <a
136
+ href={collectionHref(membership)}
137
+ class="inline-flex items-center gap-1"
138
+ onclick={() => onOpenCollection?.(membership)}
139
+ >
140
+ <Badge variant="outline">{membership.name}</Badge>
141
+ {#if membership.badge}
142
+ <StatusBadge
143
+ status={membership.badge.status}
144
+ label={membership.badge.label}
145
+ />
146
+ {/if}
147
+ </a>
148
+ {:else if onOpenCollection}
149
+ <button
150
+ type="button"
151
+ class="inline-flex items-center gap-1"
152
+ onclick={() => onOpenCollection(membership)}
153
+ >
154
+ <Badge variant="outline">{membership.name}</Badge>
155
+ {#if membership.badge}
156
+ <StatusBadge
157
+ status={membership.badge.status}
158
+ label={membership.badge.label}
159
+ />
160
+ {/if}
161
+ </button>
162
+ {:else}
163
+ <span class="inline-flex items-center gap-1">
164
+ <Badge variant="outline">{membership.name}</Badge>
165
+ {#if membership.badge}
166
+ <StatusBadge
167
+ status={membership.badge.status}
168
+ label={membership.badge.label}
169
+ />
170
+ {/if}
171
+ </span>
172
+ {/if}
173
+ {/each}
174
+ </div>
175
+ </div>
176
+ {/if}
177
+ </div>
178
+ </DetailPanel>
179
+
180
+ {#if children}
181
+ {@render children()}
182
+ {/if}
183
+ {/if}
184
+ </div>