@ai-matrx/detail 0.1.176 → 0.1.177

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/CHANGELOG.md CHANGED
@@ -1,10 +1,15 @@
1
1
  # Changelog — @ai-matrx/detail
2
2
 
3
- ## 0.1.176
3
+ ## 0.1.177
4
4
 
5
5
  UNVERIFIED: this release has no Unreleased entry and the source comparison could not be made (the caller supplied no source comparison). Whether source changed is unknown; consumer action is unknown.
6
6
  Check the package's gitHead on npm against this version's gitHead: `git diff <previous gitHead> <this gitHead> -- apps/shared/detail/src`.
7
7
 
8
+ ## 0.1.176
9
+
10
+ Automatic changed-only republish (docs/metadata drift since the last published version; verified: `git diff 0a2b8c9356 f1984cb2e2 -- apps/shared/detail/src`
11
+ has no shipped-source changes outside tests, fixtures and docs). No consumer action required.
12
+
8
13
  ## 0.1.175
9
14
 
10
15
  SOURCE CHANGED WITH NO RELEASE NOTE. This release shipped source changes that nobody described in an Unreleased entry,
@@ -69,4 +69,4 @@ export {
69
69
  requireResolveType,
70
70
  requireShell
71
71
  };
72
- //# sourceMappingURL=chunk-D62JZJMB.js.map
72
+ //# sourceMappingURL=chunk-OZZG6JAG.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/react/host.tsx"],"sourcesContent":["// @ai-matrx/detail — src/react/host.tsx\n//\n// THE SEAM. Everything this package needs from the app that hosts it arrives\n// here as ports — the same shape `@ai-matrx/associations` uses. The core and\n// the three presentations import nothing from any host; they ask the host for\n// the record-type map, the presentation setting, the shells (window / docked /\n// page), the doors, history, navigation and notifications.\n//\n// Providers NEST AND MERGE: a light provider at boot binds everything but the\n// shells (so `useOpenDetail` works anywhere), and each presentation's lazily\n// loaded entry binds its own shell right above the presentation — the window\n// shell parses the host's window manager and must never sit in a boot bundle.\n// A port that is missing when it is needed throws, naming itself.\n\n\"use client\";\n\nimport { createContext, useContext, type Context, type ComponentType, type ReactNode } from \"react\";\n\nimport type {\n DetailHistoryEntry,\n DetailInstanceData,\n DetailListContext,\n DetailPresentation,\n DetailRecordType,\n DetailRef,\n DetailRow,\n DetailSeed,\n} from \"../types\";\n\n/** Props every shell receives; the package fills the slots, the host owns the chrome. */\n/** One of the record's optional controls as data (a shell's menu draws it). */\nexport interface DetailMenuAction {\n readonly id: string;\n readonly label: string;\n readonly onSelect: () => void;\n}\n\nexport interface DetailShellSlots {\n /** Accessible plain-text title (window chrome, drawer title, document title). */\n title: string;\n /** Rich title: icon · name · type chip · the record's doors. */\n titleNode: ReactNode;\n /** Presentation switcher · previous / next · copy id. Compact. */\n actions: ReactNode;\n /**\n * Previous / next only — for a shell whose chrome has its OWN menu: it puts\n * `menuActions` there instead of a second overflow button (\"…\" beside \"⋮\").\n */\n navActions: ReactNode;\n /** The optional controls (open as window / docked / page, copy id) as data, for a shell's own menu. */\n menuActions: readonly DetailMenuAction[];\n children: ReactNode;\n onClose: () => void;\n}\n\nexport interface DetailWindowShellProps extends DetailShellSlots {\n /** `type.id` — the deep-link instance and the window manager's key. */\n instanceKey: string;\n target: DetailRef;\n /**\n * 🚨 NEW-15 — THE LIST TRAVELS INTO THE DEEP LINK. The window's `?panels=`\n * token used to carry the record and the presentation only, so a REFRESH lost\n * the previous / next controls and the counter without a word. The shell puts\n * this into the token the way the page presentation puts it into the query.\n */\n list: DetailListContext | null;\n}\n\nexport interface DetailDockedShellProps extends DetailShellSlots {\n instanceKey: string;\n target: DetailRef;\n}\n\nexport interface DetailPageShellProps extends DetailShellSlots {\n target: DetailRef;\n /** Back affordance — the page is the one presentation that left the URL. */\n onBack: () => void;\n}\n\nexport interface DetailEffectivePresentation {\n /** The effective presentation for this type, `undefined` when unresolved. */\n value: DetailPresentation | undefined;\n /** The per-record-type exception still in effect, from ANY rung. */\n forType: DetailPresentation | undefined;\n}\n\nexport interface DetailPresentationSetting {\n /** `undefined` while unresolved; `error` names the failure — never both silent. */\n value: DetailPresentation | undefined;\n error: string | null;\n /**\n * The PER-RECORD-TYPE exception in effect for this type, when there is one\n * (`undefined` = this type opens the way the default says). The pane offers\n * \"use the default for this type\" only then — an offer to remove something\n * that does not exist is a control that does nothing (NEW-2).\n */\n forType?: DetailPresentation | undefined;\n}\n\nexport interface DetailShells {\n Window?: ComponentType<DetailWindowShellProps>;\n Docked?: ComponentType<DetailDockedShellProps>;\n Page?: ComponentType<DetailPageShellProps>;\n}\n\nexport interface DetailHostPorts {\n /**\n * The ONE record-type map. `null` = the host has no registration for the\n * type. Bound by each presentation's entry (with its shell), not at boot —\n * the map pulls the registry and the frame into whatever imports it.\n */\n resolveType?: (type: string) => DetailRecordType | null;\n /** React face — the effective `ui.detail.default_presentation` for a type. A hook. */\n usePresentationSetting: (type: string) => DetailPresentationSetting;\n /** Awaited face for click handlers. Rejects with the resolver's error. */\n resolvePresentation: (type: string) => Promise<DetailPresentation>;\n /** Warm the cache so the click path is synchronous. */\n warmPresentation: (type: string) => void;\n /**\n * 🚨 NEW-10 (VERIFY-U-P1-R3) — RE-READ THE LADDER AFTER A WRITE, AND SAY WHAT\n * ANSWERS NOW. Removing your own per-type exception while your ORGANIZATION\n * holds one for the same type succeeded and the pane promised the records\n * \"now open the way you normally open records\" — they did not; the\n * organization's entry still won on the next open and nothing re-read the\n * ladder. A host binds this to a FRESH ladder read (cache invalidated), never\n * to the cached value the write just made stale. Optional: a host that cannot\n * re-read says so and the pane promises nothing.\n */\n reReadPresentation?: (type: string) => Promise<DetailEffectivePresentation>;\n /**\n * Write the person's presentation setting from inside a detail (the ONE\n * screen that writes `ui.detail.default_presentation`). `forType` non-null\n * writes the PER-RECORD-TYPE override instead of the default.\n *\n * `clear` with a `forType` REMOVES that record type's exception instead of\n * setting one, so an exception set from a record can be taken back from the\n * same place (NEW-2). It is the same map-entry write with the key absent — a\n * host never grows a second writer for it.\n *\n * Optional: a host that can only READ the setting binds nothing and the pane\n * is absent — never a disabled-looking control. A refusal comes back as\n * `{ ok: false, reason }` (the settings ladder's reasons are sentences and\n * the surface renders them), never as a thrown error.\n */\n savePresentation?: (args: {\n presentation: DetailPresentation;\n forType: string | null;\n clear?: boolean;\n }) => Promise<{ ok: true } | { ok: false; reason: string }>;\n /** Open an in-place presentation (window / docked). Page goes through `navigate`. */\n open: (args: {\n presentation: Exclude<DetailPresentation, \"page\">;\n data: DetailInstanceData;\n }) => void;\n close: (presentation: Exclude<DetailPresentation, \"page\">) => void;\n navigate: {\n pageHref: (ref: DetailRef, extra?: { list?: DetailListContext | null }) => string;\n toPage: (\n ref: DetailRef,\n extra?: {\n seed?: DetailSeed | null;\n list?: DetailListContext | null;\n /**\n * 🚨 NEW-14 (VERIFY-U-P1-R3) — MOVING INSIDE A LIST REPLACES, NEVER\n * PUSHES. Arrowing to a neighbour on the page pushed a history entry,\n * so twenty records meant twenty Backs and the chevron labeled \"Back\"\n * returned to the previous RECORD instead of the list. The record whose\n * history entry is being replaced, so the host can carry its \"this tab\n * pushed a detail page\" answer (`canGoBack`) forward to the record now\n * showing. Absent ⇒ a push, which is what opening a page IS.\n */\n replacing?: DetailRef | null;\n },\n ) => void;\n back: () => void;\n /**\n * 🚨 D1. Whether THIS tab reached the detail page from inside the app, so\n * `back()` lands on a screen the person was actually on. A deep link\n * opened in a fresh tab has NOTHING behind it: `back()` there leaves the\n * tab on `about:blank` with the whole app gone (measured 2026-09-17).\n * A host answers `false` whenever it is not certain.\n */\n canGoBack: (ref: DetailRef) => boolean;\n /**\n * Where the page goes when there is nothing behind it: the record's own\n * home (its canonical route), or the host's home when the type has none.\n * REPLACES the detail page — it is being left, not stacked on.\n */\n toRecordHome: (ref: DetailRef, entityToken: string | null) => void;\n };\n shells: DetailShells;\n doors: {\n /** The record's own doors (open / new tab / peek) — sibling of the title. */\n RecordDoors: ComponentType<{ token: string; id: string; name?: string | null }>;\n /** A reference inside the record — never a bare uuid. */\n /**\n * One id rendered as a door. `name` is the record's own name when the caller\n * has it — a door showing a truncated uuid is half a dead end (chair,\n * 2026-09-18), so a host that can name the record renders the name and keeps\n * the id for the copy control.\n */\n RefCell: ComponentType<{\n value: string;\n label: string;\n token: string;\n name?: string | null;\n }>;\n tokenFromColumnName: (column: string) => string | null;\n isUuidValue: (value: unknown) => value is string;\n /**\n * 🚨 NEW-17 (VERIFY-U-P1-R4) — WHETHER THIS RECORD REALLY OPENS ANYWHERE.\n * `RecordDoors` renders nothing when the token has no route and no peek, so\n * the honest absent state promised \"the controls above still open it where it\n * lives\" beside a header that carried no such control — for `session`, and for\n * every unregistered type. The body's sentence is derived from this answer,\n * never fixed.\n */\n hasDoor: (token: string, id: string) => boolean;\n };\n associations: {\n /** Tokens shown when a registration does not name its own. */\n defaultTokens: readonly string[];\n /** Whether a token may anchor an associations section at all. */\n canAnchor: (token: string) => boolean;\n };\n history: {\n list: (token: string, id: string, signal: AbortSignal) => Promise<DetailHistoryEntry[]>;\n /**\n * 🚨 NEW-23 (VERIFY-U-P1-R5) — WHO MADE THE CHANGE IS A PERSON, NOT AN ID.\n * Every history row printed a bare 36-character uuid under \"Changed by\",\n * because no `actor` or `user` token has a door in a host's registry: an\n * identity the UI names that opens nothing and says nothing (the\n * no-dead-ends class). A host binds its ONE existing identity resolver here\n * — never a second one — and the row shows the person's name. Optional: a\n * host that has no directory binds nothing and the row keeps the id under a\n * title that says what it is, which is honest rather than silent.\n *\n * It is given the record's own row too, because the directory a host may\n * legitimately read is usually the owning organization's members and the\n * row is where that organization is named.\n */\n ActorName?: ComponentType<{ actorId: string; row: DetailRow | null }>;\n };\n /**\n * 🚨 CUSTOM FIELDS ON EVERY RECORD VIEW (lane 7 STANDARD-TABLES W5). The ONE\n * place a window, a docked panel, the `/detail` page and a record peek get the\n * organization's own fields for a standard record: the host binds its section\n * once, and the body renders it after Fields whenever the record has an\n * `entityToken`. The section is given only the token and the id — it reads the\n * row's organization itself (never the active organization) and says in one\n * line why it shows nothing, so it is never silently absent. A peek binds a\n * compact section through a nested provider (providers merge). Optional: a\n * host without custom fields binds nothing and the section is absent.\n */\n customFields?: {\n Section: ComponentType<{ token: string; id: string }>;\n };\n notify: {\n error: (message: string) => void;\n success: (message: string) => void;\n };\n copyText: (text: string) => Promise<boolean>;\n /**\n * 🚨 THE SAME RECONNECT THE CONNECTOR ROWS SHOW. A synced record whose grant\n * has expired, been revoked or lost a scope offers exactly the repair the\n * connector surface offers, from the record's own health strip, so a person\n * never has to go and find the connections screen. Optional: a host that\n * cannot open a consent flow binds nothing and the strip states the problem\n * without offering a control that would not help (law 4). A producer that\n * answers `onReconnect: null` overrides this for its record — see\n * `DetailSourceHealth.onReconnect`.\n */\n reconnectSource?: (ref: DetailRef, source: string) => void;\n /**\n * Where the developer goes to fix an unregistered or sourceless type — a\n * phrase for the console remedy the core prints once per type per tab (the\n * PERSON sees a plain sentence; the developer sees this). Optional: the\n * default names the `resolveType` port. A host names its own registry, e.g.\n * \"the item registry (features/item-presentation/registry.tsx)\".\n */\n remedy?: { typeMap: string };\n}\n\n// 🚨 MODULE-LEVEL STATE IS BANNED IN A DUAL-BUNDLE PACKAGE — the context lives\n// on `globalThis` under a `Symbol.for` slot (the kit `confirm/opener` shape), so\n// the ESM and CJS builds, and the /react and /testing entries, all share ONE\n// context. Two module instances with two contexts is exactly how a test seat's\n// provider stops being visible to the components it mounts.\nconst CONTEXT_SLOT = Symbol.for(\"ai-matrx.detail.host-context\");\ntype ContextSlot = { [CONTEXT_SLOT]?: Context<Partial<DetailHostPorts> | null> };\nconst DetailHostContext: Context<Partial<DetailHostPorts> | null> = ((globalThis as ContextSlot)[\n CONTEXT_SLOT\n] ??= createContext<Partial<DetailHostPorts> | null>(null));\n\n/**\n * Binds ports. Nested providers merge over their parent, shells included, so\n * a presentation entry can add only its own shell.\n */\nexport function DetailHostProvider({\n ports,\n children,\n}: {\n ports: Partial<DetailHostPorts>;\n children: ReactNode;\n}) {\n const parent = useContext(DetailHostContext);\n const merged: Partial<DetailHostPorts> = {\n ...(parent ?? {}),\n ...ports,\n shells: { ...(parent?.shells ?? {}), ...(ports.shells ?? {}) },\n };\n return <DetailHostContext.Provider value={merged}>{children}</DetailHostContext.Provider>;\n}\n\nconst REQUIRED_PORTS: (keyof DetailHostPorts)[] = [\n \"usePresentationSetting\",\n \"resolvePresentation\",\n \"warmPresentation\",\n \"open\",\n \"close\",\n \"navigate\",\n \"doors\",\n \"associations\",\n \"history\",\n \"notify\",\n \"copyText\",\n];\n\n/**\n * The bound ports. Throws with the remedy when a port is missing — a detail\n * that opened with no host would otherwise render every section empty and\n * look finished.\n */\nexport function useDetailHost(): DetailHostPorts {\n const ports = useContext(DetailHostContext);\n if (!ports) {\n throw new Error(\n \"[detail] No DetailHostProvider above this component. Mount the host binding — a \" +\n \"<DetailHostProvider ports={…}> carrying every required port (README → 'Binding the \" +\n \"ports') — around the tree that opens or renders record details.\",\n );\n }\n const missing = REQUIRED_PORTS.filter((key) => ports[key] === undefined);\n if (missing.length > 0) {\n throw new Error(\n `[detail] DetailHostProvider is missing the port(s): ${missing.join(\", \")}. ` +\n \"Bind them in the host binding (README → 'Binding the ports').\",\n );\n }\n // The context value itself (the provider always sets `shells`), so the\n // object a consumer depends on keeps its identity between renders.\n return ports as DetailHostPorts;\n}\n\n/** The record-type map, or an error naming which entry forgot to bind it. */\nexport function requireResolveType(\n host: DetailHostPorts,\n): NonNullable<DetailHostPorts[\"resolveType\"]> {\n if (!host.resolveType) {\n throw new Error(\n \"[detail] No resolveType port is bound. A presentation's entry must pass \" +\n \"`resolveType` (the host's ONE record-type map) beside its shell.\",\n );\n }\n return host.resolveType;\n}\n\n/** A presentation's shell, or an error naming which entry forgot to bind it. */\nexport function requireShell<K extends keyof DetailShells>(\n shells: DetailShells,\n which: K,\n): NonNullable<DetailShells[K]> {\n const shell = shells[which];\n if (!shell) {\n throw new Error(\n `[detail] No ${which} shell is bound. The ${which} presentation's entry must wrap itself in ` +\n `<DetailHostProvider ports={{ shells: { ${which} } }}> (README → 'The three shells').`,\n );\n }\n return shell;\n}\n"],"mappings":";;;AAgBA,SAAS,eAAe,kBAAoE;AAuSnF;AAvBT,IAAM,eAAe,uBAAO,IAAI,8BAA8B;AAE9D,IAAM,oBAAgE,WACpE,YACF,MAAM,cAA+C,IAAI;AAMlD,SAAS,mBAAmB;AAAA,EACjC;AAAA,EACA;AACF,GAGG;AACD,QAAM,SAAS,WAAW,iBAAiB;AAC3C,QAAM,SAAmC;AAAA,IACvC,GAAI,UAAU,CAAC;AAAA,IACf,GAAG;AAAA,IACH,QAAQ,EAAE,GAAI,QAAQ,UAAU,CAAC,GAAI,GAAI,MAAM,UAAU,CAAC,EAAG;AAAA,EAC/D;AACA,SAAO,oBAAC,kBAAkB,UAAlB,EAA2B,OAAO,QAAS,UAAS;AAC9D;AAEA,IAAM,iBAA4C;AAAA,EAChD;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAOO,SAAS,gBAAiC;AAC/C,QAAM,QAAQ,WAAW,iBAAiB;AAC1C,MAAI,CAAC,OAAO;AACV,UAAM,IAAI;AAAA,MACR;AAAA,IAGF;AAAA,EACF;AACA,QAAM,UAAU,eAAe,OAAO,CAAC,QAAQ,MAAM,GAAG,MAAM,MAAS;AACvE,MAAI,QAAQ,SAAS,GAAG;AACtB,UAAM,IAAI;AAAA,MACR,uDAAuD,QAAQ,KAAK,IAAI,CAAC;AAAA,IAE3E;AAAA,EACF;AAGA,SAAO;AACT;AAGO,SAAS,mBACd,MAC6C;AAC7C,MAAI,CAAC,KAAK,aAAa;AACrB,UAAM,IAAI;AAAA,MACR;AAAA,IAEF;AAAA,EACF;AACA,SAAO,KAAK;AACd;AAGO,SAAS,aACd,QACA,OAC8B;AAC9B,QAAM,QAAQ,OAAO,KAAK;AAC1B,MAAI,CAAC,OAAO;AACV,UAAM,IAAI;AAAA,MACR,eAAe,KAAK,wBAAwB,KAAK,oFACL,KAAK;AAAA,IACnD;AAAA,EACF;AACA,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../src/react/host.tsx"],"sourcesContent":["// @ai-matrx/detail — src/react/host.tsx\n//\n// THE SEAM. Everything this package needs from the app that hosts it arrives\n// here as ports — the same shape `@ai-matrx/associations` uses. The core and\n// the three presentations import nothing from any host; they ask the host for\n// the record-type map, the presentation setting, the shells (window / docked /\n// page), the doors, history, navigation and notifications.\n//\n// Providers NEST AND MERGE: a light provider at boot binds everything but the\n// shells (so `useOpenDetail` works anywhere), and each presentation's lazily\n// loaded entry binds its own shell right above the presentation — the window\n// shell parses the host's window manager and must never sit in a boot bundle.\n// A port that is missing when it is needed throws, naming itself.\n\n\"use client\";\n\nimport { createContext, useContext, type Context, type ComponentType, type ReactNode } from \"react\";\n\nimport type {\n DetailHistoryEntry,\n DetailInstanceData,\n DetailListContext,\n DetailPresentation,\n DetailRecordType,\n DetailRef,\n DetailRow,\n DetailSeed,\n} from \"../types\";\n\n/** Props every shell receives; the package fills the slots, the host owns the chrome. */\n/** One of the record's optional controls as data (a shell's menu draws it). */\nexport interface DetailMenuAction {\n readonly id: string;\n readonly label: string;\n readonly onSelect: () => void;\n}\n\nexport interface DetailShellSlots {\n /** Accessible plain-text title (window chrome, drawer title, document title). */\n title: string;\n /** Rich title: icon · name · type chip · the record's doors. */\n titleNode: ReactNode;\n /** Presentation switcher · previous / next · copy id. Compact. */\n actions: ReactNode;\n /**\n * Previous / next only — for a shell whose chrome has its OWN menu: it puts\n * `menuActions` there instead of a second overflow button (\"…\" beside \"⋮\").\n */\n navActions: ReactNode;\n /** The optional controls (open as window / docked / page, copy id) as data, for a shell's own menu. */\n menuActions: readonly DetailMenuAction[];\n children: ReactNode;\n onClose: () => void;\n}\n\nexport interface DetailWindowShellProps extends DetailShellSlots {\n /** `type.id` — the deep-link instance and the window manager's key. */\n instanceKey: string;\n target: DetailRef;\n /**\n * 🚨 NEW-15 — THE LIST TRAVELS INTO THE DEEP LINK. The window's `?panels=`\n * token used to carry the record and the presentation only, so a REFRESH lost\n * the previous / next controls and the counter without a word. The shell puts\n * this into the token the way the page presentation puts it into the query.\n */\n list: DetailListContext | null;\n}\n\nexport interface DetailDockedShellProps extends DetailShellSlots {\n instanceKey: string;\n target: DetailRef;\n}\n\nexport interface DetailPageShellProps extends DetailShellSlots {\n target: DetailRef;\n /** Back affordance — the page is the one presentation that left the URL. */\n onBack: () => void;\n}\n\nexport interface DetailEffectivePresentation {\n /** The effective presentation for this type, `undefined` when unresolved. */\n value: DetailPresentation | undefined;\n /** The per-record-type exception still in effect, from ANY rung. */\n forType: DetailPresentation | undefined;\n}\n\nexport interface DetailPresentationSetting {\n /** `undefined` while unresolved; `error` names the failure — never both silent. */\n value: DetailPresentation | undefined;\n error: string | null;\n /**\n * The PER-RECORD-TYPE exception in effect for this type, when there is one\n * (`undefined` = this type opens the way the default says). The pane offers\n * \"use the default for this type\" only then — an offer to remove something\n * that does not exist is a control that does nothing (NEW-2).\n */\n forType?: DetailPresentation | undefined;\n}\n\nexport interface DetailShells {\n Window?: ComponentType<DetailWindowShellProps>;\n Docked?: ComponentType<DetailDockedShellProps>;\n Page?: ComponentType<DetailPageShellProps>;\n}\n\nexport interface DetailHostPorts {\n /**\n * The ONE record-type map. `null` = the host has no registration for the\n * type. Bound by each presentation's entry (with its shell), not at boot —\n * the map pulls the registry and the frame into whatever imports it.\n */\n resolveType?: (type: string) => DetailRecordType | null;\n /** React face — the effective `ui.detail.default_presentation` for a type. A hook. */\n usePresentationSetting: (type: string) => DetailPresentationSetting;\n /** Awaited face for click handlers. Rejects with the resolver's error. */\n resolvePresentation: (type: string) => Promise<DetailPresentation>;\n /** Warm the cache so the click path is synchronous. */\n warmPresentation: (type: string) => void;\n /**\n * 🚨 NEW-10 (VERIFY-U-P1-R3) — RE-READ THE LADDER AFTER A WRITE, AND SAY WHAT\n * ANSWERS NOW. Removing your own per-type exception while your ORGANIZATION\n * holds one for the same type succeeded and the pane promised the records\n * \"now open the way you normally open records\" — they did not; the\n * organization's entry still won on the next open and nothing re-read the\n * ladder. A host binds this to a FRESH ladder read (cache invalidated), never\n * to the cached value the write just made stale. Optional: a host that cannot\n * re-read says so and the pane promises nothing.\n */\n reReadPresentation?: (type: string) => Promise<DetailEffectivePresentation>;\n /**\n * Write the person's presentation setting from inside a detail (the ONE\n * screen that writes `ui.detail.default_presentation`). `forType` non-null\n * writes the PER-RECORD-TYPE override instead of the default.\n *\n * `clear` with a `forType` REMOVES that record type's exception instead of\n * setting one, so an exception set from a record can be taken back from the\n * same place (NEW-2). It is the same map-entry write with the key absent — a\n * host never grows a second writer for it.\n *\n * Optional: a host that can only READ the setting binds nothing and the pane\n * is absent — never a disabled-looking control. A refusal comes back as\n * `{ ok: false, reason }` (the settings ladder's reasons are sentences and\n * the surface renders them), never as a thrown error.\n */\n savePresentation?: (args: {\n presentation: DetailPresentation;\n forType: string | null;\n clear?: boolean;\n }) => Promise<{ ok: true } | { ok: false; reason: string }>;\n /** Open an in-place presentation (window / docked). Page goes through `navigate`. */\n open: (args: {\n presentation: Exclude<DetailPresentation, \"page\">;\n data: DetailInstanceData;\n }) => void;\n close: (presentation: Exclude<DetailPresentation, \"page\">) => void;\n navigate: {\n pageHref: (ref: DetailRef, extra?: { list?: DetailListContext | null }) => string;\n toPage: (\n ref: DetailRef,\n extra?: {\n seed?: DetailSeed | null;\n list?: DetailListContext | null;\n /**\n * 🚨 NEW-14 (VERIFY-U-P1-R3) — MOVING INSIDE A LIST REPLACES, NEVER\n * PUSHES. Arrowing to a neighbour on the page pushed a history entry,\n * so twenty records meant twenty Backs and the chevron labeled \"Back\"\n * returned to the previous RECORD instead of the list. The record whose\n * history entry is being replaced, so the host can carry its \"this tab\n * pushed a detail page\" answer (`canGoBack`) forward to the record now\n * showing. Absent ⇒ a push, which is what opening a page IS.\n */\n replacing?: DetailRef | null;\n },\n ) => void;\n back: () => void;\n /**\n * 🚨 D1. Whether THIS tab reached the detail page from inside the app, so\n * `back()` lands on a screen the person was actually on. A deep link\n * opened in a fresh tab has NOTHING behind it: `back()` there leaves the\n * tab on `about:blank` with the whole app gone (measured 2026-09-17).\n * A host answers `false` whenever it is not certain.\n */\n canGoBack: (ref: DetailRef) => boolean;\n /**\n * Where the page goes when there is nothing behind it: the record's own\n * home (its canonical route), or the host's home when the type has none.\n * REPLACES the detail page — it is being left, not stacked on.\n */\n toRecordHome: (ref: DetailRef, entityToken: string | null) => void;\n };\n shells: DetailShells;\n doors: {\n /** The record's own doors (open / new tab / peek) — sibling of the title. */\n RecordDoors: ComponentType<{ token: string; id: string; name?: string | null }>;\n /** A reference inside the record — never a bare uuid. */\n /**\n * One id rendered as a door. `name` is the record's own name when the caller\n * has it — a door showing a truncated uuid is half a dead end (chair,\n * 2026-09-18), so a host that can name the record renders the name and keeps\n * the id for the copy control.\n */\n RefCell: ComponentType<{\n value: string;\n label: string;\n token: string;\n name?: string | null;\n }>;\n tokenFromColumnName: (column: string) => string | null;\n isUuidValue: (value: unknown) => value is string;\n /**\n * 🚨 NEW-17 (VERIFY-U-P1-R4) — WHETHER THIS RECORD REALLY OPENS ANYWHERE.\n * `RecordDoors` renders nothing when the token has no route and no peek, so\n * the honest absent state promised \"the controls above still open it where it\n * lives\" beside a header that carried no such control — for `session`, and for\n * every unregistered type. The body's sentence is derived from this answer,\n * never fixed.\n */\n hasDoor: (token: string, id: string) => boolean;\n };\n associations: {\n /** Tokens shown when a registration does not name its own. */\n defaultTokens: readonly string[];\n /** Whether a token may anchor an associations section at all. */\n canAnchor: (token: string) => boolean;\n };\n history: {\n list: (token: string, id: string, signal: AbortSignal) => Promise<DetailHistoryEntry[]>;\n /**\n * 🚨 NEW-23 (VERIFY-U-P1-R5) — WHO MADE THE CHANGE IS A PERSON, NOT AN ID.\n * Every history row printed a bare 36-character uuid under \"Changed by\",\n * because no `actor` or `user` token has a door in a host's registry: an\n * identity the UI names that opens nothing and says nothing (the\n * no-dead-ends class). A host binds its ONE existing identity resolver here\n * — never a second one — and the row shows the person's name. Optional: a\n * host that has no directory binds nothing and the row keeps the id under a\n * title that says what it is, which is honest rather than silent.\n *\n * It is given the record's own row too, because the directory a host may\n * legitimately read is usually the owning organization's members and the\n * row is where that organization is named.\n */\n ActorName?: ComponentType<{ actorId: string; row: DetailRow | null }>;\n };\n /**\n * 🚨 CUSTOM FIELDS ON EVERY RECORD VIEW (lane 7 STANDARD-TABLES W5). The ONE\n * place a window, a docked panel, the `/detail` page and a record peek get the\n * organization's own fields for a standard record: the host binds its section\n * once, and the body renders it after Fields whenever the record has an\n * `entityToken`. The section is given only the token and the id — it reads the\n * row's organization itself (never the active organization) and says in one\n * line why it shows nothing, so it is never silently absent. A peek binds a\n * compact section through a nested provider (providers merge). Optional: a\n * host without custom fields binds nothing and the section is absent.\n */\n customFields?: {\n Section: ComponentType<{ token: string; id: string }>;\n };\n /** A card: a short title and an optional line (design-system NOTICE_TEXT_BUDGET). */\n notify: {\n error: (message: string, options?: { description?: string }) => void;\n success: (message: string, options?: { description?: string }) => void;\n };\n copyText: (text: string) => Promise<boolean>;\n /**\n * 🚨 THE SAME RECONNECT THE CONNECTOR ROWS SHOW. A synced record whose grant\n * has expired, been revoked or lost a scope offers exactly the repair the\n * connector surface offers, from the record's own health strip, so a person\n * never has to go and find the connections screen. Optional: a host that\n * cannot open a consent flow binds nothing and the strip states the problem\n * without offering a control that would not help (law 4). A producer that\n * answers `onReconnect: null` overrides this for its record — see\n * `DetailSourceHealth.onReconnect`.\n */\n reconnectSource?: (ref: DetailRef, source: string) => void;\n /**\n * Where the developer goes to fix an unregistered or sourceless type — a\n * phrase for the console remedy the core prints once per type per tab (the\n * PERSON sees a plain sentence; the developer sees this). Optional: the\n * default names the `resolveType` port. A host names its own registry, e.g.\n * \"the item registry (features/item-presentation/registry.tsx)\".\n */\n remedy?: { typeMap: string };\n}\n\n// 🚨 MODULE-LEVEL STATE IS BANNED IN A DUAL-BUNDLE PACKAGE — the context lives\n// on `globalThis` under a `Symbol.for` slot (the kit `confirm/opener` shape), so\n// the ESM and CJS builds, and the /react and /testing entries, all share ONE\n// context. Two module instances with two contexts is exactly how a test seat's\n// provider stops being visible to the components it mounts.\nconst CONTEXT_SLOT = Symbol.for(\"ai-matrx.detail.host-context\");\ntype ContextSlot = { [CONTEXT_SLOT]?: Context<Partial<DetailHostPorts> | null> };\nconst DetailHostContext: Context<Partial<DetailHostPorts> | null> = ((globalThis as ContextSlot)[\n CONTEXT_SLOT\n] ??= createContext<Partial<DetailHostPorts> | null>(null));\n\n/**\n * Binds ports. Nested providers merge over their parent, shells included, so\n * a presentation entry can add only its own shell.\n */\nexport function DetailHostProvider({\n ports,\n children,\n}: {\n ports: Partial<DetailHostPorts>;\n children: ReactNode;\n}) {\n const parent = useContext(DetailHostContext);\n const merged: Partial<DetailHostPorts> = {\n ...(parent ?? {}),\n ...ports,\n shells: { ...(parent?.shells ?? {}), ...(ports.shells ?? {}) },\n };\n return <DetailHostContext.Provider value={merged}>{children}</DetailHostContext.Provider>;\n}\n\nconst REQUIRED_PORTS: (keyof DetailHostPorts)[] = [\n \"usePresentationSetting\",\n \"resolvePresentation\",\n \"warmPresentation\",\n \"open\",\n \"close\",\n \"navigate\",\n \"doors\",\n \"associations\",\n \"history\",\n \"notify\",\n \"copyText\",\n];\n\n/**\n * The bound ports. Throws with the remedy when a port is missing — a detail\n * that opened with no host would otherwise render every section empty and\n * look finished.\n */\nexport function useDetailHost(): DetailHostPorts {\n const ports = useContext(DetailHostContext);\n if (!ports) {\n throw new Error(\n \"[detail] No DetailHostProvider above this component. Mount the host binding — a \" +\n \"<DetailHostProvider ports={…}> carrying every required port (README → 'Binding the \" +\n \"ports') — around the tree that opens or renders record details.\",\n );\n }\n const missing = REQUIRED_PORTS.filter((key) => ports[key] === undefined);\n if (missing.length > 0) {\n throw new Error(\n `[detail] DetailHostProvider is missing the port(s): ${missing.join(\", \")}. ` +\n \"Bind them in the host binding (README → 'Binding the ports').\",\n );\n }\n // The context value itself (the provider always sets `shells`), so the\n // object a consumer depends on keeps its identity between renders.\n return ports as DetailHostPorts;\n}\n\n/** The record-type map, or an error naming which entry forgot to bind it. */\nexport function requireResolveType(\n host: DetailHostPorts,\n): NonNullable<DetailHostPorts[\"resolveType\"]> {\n if (!host.resolveType) {\n throw new Error(\n \"[detail] No resolveType port is bound. A presentation's entry must pass \" +\n \"`resolveType` (the host's ONE record-type map) beside its shell.\",\n );\n }\n return host.resolveType;\n}\n\n/** A presentation's shell, or an error naming which entry forgot to bind it. */\nexport function requireShell<K extends keyof DetailShells>(\n shells: DetailShells,\n which: K,\n): NonNullable<DetailShells[K]> {\n const shell = shells[which];\n if (!shell) {\n throw new Error(\n `[detail] No ${which} shell is bound. The ${which} presentation's entry must wrap itself in ` +\n `<DetailHostProvider ports={{ shells: { ${which} } }}> (README → 'The three shells').`,\n );\n }\n return shell;\n}\n"],"mappings":";;;AAgBA,SAAS,eAAe,kBAAoE;AAwSnF;AAvBT,IAAM,eAAe,uBAAO,IAAI,8BAA8B;AAE9D,IAAM,oBAAgE,WACpE,YACF,MAAM,cAA+C,IAAI;AAMlD,SAAS,mBAAmB;AAAA,EACjC;AAAA,EACA;AACF,GAGG;AACD,QAAM,SAAS,WAAW,iBAAiB;AAC3C,QAAM,SAAmC;AAAA,IACvC,GAAI,UAAU,CAAC;AAAA,IACf,GAAG;AAAA,IACH,QAAQ,EAAE,GAAI,QAAQ,UAAU,CAAC,GAAI,GAAI,MAAM,UAAU,CAAC,EAAG;AAAA,EAC/D;AACA,SAAO,oBAAC,kBAAkB,UAAlB,EAA2B,OAAO,QAAS,UAAS;AAC9D;AAEA,IAAM,iBAA4C;AAAA,EAChD;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAOO,SAAS,gBAAiC;AAC/C,QAAM,QAAQ,WAAW,iBAAiB;AAC1C,MAAI,CAAC,OAAO;AACV,UAAM,IAAI;AAAA,MACR;AAAA,IAGF;AAAA,EACF;AACA,QAAM,UAAU,eAAe,OAAO,CAAC,QAAQ,MAAM,GAAG,MAAM,MAAS;AACvE,MAAI,QAAQ,SAAS,GAAG;AACtB,UAAM,IAAI;AAAA,MACR,uDAAuD,QAAQ,KAAK,IAAI,CAAC;AAAA,IAE3E;AAAA,EACF;AAGA,SAAO;AACT;AAGO,SAAS,mBACd,MAC6C;AAC7C,MAAI,CAAC,KAAK,aAAa;AACrB,UAAM,IAAI;AAAA,MACR;AAAA,IAEF;AAAA,EACF;AACA,SAAO,KAAK;AACd;AAGO,SAAS,aACd,QACA,OAC8B;AAC9B,QAAM,QAAQ,OAAO,KAAK;AAC1B,MAAI,CAAC,OAAO;AACV,UAAM,IAAI;AAAA,MACR,eAAe,KAAK,wBAAwB,KAAK,oFACL,KAAK;AAAA,IACnD;AAAA,EACF;AACA,SAAO;AACT;","names":[]}
@@ -511,9 +511,14 @@ interface DetailHostPorts {
511
511
  id: string;
512
512
  }>;
