smoodly 0.0.17 → 0.0.19

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 (73) hide show
  1. package/README.md +56 -6
  2. package/dist/admin/client-ops.js +1 -1
  3. package/dist/admin/editor/CanvasOverlay.js +4 -2
  4. package/dist/admin/editor/EditorView.js +19 -32
  5. package/dist/admin/editor/GlobalInspector.d.ts +5 -2
  6. package/dist/admin/editor/GlobalInspector.js +4 -3
  7. package/dist/admin/editor/NoticePane.js +3 -1
  8. package/dist/admin/forms/FieldWidget.d.ts +6 -6
  9. package/dist/admin/forms/FieldWidget.js +22 -10
  10. package/dist/admin/forms/RefPicker.d.ts +11 -0
  11. package/dist/admin/forms/RefPicker.js +30 -0
  12. package/dist/admin/forms/TagsField.d.ts +10 -0
  13. package/dist/admin/forms/TagsField.js +38 -0
  14. package/dist/admin/forms/ref-picker.d.ts +25 -0
  15. package/dist/admin/forms/ref-picker.js +30 -0
  16. package/dist/admin/forms/useRefOptions.d.ts +7 -0
  17. package/dist/admin/forms/useRefOptions.js +36 -0
  18. package/dist/admin/forms/useTagSuggestions.d.ts +5 -0
  19. package/dist/admin/forms/useTagSuggestions.js +22 -0
  20. package/dist/admin/ops-impl.js +23 -4
  21. package/dist/admin/ops.d.ts +4 -0
  22. package/dist/admin/serialize.d.ts +2 -0
  23. package/dist/admin/serialize.js +1 -0
  24. package/dist/admin/shell/AdminApp.js +8 -2
  25. package/dist/admin/shell/EntriesList.js +3 -2
  26. package/dist/admin/shell/EntryForm.js +14 -28
  27. package/dist/admin/shell/ErrorPane.d.ts +4 -2
  28. package/dist/admin/shell/ErrorPane.js +6 -4
  29. package/dist/admin/shell/GlobalFields.d.ts +3 -6
  30. package/dist/admin/shell/GlobalFields.js +1 -1
  31. package/dist/admin/shell/GlobalForm.js +8 -22
  32. package/dist/admin/shell/GlobalList.js +3 -2
  33. package/dist/admin/shell/IconRail.js +3 -2
  34. package/dist/admin/shell/PagesList.js +4 -3
  35. package/dist/admin/shell/base.d.ts +4 -1
  36. package/dist/admin/shell/base.js +15 -3
  37. package/dist/admin/shell/router.d.ts +4 -3
  38. package/dist/admin/shell/router.js +17 -14
  39. package/dist/admin/ui/ActionMenu.d.ts +3 -1
  40. package/dist/admin/ui/ActionMenu.js +4 -3
  41. package/dist/admin/ui/Combobox.d.ts +30 -0
  42. package/dist/admin/ui/Combobox.js +89 -0
  43. package/dist/admin/ui/PublishControl.d.ts +3 -1
  44. package/dist/admin/ui/PublishControl.js +2 -2
  45. package/dist/admin/ui/combo-box.d.ts +16 -0
  46. package/dist/admin/ui/combo-box.js +21 -0
  47. package/dist/admin/ui/primitives.d.ts +1 -1
  48. package/dist/admin/ui/primitives.js +1 -1
  49. package/dist/admin/ui/theme.js +43 -4
  50. package/dist/admin/validate.js +39 -0
  51. package/dist/admin-path.d.ts +9 -0
  52. package/dist/admin-path.js +24 -0
  53. package/dist/config.d.ts +10 -1
  54. package/dist/config.js +4 -0
  55. package/dist/entry-page.d.ts +5 -0
  56. package/dist/entry-page.js +1 -1
  57. package/dist/entry-store.js +1 -1
  58. package/dist/fields.d.ts +3 -0
  59. package/dist/fields.js +5 -0
  60. package/dist/index.d.ts +4 -3
  61. package/dist/index.js +2 -1
  62. package/dist/next/page-renderer.js +7 -3
  63. package/dist/page.js +1 -1
  64. package/dist/paths.d.ts +21 -0
  65. package/dist/paths.js +25 -0
  66. package/dist/render.d.ts +10 -0
  67. package/dist/render.js +2 -1
  68. package/dist/site.d.ts +14 -9
  69. package/dist/site.js +31 -18
  70. package/dist/supabase-entry-store.js +1 -1
  71. package/dist/tags.d.ts +19 -0
  72. package/dist/tags.js +50 -0
  73. package/package.json +1 -1
package/README.md CHANGED
@@ -336,9 +336,13 @@ Implemented:
336
336
  fetches the record and its tree (draft or published), resolves refs,
337
337
  reads the locale's own rows, and returns a `SitePage` — `record`,
338
338
  `template`, `tree`, localized `title`, `path`, `ancestors`, `children`,
339
- and `paths` (locale → path, for alternates) — or a `SiteEntry` with the
340
- locale's merged fields (a versioned collection's published snapshot on
341
- the public path) resolved, and `paths` for its published locales.
339
+ `locale` and `locales` — the page's `{ slug, path }` in every supported
340
+ locale, null where it is absent or, outside draft mode, unpublished
341
+ (spec 2026-09-15) — or a `SiteEntry` with the locale's merged fields (a
342
+ versioned collection's published snapshot on the public path) resolved,
343
+ `locale`, and the same `locales` map (an entry's slug and path are null
344
+ where its row claims no URL). `getEntries` items and `getEntry` carry
345
+ `locale` and `locales` too.
342
346
  `resolve(locale, path, { draft? })`
343
347
  is the two in sequence — `locate` then `load` — for a caller that just
344
348
  wants the hit. A folder (null template), an
@@ -372,13 +376,14 @@ Implemented:
372
376
  (`{ id, title, path, url, ancestors, children }`, every ancestor and
373
377
  child carrying `url` beside its locale-relative `path`, both put through
374
378
  the `href` seam) beside `zones`; a collection
375
- entry rendered by its `smoodly.entryPage` as `{ entry, path }`; a folder,
379
+ entry rendered by its `smoodly.entryPage` as `{ entry, path, locale, locales }`; a folder,
376
380
  an absent locale or an unregistered template a 404. The published read is
