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>
  );
}
