@ai-matrx/detail 0.1.166 → 0.1.168

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,10 +1,36 @@
1
1
  # Changelog — @ai-matrx/detail
2
2
 
3
- ## 0.1.166
3
+ ### Unreleased
4
+
5
+ - **Shared code ships once per side (2026-10-18).** `/react` and `/testing` build in one split ESM pass (stamped chunks), so the one module both reached ships once; the root builds alone, unstamped. CJS unsplit; declarations unchanged. ESM JS 78,854 -> 78,517 bytes; export names identical (ESM and CJS). Guard: `../scripts/check-ships-once.mjs`, run in `build`. Consumer action: none.
6
+
7
+ ## 0.1.168
4
8
 
5
9
  UNVERIFIED: this release has no Unreleased entry and the source comparison could not be made (the caller supplied no source comparison). Whether source changed is unknown; consumer action is unknown.
6
10
  Check the package's gitHead on npm against this version's gitHead: `git diff <previous gitHead> <this gitHead> -- apps/shared/detail/src`.
7
11
 
12
+ ## 0.1.167
13
+
14
+ SOURCE CHANGED WITH NO RELEASE NOTE. This release shipped source changes that nobody described in an Unreleased entry,
15
+ so they may include breaking API changes. Consumer action is UNKNOWN: read the diff before upgrading.
16
+ Diff: `git diff 92167249a3 45fa3576a7 -- apps/shared/detail/src`
17
+
18
+ Changed shipped source files (2):
19
+ - `apps/shared/detail/src/react/DetailHeader.tsx`
20
+ - `apps/shared/detail/src/types.ts`
21
+
22
+ ## 0.1.166
23
+
24
+ SOURCE CHANGED WITH NO RELEASE NOTE. This release shipped source changes that nobody described in an Unreleased entry,
25
+ so they may include breaking API changes. Consumer action is UNKNOWN: read the diff before upgrading.
26
+ Diff: `git diff 3702c2122e 92167249a3 -- apps/shared/detail/src`
27
+
28
+ Changed shipped source files (4):
29
+ - `apps/shared/detail/src/react/DetailBody.tsx`
30
+ - `apps/shared/detail/src/react/DetailHeader.tsx`
31
+ - `apps/shared/detail/src/react/DetailPresentationPane.tsx`
32
+ - `apps/shared/detail/src/react/icons.tsx`
33
+
8
34
  ## 0.1.165
9
35
 