377
381
  cached per (locale, path) and tagged `page:<id>` / `entry:<id>` — the
378
382
  `locate` hit runs outside the cache, because the tag needs the id first.
379
383
  Draft mode reads drafts uncached, and the editor param mounts
380
384
  `EditorBridge`. Metadata is the tree's `meta` plus
381
- `alternates.languages`, built through the injectable `href(path, locale)`
385
+ `alternates.languages`, the locales with a URL in the hit's `locales` map
386
+ through the injectable `href(path, locale)`
382
387
  seam (identity by default, `getPathname` with next-intl) and emitted only
383
388
  for a multi-locale site. `renderSmoodlyPage(Paired, …)` and
384
389
  `smoodlyPageMetadata` remain as the escape hatch for a FIXED page
@@ -490,6 +495,51 @@ after both are on the registry. From the repo root:
490
495
  `prepublishOnly` rebuilds each package. The Claude Code skill
491
496
  `.claude/skills/releasing-smoodly` carries the same order with its checks.
492
497
 
498
+ ## Follow-ups (logged 2026-09-17, admin path spec)
499
+
500
+ - A root page slugged like the admin path (or `api`) is unreachable:
501
+ Next's static segment wins over the site catch-all. Refuse it in the
502
+ slug check once `config.admin.path` exists.
503
+ - A robots `Disallow` for the admin path from `next/meta`.
504
+
505
+ ## Follow-ups (logged 2026-09-17, reference picker and tags spec)
506
+
507
+ - A `Create…` button in the reference picker: create the target entry
508
+ without leaving the form. Needs its own answer for modal vs navigation
509
+ and for the parent form's pending autosave.
510
+ - Server search for ref candidates: `useRefOptions` loads the whole
511
+ target collection once per locale. When a collection outgrows that,
512
+ the hook changes (a search op, plus "titles for these ids" for the
513
+ picked rows); `RefPicker` takes options as a prop and does not.
514
+ - Thumbnails in picker rows: collections have no preview-image
515
+ convention (`titleField` only).
516
+ - `entries.distinct` flattens in JS over `entries.list`; an RPC over
517
+ `jsonb_array_elements` when a collection outgrows it.
518
+ - Tags keep insertion order; no drag reorder.
519
+ - Tag suggestions exist on collection entry forms only, and only for
520
+ top-level `f.tags()` fields: a tags field inside an object, on a
521
+ section, a global or a page is a plain chip input.
522
+ - The picker's "open" link goes to `/admin/<collection>/<id>` without
523
+ the edited locale; the form opens in the URL's or the default locale.
524
+ - The Combobox list flips upwards when it does not fit under the input
525
+ (`opensUp`, measured against the nearest clipping ancestor); it does
526
+ not yet shrink when neither side has room for it.
527
+ - `RefOption.status` is the three-way `LocaleStatus` now; the old
528
+ "(draft)" suffix in option titles is gone with the native select.
529
+ - A `refList` on a global item's `settings` still loads no options in
530
+ the Globals form or the page editor: both collect targets from the
531
+ item's `fields` only.
532
+
533
+ ## Follow-ups (logged 2026-09-15, locale alternates spec)
534
+
535
+ - A layout-only language switch, if a real site needs one: a
536
+ `q().getAlternates(path)` over the `paths` index plus a proxy header
537
+ carrying the request path. Not built until asked for — the starters
538
+ render the switch from the page (`components/PageFrame.tsx`,
539
+ `components/LocaleNav.tsx`).
540
+ - The walk covered the Lorem example only; blank-intl is typechecked, not
541
+ walked. Walk it on the next blank-intl install.
542
+
493
543
  ## Follow-ups (logged 2026-09-15, custom section spec §6, plan B)
494
544
 
