@ai-matrx/detail 0.1.64 → 0.1.67

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/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/types.ts","../src/listContext.ts","../src/presentation.ts","../src/format.ts"],"sourcesContent":["// @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\";\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\nexport function titleizeKey(key: string): string {\n return key\n .replace(/_/g, \" \")\n .replace(/\\bid\\b/gi, \"ID\")\n .replace(/\\b\\w/g, (c) => c.toUpperCase())\n .trim();\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: titleizeKey(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/types.ts","../src/listContext.ts","../src/presentation.ts","../src/format.ts"],"sourcesContent":["// @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"]}
package/dist/react.cjs CHANGED
@@ -928,6 +928,7 @@ var import_react10 = require("@ai-matrx/associations/react");
928
928
  var import_format = require("@ai-matrx/kit/format");
929
929
 
930
930
  // src/format.ts
931
+ var import_text_case = require("@ai-matrx/kit/text-case");
931
932
  function formatWhen(iso) {
932
933
  const d = new Date(iso);
933
934
  if (Number.isNaN(d.getTime())) return iso;