@ai-matrx/detail 0.1.68 → 0.1.69
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 +7 -0
- package/dist/{host-BPTuBbgB.d.cts → host-DrnEXBfp.d.cts} +14 -1
- package/dist/{host-BPTuBbgB.d.ts → host-DrnEXBfp.d.ts} +14 -1
- package/dist/react.cjs +110 -78
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +15 -4
- package/dist/react.d.ts +15 -4
- package/dist/react.js +110 -78
- package/dist/react.js.map +1 -1
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
package/dist/testing.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/testing/index.tsx","../src/react/host.tsx"],"sourcesContent":["// @ai-matrx/detail/testing — the test seat for the Detail primitive.\n//\n// Stub host ports (every function a mock from the RUNNER'S OWN factory, so a\n// test asserts what the core ASKED THE HOST TO DO — which is where every one of\n// the primitive's recorded defects lived) plus a tiny react-dom renderer and\n// three stub shells. Shipped as a subpath so a HOST tests its own binding\n// against the same seat the package proves itself with; it drives\n// `react-dom/client` + `act` directly and needs no testing library.\n//\n// THE MOCK FACTORY IS INJECTED, not chosen here: vitest's `vi.fn` and jest's\n// `jest.fn` produce different mock objects and neither runner's matchers accept\n// the other's. `makePortsWith(vi.fn)` in the package, `makePortsWith(jest.fn)`\n// in a jest host — one harness, no twin.\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { act } from \"react\";\nimport { createRoot, type Root } from \"react-dom/client\";\n\nimport {\n DetailHostProvider,\n type DetailDockedShellProps,\n type DetailHostPorts,\n type DetailPageShellProps,\n type DetailWindowShellProps,\n} from \"../react/host\";\nimport type { DetailInstanceData, DetailRecordType } from \"../types\";\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any -- a mock factory's implementation type is the runner's business; `unknown` here would refuse every real impl\ntype AnyFunction = (...args: any[]) => any;\n\n/** What every runner's mock exposes and these tests read. */\nexport interface MockControls {\n mock: { calls: unknown[][] };\n mockReturnValue(value: unknown): unknown;\n mockResolvedValue(value: unknown): unknown;\n}\n\nexport type MockFn<T extends AnyFunction = AnyFunction> = T & MockControls;\n\n/** `vi.fn` or `jest.fn` — the runner's factory, handed in by the suite. */\nexport type MockFactory = <T extends AnyFunction>(implementation?: T) => MockFn<T>;\n\nexport const FILE_TYPE: DetailRecordType = {\n type: \"file\",\n label: \"File\",\n icon: ({ className }: { className?: string }) => <span className={className} />,\n accent: null,\n entityToken: \"file\",\n load: null,\n title: (row, seed) =>\n (typeof row?.file_name === \"string\" ? row.file_name : null) ??\n seed?.name?.trim() ??\n \"Untitled File\",\n fields: () => [],\n};\n\n/**\n * A minimal PAGE shell: the two controls a person leaves the page with (the\n * back chevron and, through the keyboard root, Escape) plus the slots. Enough\n * to assert WHICH port the exit consults — which is where D1 lived, twice.\n */\nexport function StubPageShell({ titleNode, actions, onBack, children }: DetailPageShellProps) {\n return (\n <div data-stub-page-shell>\n <button type=\"button\" aria-label=\"Back\" onClick={onBack}>\n back\n </button>\n {titleNode}\n {actions}\n {children}\n </div>\n );\n}\n\n/** A minimal WINDOW shell: header slots above the body, nothing else. */\nexport function StubWindowShell({ titleNode, actions, children }: DetailWindowShellProps) {\n return (\n <div data-stub-window-shell>\n <div data-header>\n {titleNode}\n {actions}\n </div>\n {children}\n </div>\n );\n}\n\n/** A minimal DOCKED shell: the same shape as the window's. */\nexport function StubDockedShell({ titleNode, actions, children }: DetailDockedShellProps) {\n return (\n <div data-stub-docked-shell>\n <div data-header>\n {titleNode}\n {actions}\n </div>\n {children}\n </div>\n );\n}\n\nexport type StubPorts = DetailHostPorts & {\n usePresentationSetting: MockFn;\n resolvePresentation: MockFn;\n warmPresentation: MockFn;\n savePresentation: MockFn;\n open: MockFn;\n close: MockFn;\n copyText: MockFn;\n};\n\n/**\n * The stub-port builder for one runner. Every port is bound; the record-type\n * map answers `FILE_TYPE` for everything; the doors answer for `file` only\n * (NEW-17: the stub's sentence must match the stub's own doors).\n */\nexport function makePortsWith(fn: MockFactory): (overrides?: Partial<DetailHostPorts>) => StubPorts {\n return (overrides = {}) =>\n ({\n resolveType: () => FILE_TYPE,\n usePresentationSetting: fn(() => ({ value: \"window\" as const, error: null })),\n resolvePresentation: fn(async () => \"window\" as const),\n warmPresentation: fn(),\n savePresentation: fn(async () => ({ ok: true as const })),\n open: fn(),\n close: fn(),\n navigate: {\n pageHref: (ref: { type: string; id: string }) => `/detail/${ref.type}/${ref.id}`,\n toPage: fn(),\n back: fn(),\n canGoBack: fn(() => false),\n toRecordHome: fn(),\n },\n shells: {},\n doors: {\n RecordDoors: ({ id }: { id: string }) => <span data-doors={id} />,\n RefCell: ({ value }: { value: string }) => <span>{value}</span>,\n tokenFromColumnName: () => null,\n isUuidValue: (value: unknown): value is string => typeof value === \"string\",\n hasDoor: (token: string) => token === \"file\",\n },\n associations: { defaultTokens: [], canAnchor: () => false },\n history: { list: fn(async () => []) },\n notify: { error: fn(), success: fn() },\n copyText: fn(async () => true),\n ...overrides,\n }) as unknown as StubPorts;\n}\n\nexport function instance(over: Partial<DetailInstanceData> = {}): DetailInstanceData {\n return {\n type: \"file\",\n id: \"11111111-2222-3333-4444-555555555555\",\n seed: null,\n list: null,\n ...over,\n };\n}\n\nexport interface Mounted {\n container: HTMLDivElement;\n root: Root;\n unmount: () => void;\n}\n\n/** Mount `ui` under a provider carrying `ports`, inside a fresh container. */\nexport function mount(ui: React.ReactElement, ports: DetailHostPorts): Mounted {\n // React 19 wants this flag before `act` drives a concurrent root.\n (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;\n const container = document.createElement(\"div\");\n document.body.appendChild(container);\n const root = createRoot(container);\n act(() => {\n root.render(<DetailHostProvider ports={ports}>{ui}</DetailHostProvider>);\n });\n return {\n container,\n root,\n unmount: () => {\n act(() => root.unmount());\n container.remove();\n },\n };\n}\n\n/** Click a control by its accessible name. Throws (loudly) when it is absent. */\nexport function clickByLabel(container: HTMLElement, label: string): void {\n const el = container.querySelector<HTMLElement>(`[aria-label=\"${label}\"]`);\n if (!el) {\n throw new Error(\n `No control labelled \"${label}\". Present: ` +\n Array.from(container.querySelectorAll(\"[aria-label]\"))\n .map((n) => n.getAttribute(\"aria-label\"))\n .join(\" | \"),\n );\n }\n act(() => {\n el.dispatchEvent(new MouseEvent(\"click\", { bubbles: true }));\n });\n}\n","// @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. */\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 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 labelled \"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":"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","names":["import_react","import_jsx_runtime"]}
|
|
1
|
+
{"version":3,"sources":["../src/testing/index.tsx","../src/react/host.tsx"],"sourcesContent":["// @ai-matrx/detail/testing — the test seat for the Detail primitive.\n//\n// Stub host ports (every function a mock from the RUNNER'S OWN factory, so a\n// test asserts what the core ASKED THE HOST TO DO — which is where every one of\n// the primitive's recorded defects lived) plus a tiny react-dom renderer and\n// three stub shells. Shipped as a subpath so a HOST tests its own binding\n// against the same seat the package proves itself with; it drives\n// `react-dom/client` + `act` directly and needs no testing library.\n//\n// THE MOCK FACTORY IS INJECTED, not chosen here: vitest's `vi.fn` and jest's\n// `jest.fn` produce different mock objects and neither runner's matchers accept\n// the other's. `makePortsWith(vi.fn)` in the package, `makePortsWith(jest.fn)`\n// in a jest host — one harness, no twin.\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { act } from \"react\";\nimport { createRoot, type Root } from \"react-dom/client\";\n\nimport {\n DetailHostProvider,\n type DetailDockedShellProps,\n type DetailHostPorts,\n type DetailPageShellProps,\n type DetailWindowShellProps,\n} from \"../react/host\";\nimport type { DetailInstanceData, DetailRecordType } from \"../types\";\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any -- a mock factory's implementation type is the runner's business; `unknown` here would refuse every real impl\ntype AnyFunction = (...args: any[]) => any;\n\n/** What every runner's mock exposes and these tests read. */\nexport interface MockControls {\n mock: { calls: unknown[][] };\n mockReturnValue(value: unknown): unknown;\n mockResolvedValue(value: unknown): unknown;\n}\n\nexport type MockFn<T extends AnyFunction = AnyFunction> = T & MockControls;\n\n/** `vi.fn` or `jest.fn` — the runner's factory, handed in by the suite. */\nexport type MockFactory = <T extends AnyFunction>(implementation?: T) => MockFn<T>;\n\nexport const FILE_TYPE: DetailRecordType = {\n type: \"file\",\n label: \"File\",\n icon: ({ className }: { className?: string }) => <span className={className} />,\n accent: null,\n entityToken: \"file\",\n load: null,\n title: (row, seed) =>\n (typeof row?.file_name === \"string\" ? row.file_name : null) ??\n seed?.name?.trim() ??\n \"Untitled File\",\n fields: () => [],\n};\n\n/**\n * A minimal PAGE shell: the two controls a person leaves the page with (the\n * back chevron and, through the keyboard root, Escape) plus the slots. Enough\n * to assert WHICH port the exit consults — which is where D1 lived, twice.\n */\nexport function StubPageShell({ titleNode, actions, onBack, children }: DetailPageShellProps) {\n return (\n <div data-stub-page-shell>\n <button type=\"button\" aria-label=\"Back\" onClick={onBack}>\n back\n </button>\n {titleNode}\n {actions}\n {children}\n </div>\n );\n}\n\n/** A minimal WINDOW shell: header slots above the body, nothing else. */\nexport function StubWindowShell({ titleNode, actions, children }: DetailWindowShellProps) {\n return (\n <div data-stub-window-shell>\n <div data-header>\n {titleNode}\n {actions}\n </div>\n {children}\n </div>\n );\n}\n\n/** A minimal DOCKED shell: the same shape as the window's. */\nexport function StubDockedShell({ titleNode, actions, children }: DetailDockedShellProps) {\n return (\n <div data-stub-docked-shell>\n <div data-header>\n {titleNode}\n {actions}\n </div>\n {children}\n </div>\n );\n}\n\nexport type StubPorts = DetailHostPorts & {\n usePresentationSetting: MockFn;\n resolvePresentation: MockFn;\n warmPresentation: MockFn;\n savePresentation: MockFn;\n open: MockFn;\n close: MockFn;\n copyText: MockFn;\n};\n\n/**\n * The stub-port builder for one runner. Every port is bound; the record-type\n * map answers `FILE_TYPE` for everything; the doors answer for `file` only\n * (NEW-17: the stub's sentence must match the stub's own doors).\n */\nexport function makePortsWith(fn: MockFactory): (overrides?: Partial<DetailHostPorts>) => StubPorts {\n return (overrides = {}) =>\n ({\n resolveType: () => FILE_TYPE,\n usePresentationSetting: fn(() => ({ value: \"window\" as const, error: null })),\n resolvePresentation: fn(async () => \"window\" as const),\n warmPresentation: fn(),\n savePresentation: fn(async () => ({ ok: true as const })),\n open: fn(),\n close: fn(),\n navigate: {\n pageHref: (ref: { type: string; id: string }) => `/detail/${ref.type}/${ref.id}`,\n toPage: fn(),\n back: fn(),\n canGoBack: fn(() => false),\n toRecordHome: fn(),\n },\n shells: {},\n doors: {\n RecordDoors: ({ id }: { id: string }) => <span data-doors={id} />,\n RefCell: ({ value }: { value: string }) => <span>{value}</span>,\n tokenFromColumnName: () => null,\n isUuidValue: (value: unknown): value is string => typeof value === \"string\",\n hasDoor: (token: string) => token === \"file\",\n },\n associations: { defaultTokens: [], canAnchor: () => false },\n history: { list: fn(async () => []) },\n notify: { error: fn(), success: fn() },\n copyText: fn(async () => true),\n ...overrides,\n }) as unknown as StubPorts;\n}\n\nexport function instance(over: Partial<DetailInstanceData> = {}): DetailInstanceData {\n return {\n type: \"file\",\n id: \"11111111-2222-3333-4444-555555555555\",\n seed: null,\n list: null,\n ...over,\n };\n}\n\nexport interface Mounted {\n container: HTMLDivElement;\n root: Root;\n unmount: () => void;\n}\n\n/** Mount `ui` under a provider carrying `ports`, inside a fresh container. */\nexport function mount(ui: React.ReactElement, ports: DetailHostPorts): Mounted {\n // React 19 wants this flag before `act` drives a concurrent root.\n (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;\n const container = document.createElement(\"div\");\n document.body.appendChild(container);\n const root = createRoot(container);\n act(() => {\n root.render(<DetailHostProvider ports={ports}>{ui}</DetailHostProvider>);\n });\n return {\n container,\n root,\n unmount: () => {\n act(() => root.unmount());\n container.remove();\n },\n };\n}\n\n/** Click a control by its accessible name. Throws (loudly) when it is absent. */\nexport function clickByLabel(container: HTMLElement, label: string): void {\n const el = container.querySelector<HTMLElement>(`[aria-label=\"${label}\"]`);\n if (!el) {\n throw new Error(\n `No control labelled \"${label}\". Present: ` +\n Array.from(container.querySelectorAll(\"[aria-label]\"))\n .map((n) => n.getAttribute(\"aria-label\"))\n .join(\" | \"),\n );\n }\n act(() => {\n el.dispatchEvent(new MouseEvent(\"click\", { bubbles: true }));\n });\n}\n","// @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 labelled \"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":";;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAiBA,IAAAA,gBAAoB;AACpB,oBAAsC;;;ACFtC,mBAA4F;AAuSnF;AAvBT,IAAM,eAAe,uBAAO,IAAI,8BAA8B;AAE9D,IAAM,oBAAgE,WACpE,YACF,UAAM,4BAA+C,IAAI;AAMlD,SAAS,mBAAmB;AAAA,EACjC;AAAA,EACA;AACF,GAGG;AACD,QAAM,aAAS,yBAAW,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,4CAAC,kBAAkB,UAAlB,EAA2B,OAAO,QAAS,UAAS;AAC9D;;;ADzQmD,IAAAC,sBAAA;AAH5C,IAAM,YAA8B;AAAA,EACzC,MAAM;AAAA,EACN,OAAO;AAAA,EACP,MAAM,CAAC,EAAE,UAAU,MAA8B,6CAAC,UAAK,WAAsB;AAAA,EAC7E,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,MAAM;AAAA,EACN,OAAO,CAAC,KAAK,UACV,OAAO,KAAK,cAAc,WAAW,IAAI,YAAY,SACtD,MAAM,MAAM,KAAK,KACjB;AAAA,EACF,QAAQ,MAAM,CAAC;AACjB;AAOO,SAAS,cAAc,EAAE,WAAW,SAAS,QAAQ,SAAS,GAAyB;AAC5F,SACE,8CAAC,SAAI,wBAAoB,MACvB;AAAA,iDAAC,YAAO,MAAK,UAAS,cAAW,QAAO,SAAS,QAAQ,kBAEzD;AAAA,IACC;AAAA,IACA;AAAA,IACA;AAAA,KACH;AAEJ;AAGO,SAAS,gBAAgB,EAAE,WAAW,SAAS,SAAS,GAA2B;AACxF,SACE,8CAAC,SAAI,0BAAsB,MACzB;AAAA,kDAAC,SAAI,eAAW,MACb;AAAA;AAAA,MACA;AAAA,OACH;AAAA,IACC;AAAA,KACH;AAEJ;AAGO,SAAS,gBAAgB,EAAE,WAAW,SAAS,SAAS,GAA2B;AACxF,SACE,8CAAC,SAAI,0BAAsB,MACzB;AAAA,kDAAC,SAAI,eAAW,MACb;AAAA;AAAA,MACA;AAAA,OACH;AAAA,IACC;AAAA,KACH;AAEJ;AAiBO,SAAS,cAAc,IAAsE;AAClG,SAAO,CAAC,YAAY,CAAC,OAClB;AAAA,IACC,aAAa,MAAM;AAAA,IACnB,wBAAwB,GAAG,OAAO,EAAE,OAAO,UAAmB,OAAO,KAAK,EAAE;AAAA,IAC5E,qBAAqB,GAAG,YAAY,QAAiB;AAAA,IACrD,kBAAkB,GAAG;AAAA,IACrB,kBAAkB,GAAG,aAAa,EAAE,IAAI,KAAc,EAAE;AAAA,IACxD,MAAM,GAAG;AAAA,IACT,OAAO,GAAG;AAAA,IACV,UAAU;AAAA,MACR,UAAU,CAAC,QAAsC,WAAW,IAAI,IAAI,IAAI,IAAI,EAAE;AAAA,MAC9E,QAAQ,GAAG;AAAA,MACX,MAAM,GAAG;AAAA,MACT,WAAW,GAAG,MAAM,KAAK;AAAA,MACzB,cAAc,GAAG;AAAA,IACnB;AAAA,IACA,QAAQ,CAAC;AAAA,IACT,OAAO;AAAA,MACL,aAAa,CAAC,EAAE,GAAG,MAAsB,6CAAC,UAAK,cAAY,IAAI;AAAA,MAC/D,SAAS,CAAC,EAAE,MAAM,MAAyB,6CAAC,UAAM,iBAAM;AAAA,MACxD,qBAAqB,MAAM;AAAA,MAC3B,aAAa,CAAC,UAAoC,OAAO,UAAU;AAAA,MACnE,SAAS,CAAC,UAAkB,UAAU;AAAA,IACxC;AAAA,IACA,cAAc,EAAE,eAAe,CAAC,GAAG,WAAW,MAAM,MAAM;AAAA,IAC1D,SAAS,EAAE,MAAM,GAAG,YAAY,CAAC,CAAC,EAAE;AAAA,IACpC,QAAQ,EAAE,OAAO,GAAG,GAAG,SAAS,GAAG,EAAE;AAAA,IACrC,UAAU,GAAG,YAAY,IAAI;AAAA,IAC7B,GAAG;AAAA,EACL;AACJ;AAEO,SAAS,SAAS,OAAoC,CAAC,GAAuB;AACnF,SAAO;AAAA,IACL,MAAM;AAAA,IACN,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,MAAM;AAAA,IACN,GAAG;AAAA,EACL;AACF;AASO,SAAS,MAAM,IAAwB,OAAiC;AAE7E,EAAC,WAAsD,2BAA2B;AAClF,QAAM,YAAY,SAAS,cAAc,KAAK;AAC9C,WAAS,KAAK,YAAY,SAAS;AACnC,QAAM,WAAO,0BAAW,SAAS;AACjC,yBAAI,MAAM;AACR,SAAK,OAAO,6CAAC,sBAAmB,OAAe,cAAG,CAAqB;AAAA,EACzE,CAAC;AACD,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,SAAS,MAAM;AACb,6BAAI,MAAM,KAAK,QAAQ,CAAC;AACxB,gBAAU,OAAO;AAAA,IACnB;AAAA,EACF;AACF;AAGO,SAAS,aAAa,WAAwB,OAAqB;AACxE,QAAM,KAAK,UAAU,cAA2B,gBAAgB,KAAK,IAAI;AACzE,MAAI,CAAC,IAAI;AACP,UAAM,IAAI;AAAA,MACR,wBAAwB,KAAK,iBAC3B,MAAM,KAAK,UAAU,iBAAiB,cAAc,CAAC,EAClD,IAAI,CAAC,MAAM,EAAE,aAAa,YAAY,CAAC,EACvC,KAAK,KAAK;AAAA,IACjB;AAAA,EACF;AACA,yBAAI,MAAM;AACR,OAAG,cAAc,IAAI,WAAW,SAAS,EAAE,SAAS,KAAK,CAAC,CAAC;AAAA,EAC7D,CAAC;AACH;","names":["import_react","import_jsx_runtime"]}
|
package/dist/testing.d.cts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
2
|
import { Root } from 'react-dom/client';
|
|
3
|
-
import { c as DetailRecordType,
|
|
3
|
+
import { c as DetailRecordType, m as DetailDockedShellProps, q as DetailPageShellProps, o as DetailHostPorts, u as DetailWindowShellProps, a as DetailInstanceData } from './host-DrnEXBfp.cjs';
|
|
4
4
|
|
|
5
5
|
type AnyFunction = (...args: any[]) => any;
|
|
6
6
|
/** What every runner's mock exposes and these tests read. */
|
package/dist/testing.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
2
|
import { Root } from 'react-dom/client';
|
|
3
|
-
import { c as DetailRecordType,
|
|
3
|
+
import { c as DetailRecordType, m as DetailDockedShellProps, q as DetailPageShellProps, o as DetailHostPorts, u as DetailWindowShellProps, a as DetailInstanceData } from './host-DrnEXBfp.js';
|
|
4
4
|
|
|
5
5
|
type AnyFunction = (...args: any[]) => any;
|
|
6
6
|
/** What every runner's mock exposes and these tests read. */
|
package/dist/testing.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/testing/index.tsx","../src/react/host.tsx"],"sourcesContent":["// @ai-matrx/detail/testing — the test seat for the Detail primitive.\n//\n// Stub host ports (every function a mock from the RUNNER'S OWN factory, so a\n// test asserts what the core ASKED THE HOST TO DO — which is where every one of\n// the primitive's recorded defects lived) plus a tiny react-dom renderer and\n// three stub shells. Shipped as a subpath so a HOST tests its own binding\n// against the same seat the package proves itself with; it drives\n// `react-dom/client` + `act` directly and needs no testing library.\n//\n// THE MOCK FACTORY IS INJECTED, not chosen here: vitest's `vi.fn` and jest's\n// `jest.fn` produce different mock objects and neither runner's matchers accept\n// the other's. `makePortsWith(vi.fn)` in the package, `makePortsWith(jest.fn)`\n// in a jest host — one harness, no twin.\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { act } from \"react\";\nimport { createRoot, type Root } from \"react-dom/client\";\n\nimport {\n DetailHostProvider,\n type DetailDockedShellProps,\n type DetailHostPorts,\n type DetailPageShellProps,\n type DetailWindowShellProps,\n} from \"../react/host\";\nimport type { DetailInstanceData, DetailRecordType } from \"../types\";\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any -- a mock factory's implementation type is the runner's business; `unknown` here would refuse every real impl\ntype AnyFunction = (...args: any[]) => any;\n\n/** What every runner's mock exposes and these tests read. */\nexport interface MockControls {\n mock: { calls: unknown[][] };\n mockReturnValue(value: unknown): unknown;\n mockResolvedValue(value: unknown): unknown;\n}\n\nexport type MockFn<T extends AnyFunction = AnyFunction> = T & MockControls;\n\n/** `vi.fn` or `jest.fn` — the runner's factory, handed in by the suite. */\nexport type MockFactory = <T extends AnyFunction>(implementation?: T) => MockFn<T>;\n\nexport const FILE_TYPE: DetailRecordType = {\n type: \"file\",\n label: \"File\",\n icon: ({ className }: { className?: string }) => <span className={className} />,\n accent: null,\n entityToken: \"file\",\n load: null,\n title: (row, seed) =>\n (typeof row?.file_name === \"string\" ? row.file_name : null) ??\n seed?.name?.trim() ??\n \"Untitled File\",\n fields: () => [],\n};\n\n/**\n * A minimal PAGE shell: the two controls a person leaves the page with (the\n * back chevron and, through the keyboard root, Escape) plus the slots. Enough\n * to assert WHICH port the exit consults — which is where D1 lived, twice.\n */\nexport function StubPageShell({ titleNode, actions, onBack, children }: DetailPageShellProps) {\n return (\n <div data-stub-page-shell>\n <button type=\"button\" aria-label=\"Back\" onClick={onBack}>\n back\n </button>\n {titleNode}\n {actions}\n {children}\n </div>\n );\n}\n\n/** A minimal WINDOW shell: header slots above the body, nothing else. */\nexport function StubWindowShell({ titleNode, actions, children }: DetailWindowShellProps) {\n return (\n <div data-stub-window-shell>\n <div data-header>\n {titleNode}\n {actions}\n </div>\n {children}\n </div>\n );\n}\n\n/** A minimal DOCKED shell: the same shape as the window's. */\nexport function StubDockedShell({ titleNode, actions, children }: DetailDockedShellProps) {\n return (\n <div data-stub-docked-shell>\n <div data-header>\n {titleNode}\n {actions}\n </div>\n {children}\n </div>\n );\n}\n\nexport type StubPorts = DetailHostPorts & {\n usePresentationSetting: MockFn;\n resolvePresentation: MockFn;\n warmPresentation: MockFn;\n savePresentation: MockFn;\n open: MockFn;\n close: MockFn;\n copyText: MockFn;\n};\n\n/**\n * The stub-port builder for one runner. Every port is bound; the record-type\n * map answers `FILE_TYPE` for everything; the doors answer for `file` only\n * (NEW-17: the stub's sentence must match the stub's own doors).\n */\nexport function makePortsWith(fn: MockFactory): (overrides?: Partial<DetailHostPorts>) => StubPorts {\n return (overrides = {}) =>\n ({\n resolveType: () => FILE_TYPE,\n usePresentationSetting: fn(() => ({ value: \"window\" as const, error: null })),\n resolvePresentation: fn(async () => \"window\" as const),\n warmPresentation: fn(),\n savePresentation: fn(async () => ({ ok: true as const })),\n open: fn(),\n close: fn(),\n navigate: {\n pageHref: (ref: { type: string; id: string }) => `/detail/${ref.type}/${ref.id}`,\n toPage: fn(),\n back: fn(),\n canGoBack: fn(() => false),\n toRecordHome: fn(),\n },\n shells: {},\n doors: {\n RecordDoors: ({ id }: { id: string }) => <span data-doors={id} />,\n RefCell: ({ value }: { value: string }) => <span>{value}</span>,\n tokenFromColumnName: () => null,\n isUuidValue: (value: unknown): value is string => typeof value === \"string\",\n hasDoor: (token: string) => token === \"file\",\n },\n associations: { defaultTokens: [], canAnchor: () => false },\n history: { list: fn(async () => []) },\n notify: { error: fn(), success: fn() },\n copyText: fn(async () => true),\n ...overrides,\n }) as unknown as StubPorts;\n}\n\nexport function instance(over: Partial<DetailInstanceData> = {}): DetailInstanceData {\n return {\n type: \"file\",\n id: \"11111111-2222-3333-4444-555555555555\",\n seed: null,\n list: null,\n ...over,\n };\n}\n\nexport interface Mounted {\n container: HTMLDivElement;\n root: Root;\n unmount: () => void;\n}\n\n/** Mount `ui` under a provider carrying `ports`, inside a fresh container. */\nexport function mount(ui: React.ReactElement, ports: DetailHostPorts): Mounted {\n // React 19 wants this flag before `act` drives a concurrent root.\n (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;\n const container = document.createElement(\"div\");\n document.body.appendChild(container);\n const root = createRoot(container);\n act(() => {\n root.render(<DetailHostProvider ports={ports}>{ui}</DetailHostProvider>);\n });\n return {\n container,\n root,\n unmount: () => {\n act(() => root.unmount());\n container.remove();\n },\n };\n}\n\n/** Click a control by its accessible name. Throws (loudly) when it is absent. */\nexport function clickByLabel(container: HTMLElement, label: string): void {\n const el = container.querySelector<HTMLElement>(`[aria-label=\"${label}\"]`);\n if (!el) {\n throw new Error(\n `No control labelled \"${label}\". Present: ` +\n Array.from(container.querySelectorAll(\"[aria-label]\"))\n .map((n) => n.getAttribute(\"aria-label\"))\n .join(\" | \"),\n );\n }\n act(() => {\n el.dispatchEvent(new MouseEvent(\"click\", { bubbles: true }));\n });\n}\n","// @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. */\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 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 labelled \"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":"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","names":["jsx"]}
|
|
1
|
+
{"version":3,"sources":["../src/testing/index.tsx","../src/react/host.tsx"],"sourcesContent":["// @ai-matrx/detail/testing — the test seat for the Detail primitive.\n//\n// Stub host ports (every function a mock from the RUNNER'S OWN factory, so a\n// test asserts what the core ASKED THE HOST TO DO — which is where every one of\n// the primitive's recorded defects lived) plus a tiny react-dom renderer and\n// three stub shells. Shipped as a subpath so a HOST tests its own binding\n// against the same seat the package proves itself with; it drives\n// `react-dom/client` + `act` directly and needs no testing library.\n//\n// THE MOCK FACTORY IS INJECTED, not chosen here: vitest's `vi.fn` and jest's\n// `jest.fn` produce different mock objects and neither runner's matchers accept\n// the other's. `makePortsWith(vi.fn)` in the package, `makePortsWith(jest.fn)`\n// in a jest host — one harness, no twin.\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { act } from \"react\";\nimport { createRoot, type Root } from \"react-dom/client\";\n\nimport {\n DetailHostProvider,\n type DetailDockedShellProps,\n type DetailHostPorts,\n type DetailPageShellProps,\n type DetailWindowShellProps,\n} from \"../react/host\";\nimport type { DetailInstanceData, DetailRecordType } from \"../types\";\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any -- a mock factory's implementation type is the runner's business; `unknown` here would refuse every real impl\ntype AnyFunction = (...args: any[]) => any;\n\n/** What every runner's mock exposes and these tests read. */\nexport interface MockControls {\n mock: { calls: unknown[][] };\n mockReturnValue(value: unknown): unknown;\n mockResolvedValue(value: unknown): unknown;\n}\n\nexport type MockFn<T extends AnyFunction = AnyFunction> = T & MockControls;\n\n/** `vi.fn` or `jest.fn` — the runner's factory, handed in by the suite. */\nexport type MockFactory = <T extends AnyFunction>(implementation?: T) => MockFn<T>;\n\nexport const FILE_TYPE: DetailRecordType = {\n type: \"file\",\n label: \"File\",\n icon: ({ className }: { className?: string }) => <span className={className} />,\n accent: null,\n entityToken: \"file\",\n load: null,\n title: (row, seed) =>\n (typeof row?.file_name === \"string\" ? row.file_name : null) ??\n seed?.name?.trim() ??\n \"Untitled File\",\n fields: () => [],\n};\n\n/**\n * A minimal PAGE shell: the two controls a person leaves the page with (the\n * back chevron and, through the keyboard root, Escape) plus the slots. Enough\n * to assert WHICH port the exit consults — which is where D1 lived, twice.\n */\nexport function StubPageShell({ titleNode, actions, onBack, children }: DetailPageShellProps) {\n return (\n <div data-stub-page-shell>\n <button type=\"button\" aria-label=\"Back\" onClick={onBack}>\n back\n </button>\n {titleNode}\n {actions}\n {children}\n </div>\n );\n}\n\n/** A minimal WINDOW shell: header slots above the body, nothing else. */\nexport function StubWindowShell({ titleNode, actions, children }: DetailWindowShellProps) {\n return (\n <div data-stub-window-shell>\n <div data-header>\n {titleNode}\n {actions}\n </div>\n {children}\n </div>\n );\n}\n\n/** A minimal DOCKED shell: the same shape as the window's. */\nexport function StubDockedShell({ titleNode, actions, children }: DetailDockedShellProps) {\n return (\n <div data-stub-docked-shell>\n <div data-header>\n {titleNode}\n {actions}\n </div>\n {children}\n </div>\n );\n}\n\nexport type StubPorts = DetailHostPorts & {\n usePresentationSetting: MockFn;\n resolvePresentation: MockFn;\n warmPresentation: MockFn;\n savePresentation: MockFn;\n open: MockFn;\n close: MockFn;\n copyText: MockFn;\n};\n\n/**\n * The stub-port builder for one runner. Every port is bound; the record-type\n * map answers `FILE_TYPE` for everything; the doors answer for `file` only\n * (NEW-17: the stub's sentence must match the stub's own doors).\n */\nexport function makePortsWith(fn: MockFactory): (overrides?: Partial<DetailHostPorts>) => StubPorts {\n return (overrides = {}) =>\n ({\n resolveType: () => FILE_TYPE,\n usePresentationSetting: fn(() => ({ value: \"window\" as const, error: null })),\n resolvePresentation: fn(async () => \"window\" as const),\n warmPresentation: fn(),\n savePresentation: fn(async () => ({ ok: true as const })),\n open: fn(),\n close: fn(),\n navigate: {\n pageHref: (ref: { type: string; id: string }) => `/detail/${ref.type}/${ref.id}`,\n toPage: fn(),\n back: fn(),\n canGoBack: fn(() => false),\n toRecordHome: fn(),\n },\n shells: {},\n doors: {\n RecordDoors: ({ id }: { id: string }) => <span data-doors={id} />,\n RefCell: ({ value }: { value: string }) => <span>{value}</span>,\n tokenFromColumnName: () => null,\n isUuidValue: (value: unknown): value is string => typeof value === \"string\",\n hasDoor: (token: string) => token === \"file\",\n },\n associations: { defaultTokens: [], canAnchor: () => false },\n history: { list: fn(async () => []) },\n notify: { error: fn(), success: fn() },\n copyText: fn(async () => true),\n ...overrides,\n }) as unknown as StubPorts;\n}\n\nexport function instance(over: Partial<DetailInstanceData> = {}): DetailInstanceData {\n return {\n type: \"file\",\n id: \"11111111-2222-3333-4444-555555555555\",\n seed: null,\n list: null,\n ...over,\n };\n}\n\nexport interface Mounted {\n container: HTMLDivElement;\n root: Root;\n unmount: () => void;\n}\n\n/** Mount `ui` under a provider carrying `ports`, inside a fresh container. */\nexport function mount(ui: React.ReactElement, ports: DetailHostPorts): Mounted {\n // React 19 wants this flag before `act` drives a concurrent root.\n (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;\n const container = document.createElement(\"div\");\n document.body.appendChild(container);\n const root = createRoot(container);\n act(() => {\n root.render(<DetailHostProvider ports={ports}>{ui}</DetailHostProvider>);\n });\n return {\n container,\n root,\n unmount: () => {\n act(() => root.unmount());\n container.remove();\n },\n };\n}\n\n/** Click a control by its accessible name. Throws (loudly) when it is absent. */\nexport function clickByLabel(container: HTMLElement, label: string): void {\n const el = container.querySelector<HTMLElement>(`[aria-label=\"${label}\"]`);\n if (!el) {\n throw new Error(\n `No control labelled \"${label}\". Present: ` +\n Array.from(container.querySelectorAll(\"[aria-label]\"))\n .map((n) => n.getAttribute(\"aria-label\"))\n .join(\" | \"),\n );\n }\n act(() => {\n el.dispatchEvent(new MouseEvent(\"click\", { bubbles: true }));\n });\n}\n","// @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 labelled \"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":"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","names":["jsx"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-matrx/detail",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.69",
|
|
4
4
|
"description": "THE Detail primitive: ONE registration per record type — header, source health strip, fields, associations, history — wrapped once so it shows as a window (the default), a docked side panel, or a page, with the same content in all three. Owns the presentation setting shape and its per-type override, the keyboard model (Escape / arrows / Cmd+Enter), the deep-link spelling with its final-URL byte budget, and the honest absent states. A host binds shells, doors, navigation, settings and notifications through typed ports.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|