495
545
  - Inline editing of a column on the canvas is OFF (2026-09-15, after the
@@ -1604,4 +1654,4 @@ the toolbar instead of pushing the content.
1604
1654
  - `serialize.ts` structural cast duplicates `PageSchema`; tighten `Registered.schema` so the cast goes.
1605
1655
  - Catch-all `templates` map should filter to open registrations to state its intent (unreachable today).
1606
1656
  - The no-database build is the real proof that routes never prerender against the stores; the unit test only stubs `next/server`. Script it (`SUPABASE_URL=http://127.0.0.1:9 npx next build` in `examples/site`, expect `ƒ /`) and add it to CI, which today runs only the package's tests and typecheck — so a regression is caught when Next changes `connection()` semantics.
1607
- - `EditorView` split (autosave extracted 2026-09-03; the rest 2026-09-08, before the TipTap widget): `usePageLocale` (switch/add/remove, over `locale-url.ts`), `useCanvasBridge` (geometry, select/hover, `postToCanvas`, the geometry timeout), `CanvasOverlay` over `overlay-geometry.ts` (`plusButtonsFor`, pure and tested), `InsertPicker`, `Outline`, `NoticePane`. `EditorView.tsx` is ~660 lines of load, gestures, publish and layout. Remaining seam: the inspector's field form (`FieldWidget` rows plus the tabs) stays put until inline editing lands (DESIGN.md §3 puts TipTap in the canvas, not the sidebar); the ref-option loading (`refTargets` + the `ops.entries.list` effect) duplicates `EntryForm`'s and could be one hook. (logged 2026-09-06, updated 2026-09-08)
1657
+ - `EditorView` split (autosave extracted 2026-09-03; the rest 2026-09-08, before the TipTap widget): `usePageLocale` (switch/add/remove, over `locale-url.ts`), `useCanvasBridge` (geometry, select/hover, `postToCanvas`, the geometry timeout), `CanvasOverlay` over `overlay-geometry.ts` (`plusButtonsFor`, pure and tested), `InsertPicker`, `Outline`, `NoticePane`. `EditorView.tsx` is ~660 lines of load, gestures, publish and layout. Remaining seam: the inspector's field form (`FieldWidget` rows plus the tabs) stays put until inline editing lands (DESIGN.md §3 puts TipTap in the canvas, not the sidebar). The ref-option loading became `forms/useRefOptions.ts` (2026-09-17). (logged 2026-09-06, updated 2026-09-08)
@@ -1,6 +1,6 @@
1
1
  const GROUPS = {
2
2
  pages: ["list", "get", "create", "saveDraft", "publish", "unpublish", "discardDraft", "delete", "rename", "move", "setTemplate", "addLocale", "removeLocale", "listVersions", "restore"],
3
- entries: ["list", "get", "create", "save", "setStatus", "discardDraft", "delete", "move", "addLocale", "removeLocale", "listVersions", "getVersion", "restore"],
3
+ entries: ["list", "distinct", "get", "create", "save", "setStatus", "discardDraft", "delete", "move", "addLocale", "removeLocale", "listVersions", "getVersion", "restore"],
4
4
  globals: ["list", "get", "save", "setStatus", "discardDraft", "listVersions", "getVersion", "restore"],
5
5
  assets: ["list", "get", "beginUpload", "finishUpload", "update", "delete", "usage"],
6
6
  preview: ["enable", "disable"],
@@ -6,7 +6,9 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
6
6
  // Positions come from the geometry the bridge reported; the layer
7
7
  // itself never touches the iframe.
8
8
  import { useState } from "react";
9
- import { IconButton } from "../ui/primitives.js";
9
+ import { cx, IconButton } from "../ui/primitives.js";
10
+ /** Pixels above a block the riding pill needs (half its height); less and it moves inside. */
11
+ const TAG_CLEARANCE = 14;
10
12
  export function CanvasOverlay({ emptyZones, hoverRect, hoverGlobal, selection, plusButtons, fieldHover, editing, onPlus, onMove, onDuplicate, onRemove, }) {
11
13
  const [hoverPlus, setHoverPlus] = useState(null);
12
14
  return (_jsxs("div", { style: { position: "absolute", inset: 0, pointerEvents: "none" }, children: [emptyZones.map((z) => (_jsxs("div", { className: "sm-dashed", style: {
@@ -22,7 +24,7 @@ export function CanvasOverlay({ emptyZones, hoverRect, hoverGlobal, selection, p
22
24
  width: selection.rect.w, height: selection.rect.h,
23
25
  outline: selection.global ? "var(--outline-selection-global)" : "var(--outline-selection)", outlineOffset: -1.5,
24
26
  borderRadius: "var(--selection-radius)",
25
- } }), _jsxs("span", { className: selection.global ? "sm-blocktag sm-blocktag--global" : "sm-blocktag", style: { position: "absolute", left: selection.rect.x, top: selection.rect.y }, children: [selection.title, selection.global && " · global", selection.locked && " · locked"] }), !selection.layout && (_jsx("div", { className: "sm-blocktoolbar", style: {
27
+ } }), _jsxs("span", { className: cx("sm-blocktag", selection.global && "sm-blocktag--global", selection.rect.y < TAG_CLEARANCE && "sm-blocktag--inside"), style: { position: "absolute", left: selection.rect.x, top: selection.rect.y }, children: [selection.title, selection.global && " · global", selection.locked && " · locked"] }), !selection.layout && (_jsx("div", { className: "sm-blocktoolbar", style: {
26
28
  position: "absolute", left: selection.rect.x + selection.rect.w - 8, top: selection.rect.y + 8,
27
29
  transform: "translateX(-100%)", pointerEvents: "auto",
28
30
  }, children: selection.locked ? (_jsx("span", { className: "sm-blocktoolbar__note", children: "fields only" })) : (_jsxs(_Fragment, { children: [_jsx(IconButton, { label: "Move up", tone: "inverse", onClick: () => onMove(selection.node.id, -1), children: "\u2191" }), _jsx(IconButton, { label: "Move down", tone: "inverse", onClick: () => onMove(selection.node.id, 1), children: "\u2193" }), _jsx(IconButton, { label: "Duplicate", tone: "inverse", onClick: () => onDuplicate(selection.node), children: "\u29C9" }), _jsx(IconButton, { label: "Remove", tone: "inverse", onClick: () => onRemove(selection.node.id), children: "\u00D7" })] })) }))] })), plusButtons.map((p) => {
@@ -32,6 +32,7 @@ import { CSS_KEY, GLOBAL_NODE_TYPE, SETTINGS_KEY, STYLES_KEY, globalNameOf } fro
32
32
  import { descriptorAt, getAtPath } from "../field-paths.js";
33
33
  import { FieldWidget } from "../forms/FieldWidget.js";
34
34
  import { revealField } from "../forms/reveal.js";
35
+ import { useRefOptions } from "../forms/useRefOptions.js";
35
36
  import { tabOf, tabsFor } from "../forms/tabs.js";
36
37
  import { InspectorFields, valueOf } from "./InspectorFields.js";
37
38
  import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, moveNodeTo, newNodeId, nodeFromSample, patchNode, patchNodeCss, patchNodePath, patchPage, placementNode, removeNode, zoneOf, } from "../tree-ops.js";
@@ -51,9 +52,8 @@ import { useCanvasBridge } from "./useCanvasBridge.js";
51
52
  import { useInlineSession } from "./useInlineSession.js";
52
53
  import { usePageLocale } from "./usePageLocale.js";
53
54
  import { useGlobalEdit } from "./useGlobalEdit.js";
54
- import { BASE } from "../shell/base.js";
55
+ import { useAdminHref } from "../shell/base.js";
55
56
  import { isFixedNode, localeStatusOf, sourceLocaleOf } from "../shell/pages-tree.js";
56
- import { entryTitle } from "../shell/entries-list.js";
57
57
  import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
58
58
  import { ResizeHandle } from "../ui/ResizeHandle.js";
59
59
  import { PublishControl } from "../ui/PublishControl.js";
@@ -63,6 +63,7 @@ export const withEditorParam = (url) => (url.includes("?") ? `${url}&smoodly-edi
63
63
  /** The site URL of a stored version — History's iframe (spec 2026-09-13 history §5); no editor parameter, so no bridge. */
64
64
  export const withVersionParam = (url, versionId) => `${url}${url.includes("?") ? "&" : "?"}smoodly-version=${encodeURIComponent(versionId)}`;
65
65
  export function EditorView({ ops, registry, id }) {
66
+ const adminHref = useAdminHref();
66
67
  const [record, setRecord] = useState(null);
67
68
  const [tree, setTree] = useState(null);
68
69
  const [loadError, setLoadError] = useState(null);
@@ -96,7 +97,6 @@ export function EditorView({ ops, registry, id }) {
96
97
  /** A global edit since the item's last save ack; and any global edit on this canvas yet. */
97
98
  const [globalDirty, setGlobalDirty] = useState(false);
98
99
  const [globalTouched, setGlobalTouched] = useState(false);
99
- const [refOptions, setRefOptions] = useState({});
100
100
  // Global item name → row id, loaded once per editor mount: the picker's
101
101
  // Global group and a bound locked zone need the id to place a node.
102
102
  const [globalIds, setGlobalIds] = useState(null);
@@ -303,31 +303,18 @@ export function EditorView({ ops, registry, id }) {
303
303
  }
304
304
  return out;
305
305
  }, [registry]);
306
- useEffect(() => {
307
- let cancelled = false;
308
- for (const target of new Set(Object.values(refTargets))) {
309
- const targetMeta = registry.collections.find((c) => c.name === target);
310
- ops.entries.list(target).then((r) => {
311
- // A list issued for the previous locale must not overwrite this one's.
312
- if (cancelled || !r.ok)
313
- return;
314
- const options = r.data
315
- // only entries present in the locale being edited can be referenced there
316
- .filter((e) => e.locales[locale])
317
- .map((e) => ({
318
- id: e.id,
319
- title: targetMeta ? entryTitle(e, targetMeta, registry, locale) : e.id,
320
- status: e.locales[locale].status,
321
- }));
322
- setRefOptions((prev) => ({ ...prev, [target]: options }));
323
- }).catch((e) => {
324
- // A rejected transport just leaves this target's options empty —
325
- // ref fields degrade to "nothing to pick from," not a stuck load.
326
- console.error(e);
327
- });
306
+ // A global item edited from the canvas picks refs too: its targets load beside the sections'.
307
+ const refTargetList = useMemo(() => {
308
+ const out = Object.values(refTargets);
309
+ for (const g of registry.globals) {
310
+ for (const d of Object.values(g.fields ?? {})) {
311
+ if ((d.type === "ref" || d.type === "refList") && typeof d.target === "string")
312
+ out.push(d.target);
313
+ }
328
314
  }
329
- return () => { cancelled = true; };
330
- }, [ops, refTargets, registry, locale]);
315
+ return out;
316
+ }, [refTargets, registry]);
317
+ const refOptions = useRefOptions(ops, registry, locale, refTargetList);
331
318
  useEffect(() => {
332
319
  if (registryRef.current.globals.length === 0) {
333
320
  setGlobalIds({});
@@ -759,9 +746,9 @@ export function EditorView({ ops, registry, id }) {
759
746
  const tabs = selectedSection ? tabsFor(selectedSection) : selectedItem ? tabsFor(selectedItem) : [];
760
747
  const activeTab = tabs.some((t) => t.tab === tab) ? tab : tabs[0]?.tab;
761
748
  const activeGroups = tabs.find((t) => t.tab === activeTab)?.groups ?? [];
762
- return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: `${BASE}/pages`, items: [
749
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: adminHref("/pages"), items: [
763
750
  {
764
- label: "Pages", href: `${BASE}/pages`,
751
+ label: "Pages", href: adminHref("/pages"),
765
752
  trailing: registry.locales.supported.length > 1 && (_jsx(LocaleSwitcher, { registry: registry, present: record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale], parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => { void doAddLocale(l); } })),
766
753
  },
767
754
  { label },
@@ -780,7 +767,7 @@ export function EditorView({ ops, registry, id }) {
780
767
  else
781
768
  mutate((t) => patchNodePath(t, session.node, session.path, doc));
782
769
  }, onEscape: inline.end }));
783
- })()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selectedSection || selectedItem ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection?.title ?? (selectedItem ? `${selectedItem.title} · global` : null) }), selectedItem && (globalTouched || globalEdit.saveState === "error") && _jsx(SaveIndicator, { state: globalEdit.saveState })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : record ? (_jsx(PageInspector, { record: record, locale: locale, registry: registry, tree: tree, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
770
+ })()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selectedSection || selectedItem ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection?.title ?? (selectedItem ? `${selectedItem.title} · global` : null) }), selectedItem && (globalTouched || globalEdit.saveState === "error") && _jsx(SaveIndicator, { state: globalEdit.saveState })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && selectedSection && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : record ? (_jsx(PageInspector, { record: record, locale: locale, registry: registry, tree: tree, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
784
771
  !isFixedNode(record, registry) &&
785
772
  !(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
786
773
  ? () => { void doRemoveLocale(); }
@@ -792,7 +779,7 @@ export function EditorView({ ops, registry, id }) {
792
779
  doPatch(selected.id, ns, key, value);
793
780
  if (ns === "fields")
794
781
  syncTexts(selected.id, selectedSection, { ...selected.fields, [key]: value });
795
- } }, `${selected.id}.${ns}.${key}`)) })), selectedItem && selectedId && (_jsx(GlobalInspector, { item: selectedItem, edit: globalEdit.items[selectedItem.name] ?? null, locale: locale, groups: activeGroups, multilingual: registry.locales.supported.length > 1, refOptions: refOptions, pending: globalDirty || globalEdit.saveState !== "saved", busy: globalBusy, inlinePath: inline.session?.node === selectedId ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
782
+ } }, `${selected.id}.${ns}.${key}`)) })), selectedItem && selectedId && (_jsx(GlobalInspector, { item: selectedItem, edit: globalEdit.items[selectedItem.name] ?? null, locale: locale, groups: activeGroups, multilingual: registry.locales.supported.length > 1, refOptions: refOptions, tabs: tabs.length > 0 ? _jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }) : undefined, pending: globalDirty || globalEdit.saveState !== "saved", busy: globalBusy, inlinePath: inline.session?.node === selectedId ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
796
783
  reveal(p, selectedItem); }, onChange: (ns, key, value) => {
797
784
  patchGlobal(selectedItem.name, globalPath(ns, key), value);
798
785
  if (ns === "fields") {
@@ -825,5 +812,5 @@ function refOptionsFor(loaded, targets, section, ns, key, descriptor) {
825
812
  if (descriptor.type !== "ref" && descriptor.type !== "refList")
826
813
  return undefined;
827
814
  const target = targets[`${section.name}.${ns}.${key}`];
828
- return target ? loaded[target] ?? [] : [];
815
+ return target ? loaded[target] : [];
829
816
  }
@@ -1,9 +1,10 @@
1
+ import type { ReactNode } from "react";
1
2
  import type { AdminGlobal } from "../serialize.ts";
2
3
  import type { Group } from "../forms/tabs.ts";
3
4
  import type { GlobalEditItem } from "./global-edit.ts";
4
5
  import { type RefOption } from "../shell/GlobalFields.tsx";
5
6
  import { type PublishBusy } from "../ui/PublishControl.tsx";
6
- export declare function GlobalInspector({ item, edit, locale, groups, multilingual, refOptions, inlinePath, onEditHere, onChange, pending, busy, onPublish, onDiscard, onUnpublish, }: {
7
+ export declare function GlobalInspector({ item, edit, locale, groups, multilingual, refOptions, inlinePath, onEditHere, onChange, pending, busy, onPublish, onDiscard, onUnpublish, tabs, }: {
7
8
  item: AdminGlobal;
8
9
  /** null while the item loads. */
9
10
  edit: GlobalEditItem | null;
@@ -11,7 +12,7 @@ export declare function GlobalInspector({ item, edit, locale, groups, multilingu
11
12
  /** The active tab's groups (spec 2026-09-14 §5). */
12
13
  groups: Group[];
13
14
  multilingual: boolean;
14
- refOptions: Record<string, RefOption[]>;
15
+ refOptions: Record<string, RefOption[] | undefined>;
15
16
  inlinePath?: string;
16
17
  onEditHere?(): void;
17
18
  onChange(ns: "fields" | "settings" | "styles" | "css", key: string, value: unknown): void;
@@ -21,4 +22,6 @@ export declare function GlobalInspector({ item, edit, locale, groups, multilingu
21
22
  onPublish(): void;
22
23
  onDiscard(): void;
23
24
  onUnpublish(): void;
25
+ /** The Content / Settings tabs, rendered between the control and the fields. */
26
+ tabs?: ReactNode;
24
27
  }): import("react").JSX.Element;
@@ -1,13 +1,14 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { BASE } from "../shell/base.js";
3
+ import { useAdminHref } from "../shell/base.js";
4
4
  import { localeStatusOf } from "../shell/pages-tree.js";
5
5
  import { GlobalFields } from "../shell/GlobalFields.js";
6
6
  import { PublishControl } from "../ui/PublishControl.js";
7
- export function GlobalInspector({ item, edit, locale, groups, multilingual, refOptions, inlinePath, onEditHere, onChange, pending, busy, onPublish, onDiscard, onUnpublish, }) {
7
+ export function GlobalInspector({ item, edit, locale, groups, multilingual, refOptions, inlinePath, onEditHere, onChange, pending, busy, onPublish, onDiscard, onUnpublish, tabs, }) {
8
+ const adminHref = useAdminHref();
8
9
  const row = edit?.record.locales[locale];
9
10
  // The same three states the global list shows; the control decides what applies.
10
11
  const status = row ? localeStatusOf(row) : "draft";
11
12
  const pages = edit ? [...new Set(edit.usage.filter((u) => u.sourceKind === "page").map((u) => u.sourceId))] : [];
12
- return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsx("p", { className: "sm-hint", children: "Global content \u2014 changes every page it is used on." }), edit && (_jsxs("p", { className: "sm-hint", children: [pages.length === 0 ? "Not placed on any page." : `Used on ${pages.length} page${pages.length === 1 ? "" : "s"}: `, pages.map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: `${BASE}/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`, children: id })] }, id)))] })), _jsx("div", { style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }, children: _jsx(PublishControl, { status: row ? status : null, pending: pending, busy: busy, disabled: !edit, onPublish: onPublish, onDiscard: onDiscard, onUnpublish: onUnpublish }) }), edit ? (_jsx(GlobalFields, { item: item, groups: groups, fields: edit.fields, errors: edit.errors, refOptions: refOptions, multilingual: multilingual, inlinePath: inlinePath, onEditHere: onEditHere, onChange: onChange })) : (_jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" }))] }));
13
+ return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsx("p", { className: "sm-hint", children: "Global content \u2014 changes every page it is used on." }), pages.length > 0 && (_jsxs("p", { className: "sm-hint", children: [`Used on ${pages.length} page${pages.length === 1 ? "" : "s"}: `, pages.map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`), children: id })] }, id)))] })), _jsx("div", { style: { display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 8, flexWrap: "wrap" }, children: _jsx(PublishControl, { status: row ? status : null, pending: pending, busy: busy, disabled: !edit, compact: true, onPublish: onPublish, onDiscard: onDiscard, onUnpublish: onUnpublish }) }), tabs, edit ? (_jsx(GlobalFields, { item: item, groups: groups, fields: edit.fields, errors: edit.errors, refOptions: refOptions, multilingual: multilingual, inlinePath: inlinePath, onEditHere: onEditHere, onChange: onChange })) : (_jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" }))] }));
13
14
  }