10
36
  Automatic changed-only republish (docs/metadata drift since the last published version; verified: `git diff c50fadabfc 3702c2122e -- apps/shared/detail/src`
@@ -0,0 +1,72 @@
1
+ "use client";
2
+
3
+ // src/react/host.tsx
4
+ import { createContext, useContext } from "react";
5
+ import { jsx } from "react/jsx-runtime";
6
+ var CONTEXT_SLOT = /* @__PURE__ */ Symbol.for("ai-matrx.detail.host-context");
7
+ var DetailHostContext = globalThis[CONTEXT_SLOT] ??= createContext(null);
8
+ function DetailHostProvider({
9
+ ports,
10
+ children
11
+ }) {
12
+ const parent = useContext(DetailHostContext);
13
+ const merged = {
14
+ ...parent ?? {},
15
+ ...ports,
16
+ shells: { ...parent?.shells ?? {}, ...ports.shells ?? {} }
17
+ };
18
+ return /* @__PURE__ */ jsx(DetailHostContext.Provider, { value: merged, children });
19
+ }
20
+ var REQUIRED_PORTS = [
21
+ "usePresentationSetting",
22
+ "resolvePresentation",
23
+ "warmPresentation",
24
+ "open",
25
+ "close",
26
+ "navigate",
27
+ "doors",
28
+ "associations",
29
+ "history",
30
+ "notify",
31
+ "copyText"
32
+ ];
33
+ function useDetailHost() {
34
+ const ports = useContext(DetailHostContext);
35
+ if (!ports) {
36
+ throw new Error(
37
+ "[detail] No DetailHostProvider above this component. Mount the host binding \u2014 a <DetailHostProvider ports={\u2026}> carrying every required port (README \u2192 'Binding the ports') \u2014 around the tree that opens or renders record details."
38
+ );
39
+ }
40
+ const missing = REQUIRED_PORTS.filter((key) => ports[key] === void 0);
41
+ if (missing.length > 0) {
42
+ throw new Error(
43
+ `[detail] DetailHostProvider is missing the port(s): ${missing.join(", ")}. Bind them in the host binding (README \u2192 'Binding the ports').`
44
+ );
45
+ }
46
+ return ports;
47
+ }
48
+ function requireResolveType(host) {
49
+ if (!host.resolveType) {
50
+ throw new Error(
51
+ "[detail] No resolveType port is bound. A presentation's entry must pass `resolveType` (the host's ONE record-type map) beside its shell."
52
+ );
53
+ }
54
+ return host.resolveType;
55
+ }
56
+ function requireShell(shells, which) {
57
+ const shell = shells[which];
58
+ if (!shell) {
59
+ throw new Error(
60
+ `[detail] No ${which} shell is bound. The ${which} presentation's entry must wrap itself in <DetailHostProvider ports={{ shells: { ${which} } }}> (README \u2192 'The three shells').`
61
+ );
62
+ }
63
+ return shell;
64
+ }
65
+
66
+ export {
67
+ DetailHostProvider,
68
+ useDetailHost,
69
+ requireResolveType,
70
+ requireShell
71
+ };
72
+ //# sourceMappingURL=chunk-QKTMHP4O.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/react/host.tsx"],"sourcesContent":["// @ai-matrx/detail — src/react/host.tsx\n//\n// THE SEAM. Everything this package needs from the app that hosts it arrives\n// here as ports — the same shape `@ai-matrx/associations` uses. The core and\n// the three presentations import nothing from any host; they ask the host for\n// the record-type map, the presentation setting, the shells (window / docked /\n// page), the doors, history, navigation and notifications.\n//\n// Providers NEST AND MERGE: a light provider at boot binds everything but the\n// shells (so `useOpenDetail` works anywhere), and each presentation's lazily\n// loaded entry binds its own shell right above the presentation — the window\n// shell parses the host's window manager and must never sit in a boot bundle.\n// A port that is missing when it is needed throws, naming itself.\n\n\"use client\";\n\nimport { createContext, useContext, type Context, type ComponentType, type ReactNode } from \"react\";\n\nimport type {\n DetailHistoryEntry,\n DetailInstanceData,\n DetailListContext,\n DetailPresentation,\n DetailRecordType,\n DetailRef,\n DetailRow,\n DetailSeed,\n} from \"../types\";\n\n/** Props every shell receives; the package fills the slots, the host owns the chrome. */\n/** One of the record's optional controls as data (a shell's menu draws it). */\nexport interface DetailMenuAction {\n readonly id: string;\n readonly label: string;\n readonly onSelect: () => void;\n}\n\nexport interface DetailShellSlots {\n /** Accessible plain-text title (window chrome, drawer title, document title). */\n title: string;\n /** Rich title: icon · name · type chip · the record's doors. */\n titleNode: ReactNode;\n /** Presentation switcher · previous / next · copy id. Compact. */\n actions: ReactNode;\n /**\n * Previous / next only — for a shell whose chrome has its OWN menu: it puts\n * `menuActions` there instead of a second overflow button (\"…\" beside \"⋮\").\n */\n navActions: ReactNode;\n /** The optional controls (open as window / docked / page, copy id) as data, for a shell's own menu. */\n menuActions: readonly DetailMenuAction[];\n children: ReactNode;\n onClose: () => void;\n}\n\nexport interface DetailWindowShellProps extends DetailShellSlots {\n /** `type.id` — the deep-link instance and the window manager's key. */\n instanceKey: string;\n target: DetailRef;\n /**\n * 🚨 NEW-15 — THE LIST TRAVELS INTO THE DEEP LINK. The window's `?panels=`\n * token used to carry the record and the presentation only, so a REFRESH lost\n * the previous / next controls and the counter without a word. The shell puts\n * this into the token the way the page presentation puts it into the query.\n */\n list: DetailListContext | null;\n}\n\nexport interface DetailDockedShellProps extends DetailShellSlots {\n instanceKey: string;\n target: DetailRef;\n}\n\nexport interface DetailPageShellProps extends DetailShellSlots {\n target: DetailRef;\n /** Back affordance — the page is the one presentation that left the URL. */\n onBack: () => void;\n}\n\nexport interface DetailEffectivePresentation {\n /** The effective presentation for this type, `undefined` when unresolved. */\n value: DetailPresentation | undefined;\n /** The per-record-type exception still in effect, from ANY rung. */\n forType: DetailPresentation | undefined;\n}\n\nexport interface DetailPresentationSetting {\n /** `undefined` while unresolved; `error` names the failure — never both silent. */\n value: DetailPresentation | undefined;\n error: string | null;\n /**\n * The PER-RECORD-TYPE exception in effect for this type, when there is one\n * (`undefined` = this type opens the way the default says). The pane offers\n * \"use the default for this type\" only then — an offer to remove something\n * that does not exist is a control that does nothing (NEW-2).\n */\n forType?: DetailPresentation | undefined;\n}\n\nexport interface DetailShells {\n Window?: ComponentType<DetailWindowShellProps>;\n Docked?: ComponentType<DetailDockedShellProps>;\n Page?: ComponentType<DetailPageShellProps>;\n}\n\nexport interface DetailHostPorts {\n /**\n * The ONE record-type map. `null` = the host has no registration for the\n * type. Bound by each presentation's entry (with its shell), not at boot —\n * the map pulls the registry and the frame into whatever imports it.\n */\n resolveType?: (type: string) => DetailRecordType | null;\n /** React face — the effective `ui.detail.default_presentation` for a type. A hook. */\n usePresentationSetting: (type: string) => DetailPresentationSetting;\n /** Awaited face for click handlers. Rejects with the resolver's error. */\n resolvePresentation: (type: string) => Promise<DetailPresentation>;\n /** Warm the cache so the click path is synchronous. */\n warmPresentation: (type: string) => void;\n /**\n * 🚨 NEW-10 (VERIFY-U-P1-R3) — RE-READ THE LADDER AFTER A WRITE, AND SAY WHAT\n * ANSWERS NOW. Removing your own per-type exception while your ORGANIZATION\n * holds one for the same type succeeded and the pane promised the records\n * \"now open the way you normally open records\" — they did not; the\n * organization's entry still won on the next open and nothing re-read the\n * ladder. A host binds this to a FRESH ladder read (cache invalidated), never\n * to the cached value the write just made stale. Optional: a host that cannot\n * re-read says so and the pane promises nothing.\n */\n reReadPresentation?: (type: string) => Promise<DetailEffectivePresentation>;\n /**\n * Write the person's presentation setting from inside a detail (the ONE\n * screen that writes `ui.detail.default_presentation`). `forType` non-null\n * writes the PER-RECORD-TYPE override instead of the default.\n *\n * `clear` with a `forType` REMOVES that record type's exception instead of\n * setting one, so an exception set from a record can be taken back from the\n * same place (NEW-2). It is the same map-entry write with the key absent — a\n * host never grows a second writer for it.\n *\n * Optional: a host that can only READ the setting binds nothing and the pane\n * is absent — never a disabled-looking control. A refusal comes back as\n * `{ ok: false, reason }` (the settings ladder's reasons are sentences and\n * the surface renders them), never as a thrown error.\n */\n savePresentation?: (args: {\n presentation: DetailPresentation;\n forType: string | null;\n clear?: boolean;\n }) => Promise<{ ok: true } | { ok: false; reason: string }>;\n /** Open an in-place presentation (window / docked). Page goes through `navigate`. */\n open: (args: {\n presentation: Exclude<DetailPresentation, \"page\">;\n data: DetailInstanceData;\n }) => void;\n close: (presentation: Exclude<DetailPresentation, \"page\">) => void;\n navigate: {\n pageHref: (ref: DetailRef, extra?: { list?: DetailListContext | null }) => string;\n toPage: (\n ref: DetailRef,\n extra?: {\n seed?: DetailSeed | null;\n list?: DetailListContext | null;\n /**\n * 🚨 NEW-14 (VERIFY-U-P1-R3) — MOVING INSIDE A LIST REPLACES, NEVER\n * PUSHES. Arrowing to a neighbour on the page pushed a history entry,\n * so twenty records meant twenty Backs and the chevron 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":";;;AAgBA,SAAS,eAAe,kBAAoE;AAuSnF;AAvBT,IAAM,eAAe,uBAAO,IAAI,8BAA8B;AAE9D,IAAM,oBAAgE,WACpE,YACF,MAAM,cAA+C,IAAI;AAMlD,SAAS,mBAAmB;AAAA,EACjC;AAAA,EACA;AACF,GAGG;AACD,QAAM,SAAS,WAAW,iBAAiB;AAC3C,QAAM,SAAmC;AAAA,IACvC,GAAI,UAAU,CAAC;AAAA,IACf,GAAG;AAAA,IACH,QAAQ,EAAE,GAAI,QAAQ,UAAU,CAAC,GAAI,GAAI,MAAM,UAAU,CAAC,EAAG;AAAA,EAC/D;AACA,SAAO,oBAAC,kBAAkB,UAAlB,EAA2B,OAAO,QAAS,UAAS;AAC9D;AAEA,IAAM,iBAA4C;AAAA,EAChD;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAOO,SAAS,gBAAiC;AAC/C,QAAM,QAAQ,WAAW,iBAAiB;AAC1C,MAAI,CAAC,OAAO;AACV,UAAM,IAAI;AAAA,MACR;AAAA,IAGF;AAAA,EACF;AACA,QAAM,UAAU,eAAe,OAAO,CAAC,QAAQ,MAAM,GAAG,MAAM,MAAS;AACvE,MAAI,QAAQ,SAAS,GAAG;AACtB,UAAM,IAAI;AAAA,MACR,uDAAuD,QAAQ,KAAK,IAAI,CAAC;AAAA,IAE3E;AAAA,EACF;AAGA,SAAO;AACT;AAGO,SAAS,mBACd,MAC6C;AAC7C,MAAI,CAAC,KAAK,aAAa;AACrB,UAAM,IAAI;AAAA,MACR;AAAA,IAEF;AAAA,EACF;AACA,SAAO,KAAK;AACd;AAGO,SAAS,aACd,QACA,OAC8B;AAC9B,QAAM,QAAQ,OAAO,KAAK;AAC1B,MAAI,CAAC,OAAO;AACV,UAAM,IAAI;AAAA,MACR,eAAe,KAAK,wBAAwB,KAAK,oFACL,KAAK;AAAA,IACnD;AAAA,EACF;AACA,SAAO;AACT;","names":[]}
@@ -184,6 +184,12 @@ interface DetailRecordType<Row extends DetailRow = DetailRow> {
184
184
  bg: string;
185
185
  ring: string;
186
186
  } | null;
187
+ /**
188
+ * The type's official identity colour (`#rrggbb`, from `@ai-matrx/icons/domain`).
189
+ * When set it wins over `accent`: the icon wears it and the type chip is tinted
190
+ * with it (chip text stays the foreground token).
191
+ */
192
+ color?: string | null;
187
193
  /**
188
194
  * The canonical entity-registry token for doors, associations and history.
189
195
  * `null` when the type is not a registry entity — the header then offers
@@ -184,6 +184,12 @@ interface DetailRecordType<Row extends DetailRow = DetailRow> {
184
184
  bg: string;
185
185
  ring: string;
186
186
  } | null;
187
+ /**
188
+ * The type's official identity colour (`#rrggbb`, from `@ai-matrx/icons/domain`).
189
+ * When set it wins over `accent`: the icon wears it and the type chip is tinted
190
+ * with it (chip text stays the foreground token).
191
+ */
192
+ color?: string | null;
187
193
  /**
188
194
  * The canonical entity-registry token for doors, associations and history.
189
195
  * `null` when the type is not a registry entity — the header then offers
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/types.ts","../src/listContext.ts","../src/presentation.ts","../src/format.ts"],"sourcesContent":["// @ai-matrx/detail — the PURE root.\n//\n// The record-type contract, the presentation setting shape and its per-type\n// override, the list-context trim with the final-URL byte budget, the\n// deep-link spelling of every presentation, and the row → fields formatter.\n// Nothing here touches React at run time (types only), so this entry is legal\n// inside a React Server Component; the React product is `@ai-matrx/detail/react`.\n\nexport {\n DEFAULT_DETAIL_LIST_CONTEXT_MAX,\n DETAIL_LIST_CONTEXT_MAX_IDS_CEILING,\n DETAIL_LIST_CONTEXT_MAX_KNOB,\n DETAIL_PRESENTATIONS,\n DETAIL_PRESENTATION_BY_TYPE_KNOB,\n DETAIL_PRESENTATION_KNOB,\n DETAIL_URL_BUDGET_BYTES,\n detailListContextMax,\n isDetailPresentation,\n presentationForTypeFromMap,\n} from \"./types\";\nexport type {\n DetailField,\n DetailFrameContext,\n DetailHealthContext,\n DetailHealthProducer,\n DetailHistoryEntry,\n DetailInstanceData,\n DetailListContext,\n DetailLoadResult,\n DetailLoadState,\n DetailOpenRequest,\n DetailPresentation,\n DetailRecordType,\n DetailRef,\n DetailRow,\n DetailSection,\n DetailSeed,\n DetailSourceHealth,\n DetailStatus,\n} from \"./types\";\n\nexport {\n decodeListItems,\n decodeRefPart,\n encodeListItems,\n encodeRefPart,\n listQueryBytes,\n trimListContext,\n} from \"./listContext\";\nexport type { TrimListContextOptions } from \"./listContext\";\n\nexport {\n DETAIL_URL_AS_ARG,\n DETAIL_URL_INDEX_ARG,\n DETAIL_URL_LIST_ARG,\n DETAIL_URL_LIST_TOTAL_ARG,\n DETAIL_URL_TYPE_KEY,\n decodeListQuery,\n decodePanelArgValue,\n detailInstanceKey,\n detailListFromUrlArgs,\n detailListToUrlArgs,\n encodeListQuery,\n encodePanelArgValue,\n finalPanelUrlLength,\n panelListValueBytes,\n panelUrlReserveBytes,\n parseDetailInstanceKey,\n presentationFromUrlArg,\n} from \"./presentation\";\n\nexport { HIDDEN_FIELDS, fieldsFromRow, formatValue, formatWhen } from \"./format\";\n","// @ai-matrx/detail — src/types.ts\n//\n// The Detail primitive's contract. ONE core per record type; the wrapper\n// yields three presentations (window | docked | page) from that one\n// registration. Nothing in this file knows how a record is fetched, how a\n// window is drawn, or which app hosts it — every app-specific capability\n// arrives through `DetailHostPorts` (see react/host.tsx). This module imports\n// React TYPES only and is legal inside a React Server Component.\n\nimport type { ComponentType, ReactNode } from \"react\";\n\n/** The three ways a record detail can show. `window` is the platform default. */\nexport const DETAIL_PRESENTATIONS = [\"window\", \"docked\", \"page\"] as const;\nexport type DetailPresentation = (typeof DETAIL_PRESENTATIONS)[number];\n\n/**\n * The knob every presentation decision reads (`platform.feature_knob`,\n * feature `ui.detail`, key `default_presentation`; seeded by matrx-frontend's\n * `migrations/detail_presentation_knob.sql`). Enum of DETAIL_PRESENTATIONS,\n * overridable by organization and user. The package never reads the settings\n * ladder itself — the host resolves this key and answers through the\n * `usePresentationSetting` / `resolvePresentation` ports.\n */\nexport const DETAIL_PRESENTATION_KNOB = \"ui.detail.default_presentation\";\n\n/**\n * The PER-RECORD-TYPE override of the setting above (`platform.feature_knob`,\n * feature `ui.detail`, key `presentation_by_type`): a json object keyed by\n * record type token, e.g. `{\"file\": \"docked\"}`. Nearest wins over\n * `default_presentation` for the type it names, and the same organization →\n * user ladder resolves it, so an organization can set \"contracts open as a\n * page\" and a person can still choose otherwise for themselves.\n *\n * WHY A SECOND KEY AND NOT THE `table` RUNG. The platform's per-table rung\n * (DD-131, precedence 50) is keyed by a `platform.entity_types` ROW ID, and\n * that table is admin-only by a restrictive policy — a browser cannot read the\n * id, so a `table` rung would be a setting the screen offers and the reader\n * never honours. One json key on the ladder the client already has is the\n * override the settings platform supports today.\n */\nexport const DETAIL_PRESENTATION_BY_TYPE_KNOB = \"ui.detail.presentation_by_type\";\n\n/**\n * How many records of the list a detail was opened FROM may ride the page\n * presentation's URL (`platform.feature_knob`, feature `ui.detail`, key\n * `list_context_max_ids`; seeded by matrx-frontend's\n * `migrations/detail_list_context_max_knob.sql`). Organization-overridable.\n *\n * Why a knob and not a constant: it is a ceiling, and every ceiling here is a\n * row an admin owns (`common-docs/policies/limits-are-knobs-agents-set-them.md`).\n * Why it exists at all: uncapped, a 500-row list produced a >20 KB href no\n * server accepts (NEW-7, VERIFY-U-P1-R2). Beyond the cap the URL carries the\n * window around the current record and the detail says the list was trimmed.\n */\nexport const DETAIL_LIST_CONTEXT_MAX_KNOB = \"ui.detail.list_context_max_ids\";\n\n/**\n * The cap when the knob has not answered yet (a cold cache, a signed-out\n * render, a host that binds no settings ladder). 100 `type.id` pairs with uuid\n * ids is the most the strictest presentation's FINAL URL can carry inside the\n * budget below, and it is far more neighbours than a person arrows through in\n * one sitting.\n */\nexport const DEFAULT_DETAIL_LIST_CONTEXT_MAX = 100;\n\n/**\n * 🚨 NEW-19 (VERIFY-U-P1-R4) — THE BUDGET BELONGS TO THE FINAL URL, ONCE.\n *\n * It used to be 6,000 characters measured on the list VALUE, one escaping layer\n * too early, and nothing bounded the whole address:\n *\n * * a detail window's deep link measured 5,992 against that 6,000 and reached\n * the address bar at 7,416, because the host's panel-URL writer re-serializes\n * the `?panels=` token through `new URLSearchParams(...).toString()`, which\n * re-escapes every `%` the token's own escaping already produced (a uuid's\n * `-` goes `-` → `%2D` → `%252D`);\n * * a detail PAGE that also carried an open window was 13,433 characters —\n * its own `?l=` plus the merged `panels=` — past the 8 KB request line, so\n * the edge answers 414 and the link is dead (VERIFY-U-P1-R4, NEW-19).\n *\n * 8,000 characters is the strictest edge in front of this platform (an 8 KB\n * request line), and the method, the path, every other parameter and the\n * token's own non-list args share it: a caller passes what the rest of the URL\n * already costs as `reservedBytes` and `trimListContext` measures the FINAL\n * serialized form. No caller may skip it, and the record SAYS what was cut\n * (`trimmedFrom`).\n */\nexport const DETAIL_URL_BUDGET_BYTES = 8000;\n\n/**\n * The hard ceiling on the knob — the number of records the final URL can really\n * carry in EVERY presentation, so a value below it always changes what travels.\n *\n * 🚨 NEW-20 (VERIFY-U-P1-R4). The ceiling was 500 while the byte budget stopped\n * a uuid list at 139, so every value an administrator could set from 139 to 500\n * behaved identically and the knob's own live basis text told them 200 records\n * would travel. Measured against the final URL: 100 uuid entries under a short\n * type token cost ~6.7 KB in the window's `?panels=` token (the strictest\n * spelling) and ~4.3 KB in the page query, both inside the budget above; 150\n * costs ~10 KB in the token and does not fit. matrx-frontend's\n * `migrations/detail_list_context_max_deliverable.sql` lowers the knob's own\n * `max_value` and default to the same number and rewrites its basis text, so\n * the settings screen cannot offer records the URL can never carry. A longer\n * type token still trims below the cap — and still says so.\n */\nexport const DETAIL_LIST_CONTEXT_MAX_IDS_CEILING = 100;\n\n/**\n * The knob's value as a usable cap; the default for anything that is not one,\n * and never more than the ceiling the byte budget can honour (NEW-12). The ONE\n * place the knob is turned into a number — the page href builder, the window's\n * deep-link token and the trim itself all read it through here.\n */\nexport function detailListContextMax(raw: unknown): number {\n const value = typeof raw === \"number\" ? raw : Number(raw);\n const asked = Number.isFinite(value) && value >= 1\n ? Math.floor(value)\n : DEFAULT_DETAIL_LIST_CONTEXT_MAX;\n return Math.min(asked, DETAIL_LIST_CONTEXT_MAX_IDS_CEILING);\n}\n\n/** The per-type entry for `type`, when the map holds a usable one. */\nexport function presentationForTypeFromMap(\n raw: unknown,\n type: string,\n): DetailPresentation | undefined {\n if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) return undefined;\n const value = (raw as Record<string, unknown>)[type];\n return isDetailPresentation(value) ? value : undefined;\n}\n\nexport function isDetailPresentation(value: unknown): value is DetailPresentation {\n return (\n typeof value === \"string\" &&\n (DETAIL_PRESENTATIONS as readonly string[]).includes(value)\n );\n}\n\n/** A record, named the way the platform names records: type token + id. */\nexport interface DetailRef {\n type: string;\n id: string;\n}\n\n/** What the opener already knows, shown instantly until the row loads. */\nexport interface DetailSeed {\n name?: string | null;\n about?: string | null;\n}\n\n/**\n * The list the record was opened FROM, so `[` / `]` move to the previous /\n * next record without going back to the list. Plain data — it travels through\n * the host's state store and the URL.\n */\nexport interface DetailListContext {\n items: DetailRef[];\n index: number;\n /**\n * Set when this list is a WINDOW cut out of a longer one (the page\n * presentation carries the list in the URL and it is capped —\n * `ui.detail.list_context_max_ids`): the length of the list it was cut from,\n * so the detail can say the neighbours beyond it are not reachable from here\n * instead of presenting the window as the whole list (NEW-7).\n */\n trimmedFrom?: number;\n}\n\n/** What `useOpenDetail` accepts. */\nexport interface DetailOpenRequest extends DetailRef {\n /** Skip the setting and open this way. Omit to honour the user's setting. */\n presentation?: DetailPresentation;\n seed?: DetailSeed | null;\n list?: DetailListContext | null;\n}\n\n/** The payload a presentation receives — everything an open request carried. */\nexport interface DetailInstanceData extends DetailRef {\n seed: DetailSeed | null;\n list: DetailListContext | null;\n}\n\n// ─── The record-type contract ───────────────────────────────────────────────\n\nexport type DetailRow = Record<string, unknown>;\n\nexport type DetailLoadResult<Row extends DetailRow = DetailRow> =\n | { row: Row }\n | { notFound: true };\n\n/** One rendered field. `ref` makes the value a door instead of text. */\nexport interface DetailField {\n key: string;\n label: string;\n text: string;\n /** Render in a monospace block (JSON, long identifiers). */\n mono?: boolean;\n /**\n * This value names another record — render it as a door.\n *\n * 🚨 AND THE DOOR SAYS WHAT IT OPENS (chair, 2026-09-18). The body used to\n * render the door from `ref.id` alone, so a field whose `text` was\n * \"Brightwater Robotics\" printed a truncated uuid instead: a door nobody can read is\n * half a dead end. `name` is the record's own name for the host's `RefCell` to\n * render; when it is omitted the body falls back to `text` (unless `text` is\n * the id itself, in which case there is nothing better to say).\n */\n ref?: { token: string; id: string; name?: string | null } | null;\n}\n\n/**\n * The \"source health strip\" for a synced record — where it came from, when it\n * was last refreshed, whether the grant behind it still works, and how to open\n * it at the source. Absent for records the platform owns outright.\n */\nexport interface DetailSourceHealth {\n /** Human name of the source (\"Google Drive\", \"Search Console\"). */\n source: string;\n lastRefreshedAt?: string | null;\n /**\n * 🚨 THE GRANT-STATE VOCABULARY. Five of the six say what is wrong with the\n * CONNECTION; `blocked` says the record cannot be refreshed and NOTHING the\n * person can click repairs it (chair, 2026-09-18).\n *\n * - `ok` → the grant behind this record works;\n * - `expired` → it lapsed and a reconnect renews it;\n * - `revoked` → someone withdrew it and a reconnect re-grants it;\n * - `missing` → we never had it, or the record is not shared with us;\n * - `unknown` → we could not check (a read failed) — reconnecting is safe;\n * - `blocked` → the source itself refuses, and no control repairs it: a\n * provider outage, a quota, a platform-configuration fault,\n * a connection the server reports as unavailable. The strip\n * says BLOCKED, states the reason, and offers NO Reconnect —\n * which `reconnectFor` enforces for this word whatever the\n * producer or the host supplies, because a button that\n * cannot work is a control that does nothing (law 4).\n *\n * `unknown` used to carry both meanings, so a blocked record was offered the\n * consent window it could not be repaired by (VERIFY-U-P1-R5, N1's class).\n */\n grant: \"ok\" | \"expired\" | \"revoked\" | \"missing\" | \"unknown\" | \"blocked\";\n /** One sentence for the grant state, in the person's language. */\n grantDetail?: string | null;\n openAtSourceHref?: string | null;\n /**\n * The per-record label for the \"open at source\" control, when the\n * registration knows it precisely (chair, 2026-09-18). `DetailBody` derives\n * a label from `source` on its own (see `deriveOpenAtSourceLabel`) for a\n * producer that has no opinion, but that derivation guesses from a family\n * name (\"Google Docs, Sheets & Drive files\" → \"Open in Google\") — a\n * registration that knows the row's exact kind (a Doc vs. a Sheet vs. a\n * plain Drive file; a Calendar event) sets this instead and it wins.\n */\n openAtSourceLabel?: string;\n /**\n * The repair offered when the grant is not `ok`. 🚨 THREE-VALUED, AND THE\n * THREE MEAN DIFFERENT THINGS (Bugbot round 18 on frontend PR 228):\n *\n * - a function → offer THIS action;\n * - `null` → offer NOTHING: the producer has decided a reconnect\n * would not repair this refusal (a share the owner must\n * grant, a record we own, an unclassified failure) and a\n * Reconnect button would be a control that does nothing;\n * - omitted → the producer has no opinion; the host's `reconnectSource`\n * port supplies the action when it has one.\n *\n * `useDetailHealth` honours `null` explicitly — it used to fill the host\n * action back in with `??`, which reads `null` as \"unset\", so a synced record\n * whose refusal a reconnect cannot fix still showed Reconnect.\n */\n onReconnect?: (() => void) | null;\n /** Offered when the record can be refreshed on demand. */\n onRefresh?: (() => void | Promise<void>) | null;\n}\n\n/** What a `health` producer is told about the record it is answering for. */\nexport interface DetailHealthContext {\n ref: DetailRef;\n /** Aborted when the record changes or the detail closes. */\n signal: AbortSignal;\n}\n\n/**\n * The health producer: sync or async, and free to answer `null` for a record\n * that is not synced. A throw or a rejection is rendered as an honest \"we could\n * not check the connection\" strip, never as silence (`useDetailHealth`).\n */\nexport type DetailHealthProducer<Row extends DetailRow = DetailRow> = (\n row: Row,\n ctx: DetailHealthContext,\n) => DetailSourceHealth | null | Promise<DetailSourceHealth | null>;\n\n/** An extra section a record type adds under the fixed ones. */\nexport interface DetailSection {\n id: string;\n label: string;\n content: ReactNode;\n}\n\n/** What a registration's optional `Frame` and sections receive. */\nexport interface DetailFrameContext {\n ref: DetailRef;\n recordType: DetailRecordType;\n title: string;\n about: string | null;\n status: DetailStatus;\n fields: DetailField[];\n presentation: DetailPresentation;\n}\n\n/**\n * ONE registration per record type. The core component (header + fixed\n * sections) is derived from this; a record type never writes presentation\n * code. Everything optional degrades to \"section absent\", never to a dead\n * or disabled-looking control.\n */\nexport interface DetailRecordType<Row extends DetailRow = DetailRow> {\n /** The type token this registration answers for (as opened). */\n type: string;\n /** Human label (\"Task\", \"File\"). */\n label: string;\n icon: ComponentType<{ className?: string }>;\n /** Accent classes for the icon and type chip; semantic tokens only. */\n accent?: { text: string; bg: string; ring: string } | null;\n /**\n * The canonical entity-registry token for doors, associations and history.\n * `null` when the type is not a registry entity — the header then offers\n * copy-id only, and the associations / history sections are absent.\n */\n entityToken: string | null;\n /**\n * Load the full row. `null` when the type has no single canonical source\n * (the detail then renders from the seed alone and says so).\n */\n load: ((id: string, signal: AbortSignal) => Promise<DetailLoadResult<Row>>) | null;\n /** The title to show; receives the loaded row (or null) and the seed. */\n title: (row: Row | null, seed: DetailSeed | null) => string;\n /**\n * 🚨 WHAT THIS ROW IS, WHEN THE TYPE CANNOT SAY (chair, 2026-09-18). `label` is\n * per TYPE, and a registered type is regularly a family: `crm.party` holds\n * 1,432 companies and 460 persons, so a type labelled \"Person\" told a person\n * that a company is a person — twice on screen, above a field reading\n * `organization` (VERIFY-U-P1-R5, N6). A registration that can tell the\n * difference from the row answers here.\n *\n * It names the RECORD, so it is used for the header's type chip and for the\n * stand-in titles (\"Company 3f2a1b9c\") only. The presentation pane keeps the\n * type-level `label`, because \"Every contact record now opens as a window\" is a\n * statement about the TYPE's setting, not about the row on screen.\n *\n * `null` (or omitted) falls back to `label`, then to the raw type token.\n */\n labelForRow?: (row: Row | null) => string | null;\n /** The field list for the fields section. */\n fields: (row: Row) => DetailField[];\n /**\n * 🚨 THE SOURCE HEALTH STRIP'S PRODUCER. Source health for a SYNCED record;\n * omit it, or answer `null`, for a record the platform owns outright.\n *\n * It may be ASYNC, because whether the grant behind a synced record still works\n * is not in the row — it is the connector's recorded per-capability health, read\n * from the server. The host wires this field when it registers the record type\n * (never a second reader of the connector's health), and `useDetailHealth`\n * resolves it. Until 2026-09-17 nothing set it, so the strip could not render on\n * any record and the promise that a refusal anywhere shows on every dependent\n * record with the same Reconnect had no witness (VERIFY-U-P1-R4).\n */\n health?: DetailHealthProducer<Row> | null;\n /**\n * Tokens the associations section shows. `null` hides the section.\n * Omitted → the host's default set minus this record's own token.\n */\n associationTokens?: string[] | null;\n /** Show the history section (row versions). Default: when `entityToken` is set. */\n history?: boolean;\n /** Extra sections under the fixed ones. */\n extraSections?: ((row: Row | null, ctx: DetailFrameContext) => DetailSection[]) | null;\n /**\n * Wraps the whole body (surface runtime, right-click menu, …). Receives the\n * live context so a host can expose the dossier to its agents. Optional.\n */\n Frame?: ComponentType<{ ctx: DetailFrameContext; children: ReactNode }> | null;\n}\n\nexport type DetailStatus =\n | \"loading\"\n | \"ready\"\n | \"not-found\"\n | \"error\"\n /** The type has no `load` — rendered from the seed alone. */\n | \"none\";\n\nexport type DetailLoadState<Row extends DetailRow = DetailRow> =\n | { status: \"loading\" }\n | { status: \"ready\"; row: Row }\n | { status: \"not-found\" }\n | { status: \"error\"; message: string }\n | { status: \"none\" };\n\n/** One row of the history section. */\nexport interface DetailHistoryEntry {\n version: number;\n operation: string;\n actorId: string | null;\n occurredAt: string;\n isCurrent: boolean;\n}\n","// @ai-matrx/detail — src/listContext.ts\n//\n// 🚨 NEW-7 / NEW-12 — THE LIST A RECORD WAS OPENED FROM IS BOUNDED BEFORE IT\n// RIDES ANY URL, BY RECORDS *AND* BY BYTES, IN ONE PLACE.\n//\n// The page presentation carries its list context in the query string\n// (`?l=type.id,…&i=<n>`) and a detail WINDOW carries the same list in its\n// `?panels=detail:…` token, so the arrows and `[` / `]` keep working after a\n// record is opened as a page and after that window is refreshed (NEW-15).\n// Uncapped, a 500-row list produced a >20 KB href — past every practical\n// request-line limit (VERIFY-U-P1-R2, break attempt 5).\n//\n// The record cap is a KNOB, not a constant: every ceiling on this platform is a\n// row an admin can change (`ui.detail.list_context_max_ids`, organization-\n// overridable). But a record count is not a length: at 2000 — the value the\n// knob's own `max_value` used to permit — the query was 84 KB, and even at 200 a\n// 30-character type token gave 13.6 KB (VERIFY-U-P1-R3, NEW-12). So the trim\n// obeys BOTH: the knob's record cap, and `DETAIL_URL_BUDGET_BYTES` measured on\n// the FINAL SERIALIZED URL. Whichever bites first, the result carries the WINDOW\n// AROUND THE CURRENT RECORD — the far ends of a long list are not what the arrows\n// are for — and says the list was trimmed (`trimmedFrom`) rather than presenting\n// the window as the whole thing.\n//\n// 🚨 NEW-19 (VERIFY-U-P1-R4) — AND THE BUDGET IS THE WHOLE URL'S, AFTER THE LAST\n// ESCAPING. It used to be a 6,000-character allowance for the list VALUE alone,\n// measured before `URLSearchParams` re-escaped it: a window deep link that\n// measured 5,992 arrived in the address bar at 7,416, and a detail PAGE that also\n// carried an open window was 13,433 characters — past the 8 KB request line, so\n// the edge answers 414 and the link is dead. A caller now passes what the rest of\n// the address already costs (`reservedBytes`) and a `measure` that returns the\n// FINAL serialized length of the list as that URL will carry it.\n//\n// Nothing here talks to the settings ladder: the host resolves the knob and\n// passes the number in, so the module stays package-shaped.\n\nimport {\n DETAIL_URL_BUDGET_BYTES,\n type DetailListContext,\n type DetailRef,\n} from \"./types\";\n\n/**\n * 🚨 NEW-25 (VERIFY-U-P1-R4) / N4 (VERIFY-U-P1-R5) — THE SEPARATORS ARE ESCAPED\n * WITHOUT A PERCENT, SO NO LATER URL DECODE CAN PUT ONE BACK.\n *\n * `encodeURIComponent` leaves `.` alone and every spelling of a record in a URL\n * splits on the FIRST dot, so `{type: \"gr.ant\", id: \"x.y\"}` came back as\n * `{type: \"gr\", id: \"ant.x.y\"}` — a DIFFERENT record, silently. Round 4 escaped\n * `.` `:` `,` to `%2E` `%3A` `%2C` here, which fixed the `?panels=` token (three\n * escaping layers, three decodes) and did NOT fix the page presentation's `?l=`\n * query: an ordinary query parse — `new URL(...).searchParams`, which is the\n * algorithm the page route's `searchParams` come from — percent-decodes the\n * value BEFORE `decodeListItems` splits it, so `%2E` was a literal `.` again and\n * the dotted token still retargeted the record. An id containing a comma was\n * truncated the same way (VERIFY-U-P1-R5, N4).\n *\n * So the escape carries no `%` at all. A ref part is percent-encoding spelled\n * with `~` as the escape character, over the alphabet `A-Za-z0-9-_~`: it holds\n * no `.`, `:`, `,` or `%`, which makes it invariant under any number of\n * percent-decodes and the split unambiguous on every layer. `~` itself is\n * escaped first (`%7E` → `~7E`), so the grammar is closed.\n */\nconst PERCENT_ESCAPE_TOO = /[.!~*'()]/g;\n\n/** One half of a `type.id` pair, carrying no character the decoders split on. */\nexport function encodeRefPart(value: string): string {\n return encodeURIComponent(value)\n .replace(PERCENT_ESCAPE_TOO, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)\n .replace(/%/g, \"~\");\n}\n\n/** Inverse of `encodeRefPart`; a hand-edited value comes back as its own text. */\nexport function decodeRefPart(value: string): string {\n try {\n return decodeURIComponent(value.replace(/~/g, \"%\"));\n } catch {\n return value;\n }\n}\n\n/** `type.id,type.id` — the ONE spelling of a list in a URL, page or panel token. */\nexport function encodeListItems(items: readonly DetailRef[]): string {\n return items.map((r) => `${encodeRefPart(r.type)}.${encodeRefPart(r.id)}`).join(\",\");\n}\n\n/** Inverse of `encodeListItems`; entries that do not name a record are dropped. */\nexport function decodeListItems(value: string | null | undefined): DetailRef[] {\n if (!value) return [];\n const items: DetailRef[] = [];\n for (const key of value.split(\",\")) {\n const dot = key.indexOf(\".\");\n if (dot <= 0 || dot === key.length - 1) continue;\n items.push({\n type: decodeRefPart(key.slice(0, dot)),\n id: decodeRefPart(key.slice(dot + 1)),\n });\n }\n return items;\n}\n\n/** How many characters these records cost in a URL — what the budget measures. */\nexport function listQueryBytes(items: readonly DetailRef[]): number {\n return encodeListItems(items).length;\n}\n\nexport interface TrimListContextOptions {\n /**\n * The character budget for the whole URL. Defaults to the platform's request\n * line (`DETAIL_URL_BUDGET_BYTES`).\n */\n budgetBytes?: number;\n /**\n * What the rest of the FINAL address already costs — the path, the query it is\n * being merged into, the token's own non-list args. The list gets what is\n * left, never a private allowance of its own (NEW-19).\n */\n reservedBytes?: number;\n /**\n * How many characters these records cost IN THE FINAL URL. The `?panels=`\n * token escapes every separator and `URLSearchParams` escapes the result\n * again, so the same records cost about 1.6× there what they cost in the page\n * query (NEW-15, NEW-19).\n */\n measure?: (items: readonly DetailRef[]) => number;\n}\n\n/**\n * The list as it may travel, plus what was left behind. Returns the same list\n * when it already fits both limits, and `null` for no list at all.\n */\nexport function trimListContext(\n list: DetailListContext | null | undefined,\n max: number,\n options: TrimListContextOptions = {},\n): DetailListContext | null {\n if (!list || list.items.length === 0) return null;\n const budget =\n (options.budgetBytes ?? DETAIL_URL_BUDGET_BYTES) - (options.reservedBytes ?? 0);\n const measure = options.measure ?? listQueryBytes;\n const total = list.items.length;\n const index = list.index >= 0 && list.index < total ? list.index : 0;\n const idCap = Number.isFinite(max) && max >= 1 ? Math.floor(max) : 1;\n\n /** The widest window of `size` records that still contains the current one. */\n const windowOf = (size: number): { items: DetailRef[]; index: number } => {\n const half = Math.floor(size / 2);\n const start = Math.min(Math.max(index - half, 0), Math.max(total - size, 0));\n return { items: list.items.slice(start, start + size), index: index - start };\n };\n\n let size = Math.min(idCap, total);\n // Shrink until the encoded value fits the byte budget. One record always\n // travels: the record being shown is the list's own current position, and a\n // single entry is ~41 characters against a 6000-character budget.\n while (size > 1 && measure(windowOf(size).items) > budget) {\n const fitted = Math.max(\n 1,\n Math.floor((size * budget) / Math.max(measure(windowOf(size).items), 1)),\n );\n size = fitted < size ? fitted : size - 1;\n }\n const cut = windowOf(size);\n const trimmedFrom = size < total ? total : list.trimmedFrom;\n return {\n items: cut.items,\n index: cut.index,\n ...(trimmedFrom && trimmedFrom > cut.items.length ? { trimmedFrom } : {}),\n };\n}\n","// @ai-matrx/detail — src/presentation.ts\n//\n// Pure helpers: the deep-link spelling of every presentation, and the in-URL\n// instance key. Deep links ride the host's ONE panel URL mechanism (`?panels=`\n// in matrx-frontend), under the type key `detail`, so every client that\n// already opens panels from a URL reaches every presentation with no new code:\n//\n// ?panels=detail:<type>.<id>:as-window\n// ?panels=detail:<type>.<id>:as-docked\n//\n// `page` is a route (the host's `navigate.pageHref`), never a `?panels=` token\n// — it is the one presentation that changes the URL. Its list context rides\n// the query (`?l=type.id,…&i=<n>&lt=<total>`); that spelling lives HERE too,\n// beside the token's, so both are cut by the ONE trim and the ONE budget.\n\nimport {\n decodeListItems,\n decodeRefPart,\n encodeListItems,\n encodeRefPart,\n trimListContext,\n} from \"./listContext\";\nimport {\n DEFAULT_DETAIL_LIST_CONTEXT_MAX,\n DETAIL_URL_BUDGET_BYTES,\n type DetailListContext,\n type DetailPresentation,\n type DetailRef,\n} from \"./types\";\n\nexport const DETAIL_URL_TYPE_KEY = \"detail\";\nexport const DETAIL_URL_AS_ARG = \"as\";\n/** The list the window was opened from, so a REFRESH keeps it (NEW-15). */\nexport const DETAIL_URL_LIST_ARG = \"l\";\nexport const DETAIL_URL_INDEX_ARG = \"i\";\nexport const DETAIL_URL_LIST_TOTAL_ARG = \"lt\";\n\n/**\n * `type.id` — the panel URL splits tokens on `:` and args on `-`/`_`, never on\n * `.`, so the two halves carry no `.` `:` or `,` of their own.\n *\n * 🚨 NEW-25 (VERIFY-U-P1-R4) — the halves used to be raw, so a type token or an\n * id containing a dot came back as a DIFFERENT record: `{type: \"gr.ant\", id:\n * \"x.y\"}` parsed as `{type: \"gr\", id: \"ant.x.y\"}`. One encoder, one decoder\n * (`encodeRefPart` / `decodeRefPart`), the same pair the list spelling uses.\n */\nexport function detailInstanceKey(ref: DetailRef): string {\n return `${encodeRefPart(ref.type)}.${encodeRefPart(ref.id)}`;\n}\n\n/** Inverse of `detailInstanceKey`; `null` when the key is not `type.id`. */\nexport function parseDetailInstanceKey(key: string | null | undefined): DetailRef | null {\n if (!key) return null;\n const dot = key.indexOf(\".\");\n if (dot <= 0 || dot === key.length - 1) return null;\n return { type: decodeRefPart(key.slice(0, dot)), id: decodeRefPart(key.slice(dot + 1)) };\n}\n\n/** Which in-place presentation a URL arg names; `window` when unspecified. */\nexport function presentationFromUrlArg(\n value: string | undefined,\n): Exclude<DetailPresentation, \"page\"> {\n return value === \"docked\" ? \"docked\" : \"window\";\n}\n\n// ─── The list context inside a `?panels=` token (NEW-15) ────────────────────\n//\n// 🚨 NEW-15 (VERIFY-U-P1-R3) — A REFRESHED DETAIL WINDOW KEEPS ITS LIST. The\n// window's token used to carry `as` and nothing else, and the hydrator passed\n// `list: null`, so a reload silently lost the previous / next controls and the\n// \"3/40\" counter. The page URL already carried the list; the token carries the\n// same three values, under the same cap and the same byte budget.\n//\n// THE TOKEN'S GRAMMAR IS THE CONSTRAINT. `?panels=` splits panels on `,`, a\n// panel's args off on `:`, arg pairs on `_` and each pair on its first `-`. A\n// uuid is full of hyphens and a list is full of commas, so a list value is\n// escaped into a form holding none of those four characters: `encodeURIComponent`\n// plus the unreserved characters it leaves alone. The result is percent-escaped\n// again by `URLSearchParams` on the way into the address bar and decoded once on\n// the way back out, which is exactly how the page query's comma already travels.\n\nconst EXTRA_ESCAPES = /[-_.!~*'()]/g;\n\n/** A value safe to carry as a `?panels=` arg — no `,` `:` `_` or `-`. */\nexport function encodePanelArgValue(value: string): string {\n return encodeURIComponent(value).replace(\n EXTRA_ESCAPES,\n (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,\n );\n}\n\n/** Inverse of `encodePanelArgValue`. */\nexport function decodePanelArgValue(value: string): string {\n try {\n return decodeURIComponent(value);\n } catch {\n // A hand-edited link. Better the raw text than a thrown hydrator.\n return value;\n }\n}\n\n/**\n * 🚨 NEW-19 (VERIFY-U-P1-R4) — MEASURE THE VALUE AS THE ADDRESS BAR CARRIES IT.\n * The token's own escaping is only the first layer: the host's panel-URL writer\n * serializes the whole `?panels=` value through `new URLSearchParams(...).toString()`,\n * which escapes every `%` again (a uuid's `-` goes `-` → `%2D` → `%252D`, three\n * characters becoming five). Measuring the token's spelling said 5,992 for a URL\n * the browser carried at 7,416.\n */\nexport function panelListValueBytes(items: readonly DetailRef[]): number {\n return encodeURIComponent(encodePanelArgValue(encodeListItems(items))).length;\n}\n\n/**\n * The length of the FINAL address a detail window's deep link produces: the path\n * and query it is merged into, plus the `panels` parameter as\n * `URLSearchParams` serializes it. What the budget is measured against, and what\n * a guard can assert.\n */\nexport function finalPanelUrlLength(\n pathAndQuery: string,\n tokenWithoutListArgs: string,\n listArgs: Record<string, string>,\n): number {\n const args = Object.entries(listArgs)\n .map(([key, value]) => `${key}-${value}`)\n .join(\"_\");\n const token = args ? `${tokenWithoutListArgs}_${args}` : tokenWithoutListArgs;\n const [path = \"\", existingQuery = \"\"] = pathAndQuery.split(\"?\");\n const params = new URLSearchParams(existingQuery);\n params.set(\"panels\", token);\n return `${path}?${params.toString()}`.length;\n}\n\n/**\n * What the address a panel token is written INTO really costs, after\n * `URLSearchParams` re-serializes it — a detail page's own `?l=` list carries raw\n * commas that become `%2C` the moment a `panels` parameter is merged beside it,\n * so the page's query grows by ~2× its comma count at the instant the window\n * opens. Reserving the pre-serialization length is how a page + window address\n * still overran the budget by 70 characters in the guard that found this.\n */\nexport function panelUrlReserveBytes(pathAndQuery: string): number {\n const [path = \"\", query = \"\"] = pathAndQuery.split(\"?\");\n const serialized = new URLSearchParams(query).toString();\n return path.length + (serialized ? serialized.length + 1 : 0);\n}\n\n/**\n * What the token costs beside the list: `panels=detail:<type>.<id>:as-window`\n * plus the `_i-<n>_lt-<n>` args and their escaping. Measured generously so the\n * budget is never overspent by the fixed part.\n */\nconst PANEL_TOKEN_FIXED_BYTES = 160;\n\n/**\n * The reserve for a caller that cannot see the address it writes into. A detail\n * PAGE carrying its own capped list is the longest address a window is opened\n * from, so the reserve is that: the budget's own share for a page query. Without\n * it, a window opened from a detail page produced a 13,433-character URL the edge\n * refuses (NEW-19).\n */\nconst DEFAULT_PANEL_URL_RESERVE_BYTES = Math.floor(DETAIL_URL_BUDGET_BYTES / 2);\n\n/**\n * The window's deep-link args for a list context — `{}` when there is no list.\n * `max` is the resolved `ui.detail.list_context_max_ids`; the budget is the\n * platform's REQUEST LINE, measured on the final serialized value, with whatever\n * the rest of the address already costs reserved (NEW-19). A caller that knows\n * the address it is writing into passes its length; one that does not gets the\n * conservative reserve above, which is what a long detail-page query costs.\n */\nexport function detailListToUrlArgs(\n list: DetailListContext | null | undefined,\n max: number,\n options: { reservedBytes?: number } = {},\n): Record<string, string> {\n const reserved =\n options.reservedBytes ?? DEFAULT_PANEL_URL_RESERVE_BYTES;\n const capped = trimListContext(list, max, {\n measure: panelListValueBytes,\n reservedBytes: reserved + PANEL_TOKEN_FIXED_BYTES,\n });\n if (!capped) return {};\n const args: Record<string, string> = {\n [DETAIL_URL_LIST_ARG]: encodePanelArgValue(encodeListItems(capped.items)),\n [DETAIL_URL_INDEX_ARG]: String(capped.index),\n };\n if (capped.trimmedFrom) args[DETAIL_URL_LIST_TOTAL_ARG] = String(capped.trimmedFrom);\n return args;\n}\n\n/** The list a `?panels=detail:` token carries; `null` when it carries none. */\nexport function detailListFromUrlArgs(\n args: Record<string, string> | null | undefined,\n): DetailListContext | null {\n const raw = args?.[DETAIL_URL_LIST_ARG];\n if (!raw) return null;\n const items = decodeListItems(decodePanelArgValue(raw));\n if (items.length === 0) return null;\n const index = Number.parseInt(args?.[DETAIL_URL_INDEX_ARG] ?? \"\", 10);\n const total = Number.parseInt(args?.[DETAIL_URL_LIST_TOTAL_ARG] ?? \"\", 10);\n return {\n items,\n index: Number.isFinite(index) && index >= 0 && index < items.length ? index : 0,\n ...(Number.isFinite(total) && total > items.length ? { trimmedFrom: total } : {}),\n };\n}\n\n// ─── The list context in the PAGE presentation's query ──────────────────────\n//\n// `/detail/<type>/<id>?l=type.id,type.id&i=<index>&lt=<total>` — the same\n// `type.id` instance key the `?panels=detail:` deep link uses. `lt` is present\n// only when the list was TRIMMED to fit the URL (NEW-7): it is the length of the\n// list the window was cut from, so the detail can say so. The host owns the\n// PATH (`navigate.pageHref`) and appends what `encodeListQuery` returns.\n\n/** `?l=`, `&i=`, `&lt=` and their values — what the query costs beside the list. */\nconst PAGE_QUERY_FIXED_BYTES = 40;\n\n/**\n * 🚨 NEW-7 — CAPPED, ALWAYS. `max` is the resolved\n * `ui.detail.list_context_max_ids` knob; the default is used when the host has\n * no answer yet, never \"no cap\". A 500-row list uncapped produced a >20 KB href\n * no server accepts (VERIFY-U-P1-R2).\n *\n * 🚨 NEW-19 (VERIFY-U-P1-R4) — AND THE BUDGET IS THE WHOLE URL'S. The path this\n * query hangs off is part of the request line, so the caller passes what it\n * costs (`reservedBytes`) and the list gets what is left. Measuring the list\n * alone is how a detail page reached 8,464 characters while the code claimed a\n * 2 KB margin.\n */\nexport function encodeListQuery(\n list: DetailListContext | null | undefined,\n max: number = DEFAULT_DETAIL_LIST_CONTEXT_MAX,\n options: { reservedBytes?: number } = {},\n): string {\n const capped = trimListContext(list, max, {\n reservedBytes: (options.reservedBytes ?? 0) + PAGE_QUERY_FIXED_BYTES,\n });\n if (!capped) return \"\";\n // Each `type.id` is encoded, the separators are not: a comma is legal in a\n // query value, and `%2C` × 200 was 400 bytes of nothing.\n const l = encodeListItems(capped.items);\n const trimmed = capped.trimmedFrom ? `&lt=${capped.trimmedFrom}` : \"\";\n return `?l=${l}&i=${capped.index}${trimmed}`;\n}\n\n/** Inverse of `encodeListQuery`, from the three query values. */\nexport function decodeListQuery(\n l: string | null | undefined,\n i: string | null | undefined,\n /** `lt` — the length of the list this window was cut from, when it was. */\n lt?: string | null | undefined,\n): DetailListContext | null {\n if (!l) return null;\n const items = decodeListItems(l);\n if (items.length === 0) return null;\n const index = Number.parseInt(i ?? \"\", 10);\n const total = Number.parseInt(lt ?? \"\", 10);\n return {\n items,\n index: Number.isFinite(index) && index >= 0 && index < items.length ? index : 0,\n ...(Number.isFinite(total) && total > items.length ? { trimmedFrom: total } : {}),\n };\n}\n","// @ai-matrx/detail — src/format.ts\n//\n// Generic row → fields formatting, lifted verbatim from the item detail\n// window it replaced so every registry-known record renders exactly as it did.\n\nimport type { DetailField } from \"./types\";\nimport { humanizeIdentifier } from \"@ai-matrx/kit/text-case\";\n\n/** Columns that are pure plumbing — hidden from a formatted field list. */\nexport const HIDDEN_FIELDS = new Set([\n \"id\",\n \"user_id\",\n \"organization_id\",\n \"created_by\",\n \"updated_by\",\n \"embedding\",\n \"search_vector\",\n \"tsv\",\n]);\n\nconst ISO_RE = /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/;\n\n\nexport function formatValue(value: unknown): { text: string; mono?: boolean } | null {\n if (value === null || value === undefined) return null;\n if (typeof value === \"boolean\") return { text: value ? \"Yes\" : \"No\" };\n if (typeof value === \"number\") {\n return { text: Number.isFinite(value) ? value.toLocaleString() : String(value) };\n }\n if (typeof value === \"string\") {\n const t = value.trim();\n if (!t) return null;\n if (ISO_RE.test(t)) {\n const d = new Date(t);\n if (!Number.isNaN(d.getTime())) {\n return { text: d.toLocaleString([], { dateStyle: \"medium\", timeStyle: \"short\" }) };\n }\n }\n return { text: t };\n }\n try {\n const json = JSON.stringify(value, null, 2);\n if (!json || json === \"{}\" || json === \"[]\") return null;\n return { text: json, mono: true };\n } catch {\n return { text: String(value), mono: true };\n }\n}\n\n/**\n * Every populated scalar column of a row as a field; a `<token>_id` column\n * holding a real uuid becomes a door (the host decides which token names a\n * registry entity — a wrong link is worse than no link).\n */\nexport function fieldsFromRow(\n row: Record<string, unknown>,\n doors: {\n tokenFromColumnName: (column: string) => string | null;\n isUuidValue: (value: unknown) => value is string;\n },\n): DetailField[] {\n const out: DetailField[] = [];\n for (const [key, raw] of Object.entries(row)) {\n if (HIDDEN_FIELDS.has(key)) continue;\n const formatted = formatValue(raw);\n if (!formatted) continue;\n const token = doors.isUuidValue(raw) ? doors.tokenFromColumnName(key) : null;\n out.push({\n key,\n label: humanizeIdentifier(key) || key,\n text: formatted.text,\n ...(formatted.mono ? { mono: true } : {}),\n ref: token ? { token, id: raw as string } : null,\n });\n }\n return out;\n}\n\nexport function formatWhen(iso: string): string {\n const d = new Date(iso);\n if (Number.isNaN(d.getTime())) return iso;\n return d.toLocaleString([], { dateStyle: \"medium\", timeStyle: \"short\" });\n}\n"],"mappings":"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","names":["size"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/types.ts","../src/listContext.ts","../src/presentation.ts","../src/format.ts"],"sourcesContent":["// @ai-matrx/detail — the PURE root.\n//\n// The record-type contract, the presentation setting shape and its per-type\n// override, the list-context trim with the final-URL byte budget, the\n// deep-link spelling of every presentation, and the row → fields formatter.\n// Nothing here touches React at run time (types only), so this entry is legal\n// inside a React Server Component; the React product is `@ai-matrx/detail/react`.\n\nexport {\n DEFAULT_DETAIL_LIST_CONTEXT_MAX,\n DETAIL_LIST_CONTEXT_MAX_IDS_CEILING,\n DETAIL_LIST_CONTEXT_MAX_KNOB,\n DETAIL_PRESENTATIONS,\n DETAIL_PRESENTATION_BY_TYPE_KNOB,\n DETAIL_PRESENTATION_KNOB,\n DETAIL_URL_BUDGET_BYTES,\n detailListContextMax,\n isDetailPresentation,\n presentationForTypeFromMap,\n} from \"./types\";\nexport type {\n DetailField,\n DetailFrameContext,\n DetailHealthContext,\n DetailHealthProducer,\n DetailHistoryEntry,\n DetailInstanceData,\n DetailListContext,\n DetailLoadResult,\n DetailLoadState,\n DetailOpenRequest,\n DetailPresentation,\n DetailRecordType,\n DetailRef,\n DetailRow,\n DetailSection,\n DetailSeed,\n DetailSourceHealth,\n DetailStatus,\n} from \"./types\";\n\nexport {\n decodeListItems,\n decodeRefPart,\n encodeListItems,\n encodeRefPart,\n listQueryBytes,\n trimListContext,\n} from \"./listContext\";\nexport type { TrimListContextOptions } from \"./listContext\";\n\nexport {\n DETAIL_URL_AS_ARG,\n DETAIL_URL_INDEX_ARG,\n DETAIL_URL_LIST_ARG,\n DETAIL_URL_LIST_TOTAL_ARG,\n DETAIL_URL_TYPE_KEY,\n decodeListQuery,\n decodePanelArgValue,\n detailInstanceKey,\n detailListFromUrlArgs,\n detailListToUrlArgs,\n encodeListQuery,\n encodePanelArgValue,\n finalPanelUrlLength,\n panelListValueBytes,\n panelUrlReserveBytes,\n parseDetailInstanceKey,\n presentationFromUrlArg,\n} from \"./presentation\";\n\nexport { HIDDEN_FIELDS, fieldsFromRow, formatValue, formatWhen } from \"./format\";\n","// @ai-matrx/detail — src/types.ts\n//\n// The Detail primitive's contract. ONE core per record type; the wrapper\n// yields three presentations (window | docked | page) from that one\n// registration. Nothing in this file knows how a record is fetched, how a\n// window is drawn, or which app hosts it — every app-specific capability\n// arrives through `DetailHostPorts` (see react/host.tsx). This module imports\n// React TYPES only and is legal inside a React Server Component.\n\nimport type { ComponentType, ReactNode } from \"react\";\n\n/** The three ways a record detail can show. `window` is the platform default. */\nexport const DETAIL_PRESENTATIONS = [\"window\", \"docked\", \"page\"] as const;\nexport type DetailPresentation = (typeof DETAIL_PRESENTATIONS)[number];\n\n/**\n * The knob every presentation decision reads (`platform.feature_knob`,\n * feature `ui.detail`, key `default_presentation`; seeded by matrx-frontend's\n * `migrations/detail_presentation_knob.sql`). Enum of DETAIL_PRESENTATIONS,\n * overridable by organization and user. The package never reads the settings\n * ladder itself — the host resolves this key and answers through the\n * `usePresentationSetting` / `resolvePresentation` ports.\n */\nexport const DETAIL_PRESENTATION_KNOB = \"ui.detail.default_presentation\";\n\n/**\n * The PER-RECORD-TYPE override of the setting above (`platform.feature_knob`,\n * feature `ui.detail`, key `presentation_by_type`): a json object keyed by\n * record type token, e.g. `{\"file\": \"docked\"}`. Nearest wins over\n * `default_presentation` for the type it names, and the same organization →\n * user ladder resolves it, so an organization can set \"contracts open as a\n * page\" and a person can still choose otherwise for themselves.\n *\n * WHY A SECOND KEY AND NOT THE `table` RUNG. The platform's per-table rung\n * (DD-131, precedence 50) is keyed by a `platform.entity_types` ROW ID, and\n * that table is admin-only by a restrictive policy — a browser cannot read the\n * id, so a `table` rung would be a setting the screen offers and the reader\n * never honours. One json key on the ladder the client already has is the\n * override the settings platform supports today.\n */\nexport const DETAIL_PRESENTATION_BY_TYPE_KNOB = \"ui.detail.presentation_by_type\";\n\n/**\n * How many records of the list a detail was opened FROM may ride the page\n * presentation's URL (`platform.feature_knob`, feature `ui.detail`, key\n * `list_context_max_ids`; seeded by matrx-frontend's\n * `migrations/detail_list_context_max_knob.sql`). Organization-overridable.\n *\n * Why a knob and not a constant: it is a ceiling, and every ceiling here is a\n * row an admin owns (`common-docs/policies/limits-are-knobs-agents-set-them.md`).\n * Why it exists at all: uncapped, a 500-row list produced a >20 KB href no\n * server accepts (NEW-7, VERIFY-U-P1-R2). Beyond the cap the URL carries the\n * window around the current record and the detail says the list was trimmed.\n */\nexport const DETAIL_LIST_CONTEXT_MAX_KNOB = \"ui.detail.list_context_max_ids\";\n\n/**\n * The cap when the knob has not answered yet (a cold cache, a signed-out\n * render, a host that binds no settings ladder). 100 `type.id` pairs with uuid\n * ids is the most the strictest presentation's FINAL URL can carry inside the\n * budget below, and it is far more neighbours than a person arrows through in\n * one sitting.\n */\nexport const DEFAULT_DETAIL_LIST_CONTEXT_MAX = 100;\n\n/**\n * 🚨 NEW-19 (VERIFY-U-P1-R4) — THE BUDGET BELONGS TO THE FINAL URL, ONCE.\n *\n * It used to be 6,000 characters measured on the list VALUE, one escaping layer\n * too early, and nothing bounded the whole address:\n *\n * * a detail window's deep link measured 5,992 against that 6,000 and reached\n * the address bar at 7,416, because the host's panel-URL writer re-serializes\n * the `?panels=` token through `new URLSearchParams(...).toString()`, which\n * re-escapes every `%` the token's own escaping already produced (a uuid's\n * `-` goes `-` → `%2D` → `%252D`);\n * * a detail PAGE that also carried an open window was 13,433 characters —\n * its own `?l=` plus the merged `panels=` — past the 8 KB request line, so\n * the edge answers 414 and the link is dead (VERIFY-U-P1-R4, NEW-19).\n *\n * 8,000 characters is the strictest edge in front of this platform (an 8 KB\n * request line), and the method, the path, every other parameter and the\n * token's own non-list args share it: a caller passes what the rest of the URL\n * already costs as `reservedBytes` and `trimListContext` measures the FINAL\n * serialized form. No caller may skip it, and the record SAYS what was cut\n * (`trimmedFrom`).\n */\nexport const DETAIL_URL_BUDGET_BYTES = 8000;\n\n/**\n * The hard ceiling on the knob — the number of records the final URL can really\n * carry in EVERY presentation, so a value below it always changes what travels.\n *\n * 🚨 NEW-20 (VERIFY-U-P1-R4). The ceiling was 500 while the byte budget stopped\n * a uuid list at 139, so every value an administrator could set from 139 to 500\n * behaved identically and the knob's own live basis text told them 200 records\n * would travel. Measured against the final URL: 100 uuid entries under a short\n * type token cost ~6.7 KB in the window's `?panels=` token (the strictest\n * spelling) and ~4.3 KB in the page query, both inside the budget above; 150\n * costs ~10 KB in the token and does not fit. matrx-frontend's\n * `migrations/detail_list_context_max_deliverable.sql` lowers the knob's own\n * `max_value` and default to the same number and rewrites its basis text, so\n * the settings screen cannot offer records the URL can never carry. A longer\n * type token still trims below the cap — and still says so.\n */\nexport const DETAIL_LIST_CONTEXT_MAX_IDS_CEILING = 100;\n\n/**\n * The knob's value as a usable cap; the default for anything that is not one,\n * and never more than the ceiling the byte budget can honour (NEW-12). The ONE\n * place the knob is turned into a number — the page href builder, the window's\n * deep-link token and the trim itself all read it through here.\n */\nexport function detailListContextMax(raw: unknown): number {\n const value = typeof raw === \"number\" ? raw : Number(raw);\n const asked = Number.isFinite(value) && value >= 1\n ? Math.floor(value)\n : DEFAULT_DETAIL_LIST_CONTEXT_MAX;\n return Math.min(asked, DETAIL_LIST_CONTEXT_MAX_IDS_CEILING);\n}\n\n/** The per-type entry for `type`, when the map holds a usable one. */\nexport function presentationForTypeFromMap(\n raw: unknown,\n type: string,\n): DetailPresentation | undefined {\n if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) return undefined;\n const value = (raw as Record<string, unknown>)[type];\n return isDetailPresentation(value) ? value : undefined;\n}\n\nexport function isDetailPresentation(value: unknown): value is DetailPresentation {\n return (\n typeof value === \"string\" &&\n (DETAIL_PRESENTATIONS as readonly string[]).includes(value)\n );\n}\n\n/** A record, named the way the platform names records: type token + id. */\nexport interface DetailRef {\n type: string;\n id: string;\n}\n\n/** What the opener already knows, shown instantly until the row loads. */\nexport interface DetailSeed {\n name?: string | null;\n about?: string | null;\n}\n\n/**\n * The list the record was opened FROM, so `[` / `]` move to the previous /\n * next record without going back to the list. Plain data — it travels through\n * the host's state store and the URL.\n */\nexport interface DetailListContext {\n items: DetailRef[];\n index: number;\n /**\n * Set when this list is a WINDOW cut out of a longer one (the page\n * presentation carries the list in the URL and it is capped —\n * `ui.detail.list_context_max_ids`): the length of the list it was cut from,\n * so the detail can say the neighbours beyond it are not reachable from here\n * instead of presenting the window as the whole list (NEW-7).\n */\n trimmedFrom?: number;\n}\n\n/** What `useOpenDetail` accepts. */\nexport interface DetailOpenRequest extends DetailRef {\n /** Skip the setting and open this way. Omit to honour the user's setting. */\n presentation?: DetailPresentation;\n seed?: DetailSeed | null;\n list?: DetailListContext | null;\n}\n\n/** The payload a presentation receives — everything an open request carried. */\nexport interface DetailInstanceData extends DetailRef {\n seed: DetailSeed | null;\n list: DetailListContext | null;\n}\n\n// ─── The record-type contract ───────────────────────────────────────────────\n\nexport type DetailRow = Record<string, unknown>;\n\nexport type DetailLoadResult<Row extends DetailRow = DetailRow> =\n | { row: Row }\n | { notFound: true };\n\n/** One rendered field. `ref` makes the value a door instead of text. */\nexport interface DetailField {\n key: string;\n label: string;\n text: string;\n /** Render in a monospace block (JSON, long identifiers). */\n mono?: boolean;\n /**\n * This value names another record — render it as a door.\n *\n * 🚨 AND THE DOOR SAYS WHAT IT OPENS (chair, 2026-09-18). The body used to\n * render the door from `ref.id` alone, so a field whose `text` was\n * \"Brightwater Robotics\" printed a truncated uuid instead: a door nobody can read is\n * half a dead end. `name` is the record's own name for the host's `RefCell` to\n * render; when it is omitted the body falls back to `text` (unless `text` is\n * the id itself, in which case there is nothing better to say).\n */\n ref?: { token: string; id: string; name?: string | null } | null;\n}\n\n/**\n * The \"source health strip\" for a synced record — where it came from, when it\n * was last refreshed, whether the grant behind it still works, and how to open\n * it at the source. Absent for records the platform owns outright.\n */\nexport interface DetailSourceHealth {\n /** Human name of the source (\"Google Drive\", \"Search Console\"). */\n source: string;\n lastRefreshedAt?: string | null;\n /**\n * 🚨 THE GRANT-STATE VOCABULARY. Five of the six say what is wrong with the\n * CONNECTION; `blocked` says the record cannot be refreshed and NOTHING the\n * person can click repairs it (chair, 2026-09-18).\n *\n * - `ok` → the grant behind this record works;\n * - `expired` → it lapsed and a reconnect renews it;\n * - `revoked` → someone withdrew it and a reconnect re-grants it;\n * - `missing` → we never had it, or the record is not shared with us;\n * - `unknown` → we could not check (a read failed) — reconnecting is safe;\n * - `blocked` → the source itself refuses, and no control repairs it: a\n * provider outage, a quota, a platform-configuration fault,\n * a connection the server reports as unavailable. The strip\n * says BLOCKED, states the reason, and offers NO Reconnect —\n * which `reconnectFor` enforces for this word whatever the\n * producer or the host supplies, because a button that\n * cannot work is a control that does nothing (law 4).\n *\n * `unknown` used to carry both meanings, so a blocked record was offered the\n * consent window it could not be repaired by (VERIFY-U-P1-R5, N1's class).\n */\n grant: \"ok\" | \"expired\" | \"revoked\" | \"missing\" | \"unknown\" | \"blocked\";\n /** One sentence for the grant state, in the person's language. */\n grantDetail?: string | null;\n openAtSourceHref?: string | null;\n /**\n * The per-record label for the \"open at source\" control, when the\n * registration knows it precisely (chair, 2026-09-18). `DetailBody` derives\n * a label from `source` on its own (see `deriveOpenAtSourceLabel`) for a\n * producer that has no opinion, but that derivation guesses from a family\n * name (\"Google Docs, Sheets & Drive files\" → \"Open in Google\") — a\n * registration that knows the row's exact kind (a Doc vs. a Sheet vs. a\n * plain Drive file; a Calendar event) sets this instead and it wins.\n */\n openAtSourceLabel?: string;\n /**\n * The repair offered when the grant is not `ok`. 🚨 THREE-VALUED, AND THE\n * THREE MEAN DIFFERENT THINGS (Bugbot round 18 on frontend PR 228):\n *\n * - a function → offer THIS action;\n * - `null` → offer NOTHING: the producer has decided a reconnect\n * would not repair this refusal (a share the owner must\n * grant, a record we own, an unclassified failure) and a\n * Reconnect button would be a control that does nothing;\n * - omitted → the producer has no opinion; the host's `reconnectSource`\n * port supplies the action when it has one.\n *\n * `useDetailHealth` honours `null` explicitly — it used to fill the host\n * action back in with `??`, which reads `null` as \"unset\", so a synced record\n * whose refusal a reconnect cannot fix still showed Reconnect.\n */\n onReconnect?: (() => void) | null;\n /** Offered when the record can be refreshed on demand. */\n onRefresh?: (() => void | Promise<void>) | null;\n}\n\n/** What a `health` producer is told about the record it is answering for. */\nexport interface DetailHealthContext {\n ref: DetailRef;\n /** Aborted when the record changes or the detail closes. */\n signal: AbortSignal;\n}\n\n/**\n * The health producer: sync or async, and free to answer `null` for a record\n * that is not synced. A throw or a rejection is rendered as an honest \"we could\n * not check the connection\" strip, never as silence (`useDetailHealth`).\n */\nexport type DetailHealthProducer<Row extends DetailRow = DetailRow> = (\n row: Row,\n ctx: DetailHealthContext,\n) => DetailSourceHealth | null | Promise<DetailSourceHealth | null>;\n\n/** An extra section a record type adds under the fixed ones. */\nexport interface DetailSection {\n id: string;\n label: string;\n content: ReactNode;\n}\n\n/** What a registration's optional `Frame` and sections receive. */\nexport interface DetailFrameContext {\n ref: DetailRef;\n recordType: DetailRecordType;\n title: string;\n about: string | null;\n status: DetailStatus;\n fields: DetailField[];\n presentation: DetailPresentation;\n}\n\n/**\n * ONE registration per record type. The core component (header + fixed\n * sections) is derived from this; a record type never writes presentation\n * code. Everything optional degrades to \"section absent\", never to a dead\n * or disabled-looking control.\n */\nexport interface DetailRecordType<Row extends DetailRow = DetailRow> {\n /** The type token this registration answers for (as opened). */\n type: string;\n /** Human label (\"Task\", \"File\"). */\n label: string;\n icon: ComponentType<{ className?: string }>;\n /** Accent classes for the icon and type chip; semantic tokens only. */\n accent?: { text: string; bg: string; ring: string } | null;\n /**\n * The type's official identity colour (`#rrggbb`, from `@ai-matrx/icons/domain`).\n * When set it wins over `accent`: the icon wears it and the type chip is tinted\n * with it (chip text stays the foreground token).\n */\n color?: string | null;\n /**\n * The canonical entity-registry token for doors, associations and history.\n * `null` when the type is not a registry entity — the header then offers\n * copy-id only, and the associations / history sections are absent.\n */\n entityToken: string | null;\n /**\n * Load the full row. `null` when the type has no single canonical source\n * (the detail then renders from the seed alone and says so).\n */\n load: ((id: string, signal: AbortSignal) => Promise<DetailLoadResult<Row>>) | null;\n /** The title to show; receives the loaded row (or null) and the seed. */\n title: (row: Row | null, seed: DetailSeed | null) => string;\n /**\n * 🚨 WHAT THIS ROW IS, WHEN THE TYPE CANNOT SAY (chair, 2026-09-18). `label` is\n * per TYPE, and a registered type is regularly a family: `crm.party` holds\n * 1,432 companies and 460 persons, so a type labelled \"Person\" told a person\n * that a company is a person — twice on screen, above a field reading\n * `organization` (VERIFY-U-P1-R5, N6). A registration that can tell the\n * difference from the row answers here.\n *\n * It names the RECORD, so it is used for the header's type chip and for the\n * stand-in titles (\"Company 3f2a1b9c\") only. The presentation pane keeps the\n * type-level `label`, because \"Every contact record now opens as a window\" is a\n * statement about the TYPE's setting, not about the row on screen.\n *\n * `null` (or omitted) falls back to `label`, then to the raw type token.\n */\n labelForRow?: (row: Row | null) => string | null;\n /** The field list for the fields section. */\n fields: (row: Row) => DetailField[];\n /**\n * 🚨 THE SOURCE HEALTH STRIP'S PRODUCER. Source health for a SYNCED record;\n * omit it, or answer `null`, for a record the platform owns outright.\n *\n * It may be ASYNC, because whether the grant behind a synced record still works\n * is not in the row — it is the connector's recorded per-capability health, read\n * from the server. The host wires this field when it registers the record type\n * (never a second reader of the connector's health), and `useDetailHealth`\n * resolves it. Until 2026-09-17 nothing set it, so the strip could not render on\n * any record and the promise that a refusal anywhere shows on every dependent\n * record with the same Reconnect had no witness (VERIFY-U-P1-R4).\n */\n health?: DetailHealthProducer<Row> | null;\n /**\n * Tokens the associations section shows. `null` hides the section.\n * Omitted → the host's default set minus this record's own token.\n */\n associationTokens?: string[] | null;\n /** Show the history section (row versions). Default: when `entityToken` is set. */\n history?: boolean;\n /** Extra sections under the fixed ones. */\n extraSections?: ((row: Row | null, ctx: DetailFrameContext) => DetailSection[]) | null;\n /**\n * Wraps the whole body (surface runtime, right-click menu, …). Receives the\n * live context so a host can expose the dossier to its agents. Optional.\n */\n Frame?: ComponentType<{ ctx: DetailFrameContext; children: ReactNode }> | null;\n}\n\nexport type DetailStatus =\n | \"loading\"\n | \"ready\"\n | \"not-found\"\n | \"error\"\n /** The type has no `load` — rendered from the seed alone. */\n | \"none\";\n\nexport type DetailLoadState<Row extends DetailRow = DetailRow> =\n | { status: \"loading\" }\n | { status: \"ready\"; row: Row }\n | { status: \"not-found\" }\n | { status: \"error\"; message: string }\n | { status: \"none\" };\n\n/** One row of the history section. */\nexport interface DetailHistoryEntry {\n version: number;\n operation: string;\n actorId: string | null;\n occurredAt: string;\n isCurrent: boolean;\n}\n","// @ai-matrx/detail — src/listContext.ts\n//\n// 🚨 NEW-7 / NEW-12 — THE LIST A RECORD WAS OPENED FROM IS BOUNDED BEFORE IT\n// RIDES ANY URL, BY RECORDS *AND* BY BYTES, IN ONE PLACE.\n//\n// The page presentation carries its list context in the query string\n// (`?l=type.id,…&i=<n>`) and a detail WINDOW carries the same list in its\n// `?panels=detail:…` token, so the arrows and `[` / `]` keep working after a\n// record is opened as a page and after that window is refreshed (NEW-15).\n// Uncapped, a 500-row list produced a >20 KB href — past every practical\n// request-line limit (VERIFY-U-P1-R2, break attempt 5).\n//\n// The record cap is a KNOB, not a constant: every ceiling on this platform is a\n// row an admin can change (`ui.detail.list_context_max_ids`, organization-\n// overridable). But a record count is not a length: at 2000 — the value the\n// knob's own `max_value` used to permit — the query was 84 KB, and even at 200 a\n// 30-character type token gave 13.6 KB (VERIFY-U-P1-R3, NEW-12). So the trim\n// obeys BOTH: the knob's record cap, and `DETAIL_URL_BUDGET_BYTES` measured on\n// the FINAL SERIALIZED URL. Whichever bites first, the result carries the WINDOW\n// AROUND THE CURRENT RECORD — the far ends of a long list are not what the arrows\n// are for — and says the list was trimmed (`trimmedFrom`) rather than presenting\n// the window as the whole thing.\n//\n// 🚨 NEW-19 (VERIFY-U-P1-R4) — AND THE BUDGET IS THE WHOLE URL'S, AFTER THE LAST\n// ESCAPING. It used to be a 6,000-character allowance for the list VALUE alone,\n// measured before `URLSearchParams` re-escaped it: a window deep link that\n// measured 5,992 arrived in the address bar at 7,416, and a detail PAGE that also\n// carried an open window was 13,433 characters — past the 8 KB request line, so\n// the edge answers 414 and the link is dead. A caller now passes what the rest of\n// the address already costs (`reservedBytes`) and a `measure` that returns the\n// FINAL serialized length of the list as that URL will carry it.\n//\n// Nothing here talks to the settings ladder: the host resolves the knob and\n// passes the number in, so the module stays package-shaped.\n\nimport {\n DETAIL_URL_BUDGET_BYTES,\n type DetailListContext,\n type DetailRef,\n} from \"./types\";\n\n/**\n * 🚨 NEW-25 (VERIFY-U-P1-R4) / N4 (VERIFY-U-P1-R5) — THE SEPARATORS ARE ESCAPED\n * WITHOUT A PERCENT, SO NO LATER URL DECODE CAN PUT ONE BACK.\n *\n * `encodeURIComponent` leaves `.` alone and every spelling of a record in a URL\n * splits on the FIRST dot, so `{type: \"gr.ant\", id: \"x.y\"}` came back as\n * `{type: \"gr\", id: \"ant.x.y\"}` — a DIFFERENT record, silently. Round 4 escaped\n * `.` `:` `,` to `%2E` `%3A` `%2C` here, which fixed the `?panels=` token (three\n * escaping layers, three decodes) and did NOT fix the page presentation's `?l=`\n * query: an ordinary query parse — `new URL(...).searchParams`, which is the\n * algorithm the page route's `searchParams` come from — percent-decodes the\n * value BEFORE `decodeListItems` splits it, so `%2E` was a literal `.` again and\n * the dotted token still retargeted the record. An id containing a comma was\n * truncated the same way (VERIFY-U-P1-R5, N4).\n *\n * So the escape carries no `%` at all. A ref part is percent-encoding spelled\n * with `~` as the escape character, over the alphabet `A-Za-z0-9-_~`: it holds\n * no `.`, `:`, `,` or `%`, which makes it invariant under any number of\n * percent-decodes and the split unambiguous on every layer. `~` itself is\n * escaped first (`%7E` → `~7E`), so the grammar is closed.\n */\nconst PERCENT_ESCAPE_TOO = /[.!~*'()]/g;\n\n/** One half of a `type.id` pair, carrying no character the decoders split on. */\nexport function encodeRefPart(value: string): string {\n return encodeURIComponent(value)\n .replace(PERCENT_ESCAPE_TOO, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)\n .replace(/%/g, \"~\");\n}\n\n/** Inverse of `encodeRefPart`; a hand-edited value comes back as its own text. */\nexport function decodeRefPart(value: string): string {\n try {\n return decodeURIComponent(value.replace(/~/g, \"%\"));\n } catch {\n return value;\n }\n}\n\n/** `type.id,type.id` — the ONE spelling of a list in a URL, page or panel token. */\nexport function encodeListItems(items: readonly DetailRef[]): string {\n return items.map((r) => `${encodeRefPart(r.type)}.${encodeRefPart(r.id)}`).join(\",\");\n}\n\n/** Inverse of `encodeListItems`; entries that do not name a record are dropped. */\nexport function decodeListItems(value: string | null | undefined): DetailRef[] {\n if (!value) return [];\n const items: DetailRef[] = [];\n for (const key of value.split(\",\")) {\n const dot = key.indexOf(\".\");\n if (dot <= 0 || dot === key.length - 1) continue;\n items.push({\n type: decodeRefPart(key.slice(0, dot)),\n id: decodeRefPart(key.slice(dot + 1)),\n });\n }\n return items;\n}\n\n/** How many characters these records cost in a URL — what the budget measures. */\nexport function listQueryBytes(items: readonly DetailRef[]): number {\n return encodeListItems(items).length;\n}\n\nexport interface TrimListContextOptions {\n /**\n * The character budget for the whole URL. Defaults to the platform's request\n * line (`DETAIL_URL_BUDGET_BYTES`).\n */\n budgetBytes?: number;\n /**\n * What the rest of the FINAL address already costs — the path, the query it is\n * being merged into, the token's own non-list args. The list gets what is\n * left, never a private allowance of its own (NEW-19).\n */\n reservedBytes?: number;\n /**\n * How many characters these records cost IN THE FINAL URL. The `?panels=`\n * token escapes every separator and `URLSearchParams` escapes the result\n * again, so the same records cost about 1.6× there what they cost in the page\n * query (NEW-15, NEW-19).\n */\n measure?: (items: readonly DetailRef[]) => number;\n}\n\n/**\n * The list as it may travel, plus what was left behind. Returns the same list\n * when it already fits both limits, and `null` for no list at all.\n */\nexport function trimListContext(\n list: DetailListContext | null | undefined,\n max: number,\n options: TrimListContextOptions = {},\n): DetailListContext | null {\n if (!list || list.items.length === 0) return null;\n const budget =\n (options.budgetBytes ?? DETAIL_URL_BUDGET_BYTES) - (options.reservedBytes ?? 0);\n const measure = options.measure ?? listQueryBytes;\n const total = list.items.length;\n const index = list.index >= 0 && list.index < total ? list.index : 0;\n const idCap = Number.isFinite(max) && max >= 1 ? Math.floor(max) : 1;\n\n /** The widest window of `size` records that still contains the current one. */\n const windowOf = (size: number): { items: DetailRef[]; index: number } => {\n const half = Math.floor(size / 2);\n const start = Math.min(Math.max(index - half, 0), Math.max(total - size, 0));\n return { items: list.items.slice(start, start + size), index: index - start };\n };\n\n let size = Math.min(idCap, total);\n // Shrink until the encoded value fits the byte budget. One record always\n // travels: the record being shown is the list's own current position, and a\n // single entry is ~41 characters against a 6000-character budget.\n while (size > 1 && measure(windowOf(size).items) > budget) {\n const fitted = Math.max(\n 1,\n Math.floor((size * budget) / Math.max(measure(windowOf(size).items), 1)),\n );\n size = fitted < size ? fitted : size - 1;\n }\n const cut = windowOf(size);\n const trimmedFrom = size < total ? total : list.trimmedFrom;\n return {\n items: cut.items,\n index: cut.index,\n ...(trimmedFrom && trimmedFrom > cut.items.length ? { trimmedFrom } : {}),\n };\n}\n","// @ai-matrx/detail — src/presentation.ts\n//\n// Pure helpers: the deep-link spelling of every presentation, and the in-URL\n// instance key. Deep links ride the host's ONE panel URL mechanism (`?panels=`\n// in matrx-frontend), under the type key `detail`, so every client that\n// already opens panels from a URL reaches every presentation with no new code:\n//\n// ?panels=detail:<type>.<id>:as-window\n// ?panels=detail:<type>.<id>:as-docked\n//\n// `page` is a route (the host's `navigate.pageHref`), never a `?panels=` token\n// — it is the one presentation that changes the URL. Its list context rides\n// the query (`?l=type.id,…&i=<n>&lt=<total>`); that spelling lives HERE too,\n// beside the token's, so both are cut by the ONE trim and the ONE budget.\n\nimport {\n decodeListItems,\n decodeRefPart,\n encodeListItems,\n encodeRefPart,\n trimListContext,\n} from \"./listContext\";\nimport {\n DEFAULT_DETAIL_LIST_CONTEXT_MAX,\n DETAIL_URL_BUDGET_BYTES,\n type DetailListContext,\n type DetailPresentation,\n type DetailRef,\n} from \"./types\";\n\nexport const DETAIL_URL_TYPE_KEY = \"detail\";\nexport const DETAIL_URL_AS_ARG = \"as\";\n/** The list the window was opened from, so a REFRESH keeps it (NEW-15). */\nexport const DETAIL_URL_LIST_ARG = \"l\";\nexport const DETAIL_URL_INDEX_ARG = \"i\";\nexport const DETAIL_URL_LIST_TOTAL_ARG = \"lt\";\n\n/**\n * `type.id` — the panel URL splits tokens on `:` and args on `-`/`_`, never on\n * `.`, so the two halves carry no `.` `:` or `,` of their own.\n *\n * 🚨 NEW-25 (VERIFY-U-P1-R4) — the halves used to be raw, so a type token or an\n * id containing a dot came back as a DIFFERENT record: `{type: \"gr.ant\", id:\n * \"x.y\"}` parsed as `{type: \"gr\", id: \"ant.x.y\"}`. One encoder, one decoder\n * (`encodeRefPart` / `decodeRefPart`), the same pair the list spelling uses.\n */\nexport function detailInstanceKey(ref: DetailRef): string {\n return `${encodeRefPart(ref.type)}.${encodeRefPart(ref.id)}`;\n}\n\n/** Inverse of `detailInstanceKey`; `null` when the key is not `type.id`. */\nexport function parseDetailInstanceKey(key: string | null | undefined): DetailRef | null {\n if (!key) return null;\n const dot = key.indexOf(\".\");\n if (dot <= 0 || dot === key.length - 1) return null;\n return { type: decodeRefPart(key.slice(0, dot)), id: decodeRefPart(key.slice(dot + 1)) };\n}\n\n/** Which in-place presentation a URL arg names; `window` when unspecified. */\nexport function presentationFromUrlArg(\n value: string | undefined,\n): Exclude<DetailPresentation, \"page\"> {\n return value === \"docked\" ? \"docked\" : \"window\";\n}\n\n// ─── The list context inside a `?panels=` token (NEW-15) ────────────────────\n//\n// 🚨 NEW-15 (VERIFY-U-P1-R3) — A REFRESHED DETAIL WINDOW KEEPS ITS LIST. The\n// window's token used to carry `as` and nothing else, and the hydrator passed\n// `list: null`, so a reload silently lost the previous / next controls and the\n// \"3/40\" counter. The page URL already carried the list; the token carries the\n// same three values, under the same cap and the same byte budget.\n//\n// THE TOKEN'S GRAMMAR IS THE CONSTRAINT. `?panels=` splits panels on `,`, a\n// panel's args off on `:`, arg pairs on `_` and each pair on its first `-`. A\n// uuid is full of hyphens and a list is full of commas, so a list value is\n// escaped into a form holding none of those four characters: `encodeURIComponent`\n// plus the unreserved characters it leaves alone. The result is percent-escaped\n// again by `URLSearchParams` on the way into the address bar and decoded once on\n// the way back out, which is exactly how the page query's comma already travels.\n\nconst EXTRA_ESCAPES = /[-_.!~*'()]/g;\n\n/** A value safe to carry as a `?panels=` arg — no `,` `:` `_` or `-`. */\nexport function encodePanelArgValue(value: string): string {\n return encodeURIComponent(value).replace(\n EXTRA_ESCAPES,\n (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,\n );\n}\n\n/** Inverse of `encodePanelArgValue`. */\nexport function decodePanelArgValue(value: string): string {\n try {\n return decodeURIComponent(value);\n } catch {\n // A hand-edited link. Better the raw text than a thrown hydrator.\n return value;\n }\n}\n\n/**\n * 🚨 NEW-19 (VERIFY-U-P1-R4) — MEASURE THE VALUE AS THE ADDRESS BAR CARRIES IT.\n * The token's own escaping is only the first layer: the host's panel-URL writer\n * serializes the whole `?panels=` value through `new URLSearchParams(...).toString()`,\n * which escapes every `%` again (a uuid's `-` goes `-` → `%2D` → `%252D`, three\n * characters becoming five). Measuring the token's spelling said 5,992 for a URL\n * the browser carried at 7,416.\n */\nexport function panelListValueBytes(items: readonly DetailRef[]): number {\n return encodeURIComponent(encodePanelArgValue(encodeListItems(items))).length;\n}\n\n/**\n * The length of the FINAL address a detail window's deep link produces: the path\n * and query it is merged into, plus the `panels` parameter as\n * `URLSearchParams` serializes it. What the budget is measured against, and what\n * a guard can assert.\n */\nexport function finalPanelUrlLength(\n pathAndQuery: string,\n tokenWithoutListArgs: string,\n listArgs: Record<string, string>,\n): number {\n const args = Object.entries(listArgs)\n .map(([key, value]) => `${key}-${value}`)\n .join(\"_\");\n const token = args ? `${tokenWithoutListArgs}_${args}` : tokenWithoutListArgs;\n const [path = \"\", existingQuery = \"\"] = pathAndQuery.split(\"?\");\n const params = new URLSearchParams(existingQuery);\n params.set(\"panels\", token);\n return `${path}?${params.toString()}`.length;\n}\n\n/**\n * What the address a panel token is written INTO really costs, after\n * `URLSearchParams` re-serializes it — a detail page's own `?l=` list carries raw\n * commas that become `%2C` the moment a `panels` parameter is merged beside it,\n * so the page's query grows by ~2× its comma count at the instant the window\n * opens. Reserving the pre-serialization length is how a page + window address\n * still overran the budget by 70 characters in the guard that found this.\n */\nexport function panelUrlReserveBytes(pathAndQuery: string): number {\n const [path = \"\", query = \"\"] = pathAndQuery.split(\"?\");\n const serialized = new URLSearchParams(query).toString();\n return path.length + (serialized ? serialized.length + 1 : 0);\n}\n\n/**\n * What the token costs beside the list: `panels=detail:<type>.<id>:as-window`\n * plus the `_i-<n>_lt-<n>` args and their escaping. Measured generously so the\n * budget is never overspent by the fixed part.\n */\nconst PANEL_TOKEN_FIXED_BYTES = 160;\n\n/**\n * The reserve for a caller that cannot see the address it writes into. A detail\n * PAGE carrying its own capped list is the longest address a window is opened\n * from, so the reserve is that: the budget's own share for a page query. Without\n * it, a window opened from a detail page produced a 13,433-character URL the edge\n * refuses (NEW-19).\n */\nconst DEFAULT_PANEL_URL_RESERVE_BYTES = Math.floor(DETAIL_URL_BUDGET_BYTES / 2);\n\n/**\n * The window's deep-link args for a list context — `{}` when there is no list.\n * `max` is the resolved `ui.detail.list_context_max_ids`; the budget is the\n * platform's REQUEST LINE, measured on the final serialized value, with whatever\n * the rest of the address already costs reserved (NEW-19). A caller that knows\n * the address it is writing into passes its length; one that does not gets the\n * conservative reserve above, which is what a long detail-page query costs.\n */\nexport function detailListToUrlArgs(\n list: DetailListContext | null | undefined,\n max: number,\n options: { reservedBytes?: number } = {},\n): Record<string, string> {\n const reserved =\n options.reservedBytes ?? DEFAULT_PANEL_URL_RESERVE_BYTES;\n const capped = trimListContext(list, max, {\n measure: panelListValueBytes,\n reservedBytes: reserved + PANEL_TOKEN_FIXED_BYTES,\n });\n if (!capped) return {};\n const args: Record<string, string> = {\n [DETAIL_URL_LIST_ARG]: encodePanelArgValue(encodeListItems(capped.items)),\n [DETAIL_URL_INDEX_ARG]: String(capped.index),\n };\n if (capped.trimmedFrom) args[DETAIL_URL_LIST_TOTAL_ARG] = String(capped.trimmedFrom);\n return args;\n}\n\n/** The list a `?panels=detail:` token carries; `null` when it carries none. */\nexport function detailListFromUrlArgs(\n args: Record<string, string> | null | undefined,\n): DetailListContext | null {\n const raw = args?.[DETAIL_URL_LIST_ARG];\n if (!raw) return null;\n const items = decodeListItems(decodePanelArgValue(raw));\n if (items.length === 0) return null;\n const index = Number.parseInt(args?.[DETAIL_URL_INDEX_ARG] ?? \"\", 10);\n const total = Number.parseInt(args?.[DETAIL_URL_LIST_TOTAL_ARG] ?? \"\", 10);\n return {\n items,\n index: Number.isFinite(index) && index >= 0 && index < items.length ? index : 0,\n ...(Number.isFinite(total) && total > items.length ? { trimmedFrom: total } : {}),\n };\n}\n\n// ─── The list context in the PAGE presentation's query ──────────────────────\n//\n// `/detail/<type>/<id>?l=type.id,type.id&i=<index>&lt=<total>` — the same\n// `type.id` instance key the `?panels=detail:` deep link uses. `lt` is present\n// only when the list was TRIMMED to fit the URL (NEW-7): it is the length of the\n// list the window was cut from, so the detail can say so. The host owns the\n// PATH (`navigate.pageHref`) and appends what `encodeListQuery` returns.\n\n/** `?l=`, `&i=`, `&lt=` and their values — what the query costs beside the list. */\nconst PAGE_QUERY_FIXED_BYTES = 40;\n\n/**\n * 🚨 NEW-7 — CAPPED, ALWAYS. `max` is the resolved\n * `ui.detail.list_context_max_ids` knob; the default is used when the host has\n * no answer yet, never \"no cap\". A 500-row list uncapped produced a >20 KB href\n * no server accepts (VERIFY-U-P1-R2).\n *\n * 🚨 NEW-19 (VERIFY-U-P1-R4) — AND THE BUDGET IS THE WHOLE URL'S. The path this\n * query hangs off is part of the request line, so the caller passes what it\n * costs (`reservedBytes`) and the list gets what is left. Measuring the list\n * alone is how a detail page reached 8,464 characters while the code claimed a\n * 2 KB margin.\n */\nexport function encodeListQuery(\n list: DetailListContext | null | undefined,\n max: number = DEFAULT_DETAIL_LIST_CONTEXT_MAX,\n options: { reservedBytes?: number } = {},\n): string {\n const capped = trimListContext(list, max, {\n reservedBytes: (options.reservedBytes ?? 0) + PAGE_QUERY_FIXED_BYTES,\n });\n if (!capped) return \"\";\n // Each `type.id` is encoded, the separators are not: a comma is legal in a\n // query value, and `%2C` × 200 was 400 bytes of nothing.\n const l = encodeListItems(capped.items);\n const trimmed = capped.trimmedFrom ? `&lt=${capped.trimmedFrom}` : \"\";\n return `?l=${l}&i=${capped.index}${trimmed}`;\n}\n\n/** Inverse of `encodeListQuery`, from the three query values. */\nexport function decodeListQuery(\n l: string | null | undefined,\n i: string | null | undefined,\n /** `lt` — the length of the list this window was cut from, when it was. */\n lt?: string | null | undefined,\n): DetailListContext | null {\n if (!l) return null;\n const items = decodeListItems(l);\n if (items.length === 0) return null;\n const index = Number.parseInt(i ?? \"\", 10);\n const total = Number.parseInt(lt ?? \"\", 10);\n return {\n items,\n index: Number.isFinite(index) && index >= 0 && index < items.length ? index : 0,\n ...(Number.isFinite(total) && total > items.length ? { trimmedFrom: total } : {}),\n };\n}\n","// @ai-matrx/detail — src/format.ts\n//\n// Generic row → fields formatting, lifted verbatim from the item detail\n// window it replaced so every registry-known record renders exactly as it did.\n\nimport type { DetailField } from \"./types\";\nimport { humanizeIdentifier } from \"@ai-matrx/kit/text-case\";\n\n/** Columns that are pure plumbing — hidden from a formatted field list. */\nexport const HIDDEN_FIELDS = new Set([\n \"id\",\n \"user_id\",\n \"organization_id\",\n \"created_by\",\n \"updated_by\",\n \"embedding\",\n \"search_vector\",\n \"tsv\",\n]);\n\nconst ISO_RE = /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/;\n\n\nexport function formatValue(value: unknown): { text: string; mono?: boolean } | null {\n if (value === null || value === undefined) return null;\n if (typeof value === \"boolean\") return { text: value ? \"Yes\" : \"No\" };\n if (typeof value === \"number\") {\n return { text: Number.isFinite(value) ? value.toLocaleString() : String(value) };\n }\n if (typeof value === \"string\") {\n const t = value.trim();\n if (!t) return null;\n if (ISO_RE.test(t)) {\n const d = new Date(t);\n if (!Number.isNaN(d.getTime())) {\n return { text: d.toLocaleString([], { dateStyle: \"medium\", timeStyle: \"short\" }) };\n }\n }\n return { text: t };\n }\n try {\n const json = JSON.stringify(value, null, 2);\n if (!json || json === \"{}\" || json === \"[]\") return null;\n return { text: json, mono: true };\n } catch {\n return { text: String(value), mono: true };\n }\n}\n\n/**\n * Every populated scalar column of a row as a field; a `<token>_id` column\n * holding a real uuid becomes a door (the host decides which token names a\n * registry entity — a wrong link is worse than no link).\n */\nexport function fieldsFromRow(\n row: Record<string, unknown>,\n doors: {\n tokenFromColumnName: (column: string) => string | null;\n isUuidValue: (value: unknown) => value is string;\n },\n): DetailField[] {\n const out: DetailField[] = [];\n for (const [key, raw] of Object.entries(row)) {\n if (HIDDEN_FIELDS.has(key)) continue;\n const formatted = formatValue(raw);\n if (!formatted) continue;\n const token = doors.isUuidValue(raw) ? doors.tokenFromColumnName(key) : null;\n out.push({\n key,\n label: humanizeIdentifier(key) || key,\n text: formatted.text,\n ...(formatted.mono ? { mono: true } : {}),\n ref: token ? { token, id: raw as string } : null,\n });\n }\n return out;\n}\n\nexport function formatWhen(iso: string): string {\n const d = new Date(iso);\n if (Number.isNaN(d.getTime())) return iso;\n return d.toLocaleString([], { dateStyle: \"medium\", timeStyle: \"short\" });\n}\n"],"mappings":"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","names":["size"]}
package/dist/index.d.cts CHANGED
@@ -279,6 +279,12 @@ interface DetailRecordType<Row extends DetailRow = DetailRow> {
279
279
  bg: string;
280
280
  ring: string;
281
281
  } | null;
282
+ /**
283
+ * The type's official identity colour (`#rrggbb`, from `@ai-matrx/icons/domain`).
284
+ * When set it wins over `accent`: the icon wears it and the type chip is tinted
285
+ * with it (chip text stays the foreground token).
286
+ */
287
+ color?: string | null;
282
288
  /**
283
289
  * The canonical entity-registry token for doors, associations and history.
284
290
  * `null` when the type is not a registry entity — the header then offers
package/dist/index.d.ts CHANGED
@@ -279,6 +279,12 @@ interface DetailRecordType<Row extends DetailRow = DetailRow> {
279
279
  bg: string;
280
280
  ring: string;
281
281
  } | null;
282
+ /**
283
+ * The type's official identity colour (`#rrggbb`, from `@ai-matrx/icons/domain`).
284
+ * When set it wins over `accent`: the icon wears it and the type chip is tinted
285
+ * with it (chip text stays the foreground token).
286
+ */
287
+ color?: string | null;
282
288
  /**
283
289
  * The canonical entity-registry token for doors, associations and history.
284
290
  * `null` when the type is not a registry entity — the header then offers