loopable / web /src /customer-grid /RecordDetail.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
dcdb685 verified
Raw
History Blame Contribute Delete
64.8 kB
import { lazy, Suspense, useEffect, useId, useLayoutEffect, useMemo, useRef, useState }
from "react";
import type { ReactNode, TextareaHTMLAttributes } from "react";
import { BodyPortal, useOverlayLayer } from "./OverlaySurface";
/**
* ⭐ WAVE-27 item 20 (R5 / contract C10) β€” the embedded grid a Details section renders.
*
* β›” LAZY BECAUSE THE IMPORT IS A CYCLE, not because it is big. `CustomerGrid` mounts
* `RecordDetail`; a static import back the other way is `A -> B -> A`, which bundlers resolve by
* leaving one side `undefined` at module-evaluation time β€” a crash that appears only on whichever
* module happens to evaluate first, i.e. the kind that survives every local test and lands in
* production. `React.lazy` defers the resolution to first RENDER, by which point both modules
* exist. It also composes with item 34's route-level splitting instead of fighting it: a reader
* who never opens a Details tab never downloads a second copy of anything.
*/
const LinkedGrid = lazy(() => import("./CustomerGrid"));
import {
automationDetail,
automationState,
automationStateLabel,
checkboxOn,
formatDisplay,
codePreview,
jsonPreview,
} from "./cells";
// The ONE scheme guard, shared with the kanban card since wave 26 (item 11). From `display`
// rather than `cells` because `cells` re-exports only the handful the canvas needed.
import { actionHref } from "./display";
import JsonViewer, { CodeViewer } from "./JsonViewer";
import { StarRow, StarIcon } from "./Stars";
import { codeLanguageOf, isPickType, mayEditField, ratingMax } from "./types";
import type { CustomerDoc, DisplaySpec, Field, Row, Viewer } from "./types";
// Item 9 (wave 14) β€” the pure value round-trip. Everything that could silently
// LOSE a stored value moving it into an editor and back lives there, gated by
// `web/verify_record_fields.py`; this file is dispatch and markup.
import {
choiceEntries,
composeDateValue,
composeNumberValue,
composeRatingValue,
dateInputValue,
multiParts,
numberInputValue,
ratingValue,
rawText,
toggleMulti,
userInitials,
} from "./recordFields";
/**
* ⭐⭐ WAVE-29 T24 (owner item 13, and D-121's class) β€” the record panel READS THE OPTION COLOURS.
*
* The canvas has honoured them since wave 14 (`cells.optionTint`) and so has the kanban card
* (`viewModes.tsx`), while this panel painted every chosen chip the same fixed blue and every
* single-select as an unstyled native `<select>` β€” so the SAME record showed a colour-coded
* status in the grid and a colourless one the moment somebody opened it. That is D-121 exactly
* (the record panel ignoring a field's format while the canvas honours it), one property along.
*
* ⚠ `optionTint` is THE resolver and it is not called twice: it already answers "colour coding is
* switched off" with `undefined`, in which case no inline style is set and the stylesheet's
* neutral chip is what paints. `optionBorderColor` is the same darkened companion the canvas
* draws its outline with, so a chip here and a bubble there cannot drift apart.
*/
import { optionBorderColor, optionTint, pickTint } from "./choiceColors";
// Wave-19 R7 β€” ONE asset resolver and ONE upload path, shared with the catalog. A second URL
// builder here is how the modal and the printed page would disagree about a row's picture.
import { assetUrl, uploadRecordImage } from "./catalogData";
// C-EMBED β€” the ONLY thing item 13 takes from the grid's side of the house. Not a hook, not a
// context, not a slice of CustomerGrid's state: the panel was written to receive every input
// as a prop precisely so it could be mounted twice, and importing anything else from over
// there is what would make the record modal depend on the table being on screen.
import TimeSeriesPanel from "./TimeSeriesPanel";
import type { SurfaceScope } from "./apiBridge";
import { Documents } from "./Documents";
import { FIELD_DRAG_TYPE, moveKey, nudgeKey, resolveFieldOrder } from "./recordLayout";
import RecordComments from "./RecordComments";
import { isStreamlitComponent } from "./hostBridge";
import "./RecordDetail.css";
export interface RecordDetailProps {
fields: Field[];
record: Row;
titleKey: string;
positionLabel: string;
canPrev: boolean;
canNext: boolean;
onPrev: () => void;
onNext: () => void;
onClose: () => void;
onNotesChange: (key: string, value: string) => void;
onNotesCommit: (key: string, value: string) => void;
/** Wave-5 item 1 β€” the permissions courtesy check rides into the drawer too: a field the
* viewer may not edit renders read-only here, exactly as its cells do. */
viewer?: Viewer;
/** Wave-8 I12c (C5) β€” this customer's document metadata and the plumbing for the
* section. Absent `docs` renders the section with an honest empty state; absent
* handlers hide it entirely (a host that does not serve documents shows none). */
docs?: CustomerDoc[];
docPayload?: { pid: number; docId: string; name: string; mime: string; data_b64: string };
onDocAdd?: (file: { name: string; mime: string; size: number; data_b64: string }) => void;
onDocFetch?: (docId: string) => void;
onDocDelete?: (docId: string) => void;
/**
* Wave 2026-08-02 item 5 (C-LAYOUT) β€” this USER'S field order for the record modal, from
* `GridWorkspace.recordLayout.order`. Keys the table no longer has are ignored and fields the
* order does not name append in the default order, so a stale layout degrades to a partial
* one rather than to a missing field.
*
* Optional on purpose: absent, the reordering still works for the life of the modal β€” it just
* does not outlive it. That is the honest degradation for a host that serves no layout (the
* Streamlit embed until its bridge carries the event), and it is what lets this ship before
* the persistence half lands.
*/
recordLayout?: string[];
/** The new order, already pruned and deduped. Wired to the `record_layout` event. */
onRecordLayout?: (order: string[]) => void;
/**
* Wave 2026-08-02 item 13 (C-EMBED) β€” which surface this modal was opened from, passed
* through to the Insights panel's fetch.
*
* ⚠ ABSENT HIDES THE INSIGHTS TAB. There is no default, and picking one would have been a
* quiet bug: defaulting to `customer` for a modal opened from the cohort surface asks the
* customer endpoint about a pid that is usually in the reader's customer book as well β€” so
* the server answers happily, with customer-scope numbers under a cohort-scope record. No
* 403, no marker, a plausible wrong answer. That is the same failure class the rank gate
* exists to catch, and the fix is the same one: refuse to answer rather than answer a
* question nobody asked. The tab appears the moment the host says which surface this is.
*/
scope?: SurfaceScope;
/**
* Wave 14 item 9 β€” the assignable people for `user`-type fields, from
* `CustomersPayload.userOptions`. A `user` field takes its vocabulary from the HOST, not
* from the field definition, so this is the one thing item 9 cannot build for itself.
*
* ⚠ ABSENT (or empty) RENDERS THE FIELD READ-ONLY, never an empty picker: a picker with no
* choices is a writer whose only reachable value is `""`, so opening it could clear an
* assignment and could never set one. The grid says the same thing in words when it hits
* this state (`CustomerGrid.tsx:3632`).
*/
userOptions?: string[];
/**
* Wave 14 item 11 (C-AVATAR) β€” `username β†’ data URL`, from `GridWorkspace.userAvatars`.
* Absent, or missing this user, renders the initials fallback: a deterministic pastel disc,
* never a broken-image glyph. The photo is an upgrade to a complete surface, not a
* dependency of one.
*/
userAvatars?: Record<string, string>;
}
/* Wave-5 item 11's `actionHref` MOVED to `display.ts` in wave 26 (item 11): the kanban card is a
second surface that needs the same scheme guard, and two copies of one is how the second one
ends up accepting `javascript:`. Imported below; the behaviour is byte-identical. */
/**
* Wave 2026-08-02 item 2 β€” a note field that is the SIZE OF ITS CONTENT.
*
* The owner's complaint was a "big static box": every editable overlay field opened as a
* 76px-tall textarea whether it held a paragraph or nothing at all, so a record whose notes
* were all empty read as a column of empty boxes. Here the resting height is one field row β€”
* identical to the read-only value slots beside it β€” and it grows as you type.
*
* `height: auto` FIRST is not a formality: `scrollHeight` never reports less than the height
* already set, so measuring without releasing the previous one makes the box grow and never
* shrink. The border term is `box-sizing: border-box` (index.css:17) β€” under it `height`
* includes the border while `scrollHeight` does not, and the 2px difference is exactly enough
* to keep a scrollbar permanently visible.
*
* Re-measures on `value`, which covers prev/next: this element is reused across records (only
* RecordComments is keyed by pid), so a stale height would otherwise follow you to the next
* customer. The width can also change without the value changing β€” a browser resize β€” hence
* the listener; a ResizeObserver on the element itself would observe the height WE set.
*/
function AutoTextarea({
value,
...rest
}: TextareaHTMLAttributes<HTMLTextAreaElement> & { value: string }) {
const ref = useRef<HTMLTextAreaElement>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const fit = () => {
el.style.height = "auto";
const cs = window.getComputedStyle(el);
const borders =
cs.boxSizing === "border-box"
? (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.borderBottomWidth) || 0)
: 0;
el.style.height = `${el.scrollHeight + borders}px`;
};
fit();
window.addEventListener("resize", fit);
return () => window.removeEventListener("resize", fit);
}, [value]);
return <textarea ref={ref} value={value} {...rest} />;
}
/**
* Wave 14 item 11 (C-AVATAR) β€” one person, as a face.
*
* R6: the grid's CELLS show the photo/icon ONLY; the record modal and its pickers show the
* avatar **and the name**. So this renders the disc alone and its callers put the name beside
* it β€” the disc is 20px here, and two initials at that size identify nobody on their own.
*
* **Fallback FIRST, on purpose.** The photo is `userAvatars[name]` when the host has one, and
* the initials disc when it does not β€” which is every deployment until C-AVATAR lands, plus
* every user who never uploaded one, plus every render before the workspace read returns.
* That is the majority state, not the error state, so it is the one that is designed: a
* deterministic pastel from `pickTint` (the same hash the option pills use, so the same person
* is the same colour everywhere) and never a broken-image glyph.
*
* ⚠ Data URLs only. C-AVATAR is explicit that these are never fetched from an external host β€”
* same-origin-safe, CSP-safe, and no third party learns who is looking at which record.
*/
function UserAvatar({
name,
avatars,
}: {
name: string;
avatars?: Record<string, string>;
}) {
const photo = avatars?.[name];
if (photo && photo.startsWith("data:image/"))
return <img className="cg-detail-avatar" src={photo} alt="" aria-hidden />;
const tint = pickTint(name);
return (
<span
className="cg-detail-avatar cg-detail-avatar--initials"
style={{ background: tint.bg, color: tint.fg }}
aria-hidden
>
{userInitials(name)}
</span>
);
}
/** The six dots. An inline SVG, never a glyph β€” the owner constant, and `β Ώ` renders as a
* different width in every fallback font the grid has been seen in. */
function SixDots() {
return (
<svg width="10" height="14" viewBox="0 0 10 14" aria-hidden focusable="false">
{[3, 7, 11].map((cy) =>
[3, 7].map((cx) => (
<circle key={`${cx}-${cy}`} cx={cx} cy={cy} r="1.15" fill="currentColor" />
))
)}
</svg>
);
}
export default function RecordDetail({
fields,
record,
titleKey,
positionLabel,
canPrev,
canNext,
onPrev,
onNext,
onClose,
onNotesChange,
onNotesCommit,
viewer,
docs,
docPayload,
onDocAdd,
onDocFetch,
onDocDelete,
recordLayout,
onRecordLayout,
scope,
userOptions,
userAvatars,
}: RecordDetailProps) {
const panelRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const tabsId = useId();
const title = String(record[titleKey] ?? "Untitled");
// Wave 19 (item 12): `scope` joins the predicate β€” the comments rail addresses a server
// endpoint per topic now, so a host that names no surface gets no rail rather than a rail
// pointed at a guess. In practice CustomerGrid always names one; this is the fail-closed edge.
const showComments = !isStreamlitComponent() && scope != null;
// Wave 19 (R7): which image field is mid-upload, and the last upload failure. ONE pair for the
// whole modal rather than per field β€” a record has at most a handful of picture columns and
// only one file dialog can be open at a time, so per-field state would be bookkeeping with no
// reachable second case.
const [imageBusy, setImageBusy] = useState("");
const [imageError, setImageError] = useState("");
/** ⭐ Wave-23 C7 β€” which json field's viewer is open, by KEY. One at a time for the same
* reason `imageBusy` is one pair: a record has a handful of document columns and only one
* modal can be on screen. */
const [jsonKey, setJsonKey] = useState<string | null>(null);
/** ⭐ Wave-27 item 13 (R13) β€” the open CODE column, the `jsonKey` pattern one kind over. */
const [codeKey, setCodeKey] = useState<string | null>(null);
const overlayFields = fields.filter((field) => field.source === "overlay");
// ⚠ These five buckets are the DEFAULT ORDER of the interleaved list (C-LAYOUT) and NOTHING
// ELSE. They used to double as the editability split β€” picked/rating fields rendered
// read-only here because "their picker is the door" β€” and **wave-14 item 9 removes that
// split entirely**: every user-created type now has its own editor in this panel, and the
// constrained types get a CONSTRAINED control (a select, a chip row, a star row), never a
// textarea. The concern the old comment protected was letting "Done", "done" and "DONE" into
// a closed vocabulary through a free-text side door; a picker cannot do that.
//
// The one gate that survives is `mayEditField` β€” stratum, read-only-by-nature
// (formula/created_time) and the viewer's permissions, in one verdict. The drawer must not
// be the side door around field permissions either.
const typedOverlay = overlayFields.filter(
(field) =>
!isPickType(field.type) &&
field.type !== "checkbox" &&
field.type !== "rating"
);
const checkboxOverlay = overlayFields.filter((field) => field.type === "checkbox");
const ratingOverlay = overlayFields.filter((field) => field.type === "rating");
const pickedOverlay = overlayFields.filter((field) => isPickType(field.type));
const detailFields = fields.filter(
(field) => field.source !== "overlay" && field.key !== titleKey
);
// --- C-LAYOUT (item 5) β€” ONE interleaved field list, in THIS user's order -------------
//
// The five buckets above survive as the DEFAULT ORDER and nothing else. Definition order
// would have been the obvious default and is the wrong one: overlay fields are appended
// AFTER the Odoo columns, so a user's own notes would have sunk to the bottom of every
// record the day this shipped. Keeping today's arrangement as the default means a user who
// never touches a grip sees no change at all.
//
// The two section headings ("Notes" / "Customer details") are gone with them β€” see the
// dated amendment in the wave doc. A heading over an interleaved list would be a caption
// that stops being true the first time somebody drags across it, and the distinction it
// carried (your stratum vs the ERP's) is now carried by every row itself: white slot =
// editable, grey slot = read-only. Item 13's `Fields | Insights` row becomes the label for
// the list as a whole.
const fieldByKey = new Map(fields.map((field) => [field.key, field]));
const defaultOrder = [
...typedOverlay,
...checkboxOverlay,
...ratingOverlay,
...pickedOverlay,
...detailFields,
].map((field) => field.key);
// The order we have asked for but not yet seen echoed. Without it a drag would snap back
// for one paint on every round trip (the no-blip law); it clears itself the moment the
// host's copy agrees, so a REJECTED order reverts visibly instead of lying.
const [pendingOrder, setPendingOrder] = useState<string[] | null>(null);
useEffect(() => {
setPendingOrder((p) =>
p && JSON.stringify(p) === JSON.stringify(recordLayout ?? []) ? null : p
);
}, [recordLayout]);
// `defaultOrder` is the whole vocabulary, so this is a PERMUTATION of it and nothing else β€”
// a saved layout can reorder the panel, never change what is in it. See recordLayout.ts.
const orderedKeys = resolveFieldOrder(pendingOrder ?? recordLayout, defaultOrder);
// The key being dragged, twice over, written only in `onDragStart`/`onDragEnd` and always
// together. The REF is what the handlers read: `dragover` must call preventDefault for a
// drop to be allowed at all, and gating that on state would make the gesture depend on a
// re-render landing between two mouse events. The STATE is what the class reads, because a
// ref cannot re-render the row it is meant to fade.
const dragKeyRef = useRef<string | null>(null);
const [dragKey, setDragKey] = useState<string | null>(null);
const [dropKey, setDropKey] = useState<string | null>(null);
const gripRefs = useRef(new Map<string, HTMLButtonElement | null>());
// A keyboard move rewrites the list, and React moves the focused button with it β€” but a
// moved node does not reliably keep focus, and losing it after one ArrowDown would end the
// interaction the fallback exists to provide. Re-aimed explicitly, after the paint.
const refocusGrip = useRef<string | null>(null);
useLayoutEffect(() => {
const key = refocusGrip.current;
if (!key) return;
refocusGrip.current = null;
gripRefs.current.get(key)?.focus();
});
// ROVING TABINDEX. `opacity: 0` hides the grips from the eye but not from the tab order β€”
// they keep their client rects, so `useOverlayLayer`'s focusable sweep counts every one of
// them β€” and a record with 26 fields would have gone from ~6 tab stops to ~32, making the
// comment box on the far side of the panel 26 presses away. One grip is tabbable; the arrow
// keys move within the group, which is the same gesture that reorders. Falls back to the
// first row whenever the remembered one is gone (a deleted field must not strand the group).
const [activeGrip, setActiveGrip] = useState<string | null>(null);
const rovingKey =
activeGrip && orderedKeys.includes(activeGrip) ? activeGrip : orderedKeys[0];
const commitOrder = (next: string[]) => {
if (next === orderedKeys) return; // moveKey/nudgeKey return the input on a no-op
setPendingOrder(next);
onRecordLayout?.(next);
};
// --- C-EMBED (item 13) β€” Fields | Insights ---------------------------------------------
//
// The owner's framing: in the record, the FILTER IS THE LOCK β€” you are looking at one
// customer, so the pid set is not a control, it is the premise. Hence `locked`, and hence a
// panel that is otherwise the same component the table mounts, with the same server, the
// same buckets and the same additivity rules. Two implementations of "sales by month" that
// could disagree is the thing worth avoiding here, not the duplicated markup.
//
// The display state is LOCAL and deliberately not persisted (v1): a bucket chosen while
// reading one customer is a way of looking, not a property of the record. `onDisplay` is
// passed all the same β€” the panel DISABLES its controls without one, and the contract says
// the controls stay live.
// No scope, no Insights β€” see the prop's note. `tab` is forced back rather than merely
// unreachable, so a host that stops supplying the scope mid-session cannot leave the modal
// showing a panel it can no longer address.
// ⭐ WAVE-27 item 20 (owner ruling R5, contract C10) β€” the tab is "Details" now, and the
// rename is the smaller half. It USED to be "Insights" and hold exactly one thing, a
// time-series panel; R5 makes it the record's whole relational context β€” every database this
// record LINKS to, rendered as a real grid of just its linked rows β€” with the time series
// demoted to one section among them.
const [tab, setTab] = useState<"fields" | "details">("fields");
const canInsights = scope != null;
const activeTab = canInsights ? tab : "fields";
// `mode: "timeseries"` is the only required member of DisplaySpec and it is a truthful one
// here: this IS the time-series reading of the record. Every other key is absent, which the
// panel reads as "use my defaults".
const [tsDisplay, setTsDisplay] = useState<DisplaySpec>({ mode: "timeseries" });
// ⚠ Memoised, not `[Number(record.pid)]` inline: the panel's fetch effect depends on `pids`
// by identity, so a fresh array every render would re-fetch on every render β€” forever.
const insightPids = useMemo(() => [Number(record.pid)], [record.pid]);
// wave17 item 5 β€” the same population as `insightPids`, as VALUES, so a snapshot metric can
// read this record's own numbers. Memoised for the identical reason the pid list is.
const insightRows = useMemo(() => [record], [record]);
/**
* ⭐ WAVE-27 item 20 (R5 / C10) β€” one section per LINK column on this record's table.
*
* ⚠ A link cell is a COMMA-JOINED LIST OF ROW IDS (the scalar `Row` contract β€” an array cell
* cannot exist here), parsed exactly the way `CustomerGrid`'s own link modal parses it: trim,
* to number, keep positive integers, de-duplicate. A second parse that disagreed about a
* trailing comma would show a different set of records than the picker just wrote.
*
* ⚠ A link with NO TARGET TABLE is skipped rather than rendered empty. `_clean_field` refuses
* a `link` without its bag, so this can only be a definition older than that rule β€” and an
* embedded grid of `scope: ""` would fetch the customer table and present somebody else's
* records under this column's name.
*
* Memoised: `embeddedRecordIds` is read by identity inside the grid's own row projection, so a
* fresh array per render would re-project on every keystroke in the drawer.
*/
/**
* ⭐ WAVE-27 item 20 β€” which Details sections have been OPENED.
*
* β›” THE SECTIONS ARE COLLAPSED UNTIL ASKED FOR, and that is a correctness decision as much
* as a performance one. An embedded grid FETCHES ITS WHOLE TABLE and filters to
* `embeddedRecordIds` client-side (`CustomerGrid.tsx:621-628`) β€” so on an Instagram profile,
* whose preset carries FOUR link columns (`posts_link`, `profile_snapshots_link`,
* `post_snapshots_link`, `comments_link`), opening this tab would mount four glide canvases
* and pull four tables, two of them append-only snapshot tables. The count in each heading is
* already known WITHOUT the grid (it is the length of the parsed id list), so a collapsed
* section still tells you what is in it.
*/
const [openLinks, setOpenLinks] = useState<Set<string>>(new Set());
const linkSections = useMemo(
() =>
fields
.filter((f) => f.type === "link" && typeof f.link?.table === "string" && f.link.table)
.map((f) => ({
field: f,
table: f.link!.table as SurfaceScope,
ids: [...new Set(
String(record[f.key] ?? "")
.split(",")
.map((part) => Number(part.trim()))
.filter((pid) => Number.isInteger(pid) && pid > 0)
)],
})),
[fields, record]
);
/**
* The six-dot handle. `draggable` sits HERE and not on the row: a draggable ancestor
* swallows text selection inside its inputs, and selecting a phrase in a note to retype it
* is the more common gesture by a wide margin.
*
* ⚠ The kanban's post-drag click guard (`viewModes.tsx:483`) has NO counterpart here, and
* that is a finding rather than an omission β€” see the dated amendment. It suppresses a
* post-`dragend` click that would otherwise OPEN a record; a field row has no click action
* to suppress, and the modal's own outside-dismissal listens on `pointerdown`
* (`OverlaySurface.tsx:139`), which fires at drag START, inside the panel. A guard here
* would be a flag nothing reads.
*/
const grip = (field: Field, index: number) => (
<button
type="button"
className="cg-detail-grip"
ref={(el) => {
gripRefs.current.set(field.key, el);
}}
draggable
tabIndex={field.key === rovingKey ? 0 : -1}
onFocus={() => setActiveGrip(field.key)}
// The POSITION is in the name, not only in the list: a keyboard move repaints silently,
// and a fallback whose effect a screen-reader user cannot hear is present rather than
// usable. Re-read on every move, because the label changes with the position.
aria-label={`Reorder ${field.label}, position ${index + 1} of ${orderedKeys.length}`}
title="Drag to reorder β€” or press the arrow keys"
onDragStart={(event) => {
dragKeyRef.current = field.key;
setDragKey(field.key);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(FIELD_DRAG_TYPE, field.key);
// The ghost is the whole ROW. Dragging a 10px handle across a 700px panel gives the
// eye nothing to aim with, and the thing being moved is the field, not the dots.
const row = event.currentTarget.parentElement;
if (row) event.dataTransfer.setDragImage(row, 14, row.clientHeight / 2);
}}
onDragEnd={() => {
dragKeyRef.current = null;
setDragKey(null);
setDropKey(null);
}}
onKeyDown={(event) => {
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
event.preventDefault(); // the body scrolls otherwise, chasing the row you moved
const next = nudgeKey(orderedKeys, field.key, event.key === "ArrowUp" ? -1 : 1);
if (next === orderedKeys) return;
refocusGrip.current = field.key;
commitOrder(next);
}}
>
<SixDots />
</button>
);
/** One row of the interleaved list: the handle, the label, and the type's own value slot. */
const renderRow = (field: Field, index: number) => {
const inputId = `cg-ov-${field.key}`;
// ONE verdict, `types.mayEditField` β€” stratum + read-only-by-nature + permissions.
// No second permission test anywhere below: a `formula` or `created_time` field must stay
// read-only after item 9, and that is this function's answer, not a type check of mine.
const mayEdit = mayEditField(field, viewer);
// The ONE exception, and it is a vocabulary problem rather than a permission one: a `user`
// field with no supplied people has nothing to offer, so it reads rather than pretends.
const noVocabulary = field.type === "user" && !userOptions?.length;
const editable = mayEdit && !noVocabulary;
// The free-TEXT family keeps the AutoTextarea (and the `--edit` class its CSS chain hangs
// off). Every other editable type gets a constrained control below.
const isEdit =
editable &&
!isPickType(field.type) &&
field.type !== "checkbox" &&
field.type !== "rating" &&
field.type !== "date" &&
field.type !== "int" &&
field.type !== "currency" &&
// Wave-19 R7 β€” an `image` cell holds a REFERENCE, and a textarea over a reference invites
// somebody to type `rec:` by hand at a picture that does not exist. Its slot is a
// thumbnail plus an upload control, so it joins the constrained-control family.
field.type !== "image" &&
// ⭐ Wave-23 C7 β€” a `json` value is NEVER edited through a free textarea, here or in the
// grid. `AutoTextarea` commits on blur straight through `onNotesCommit`, so this branch
// would be a door that saves an unparseable document with no check at all β€” the exact
// thing the viewer's parse-on-save exists to prevent, sitting one panel away from it.
// It joins the constrained-control family: preview + "Open", and the viewer owns the edit.
field.type !== "json" &&
field.type !== "pct";
const isControl = editable && !isEdit;
const cls =
"cg-detail-field" +
(isEdit ? " cg-detail-field--edit" : "") +
(isControl ? " cg-detail-field--control" : "") +
// β›” NO `is-machine` TINT (owner item 2, 2026-08-06). It followed the grid cell's wash by
// design β€” "the SAME claim the grid cell makes, on the DOM side" β€” so it follows it out.
// Keeping the drawer grey while the table stopped being grey would leave the product
// saying one thing in two places about the same column, which is the sibling-panel rule
// (DESIGN.md Β§2) broken in the direction nobody notices until they open a record.
//
// ⚠ THE CLAIM ITSELF SURVIVES, and more honestly than a tint did: `mayEditField` now
// refuses a machine-written column (`isMachineWritten`), so the drawer renders it as a
// READ-ONLY slot rather than "an ordinary editable-looking slot" with a grey wash on it.
// That was the paragraph's actual worry, and it is now answered by behaviour.
(dropKey === field.key ? " is-dropzone" : "") +
(dragKey === field.key ? " is-dragging" : "");
let slot: ReactNode;
if (isEdit) {
const raw = String(record[field.key] ?? "");
const href = actionHref(field, raw);
slot = (
// Item 2 β€” the editable slot and its link stack in the VALUE column, so an editable
// row reads on the same label-left/value-right grid as every read-only one. A wrapper
// element rather than flex-wrap: the alignment must not depend on a hard-coded label
// width, which the mobile breakpoint changes.
<div className="cg-detail-editwrap">
<AutoTextarea
id={inputId}
className="cg-detail-textarea"
value={raw}
placeholder={`Add ${field.label.toLowerCase()}…`}
onChange={(event) => onNotesChange(field.key, event.target.value)}
onBlur={(event) => onNotesCommit(field.key, event.target.value)}
/>
{/* Wave-5 item 11 β€” url/email/phone become ACTIONABLE where valid. The raw text
stays the editable truth; the link is an affordance. */}
{href && (
<a
className="cg-detail-action"
href={href}
target={field.type === "url" ? "_blank" : undefined}
rel={field.type === "url" ? "noopener noreferrer" : undefined}
>
{field.type === "url"
? "Open link"
: field.type === "email"
? "Send email"
: "Call"}
</a>
)}
</div>
);
} else if (field.source === "overlay" && field.type === "checkbox") {
slot = (
<input
id={inputId}
type="checkbox"
className="cg-detail-checkbox"
checked={checkboxOn(record[field.key] as string | number | null)}
disabled={!editable}
onChange={(event) =>
onNotesCommit(field.key, event.target.checked ? "1" : "")
}
/>
);
} else if (field.type === "image") {
// ⭐ Wave-19 R7 / C5 β€” the PICTURE slot: what is there, and (when editable) a way to
// replace or remove it. Read-only rows still get the thumbnail, because seeing the image
// is the whole point of the column and permission to edit is a separate question.
//
// ⚠ The `<img>` is deliberately plain. Its `onError` hides it rather than painting a
// broken glyph, and the caption below says WHICH reference failed β€” the grid cell can
// only show an empty frame, so this modal is where "that SKU has no master on file" gets
// said in words.
const ref = String(record[field.key] ?? "").trim();
slot = (
<span className="cg-detail-value cg-detail-value--live cg-detail-imageslot">
{ref ? (
<img
className="cg-detail-image"
src={assetUrl(ref, "web")}
alt={`${field.label} for ${title}`}
onError={(event) => {
event.currentTarget.style.display = "none";
}}
/>
) : (
<span className="cg-detail-imageempty" aria-hidden />
)}
<span className="cg-detail-imagemeta">
<span className="cg-detail-imageref">{ref || "No image"}</span>
{editable && (
<span className="cg-detail-imageacts">
<label className="cg-detail-imagebtn">
{ref ? "Replace" : "Upload"}
<input
type="file"
accept="image/png,image/jpeg"
onChange={(event) => {
const file = event.target.files?.[0];
// Reset the input BEFORE the await: picking the same file twice in a row
// fires no change event otherwise, so a failed upload could not be retried
// with the same picture.
event.target.value = "";
if (!file) return;
setImageBusy(field.key);
void uploadRecordImage(file)
.then((newRef) => {
setImageError("");
onNotesCommit(field.key, newRef);
})
.catch((reason: unknown) =>
setImageError(
reason instanceof Error
? reason.message
: "That image was not saved."
)
)
.finally(() => setImageBusy(""));
}}
/>
</label>
{ref && (
<button
type="button"
className="cg-detail-imagebtn"
onClick={() => onNotesCommit(field.key, "")}
>
Remove
</button>
)}
</span>
)}
{imageBusy === field.key && (
<span className="lp-spin" role="status" aria-label="Uploading" />
)}
{imageError && imageBusy !== field.key && (
<span className="cg-detail-imageerr">{imageError}</span>
)}
</span>
</span>
);
} else if (isControl && field.type === "rating") {
// Item 9 β€” the stars ARE the picker now. Clicking the lit star clears the field
// (`composeRatingValue`): without it a rating could be changed here but never removed,
// and the grid's separate Clear row has no counterpart in a one-row slot.
const max = ratingMax(field);
const n = ratingValue(field, record[field.key]);
slot = (
<span
className="cg-detail-value cg-detail-value--live"
role="group"
aria-labelledby={`${inputId}-lbl`}
>
<span className="cg-detail-stars">
{Array.from({ length: max }, (_, i) => i + 1).map((star) => (
<button
key={star}
type="button"
className="cg-detail-star"
aria-pressed={star <= n}
aria-label={
star === n
? `Clear ${field.label}`
: `${star} star${star === 1 ? "" : "s"}`
}
title={star === n ? "Click again to clear" : undefined}
onClick={() =>
onNotesCommit(
field.key,
composeRatingValue(field, record[field.key], star)
)
}
>
<StarIcon on={star <= n} />
</button>
))}
</span>
</span>
);
} else if (isControl && (field.type === "select" || field.type === "user")) {
// A NATIVE select, deliberately (C-FLDSEL swaps the field-CHOOSING surfaces, not the
// value pickers). The stale entry is what stops the browser's "no matching option β†’
// paint the FIRST one" behaviour from showing a different choice than the cell holds.
//
// ⚠ R6 asks for avatar + NAME in the record modal, and that is what this renders β€” the
// disc beside the picker, which carries the SELECTED person. The option LIST has no
// avatars because an `<option>` cannot contain an image; that is the same platform
// limit this wave already ruled on for field-type icons (item 20's whole reason for
// existing), and the answer there was a custom listbox. Building a second one for user
// values would trade the native keyboard and a11y behaviour C-FLDSEL explicitly says
// not to lose, for a picture in a list you are already reading by name.
const current = rawText(record[field.key]);
const entries = choiceEntries(field, userOptions, current);
slot = (
<span className="cg-detail-value cg-detail-value--live cg-detail-pickslot">
{field.type === "user" && current !== "" && (
<UserAvatar name={current} avatars={userAvatars} />
)}
{/* T24: the control wears the SELECTED option's colour. An `<option>` cannot be
styled portably (the same platform limit this block already records for avatars),
so the colour goes on the box β€” which is where the reader is looking anyway, and
matches what the canvas paints for the same cell. `user` columns are excluded: a
person is not a choice with a palette, and `optionTint` would hash a name into
one. */}
<select
id={inputId}
className="cg-detail-select"
value={current}
style={
field.type === "select"
? (() => {
const tint = optionTint(field, current);
return tint
? { background: tint.bg, color: tint.fg,
borderColor: optionBorderColor(tint.bg) }
: undefined;
})()
: undefined
}
onChange={(event) => onNotesCommit(field.key, event.target.value)}
>
<option value="">
{field.type === "user" ? "Unassigned" : "None"}
</option>
{entries.map((entry) => (
<option key={entry.value} value={entry.value}>
{entry.label}
</option>
))}
</select>
</span>
);
} else if (isControl && field.type === "multiselect") {
// A chip row rather than a `<select multiple>`: the native control needs ctrl-click to
// add a second value and shows a scrollbox of four rows for a three-choice field. Each
// toggle commits on its own β€” the event log absorbs the burst, exactly as the grid's
// picker does.
const current = record[field.key];
const held = multiParts(current);
const entries = choiceEntries(field, userOptions, current);
slot = (
<span
className="cg-detail-value cg-detail-value--live"
role="group"
aria-labelledby={`${inputId}-lbl`}
>
{entries.length === 0 ? (
<span className="cg-detail-nochoice">
No choices yet. Add them from the column menu.
</span>
) : (
<span className="cg-detail-chips">
{entries.map((entry) => {
const on = held.includes(entry.value);
// T24: the HELD chips wear the option's own colour; an unheld one stays neutral,
// because tinting every choice would make "which of these is on" a judgement
// about saturation rather than a state you can read.
const tint = on ? optionTint(field, entry.value) : undefined;
return (
<button
key={entry.value}
type="button"
className={"cg-detail-chip" + (on ? " is-on" : "")}
style={
tint
? {
background: tint.bg,
color: tint.fg,
borderColor: optionBorderColor(tint.bg),
}
: undefined
}
aria-pressed={on}
title={entry.stale ? "No longer a choice on this field" : undefined}
onClick={() =>
onNotesCommit(field.key, toggleMulti(current, entry.value))
}
>
{entry.label}
</button>
);
})}
</span>
)}
</span>
);
} else if (isControl && field.type === "date") {
// `dateInputValue` returns null for text a date input cannot represent. Falling back to
// a TEXT box there is the whole point: a date picker fed "02/08/2026" paints BLANK, so a
// record that has a date would look like one that has none β€” and the next save would
// make that true. See recordFields.ts note (1).
const current = record[field.key];
const iso = dateInputValue(current);
slot =
iso === null ? (
<span className="cg-detail-value cg-detail-value--live cg-detail-pickslot">
<input
id={inputId}
type="text"
className="cg-detail-input"
value={rawText(current)}
title="This value is not a date the picker can read β€” it is shown as stored."
onChange={(event) => onNotesChange(field.key, event.target.value)}
onBlur={(event) => onNotesCommit(field.key, event.target.value)}
/>
<span className="cg-detail-hint">not a recognised date</span>
</span>
) : (
<span className="cg-detail-value cg-detail-value--live">
<input
id={inputId}
type="date"
className="cg-detail-input"
value={iso}
onChange={(event) =>
onNotesCommit(field.key, composeDateValue(current, event.target.value))
}
/>
</span>
);
} else if (
isControl &&
(field.type === "int" || field.type === "currency" || field.type === "pct")
) {
// The plain number, never the display string: `display.ts` renders "$1,234.50" and a
// number input rejects the mark and the separator, so a formatted value would render as
// an EMPTY box over a populated cell. The unit lives beside the box instead.
const unit = field.type === "currency" ? "$" : field.type === "pct" ? "%" : "";
slot = (
<span className="cg-detail-value cg-detail-value--live cg-detail-pickslot">
{field.type === "currency" && <span className="cg-detail-unit">{unit}</span>}
<input
id={inputId}
type="number"
className="cg-detail-input cg-detail-input--num"
value={numberInputValue(record[field.key])}
step={field.type === "int" ? 1 : "any"}
placeholder="β€”"
onChange={(event) => onNotesChange(field.key, event.target.value)}
onBlur={(event) => {
// Junk the browser let through is REFUSED, not coerced: `num()` downstream turns
// it into a real 0, and a fabricated zero on a currency row is a wrong number
// that reconciles against nothing.
const next = composeNumberValue(event.target.value);
if (next === null) onNotesChange(field.key, numberInputValue(record[field.key]));
else onNotesCommit(field.key, next);
}}
/>
{field.type === "pct" && <span className="cg-detail-unit">{unit}</span>}
</span>
);
} else if (field.source === "overlay" && field.type === "rating") {
// Read-only rating (no permission, or an Odoo-sourced star column).
slot = (
<span
className="cg-detail-value"
title={
noVocabulary ? undefined : "You do not have permission to edit this field"
}
>
<StarRow value={ratingValue(field, record[field.key])} max={ratingMax(field)} />
</span>
);
} else if (field.type === "json") {
// ⭐ Wave-23 C7 β€” the drawer shows the same compact preview the grid cell does and hands
// the document to the SAME viewer, because two readers of one document that fold it
// differently is a disagreement nobody can see in either one alone. The button is the
// whole affordance: a `<pre>` of 32 KB inside a field row would push every column below
// it off the panel.
const raw = rawText(record[field.key]);
slot = (
<span className="cg-detail-value cg-detail-jsonslot">
<span className="cg-detail-jsonpreview">{jsonPreview(raw) || "β€”"}</span>
<button
type="button"
className="cg-detail-imagebtn"
onClick={() => setJsonKey(field.key)}
>
{/* "Open" for a reader, "Open" for an editor β€” the viewer decides what it offers
once it is open, and a button that says "Edit" to somebody who then cannot would
be the painted-but-inert control the standing rule is about. */}
Open
</button>
</span>
);
} else if (field.type === "code") {
// ⭐ WAVE-27 item 13 (R13) β€” the SAME shape the json slot above uses, and deliberately
// so: both are big values that live behind one button, and giving them different
// affordances in the same list would make "open the big value" two gestures to learn.
// What differs is the preview (a snippet has no structure to summarise β€” see
// `codePreview`) and the viewer it opens.
const raw = rawText(record[field.key]);
slot = (
<span className="cg-detail-value cg-detail-jsonslot">
<span className="cg-detail-jsonpreview">{codePreview(raw) || "β€”"}</span>
<button
type="button"
className="cg-detail-imagebtn"
onClick={() => setCodeKey(field.key)}
>
Open
</button>
</span>
);
} else if (field.type === "automation") {
// C5-AUTOFIELD (wave 18). It has to be its OWN branch and not the read-only fallback
// below, because that branch explains read-only-ness as a PERMISSION ("You do not have
// permission to edit this field") β€” and for an automation column that sentence is simply
// false. Nobody may type here, admins included: the value is what the last run wrote.
const raw = rawText(record[field.key]);
const state = automationState(raw);
slot = (
<span className="cg-detail-value cg-detail-auto" title={raw || "Not run yet"}>
<span className={`cg-detail-auto-chip is-${state}`}>
{automationStateLabel(raw)}
</span>
{automationDetail(raw) ? (
<span className="cg-detail-auto-detail">{automationDetail(raw)}</span>
) : null}
</span>
);
} else {
// Everything read-only, with the reason where there is one. After item 9 a picked field
// is no longer read-only BY DESIGN, so the only reasons left are real ones: no
// permission, no vocabulary to pick from, or a value the ERP owns.
const reason =
field.source !== "overlay"
? undefined
: noVocabulary
? "No assignable people were supplied for this workspace"
: "You do not have permission to edit this field";
const isUser = field.type === "user" && rawText(record[field.key]) !== "";
slot = (
<span className={"cg-detail-value" + (isUser ? " cg-detail-pickslot" : "")} title={reason}>
{isUser && <UserAvatar name={rawText(record[field.key])} avatars={userAvatars} />}
{formatDisplay(field, record[field.key]) || "β€”"}
</span>
);
}
// Every native control gets its label back. Without this the label stops being clickable
// for most of the panel the moment item 9 lands β€” the grouped controls (stars, chips) take
// `aria-labelledby` instead, because a `<label for>` can only point at ONE element.
const groupLabel = isControl && (field.type === "rating" || field.type === "multiselect");
const labelIsFor = !groupLabel && (isEdit || isControl || (field.source === "overlay" && field.type === "checkbox"));
return (
<div
className={cls}
key={field.key}
onDragOver={(event) => {
// Only OUR drag. Without the guard the whole list would advertise itself as a drop
// target for a dragged file or a selection from another tab.
if (!dragKeyRef.current) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
if (dropKey !== field.key) setDropKey(field.key);
}}
onDragLeave={(event) => {
// `dragleave` also fires crossing into a CHILD (the label, the textarea), and
// clearing on those makes the drop marker strobe under a stationary pointer.
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
setDropKey((k) => (k === field.key ? null : k));
}}
onDrop={(event) => {
// `drop` bubbles, so cancelling it here also cancels a textarea's native "insert
// the dragged text" β€” the second layer under FIELD_DRAG_TYPE.
event.preventDefault();
const key = event.dataTransfer.getData(FIELD_DRAG_TYPE) || dragKeyRef.current;
setDropKey(null);
if (key) commitOrder(moveKey(orderedKeys, key, field.key));
}}
>
{grip(field, index)}
{labelIsFor ? (
<label className="cg-detail-label" htmlFor={inputId}>
{field.label}
</label>
) : (
<span className="cg-detail-label" id={groupLabel ? `${inputId}-lbl` : undefined}>
{field.label}
</span>
)}
{slot}
</div>
);
};
// One modal surface is shared by Grid, List, Calendar, Kanban, and Map.
// Focus stays inside it, then returns to the exact opener on close.
useOverlayLayer({
panelRef,
onDismiss: onClose,
dismissOnOutside: true,
initialFocus: "[data-overlay-autofocus]",
trapFocus: true,
});
return (
<BodyPortal>
<div className="cg-record-backdrop">
<div
className={"cg-record-modal" + (showComments ? "" : " cg-record-modal--single")}
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
data-overlay-kind="record-detail"
tabIndex={-1}
>
<div className="cg-detail-header">
<div className="cg-detail-nav">
<button
type="button"
className="cg-detail-navbtn"
onClick={onPrev}
disabled={!canPrev}
aria-label="Previous record"
title="Previous record"
>
β€Ή
</button>
<button
type="button"
className="cg-detail-navbtn"
onClick={onNext}
disabled={!canNext}
aria-label="Next record"
title="Next record"
>
β€Ί
</button>
<span className="cg-detail-pos">{positionLabel}</span>
</div>
<button
type="button"
className="cg-detail-close"
data-overlay-autofocus
onClick={onClose}
aria-label="Close panel"
title="Close"
>
Γ—
</button>
</div>
<div className="cg-record-layout">
<main className="cg-record-main">
<div className="cg-detail-titlewrap">
<div className="cg-detail-title" id={titleId} title={title}>
{title}
</div>
</div>
{/* C-EMBED (item 13) β€” the two ways of reading one record. This row is also
what the interleaved field list lost when its two section headings went
(see C-LAYOUT's amendment): the label for the list as a whole. */}
{canInsights && (
<div className="cg-record-tabs" role="tablist" aria-label="Record view">
{(["fields", "details"] as const).map((key) => (
<button
key={key}
type="button"
role="tab"
id={`${tabsId}-${key}`}
aria-selected={activeTab === key}
aria-controls={`${tabsId}-panel`}
className={"cg-record-tab" + (activeTab === key ? " is-on" : "")}
onClick={() => setTab(key)}
>
{key === "fields" ? "Fields" : "Details"}
</button>
))}
</div>
)}
<div
className="cg-detail-body"
id={`${tabsId}-panel`}
role={canInsights ? "tabpanel" : undefined}
aria-labelledby={canInsights ? `${tabsId}-${activeTab}` : undefined}
>
{activeTab === "details" && scope ? (
<div className="cg-record-insights">
{/* ⭐ WAVE-27 item 20 (R5 / C10) β€” ONE SECTION PER LINK FIELD, each an embedded
grid of the linked database showing exactly this record's linked rows.
⚠ THE ROWS ARE THE PREMISE, NOT A FILTER, which is the same idea `locked`
states for the time series below: you are reading ONE record, so "which rows"
is settled by the record and the embedded grid's toolbar narrows within them.
`embeddedRecordIds` is the projection that already enforces that
(`CustomerGrid.tsx:621-628`), so nothing new decides it here. */}
{linkSections.map((sec) => (
<section className="cg-detail-section cg-detail-linksec" key={sec.field.key}>
<h3 className="cg-detail-sechead">
{sec.ids.length === 0 ? (
<span>{sec.field.label}</span>
) : (
<button
type="button"
className="cg-detail-sectoggle"
aria-expanded={openLinks.has(sec.field.key)}
onClick={() =>
setOpenLinks((prev) => {
const next = new Set(prev);
if (next.has(sec.field.key)) next.delete(sec.field.key);
else next.add(sec.field.key);
return next;
})
}
>
{sec.field.label}
</button>
)}
<span className="cg-detail-seccount">
{sec.ids.length.toLocaleString()}{" "}
{sec.ids.length === 1 ? "record" : "records"}
</span>
</h3>
{sec.ids.length === 0 ? (
// Stated, never an empty grid. An embedded grid of nothing looks like a
// surface that failed to load; a sentence says which of the two it is.
<p className="cg-detail-secempty">
Nothing linked yet. Open the {sec.field.label} cell on the Fields tab to
link records.
</p>
) : !openLinks.has(sec.field.key) ? null : (
<div className="cg-detail-linkgrid">
{/* ⚠ LAZY, and it is load-bearing rather than a performance nicety:
`CustomerGrid` mounts THIS component, so a static import here would be
a module cycle. `React.lazy` breaks it at the module graph (the import
only resolves when a section actually renders) and composes with the
route-level splitting rather than fighting it. */}
{/* β›” THE SHARED MARK, NEVER A SENTENCE (owner R6, and `verify_icons`
enforces it across every source file β€” it caught a "Loading…" here on
this gate's first run). `aria-label` is REQUIRED by C-SPIN: "no words"
means no words ON SCREEN, and deleting the label would take the wait
away from a screen reader entirely. */}
<Suspense
fallback={
<div className="cg-detail-secempty">
<span className="lp-spin" role="status" aria-label="Loading" />
</div>
}
>
{/* β›” ONE ATTRIBUTE IS MISSING ON PURPOSE AND IT IS THE VIEW-KIND
SWITCHER β€” `embeddedMode="browse"`. R5 asks for the FULL switcher
here; `CustomerGrid.tsx:4113` suppresses the mode control for every
embedded grid (`serverWindowed || embedded ? undefined : …`), so the
prop that distinguishes a BROWSE embed from the link PICKER embed
does not exist yet. It is requested as dated amendment A3 to contract
C10 in the wave-27 split doc (SESSION C owns `CustomerGrid.tsx`).
Everything else in this section is already correct β€” the target
table, the pinned rows and the toolbar all work today β€” so this ships
useful rather than waiting. When the prop lands, this becomes one
added line and nothing else moves. It is deliberately NOT smuggled in
through a cast: a spread that passed an unknown prop would typecheck
forever and silently do nothing, which is the failure mode this wave
keeps paying for. */}
<LinkedGrid
scope={sec.table}
embedded
embeddedRecordIds={sec.ids}
/>
</Suspense>
</div>
)}
</section>
))}
{/* The time series STAYS, as ONE section (R5's own wording). `locked` says the pid
set is the PREMISE, not a control: you are reading one customer, so the filter
surface is the record itself (the owner's words). The panel keeps its own
bucket/span/field controls live against local state. */}
<section className="cg-detail-section">
<TimeSeriesPanel
scope={scope}
pids={insightPids}
fields={fields}
/* wave17 item 5 / R9 β€” the population `insightPids` names, which here is one
record. sum-vs-avg is then moot: both reduce to this record's own value. */
rows={insightRows}
display={tsDisplay}
onDisplay={(next) => setTsDisplay((d) => ({ ...d, ...next }))}
locked
/>
</section>
</div>
) : (
<>
{/* C-LAYOUT β€” ONE list, this user's order, every type interleaved. */}
<section className="cg-detail-section cg-detail-fieldlist">
{orderedKeys.map((key, index) => {
const field = fieldByKey.get(key);
return field ? renderRow(field, index) : null;
})}
</section>
{/* Wave-8 I12c (C5) β€” Documents. Rendered only when the host actually
serves them: a section with a dead upload control on a deployment
that has no document storage would be a promise the app cannot keep. */}
{onDocAdd && onDocFetch && onDocDelete && (
<Documents
pid={Number(record.pid)}
docs={docs ?? []}
docPayload={docPayload}
onAdd={onDocAdd}
onFetch={onDocFetch}
onDelete={onDocDelete}
/>
)}
</>
)}
</div>
</main>
{/* ⭐ WAVE 19 (owner item 12) β€” the rail finally gets the SCOPE this modal has held
since wave 13 and never passed on. `scope != null` gates it for exactly the
reason it gates Insights (see the prop's own note): a pid means nothing without
the topic it belongs to, and a guessed topic answers a question nobody asked.
⚠ The key is scope+pid, not pid: two databases can hold the same numeric id, and
a key that only changed on the number would REUSE this component across a surface
switch, painting one record's comments under another's name until the fetch
returned. */}
{showComments && scope != null && (
<RecordComments
key={`${scope}:${Number(record.pid)}`}
scope={scope}
pid={Number(record.pid)}
viewer={viewer}
/>
)}
</div>
</div>
</div>
{/* ⭐ Wave-23 C7 β€” the document viewer, INSIDE this portal and therefore stacked above
this modal. `useOverlayLayer`'s stack is what makes that work: it registers on top, so
Escape closes the viewer and leaves the record open rather than dismissing both. */}
{jsonKey && (() => {
const field = fields.find((f) => f.key === jsonKey);
if (!field) return null;
return (
<JsonViewer
label={field.label}
value={rawText(record[field.key])}
onSave={
mayEditField(field, viewer)
? (next) => onNotesCommit(field.key, next)
: undefined
}
onClose={() => setJsonKey(null)}
/>
);
})()}
{/* ⭐ Wave-27 item 13 (R13) β€” the code editor, stacked the same way and for the same
reason: `useOverlayLayer` puts it on top, so Escape closes the editor and leaves the
record open. */}
{codeKey && (() => {
const field = fields.find((f) => f.key === codeKey);
if (!field) return null;
return (
<CodeViewer
label={field.label}
value={rawText(record[field.key])}
language={codeLanguageOf(field)}
onSave={
mayEditField(field, viewer)
? (next) => onNotesCommit(field.key, next)
: undefined
}
onClose={() => setCodeKey(null)}
/>
);
})()}
</BodyPortal>
);
}