@@ -1,4 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useAdminHref } from "../shell/base.js";
2
3
  export function NoticePane({ children }) {
3
- return (_jsx("div", { style: { display: "grid", placeItems: "center", flex: 1, padding: 40, background: "var(--bg)" }, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }, children: [children, _jsx("a", { href: "/admin/pages", children: "\u2190 Back to pages" })] }) }));
4
+ const adminHref = useAdminHref();
5
+ return (_jsx("div", { style: { display: "grid", placeItems: "center", flex: 1, padding: 40, background: "var(--bg)" }, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }, children: [children, _jsx("a", { href: adminHref("/pages"), children: "\u2190 Back to pages" })] }) }));
4
6
  }
@@ -1,11 +1,12 @@
1
1
  import type { ReactElement, ReactNode } from "react";
2
2
  import type { AdminField } from "../serialize.ts";
3
+ import type { RefOption } from "./ref-picker.ts";
3
4
  /** The two namespaces a `fields.` / `settings.` condition reads (spec 2026-09-14 §12). */
4
5
  export type WidgetScope = {
5
6
  fields?: Record<string, unknown>;
6
7
  settings?: Record<string, unknown>;
7
8
  };
8
- export declare function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions, fieldKey, prefix, mark, inlinePath, onEditHere, scope, emptyLabel, }: {
9
+ export declare function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions, suggestions, fieldKey, prefix, mark, inlinePath, onEditHere, scope, emptyLabel, }: {
9
10
  descriptor: AdminField;
10
11
  value: unknown;
11
12
  onChange(value: unknown): void;
@@ -14,11 +15,10 @@ export declare function FieldWidget({ descriptor: d, value: valueProp, onChange,
14
15
  path?: string;
15
16
  /** Render the control alone, no label wrapper — list items. */
16
17
  bare?: boolean;
17
- refOptions?: {
18
- id: string;
19
- title: string;
20
- status: string;
21
- }[];
18
+ /** Every entry of a ref field's target; undefined while loading. */
19
+ refOptions?: RefOption[];
20
+ /** Tags the collection's other entries use (an entry form's tags fields only). */
21
+ suggestions?: string[];
22
22
  fieldKey: string;
23
23
  prefix?: string;
24
24
  /** A glyph after the label — the forms' "same in every language" mark. */
@@ -3,20 +3,23 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { RichtextWidget } from "./RichtextWidget.js";
4
4
  import { MediaWidget } from "./MediaWidget.js";
5
5
  import { ListField } from "./ListField.js";
6
+ import { RefPicker } from "./RefPicker.js";
7
+ import { TagsField } from "./TagsField.js";
6
8
  import { RowsWidget } from "./RowsWidget.js";
7
9
  import { optionsOf } from "../../fields.js";
8
10
  import { isHidden } from "../../conditions.js";
9
11
  import { cssProblem } from "../../css.js";
10
- import { Button, Field, IconButton, Switch } from "../ui/primitives.js";
12
+ import { Button, Field, Switch } from "../ui/primitives.js";
11
13
  import { patchObject } from "./list.js";
12
14
  /** A key as a caption, sentence case: `spacingTop` → "Spacing top", `css` → "Custom CSS". */
13
15
  const label = (key) => key === "css" ? "Custom CSS" : key.replace(/([A-Z])/g, (c) => ` ${c.toLowerCase()}`).replace(/^./, (c) => c.toUpperCase());
14
- export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions = [], fieldKey, prefix, mark, inlinePath, onEditHere, scope, emptyLabel, }) {
16
+ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions, suggestions, fieldKey, prefix, mark, inlinePath, onEditHere, scope, emptyLabel, }) {
15
17
  const path = pathProp ?? fieldKey;
16
- const message = errors?.[path];
18
+ // A tags field has no per-chip slot: the first item error shows under the field.
19
+ const below = d.type === "tags" && errors ? Object.keys(errors).find((k) => k.startsWith(`${path}.`)) : undefined;
20
+ const message = errors?.[path] ?? (below ? errors?.[below] : undefined);
17
21
  // The descriptor's default shows while the value is absent (spec 2026-09-10 page fields §1).
18
22
  const value = valueProp === undefined && d.default !== undefined ? d.default : valueProp;
19
- const optionTitle = (o) => o.status === "draft" ? `${o.title} (draft)` : o.title;
20
23
  // The custom section's rows (spec 2026-09-14 §6): the row cards instead of the generic list.
21
24
  if (d.widget === "rows") {
22
25
  const rows = _jsx(RowsWidget, { descriptor: d, value: value, onChange: onChange, errors: errors, path: path, inlinePath: inlinePath, onEditHere: onEditHere });
@@ -24,7 +27,7 @@ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors,
24
27
  }
25
28
  let control;
26
29
  let counter;
27
- let asDiv = d.type === "refList";
30
+ let asDiv = false;
28
31
  switch (d.type) {
29
32
  case "text":
30
33
  case "link":
@@ -76,6 +79,14 @@ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors,
76
79
  control = (_jsx("div", { className: "sm-choices", children: all.map((o) => (_jsxs("label", { className: "sm-choice", children: [_jsx("input", { type: "checkbox", checked: chosen.includes(o.value), onChange: (e) => toggle(o.value, e.target.checked) }), o.label] }, o.value))) }));
77
80
  break;
78
81
  }
82
+ case "tags": {
83
+ asDiv = true; // chips carry buttons
84
+ const max = typeof d.max === "number" ? d.max : undefined;
85
+ if (max !== undefined)
86
+ counter = `${Array.isArray(value) ? value.length : 0} of ${max}`;
87
+ control = _jsx(TagsField, { value: value, onChange: onChange, max: max, suggestions: suggestions, label: label(fieldKey) });
88
+ break;
89
+ }
79
90
  case "richtext":
80
91
  // The toolbar holds buttons, so this control can never sit in a <label>.
81
92
  asDiv = true;
@@ -90,12 +101,13 @@ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors,
90
101
  control = _jsx(MediaWidget, { descriptor: d, value: value, onChange: onChange });
91
102
  break;
92
103
  case "ref":
93
- control = (_jsxs("select", { className: "sm-select", value: String(value ?? ""), onChange: (e) => onChange(e.target.value || undefined), children: [_jsx("option", { value: "", children: "\u2014" }), refOptions.map((o) => _jsx("option", { value: o.id, children: optionTitle(o) }, o.id))] }));
94
- break;
95
104
  case "refList": {
96
- const ids = Array.isArray(value) ? value : [];
97
- const byId = new Map(refOptions.map((o) => [o.id, o]));
98
- control = (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [ids.map((id, i) => (_jsxs("div", { style: { display: "flex", gap: 6, alignItems: "center" }, children: [_jsx("span", { className: "sm-input", style: { display: "flex", alignItems: "center", flex: 1 }, children: byId.get(id) ? optionTitle(byId.get(id)) : id }), _jsx(IconButton, { label: "Remove", size: 28, onClick: () => onChange(ids.filter((_, j) => j !== i)), children: "\u00D7" })] }, `${id}-${i}`))), _jsxs("select", { className: "sm-select", value: "", onChange: (e) => e.target.value && onChange([...ids, e.target.value]), children: [_jsx("option", { value: "", children: "Add\u2026" }), refOptions.filter((o) => !ids.includes(o.id)).map((o) => (_jsx("option", { value: o.id, children: optionTitle(o) }, o.id)))] })] }));
105
+ asDiv = true; // rows of buttons: never inside one implicit label
106
+ const multiple = d.type === "refList";
107
+ const max = typeof d.max === "number" ? d.max : undefined;
108
+ if (multiple && max !== undefined)
109
+ counter = `${Array.isArray(value) ? value.length : 0} of ${max}`;
110
+ control = _jsx(RefPicker, { value: value, onChange: onChange, options: refOptions, multiple: multiple, max: max, target: typeof d.target === "string" ? d.target : "" });
99
111
  break;
