@ai-matrx/detail 0.1.0

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.
@@ -0,0 +1,505 @@
1
+ import { ComponentType, ReactNode } from 'react';
2
+
3
+ /** The three ways a record detail can show. `window` is the platform default. */
4
+ declare const DETAIL_PRESENTATIONS: readonly ["window", "docked", "page"];
5
+ type DetailPresentation = (typeof DETAIL_PRESENTATIONS)[number];
6
+ /**
7
+ * The knob every presentation decision reads (`platform.feature_knob`,
8
+ * feature `ui.detail`, key `default_presentation`; seeded by matrx-frontend's
9
+ * `migrations/detail_presentation_knob.sql`). Enum of DETAIL_PRESENTATIONS,
10
+ * overridable by organization and user. The package never reads the settings
11
+ * ladder itself — the host resolves this key and answers through the
12
+ * `usePresentationSetting` / `resolvePresentation` ports.
13
+ */
14
+ declare const DETAIL_PRESENTATION_KNOB = "ui.detail.default_presentation";
15
+ /**
16
+ * The PER-RECORD-TYPE override of the setting above (`platform.feature_knob`,
17
+ * feature `ui.detail`, key `presentation_by_type`): a json object keyed by
18
+ * record type token, e.g. `{"file": "docked"}`. Nearest wins over
19
+ * `default_presentation` for the type it names, and the same organization →
20
+ * user ladder resolves it, so an organization can set "contracts open as a
21
+ * page" and a person can still choose otherwise for themselves.
22
+ *
23
+ * WHY A SECOND KEY AND NOT THE `table` RUNG. The platform's per-table rung
24
+ * (DD-131, precedence 50) is keyed by a `platform.entity_types` ROW ID, and
25
+ * that table is admin-only by a restrictive policy — a browser cannot read the
26
+ * id, so a `table` rung would be a setting the screen offers and the reader
27
+ * never honours. One json key on the ladder the client already has is the
28
+ * override the settings platform supports today.
29
+ */
30
+ declare const DETAIL_PRESENTATION_BY_TYPE_KNOB = "ui.detail.presentation_by_type";
31
+ /**
32
+ * How many records of the list a detail was opened FROM may ride the page
33
+ * presentation's URL (`platform.feature_knob`, feature `ui.detail`, key
34
+ * `list_context_max_ids`; seeded by matrx-frontend's
35
+ * `migrations/detail_list_context_max_knob.sql`). Organization-overridable.
36
+ *
37
+ * Why a knob and not a constant: it is a ceiling, and every ceiling here is a
38
+ * row an admin owns (`common-docs/policies/limits-are-knobs-agents-set-them.md`).
39
+ * Why it exists at all: uncapped, a 500-row list produced a >20 KB href no
40
+ * server accepts (NEW-7, VERIFY-U-P1-R2). Beyond the cap the URL carries the
41
+ * window around the current record and the detail says the list was trimmed.
42
+ */
43
+ declare const DETAIL_LIST_CONTEXT_MAX_KNOB = "ui.detail.list_context_max_ids";
44
+ /**
45
+ * The cap when the knob has not answered yet (a cold cache, a signed-out
46
+ * render, a host that binds no settings ladder). 100 `type.id` pairs with uuid
47
+ * ids is the most the strictest presentation's FINAL URL can carry inside the
48
+ * budget below, and it is far more neighbours than a person arrows through in
49
+ * one sitting.
50
+ */
51
+ declare const DEFAULT_DETAIL_LIST_CONTEXT_MAX = 100;
52
+ /**
53
+ * 🚨 NEW-19 (VERIFY-U-P1-R4) — THE BUDGET BELONGS TO THE FINAL URL, ONCE.
54
+ *
55
+ * It used to be 6,000 characters measured on the list VALUE, one escaping layer
56
+ * too early, and nothing bounded the whole address:
57
+ *
58
+ * * a detail window's deep link measured 5,992 against that 6,000 and reached
59
+ * the address bar at 7,416, because the host's panel-URL writer re-serializes
60
+ * the `?panels=` token through `new URLSearchParams(...).toString()`, which
61
+ * re-escapes every `%` the token's own escaping already produced (a uuid's
62
+ * `-` goes `-` → `%2D` → `%252D`);
63
+ * * a detail PAGE that also carried an open window was 13,433 characters —
64
+ * its own `?l=` plus the merged `panels=` — past the 8 KB request line, so
65
+ * the edge answers 414 and the link is dead (VERIFY-U-P1-R4, NEW-19).
66
+ *
67
+ * 8,000 characters is the strictest edge in front of this platform (an 8 KB
68
+ * request line), and the method, the path, every other parameter and the
69
+ * token's own non-list args share it: a caller passes what the rest of the URL
70
+ * already costs as `reservedBytes` and `trimListContext` measures the FINAL
71
+ * serialized form. No caller may skip it, and the record SAYS what was cut
72
+ * (`trimmedFrom`).
73
+ */
74
+ declare const DETAIL_URL_BUDGET_BYTES = 8000;
75
+ /**
76
+ * The hard ceiling on the knob — the number of records the final URL can really
77
+ * carry in EVERY presentation, so a value below it always changes what travels.
78
+ *
79
+ * 🚨 NEW-20 (VERIFY-U-P1-R4). The ceiling was 500 while the byte budget stopped
80
+ * a uuid list at 139, so every value an administrator could set from 139 to 500
81
+ * behaved identically and the knob's own live basis text told them 200 records
82
+ * would travel. Measured against the final URL: 100 uuid entries under a short
83
+ * type token cost ~6.7 KB in the window's `?panels=` token (the strictest
84
+ * spelling) and ~4.3 KB in the page query, both inside the budget above; 150
85
+ * costs ~10 KB in the token and does not fit. matrx-frontend's
86
+ * `migrations/detail_list_context_max_deliverable.sql` lowers the knob's own
87
+ * `max_value` and default to the same number and rewrites its basis text, so
88
+ * the settings screen cannot offer records the URL can never carry. A longer
89
+ * type token still trims below the cap — and still says so.
90
+ */
91
+ declare const DETAIL_LIST_CONTEXT_MAX_IDS_CEILING = 100;
92
+ /**
93
+ * The knob's value as a usable cap; the default for anything that is not one,
94
+ * and never more than the ceiling the byte budget can honour (NEW-12). The ONE
95
+ * place the knob is turned into a number — the page href builder, the window's
96
+ * deep-link token and the trim itself all read it through here.
97
+ */
98
+ declare function detailListContextMax(raw: unknown): number;
99
+ /** The per-type entry for `type`, when the map holds a usable one. */
100
+ declare function presentationForTypeFromMap(raw: unknown, type: string): DetailPresentation | undefined;
101
+ declare function isDetailPresentation(value: unknown): value is DetailPresentation;
102
+ /** A record, named the way the platform names records: type token + id. */
103
+ interface DetailRef {
104
+ type: string;
105
+ id: string;
106
+ }
107
+ /** What the opener already knows, shown instantly until the row loads. */
108
+ interface DetailSeed {
109
+ name?: string | null;
110
+ about?: string | null;
111
+ }
112
+ /**
113
+ * The list the record was opened FROM, so `[` / `]` move to the previous /
114
+ * next record without going back to the list. Plain data — it travels through
115
+ * the host's state store and the URL.
116
+ */
117
+ interface DetailListContext {
118
+ items: DetailRef[];
119
+ index: number;
120
+ /**
121
+ * Set when this list is a WINDOW cut out of a longer one (the page
122
+ * presentation carries the list in the URL and it is capped —
123
+ * `ui.detail.list_context_max_ids`): the length of the list it was cut from,
124
+ * so the detail can say the neighbours beyond it are not reachable from here
125
+ * instead of presenting the window as the whole list (NEW-7).
126
+ */
127
+ trimmedFrom?: number;
128
+ }
129
+ /** What `useOpenDetail` accepts. */
130
+ interface DetailOpenRequest extends DetailRef {
131
+ /** Skip the setting and open this way. Omit to honour the user's setting. */
132
+ presentation?: DetailPresentation;
133
+ seed?: DetailSeed | null;
134
+ list?: DetailListContext | null;
135
+ }
136
+ /** The payload a presentation receives — everything an open request carried. */
137
+ interface DetailInstanceData extends DetailRef {
138
+ seed: DetailSeed | null;
139
+ list: DetailListContext | null;
140
+ }
141
+ type DetailRow = Record<string, unknown>;
142
+ type DetailLoadResult<Row extends DetailRow = DetailRow> = {
143
+ row: Row;
144
+ } | {
145
+ notFound: true;
146
+ };
147
+ /** One rendered field. `ref` makes the value a door instead of text. */
148
+ interface DetailField {
149
+ key: string;
150
+ label: string;
151
+ text: string;
152
+ /** Render in a monospace block (JSON, long identifiers). */
153
+ mono?: boolean;
154
+ /**
155
+ * This value names another record — render it as a door.
156
+ *
157
+ * 🚨 AND THE DOOR SAYS WHAT IT OPENS (chair, 2026-09-18). The body used to
158
+ * render the door from `ref.id` alone, so a field whose `text` was
159
+ * "Acme Robotics" printed a truncated uuid instead: a door nobody can read is
160
+ * half a dead end. `name` is the record's own name for the host's `RefCell` to
161
+ * render; when it is omitted the body falls back to `text` (unless `text` is
162
+ * the id itself, in which case there is nothing better to say).
163
+ */
164
+ ref?: {
165
+ token: string;
166
+ id: string;
167
+ name?: string | null;
168
+ } | null;
169
+ }
170
+ /**
171
+ * The "source health strip" for a synced record — where it came from, when it
172
+ * was last refreshed, whether the grant behind it still works, and how to open
173
+ * it at the source. Absent for records the platform owns outright.
174
+ */
175
+ interface DetailSourceHealth {
176
+ /** Human name of the source ("Google Drive", "Search Console"). */
177
+ source: string;
178
+ lastRefreshedAt?: string | null;
179
+ /**
180
+ * 🚨 THE GRANT-STATE VOCABULARY. Five of the six say what is wrong with the
181
+ * CONNECTION; `blocked` says the record cannot be refreshed and NOTHING the
182
+ * person can click repairs it (chair, 2026-09-18).
183
+ *
184
+ * - `ok` → the grant behind this record works;
185
+ * - `expired` → it lapsed and a reconnect renews it;
186
+ * - `revoked` → someone withdrew it and a reconnect re-grants it;
187
+ * - `missing` → we never had it, or the record is not shared with us;
188
+ * - `unknown` → we could not check (a read failed) — reconnecting is safe;
189
+ * - `blocked` → the source itself refuses, and no control repairs it: a
190
+ * provider outage, a quota, a platform-configuration fault,
191
+ * a connection the server reports as unavailable. The strip
192
+ * says BLOCKED, states the reason, and offers NO Reconnect —
193
+ * which `reconnectFor` enforces for this word whatever the
194
+ * producer or the host supplies, because a button that
195
+ * cannot work is a control that does nothing (law 4).
196
+ *
197
+ * `unknown` used to carry both meanings, so a blocked record was offered the
198
+ * consent window it could not be repaired by (VERIFY-U-P1-R5, N1's class).
199
+ */
200
+ grant: "ok" | "expired" | "revoked" | "missing" | "unknown" | "blocked";
201
+ /** One sentence for the grant state, in the person's language. */
202
+ grantDetail?: string | null;
203
+ openAtSourceHref?: string | null;
204
+ /**
205
+ * The per-record label for the "open at source" control, when the
206
+ * registration knows it precisely (chair, 2026-09-18). `DetailBody` derives
207
+ * a label from `source` on its own (see `deriveOpenAtSourceLabel`) for a
208
+ * producer that has no opinion, but that derivation guesses from a family
209
+ * name ("Google Docs, Sheets & Drive files" → "Open in Google") — a
210
+ * registration that knows the row's exact kind (a Doc vs. a Sheet vs. a
211
+ * plain Drive file; a Calendar event) sets this instead and it wins.
212
+ */
213
+ openAtSourceLabel?: string;
214
+ /**
215
+ * The repair offered when the grant is not `ok`. 🚨 THREE-VALUED, AND THE
216
+ * THREE MEAN DIFFERENT THINGS (Bugbot round 18 on frontend PR 228):
217
+ *
218
+ * - a function → offer THIS action;
219
+ * - `null` → offer NOTHING: the producer has decided a reconnect
220
+ * would not repair this refusal (a share the owner must
221
+ * grant, a record we own, an unclassified failure) and a
222
+ * Reconnect button would be a control that does nothing;
223
+ * - omitted → the producer has no opinion; the host's `reconnectSource`
224
+ * port supplies the action when it has one.
225
+ *
226
+ * `useDetailHealth` honours `null` explicitly — it used to fill the host
227
+ * action back in with `??`, which reads `null` as "unset", so a synced record
228
+ * whose refusal a reconnect cannot fix still showed Reconnect.
229
+ */
230
+ onReconnect?: (() => void) | null;
231
+ /** Offered when the record can be refreshed on demand. */
232
+ onRefresh?: (() => void | Promise<void>) | null;
233
+ }
234
+ /** What a `health` producer is told about the record it is answering for. */
235
+ interface DetailHealthContext {
236
+ ref: DetailRef;
237
+ /** Aborted when the record changes or the detail closes. */
238
+ signal: AbortSignal;
239
+ }
240
+ /**
241
+ * The health producer: sync or async, and free to answer `null` for a record
242
+ * that is not synced. A throw or a rejection is rendered as an honest "we could
243
+ * not check the connection" strip, never as silence (`useDetailHealth`).
244
+ */
245
+ type DetailHealthProducer<Row extends DetailRow = DetailRow> = (row: Row, ctx: DetailHealthContext) => DetailSourceHealth | null | Promise<DetailSourceHealth | null>;
246
+ /** An extra section a record type adds under the fixed ones. */
247
+ interface DetailSection {
248
+ id: string;
249
+ label: string;
250
+ content: ReactNode;
251
+ }
252
+ /** What a registration's optional `Frame` and sections receive. */
253
+ interface DetailFrameContext {
254
+ ref: DetailRef;
255
+ recordType: DetailRecordType;
256
+ title: string;
257
+ about: string | null;
258
+ status: DetailStatus;
259
+ fields: DetailField[];
260
+ presentation: DetailPresentation;
261
+ }
262
+ /**
263
+ * ONE registration per record type. The core component (header + fixed
264
+ * sections) is derived from this; a record type never writes presentation
265
+ * code. Everything optional degrades to "section absent", never to a dead
266
+ * or disabled-looking control.
267
+ */
268
+ interface DetailRecordType<Row extends DetailRow = DetailRow> {
269
+ /** The type token this registration answers for (as opened). */
270
+ type: string;
271
+ /** Human label ("Task", "File"). */
272
+ label: string;
273
+ icon: ComponentType<{
274
+ className?: string;
275
+ }>;
276
+ /** Accent classes for the icon and type chip; semantic tokens only. */
277
+ accent?: {
278
+ text: string;
279
+ bg: string;
280
+ ring: string;
281
+ } | null;
282
+ /**
283
+ * The canonical entity-registry token for doors, associations and history.
284
+ * `null` when the type is not a registry entity — the header then offers
285
+ * copy-id only, and the associations / history sections are absent.
286
+ */
287
+ entityToken: string | null;
288
+ /**
289
+ * Load the full row. `null` when the type has no single canonical source
290
+ * (the detail then renders from the seed alone and says so).
291
+ */
292
+ load: ((id: string, signal: AbortSignal) => Promise<DetailLoadResult<Row>>) | null;
293
+ /** The title to show; receives the loaded row (or null) and the seed. */
294
+ title: (row: Row | null, seed: DetailSeed | null) => string;
295
+ /**
296
+ * 🚨 WHAT THIS ROW IS, WHEN THE TYPE CANNOT SAY (chair, 2026-09-18). `label` is
297
+ * per TYPE, and a registered type is regularly a family: `crm.party` holds
298
+ * 1,432 companies and 460 persons, so a type labelled "Person" told a person
299
+ * that a company is a person — twice on screen, above a field reading
300
+ * `organization` (VERIFY-U-P1-R5, N6). A registration that can tell the
301
+ * difference from the row answers here.
302
+ *
303
+ * It names the RECORD, so it is used for the header's type chip and for the
304
+ * stand-in titles ("Company 3f2a1b9c") only. The presentation pane keeps the
305
+ * type-level `label`, because "Every contact record now opens as a window" is a
306
+ * statement about the TYPE's setting, not about the row on screen.
307
+ *
308
+ * `null` (or omitted) falls back to `label`, then to the raw type token.
309
+ */
310
+ labelForRow?: (row: Row | null) => string | null;
311
+ /** The field list for the fields section. */
312
+ fields: (row: Row) => DetailField[];
313
+ /**
314
+ * 🚨 THE SOURCE HEALTH STRIP'S PRODUCER. Source health for a SYNCED record;
315
+ * omit it, or answer `null`, for a record the platform owns outright.
316
+ *
317
+ * It may be ASYNC, because whether the grant behind a synced record still works
318
+ * is not in the row — it is the connector's recorded per-capability health, read
319
+ * from the server. The host wires this field when it registers the record type
320
+ * (never a second reader of the connector's health), and `useDetailHealth`
321
+ * resolves it. Until 2026-09-17 nothing set it, so the strip could not render on
322
+ * any record and the promise that a refusal anywhere shows on every dependent
323
+ * record with the same Reconnect had no witness (VERIFY-U-P1-R4).
324
+ */
325
+ health?: DetailHealthProducer<Row> | null;
326
+ /**
327
+ * Tokens the associations section shows. `null` hides the section.
328
+ * Omitted → the host's default set minus this record's own token.
329
+ */
330
+ associationTokens?: string[] | null;
331
+ /** Show the history section (row versions). Default: when `entityToken` is set. */
332
+ history?: boolean;
333
+ /** Extra sections under the fixed ones. */
334
+ extraSections?: ((row: Row | null, ctx: DetailFrameContext) => DetailSection[]) | null;
335
+ /**
336
+ * Wraps the whole body (surface runtime, right-click menu, …). Receives the
337
+ * live context so a host can expose the dossier to its agents. Optional.
338
+ */
339
+ Frame?: ComponentType<{
340
+ ctx: DetailFrameContext;
341
+ children: ReactNode;
342
+ }> | null;
343
+ }
344
+ type DetailStatus = "loading" | "ready" | "not-found" | "error"
345
+ /** The type has no `load` — rendered from the seed alone. */
346
+ | "none";
347
+ type DetailLoadState<Row extends DetailRow = DetailRow> = {
348
+ status: "loading";
349
+ } | {
350
+ status: "ready";
351
+ row: Row;
352
+ } | {
353
+ status: "not-found";
354
+ } | {
355
+ status: "error";
356
+ message: string;
357
+ } | {
358
+ status: "none";
359
+ };
360
+ /** One row of the history section. */
361
+ interface DetailHistoryEntry {
362
+ version: number;
363
+ operation: string;
364
+ actorId: string | null;
365
+ occurredAt: string;
366
+ isCurrent: boolean;
367
+ }
368
+
369
+ /** One half of a `type.id` pair, carrying no character the decoders split on. */
370
+ declare function encodeRefPart(value: string): string;
371
+ /** Inverse of `encodeRefPart`; a hand-edited value comes back as its own text. */
372
+ declare function decodeRefPart(value: string): string;
373
+ /** `type.id,type.id` — the ONE spelling of a list in a URL, page or panel token. */
374
+ declare function encodeListItems(items: readonly DetailRef[]): string;
375
+ /** Inverse of `encodeListItems`; entries that do not name a record are dropped. */
376
+ declare function decodeListItems(value: string | null | undefined): DetailRef[];
377
+ /** How many characters these records cost in a URL — what the budget measures. */
378
+ declare function listQueryBytes(items: readonly DetailRef[]): number;
379
+ interface TrimListContextOptions {
380
+ /**
381
+ * The character budget for the whole URL. Defaults to the platform's request
382
+ * line (`DETAIL_URL_BUDGET_BYTES`).
383
+ */
384
+ budgetBytes?: number;
385
+ /**
386
+ * What the rest of the FINAL address already costs — the path, the query it is
387
+ * being merged into, the token's own non-list args. The list gets what is
388
+ * left, never a private allowance of its own (NEW-19).
389
+ */
390
+ reservedBytes?: number;
391
+ /**
392
+ * How many characters these records cost IN THE FINAL URL. The `?panels=`
393
+ * token escapes every separator and `URLSearchParams` escapes the result
394
+ * again, so the same records cost about 1.6× there what they cost in the page
395
+ * query (NEW-15, NEW-19).
396
+ */
397
+ measure?: (items: readonly DetailRef[]) => number;
398
+ }
399
+ /**
400
+ * The list as it may travel, plus what was left behind. Returns the same list
401
+ * when it already fits both limits, and `null` for no list at all.
402
+ */
403
+ declare function trimListContext(list: DetailListContext | null | undefined, max: number, options?: TrimListContextOptions): DetailListContext | null;
404
+
405
+ declare const DETAIL_URL_TYPE_KEY = "detail";
406
+ declare const DETAIL_URL_AS_ARG = "as";
407
+ /** The list the window was opened from, so a REFRESH keeps it (NEW-15). */
408
+ declare const DETAIL_URL_LIST_ARG = "l";
409
+ declare const DETAIL_URL_INDEX_ARG = "i";
410
+ declare const DETAIL_URL_LIST_TOTAL_ARG = "lt";
411
+ /**
412
+ * `type.id` — the panel URL splits tokens on `:` and args on `-`/`_`, never on
413
+ * `.`, so the two halves carry no `.` `:` or `,` of their own.
414
+ *
415
+ * 🚨 NEW-25 (VERIFY-U-P1-R4) — the halves used to be raw, so a type token or an
416
+ * id containing a dot came back as a DIFFERENT record: `{type: "gr.ant", id:
417
+ * "x.y"}` parsed as `{type: "gr", id: "ant.x.y"}`. One encoder, one decoder
418
+ * (`encodeRefPart` / `decodeRefPart`), the same pair the list spelling uses.
419
+ */
420
+ declare function detailInstanceKey(ref: DetailRef): string;
421
+ /** Inverse of `detailInstanceKey`; `null` when the key is not `type.id`. */
422
+ declare function parseDetailInstanceKey(key: string | null | undefined): DetailRef | null;
423
+ /** Which in-place presentation a URL arg names; `window` when unspecified. */
424
+ declare function presentationFromUrlArg(value: string | undefined): Exclude<DetailPresentation, "page">;
425
+ /** A value safe to carry as a `?panels=` arg — no `,` `:` `_` or `-`. */
426
+ declare function encodePanelArgValue(value: string): string;
427
+ /** Inverse of `encodePanelArgValue`. */
428
+ declare function decodePanelArgValue(value: string): string;
429
+ /**
430
+ * 🚨 NEW-19 (VERIFY-U-P1-R4) — MEASURE THE VALUE AS THE ADDRESS BAR CARRIES IT.
431
+ * The token's own escaping is only the first layer: the host's panel-URL writer
432
+ * serializes the whole `?panels=` value through `new URLSearchParams(...).toString()`,
433
+ * which escapes every `%` again (a uuid's `-` goes `-` → `%2D` → `%252D`, three
434
+ * characters becoming five). Measuring the token's spelling said 5,992 for a URL
435
+ * the browser carried at 7,416.
436
+ */
437
+ declare function panelListValueBytes(items: readonly DetailRef[]): number;
438
+ /**
439
+ * The length of the FINAL address a detail window's deep link produces: the path
440
+ * and query it is merged into, plus the `panels` parameter as
441
+ * `URLSearchParams` serializes it. What the budget is measured against, and what
442
+ * a guard can assert.
443
+ */
444
+ declare function finalPanelUrlLength(pathAndQuery: string, tokenWithoutListArgs: string, listArgs: Record<string, string>): number;
445
+ /**
446
+ * What the address a panel token is written INTO really costs, after
447
+ * `URLSearchParams` re-serializes it — a detail page's own `?l=` list carries raw
448
+ * commas that become `%2C` the moment a `panels` parameter is merged beside it,
449
+ * so the page's query grows by ~2× its comma count at the instant the window
450
+ * opens. Reserving the pre-serialization length is how a page + window address
451
+ * still overran the budget by 70 characters in the guard that found this.
452
+ */
453
+ declare function panelUrlReserveBytes(pathAndQuery: string): number;
454
+ /**
455
+ * The window's deep-link args for a list context — `{}` when there is no list.
456
+ * `max` is the resolved `ui.detail.list_context_max_ids`; the budget is the
457
+ * platform's REQUEST LINE, measured on the final serialized value, with whatever
458
+ * the rest of the address already costs reserved (NEW-19). A caller that knows
459
+ * the address it is writing into passes its length; one that does not gets the
460
+ * conservative reserve above, which is what a long detail-page query costs.
461
+ */
462
+ declare function detailListToUrlArgs(list: DetailListContext | null | undefined, max: number, options?: {
463
+ reservedBytes?: number;
464
+ }): Record<string, string>;
465
+ /** The list a `?panels=detail:` token carries; `null` when it carries none. */
466
+ declare function detailListFromUrlArgs(args: Record<string, string> | null | undefined): DetailListContext | null;
467
+ /**
468
+ * 🚨 NEW-7 — CAPPED, ALWAYS. `max` is the resolved
469
+ * `ui.detail.list_context_max_ids` knob; the default is used when the host has
470
+ * no answer yet, never "no cap". A 500-row list uncapped produced a >20 KB href
471
+ * no server accepts (VERIFY-U-P1-R2).
472
+ *
473
+ * 🚨 NEW-19 (VERIFY-U-P1-R4) — AND THE BUDGET IS THE WHOLE URL'S. The path this
474
+ * query hangs off is part of the request line, so the caller passes what it
475
+ * costs (`reservedBytes`) and the list gets what is left. Measuring the list
476
+ * alone is how a detail page reached 8,464 characters while the code claimed a
477
+ * 2 KB margin.
478
+ */
479
+ declare function encodeListQuery(list: DetailListContext | null | undefined, max?: number, options?: {
480
+ reservedBytes?: number;
481
+ }): string;
482
+ /** Inverse of `encodeListQuery`, from the three query values. */
483
+ declare function decodeListQuery(l: string | null | undefined, i: string | null | undefined,
484
+ /** `lt` — the length of the list this window was cut from, when it was. */
485
+ lt?: string | null | undefined): DetailListContext | null;
486
+
487
+ /** Columns that are pure plumbing — hidden from a formatted field list. */
488
+ declare const HIDDEN_FIELDS: Set<string>;
489
+ declare function titleizeKey(key: string): string;
490
+ declare function formatValue(value: unknown): {
491
+ text: string;
492
+ mono?: boolean;
493
+ } | null;
494
+ /**
495
+ * Every populated scalar column of a row as a field; a `<token>_id` column
496
+ * holding a real uuid becomes a door (the host decides which token names a
497
+ * registry entity — a wrong link is worse than no link).
498
+ */
499
+ declare function fieldsFromRow(row: Record<string, unknown>, doors: {
500
+ tokenFromColumnName: (column: string) => string | null;
501
+ isUuidValue: (value: unknown) => value is string;
502
+ }): DetailField[];
503
+ declare function formatWhen(iso: string): string;
504
+
505
+ export { DEFAULT_DETAIL_LIST_CONTEXT_MAX, DETAIL_LIST_CONTEXT_MAX_IDS_CEILING, DETAIL_LIST_CONTEXT_MAX_KNOB, DETAIL_PRESENTATIONS, DETAIL_PRESENTATION_BY_TYPE_KNOB, DETAIL_PRESENTATION_KNOB, DETAIL_URL_AS_ARG, DETAIL_URL_BUDGET_BYTES, DETAIL_URL_INDEX_ARG, DETAIL_URL_LIST_ARG, DETAIL_URL_LIST_TOTAL_ARG, DETAIL_URL_TYPE_KEY, type DetailField, type DetailFrameContext, type DetailHealthContext, type DetailHealthProducer, type DetailHistoryEntry, type DetailInstanceData, type DetailListContext, type DetailLoadResult, type DetailLoadState, type DetailOpenRequest, type DetailPresentation, type DetailRecordType, type DetailRef, type DetailRow, type DetailSection, type DetailSeed, type DetailSourceHealth, type DetailStatus, HIDDEN_FIELDS, type TrimListContextOptions, decodeListItems, decodeListQuery, decodePanelArgValue, decodeRefPart, detailInstanceKey, detailListContextMax, detailListFromUrlArgs, detailListToUrlArgs, encodeListItems, encodeListQuery, encodePanelArgValue, encodeRefPart, fieldsFromRow, finalPanelUrlLength, formatValue, formatWhen, isDetailPresentation, listQueryBytes, panelListValueBytes, panelUrlReserveBytes, parseDetailInstanceKey, presentationForTypeFromMap, presentationFromUrlArg, titleizeKey, trimListContext };