513
513
  };
514
+ /** A card: a short title and an optional line (design-system NOTICE_TEXT_BUDGET). */
514
515
  notify: {
515
- error: (message: string) => void;
516
- success: (message: string) => void;
516
+ error: (message: string, options?: {
517
+ description?: string;
518
+ }) => void;
519
+ success: (message: string, options?: {
520
+ description?: string;
521
+ }) => void;
517
522
  };
518
523
  copyText: (text: string) => Promise<boolean>;
519
524
  /**
@@ -511,9 +511,14 @@ interface DetailHostPorts {
511
511
  id: string;
512
512
  }>;
513
513
  };
514
+ /** A card: a short title and an optional line (design-system NOTICE_TEXT_BUDGET). */
514
515
  notify: {
515
- error: (message: string) => void;
516
- success: (message: string) => void;
516
+ error: (message: string, options?: {
517
+ description?: string;
518
+ }) => void;
519
+ success: (message: string, options?: {
520
+ description?: string;
521
+ }) => void;
517
522
  };
518
523
  copyText: (text: string) => Promise<boolean>;
519
524
  /**
package/dist/react.cjs CHANGED
@@ -466,9 +466,7 @@ function useDetailCore(data, presentation, options) {
466
466
  onNext: hasNext ? () => openNeighbour(1) : null,
467
467
  // 🚨 NEW-22 — Cmd+Enter is one of the three promised bindings; on a record
468
468
  // with no editor open it ANSWERS rather than doing nothing in silence.
469
- onSaveUnavailable: () => host.notify.error(
470
- `There is nothing to save on this ${typeLabel.toLowerCase()} \u2014 nothing here is being edited. Changes to the record are made where it lives.`
471
- )
469
+ onSaveUnavailable: () => host.notify.error("Nothing to save", { description: `Edit this ${typeLabel.toLowerCase()} where it lives` })
472
470
  });
473
471
  return {
474
472
  ref,
@@ -532,9 +530,7 @@ function useOpenDetail(warmType) {
532
530
  } catch (error) {
533
531
  const message = error instanceof Error ? error.message : String(error);
534
532
  console.error(`[detail] ${DETAIL_PRESENTATION_KNOB} could not be resolved`, error);
535
- host.notify.error(
536
- `Your detail presentation setting could not be read (${DETAIL_PRESENTATION_KNOB}: ${message}). Opening as a window until it can be read; an administrator can register or repair the setting.`
537
- );
533
+ host.notify.error("Opened as a window", { description: "Detail setting couldn't be read" });
538
534
  presentation = "window";
539
535
  }
540
536
  }
@@ -654,7 +650,7 @@ function useDetailMenuActions(core) {
654
650
  onSelect: () => {
655
651
  void host.copyText(core.ref.id).then((ok) => {
656
652
  if (ok) host.notify.success("Record id copied");
657
- else host.notify.error("Could not copy the record id to the clipboard.");
653
+ else host.notify.error("Record id not copied");
658
654
  });
659
655
  }
660
656
  }
@@ -667,7 +663,7 @@ function DetailActions({ core, optional = "inline" }) {
667
663
  const copyId = async () => {
668
664
  const ok = await host.copyText(core.ref.id);
669
665
  if (!ok) {
670
- host.notify.error("Could not copy the record id to the clipboard.");
666
+ host.notify.error("Record id not copied");
671
667
  return;
672
668
  }
673
669
  host.notify.success("Record id copied");
@@ -861,9 +857,9 @@ function DetailPresentationPane({ core }) {
861
857
  });
862
858
  if (result.ok) {
863
859
  setState({ status: "saved" });
864
- host.notify.success(
865
- forThisType ? `Every ${core.typeLabel.toLowerCase()} now opens as ${WORD[pending]} for you.` : `Record details now open as ${WORD[pending]} for you.`
866
- );
860
+ host.notify.success("Saved", {
861
+ description: forThisType ? `Every ${core.typeLabel.toLowerCase()} opens as ${WORD[pending]}` : `Details open as ${WORD[pending]}`
862
+ });
867
863
  } else {
868
864
  setState({ status: "refused", reason: result.reason });
869
865
  }
@@ -1025,6 +1021,7 @@ function DetailPresentationPane({ core }) {
1025
1021
  // src/react/DetailBody.tsx
1026
1022
  var import_icons7 = require("@ai-matrx/design-system/icons");
1027
1023
  var import_outbound_react = require("@ai-matrx/kit/outbound-react");
1024
+ var import_error_sentence = require("@ai-matrx/design-system/error-sentence");
1028
1025
  var import_jsx_runtime4 = require("react/jsx-runtime");
1029
1026
  var SLOW_LOAD_NOTICE_MS = 6e3;
1030
1027
  function useSlowLoadNotice(active, key) {
@@ -1091,9 +1088,7 @@ function HealthStrip({ health }) {
1091
1088
  try {
1092
1089
  await health.onRefresh();
1093
1090
  } catch (error) {
1094
- host.notify.error(
1095
- `Refresh from ${health.source} failed: ${error instanceof Error ? error.message : String(error)}`
1096
- );
1091
+ host.notify.error("Not refreshed", { description: `${health.source}: ${(0, import_error_sentence.peopleErrorSentence)(error, null, { card: true })}` });
1097
1092
  } finally {
1098
1093
  setRefreshing(false);
1099
1094
  }