100
112
  }
101
113
  case "object": {
@@ -0,0 +1,11 @@
1
+ import { type RefOption } from "./ref-picker.ts";
2
+ export declare function RefPicker({ value, onChange, options, multiple, max, target }: {
3
+ value: unknown;
4
+ onChange(value: unknown): void;
5
+ /** Every entry of the target collection; undefined while loading. */
6
+ options: RefOption[] | undefined;
7
+ multiple: boolean;
8
+ max?: number;
9
+ /** The target collection's name: the rows link to its admin form. */
10
+ target: string;
11
+ }): import("react").JSX.Element;
@@ -0,0 +1,30 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // One widget for f.ref and f.refList (spec 2026-09-17 §2): a search input,
4
+ // the picked entries under it. A refList's rows drag like a list's.
5
+ import { ExternalLink } from "lucide-react";
6
+ import { Combobox } from "../ui/Combobox.js";
7
+ import { Grip, IconButton, cx } from "../ui/primitives.js";
8
+ import { statusLabel } from "../ui/status-label.js";
9
+ import { useSortable } from "../ui/useSortable.js";
10
+ import { useAdminHref } from "../shell/base.js";
11
+ import { moveItem } from "./list.js";
12
+ import { pickRef, refCandidates, refIds, refRows, refValue, statusTone } from "./ref-picker.js";
13
+ const titleCase = (name) => name.replace(/^./, (c) => c.toUpperCase());
14
+ function StatusDot({ status }) {
15
+ if (status === "")
16
+ return null;
17
+ return _jsx("span", { className: "sm-dot", title: statusLabel(status), style: { background: `var(--status-${statusTone(status)}-fg)` } });
18
+ }
19
+ export function RefPicker({ value, onChange, options, multiple, max, target }) {
20
+ const adminHref = useAdminHref();
21
+ const ids = refIds(value, multiple);
22
+ const rows = refRows(ids, options);
23
+ const candidates = refCandidates(ids, options);
24
+ const byId = new Map(candidates.map((o) => [o.id, o]));
25
+ const atMax = multiple && typeof max === "number" && ids.length >= max;
26
+ const set = (next) => onChange(refValue(next, multiple));
27
+ const sortable = useSortable({ count: multiple ? rows.length : 0, onDrop: (from, to) => set(moveItem(ids, from, to)) });
28
+ const present = (options ?? []).filter((o) => !o.absent).length;
29
+ return (_jsxs("div", { className: "sm-refpicker", children: [_jsx(Combobox, { label: `Search ${titleCase(target)}`, keepOpen: multiple, options: candidates.map((o) => ({ value: o.id, label: o.title })), disabled: options === undefined || atMax, placeholder: atMax ? `Maximum ${max}` : options === undefined ? "Loading…" : "Type to search", emptyText: present === 0 ? `No entries in ${titleCase(target)} yet` : "Every entry is already picked", renderOption: (o) => (_jsxs(_Fragment, { children: [_jsx("span", { className: "sm-refpicker__title", children: o.label }), _jsx(StatusDot, { status: byId.get(o.value)?.status ?? "" })] })), onPick: (id) => set(pickRef(ids, id, multiple, max)) }), rows.length > 0 && (_jsx("div", { className: "sm-refpicker__rows", ref: sortable.containerRef, children: rows.map((r, i) => (_jsxs("div", { style: multiple ? sortable.rowStyle(i) : undefined, className: cx("sm-refrow", r.state !== "ok" && "sm-refrow--muted", multiple && sortable.rowClass(i)), children: [multiple && _jsx(Grip, { ...sortable.handleProps(i) }), _jsxs("span", { className: "sm-refpicker__title", children: [r.title, r.state === "absent" && _jsx("span", { className: "sm-refrow__note", children: " \u00B7 Not available in this locale" })] }), _jsx(StatusDot, { status: r.status }), (r.state === "ok" || r.state === "absent") && (_jsx("a", { className: "sm-iconbtn", href: adminHref(`/${target}/${r.id}`), target: "_blank", rel: "noreferrer", "aria-label": "Open entry", title: "Open entry", children: _jsx(ExternalLink, { size: 13, strokeWidth: 1.75, "aria-hidden": "true" }) })), _jsx(IconButton, { label: "Remove", onClick: () => set(ids.filter((_, j) => j !== i)), children: "\u00D7" })] }, `${r.id}-${i}`))) }))] }));
30
+ }
@@ -0,0 +1,10 @@
1
+ export declare function TagsField({ value, onChange, max, suggestions, label, initialAdding }: {
2
+ value: unknown;
3
+ onChange(value: string[]): void;
4
+ max?: number;
5
+ /** Tags the collection's other entries use; absent outside a collection form — then no list opens. */
6
+ suggestions?: string[];
7
+ label: string;
8
+ /** Start with the input open (static-markup tests). */
9
+ initialAdding?: boolean;
10
+ }): import("react").JSX.Element;
@@ -0,0 +1,38 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // f.tags() as chips (spec 2026-09-17 §5): a × per chip, a dashed "+ Add"
4
+ // that becomes an inline Combobox. Enter or a comma commits and keeps the
5
+ // input open, Escape closes, leaving commits what was typed, Backspace on
6
+ // an empty input removes the last chip. Every commit goes through addTags.
7
+ import { useEffect, useRef, useState } from "react";
8
+ import { addTags, sameTag } from "../../tags.js";
9
+ import { Combobox } from "../ui/Combobox.js";
10
+ import { cx } from "../ui/primitives.js";
11
+ export function TagsField({ value, onChange, max, suggestions, label, initialAdding = false }) {
12
+ const tags = Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
13
+ const [adding, setAdding] = useState(initialAdding);
14
+ const [flash, setFlash] = useState(null);
15
+ const timer = useRef(null);
16
+ useEffect(() => () => { if (timer.current)
17
+ clearTimeout(timer.current); }, []);
18
+ const atMax = typeof max === "number" && tags.length >= max;
19
+ const commit = (input) => {
20
+ const r = addTags(tags, input, { max, known: suggestions });
21
+ if (r.tags.length !== tags.length)
22
+ onChange(r.tags);
23
+ if (r.duplicate !== null) {
24
+ setFlash(r.duplicate);
25
+ if (timer.current)
26
+ clearTimeout(timer.current);
27
+ timer.current = setTimeout(() => setFlash(null), 600);
28
+ }
29
+ };
30
+ return (_jsxs("div", { className: "sm-tags", children: [tags.map((t, i) => (_jsxs("span", { className: cx("sm-tag", flash === t && "sm-tag--flash"), children: [t, _jsx("button", { type: "button", className: "sm-tag__x", "aria-label": `Remove ${t}`, onClick: () => onChange(tags.filter((_, j) => j !== i)), children: "\u00D7" })] }, t))), adding && !atMax && (_jsx(Combobox, { className: "sm-combo--tag", label: `Add to ${label}`, placeholder: "Add a tag", autoFocus: true, keepOpen: true, initialOpen: true, options: (suggestions ?? []).filter((s) => !tags.some((t) => sameTag(t, s))).map((s) => ({ value: s, label: s })), onPick: commit, onSubmit: commit, createRow: suggestions !== undefined, onQueryChange: (next) => { if (!next.includes(","))
31
+ return next; commit(next); return ""; }, onInputKey: (e, query) => {
32
+ if (e.key !== "Backspace" || query !== "" || tags.length === 0)
33
+ return false;
34
+ onChange(tags.slice(0, -1));
35
+ return true;
36
+ }, onClose: (query, reason) => { if (reason === "blur" && query.trim() !== "")
37
+ commit(query); setAdding(false); } })), !adding && !atMax && _jsx("button", { type: "button", className: "sm-tag sm-tag--add", onClick: () => setAdding(true), children: "+ Add" })] }));
38
+ }
@@ -0,0 +1,25 @@
1
+ /** One entry of the target collection. `status` is its LocaleStatus in the edited
2
+ * locale; `absent` marks an entry with no row there — never offered, but a picked
3
+ * one is named instead of reading as missing. */
4
+ export type RefOption = {
5
+ id: string;
6
+ title: string;
7
+ status: string;
8
+ absent?: true;
9
+ };
10
+ export type RefRow = {
11
+ id: string;
12
+ title: string;
13
+ status: string;
14
+ state: "ok" | "absent" | "missing" | "loading";
15
+ };
16
+ export declare const refIds: (value: unknown, multiple: boolean) => string[];
17
+ export declare const refValue: (ids: string[], multiple: boolean) => string | string[] | undefined;
18
+ /** A pick: a single ref is replaced, a list grows — never past `max`, never twice the same entry. */
19
+ export declare function pickRef(ids: string[], id: string, multiple: boolean, max?: number): string[];
20
+ /** What the dropdown offers: present in the edited locale, not picked yet. */
21
+ export declare const refCandidates: (ids: string[], options: RefOption[] | undefined) => RefOption[];
22
+ /** The selected rows under the input. `options` undefined = still loading. */
23
+ export declare function refRows(ids: string[], options: RefOption[] | undefined): RefRow[];
24
+ /** The badge tone of a LocaleStatus (ui/primitives.tsx's STATUS_TONE, keyed by the stored status). */
25
+ export declare const statusTone: (status: string) => "published" | "changes" | "draft";
@@ -0,0 +1,30 @@
1
+ // The reference picker's value logic as pure functions (spec 2026-09-17 §2):
2
+ // one widget for `ref` (an id) and `refList` (an id array). RefPicker renders.
3
+ export const refIds = (value, multiple) => multiple
4
+ ? (Array.isArray(value) ? value.filter((v) => typeof v === "string") : [])
5
+ : (typeof value === "string" && value !== "" ? [value] : []);
6
+ export const refValue = (ids, multiple) => multiple ? ids : ids[0];
7
+ /** A pick: a single ref is replaced, a list grows — never past `max`, never twice the same entry. */
8
+ export function pickRef(ids, id, multiple, max) {
9
+ if (!multiple)
10
+ return [id];
11
+ if (ids.includes(id) || (typeof max === "number" && ids.length >= max))
12
+ return ids;
13
+ return [...ids, id];
14
+ }
15
+ /** What the dropdown offers: present in the edited locale, not picked yet. */
16
+ export const refCandidates = (ids, options) => (options ?? []).filter((o) => !o.absent && !ids.includes(o.id));
17
+ /** The selected rows under the input. `options` undefined = still loading. */
18
+ export function refRows(ids, options) {
19
+ if (options === undefined)
20
+ return ids.map((id) => ({ id, title: "…", status: "", state: "loading" }));
21
+ const byId = new Map(options.map((o) => [o.id, o]));
22
+ return ids.map((id) => {
23
+ const o = byId.get(id);
24
+ if (!o)
25
+ return { id, title: "Missing entry", status: "", state: "missing" };
26
+ return { id, title: o.title, status: o.status, state: o.absent ? "absent" : "ok" };
27
+ });
28
+ }
29
+ /** The badge tone of a LocaleStatus (ui/primitives.tsx's STATUS_TONE, keyed by the stored status). */
30
+ export const statusTone = (status) => status === "published" ? "published" : status === "draft edits" ? "changes" : "draft";
@@ -0,0 +1,7 @@
1
+ import type { EntryRecord } from "../../entry-store.ts";
2
+ import type { AdminOps } from "../ops.ts";
3
+ import type { AdminCollection, AdminRegistry } from "../serialize.ts";
4
+ import type { RefOption } from "./ref-picker.ts";
5
+ export declare function refOptionsOf(records: EntryRecord[], meta: AdminCollection | undefined, registry: AdminRegistry, locale: string): RefOption[];
6
+ /** Options by target collection; a target is `undefined` until its list arrives. */
7
+ export declare function useRefOptions(ops: AdminOps, registry: AdminRegistry, locale: string, targets: readonly string[]): Record<string, RefOption[] | undefined>;