// The chrome: sign-in, the masthead, the per-role tab row, the footer.
//
// THE PRINCIPLE THE WHOLE THING HANGS ON
//
//     The screen mirrors the database. What a role cannot do, its workspace does
//     not show — and what its workspace does not show, the database would refuse
//     anyway.
//
// The second half is what makes the first half a control rather than a
// decoration. Hiding a tab is cosmetic; anybody can type a URL. So the tab sets
// below decide what is OFFERED, and every pane behind them is fed by a
// role-scoped endpoint that would refuse the wrong caller on its own. Typing
// another role's address reaches a refusal state having fetched nothing,
// because there is no call in this app that fetches broadly.

const { useState, useEffect, useCallback } = React;

// ── The six workspaces (architecture §3, owner decision U8) ──────────────
// This table IS the specification. The acceptance test asserts the rendered tab
// set matches it exactly for each role, so a tab added here without a pane, or
// a pane added without a tab, is caught rather than discovered.
// EVERY WORKSPACE NOW OPENS ON `today`, and that is the one entry in this
// table shared by all six roles for a reason that is not a grant. Until
// 2026-08-23 each role landed in a working list — the requester in forty-eight
// deals, Legal in seventy-two tickets, the auditor in a chain of several
// hundred acts. Each is the right screen for the job it names and none is the
// right screen for the first ten seconds of a working day, which asks what is
// waiting on ME, where the work is sitting, and how the whole thing looks.
//
// It confers nothing. The pane holds no read of its own: it draws the waiting
// derivation the notification digest already uses, the counts THIS RAIL
// fetches, and one composition per role taken from a read that role already
// holds a tab onto. A role with no tab onto a thing has no card for it either.
//
// `tabs[0].key` is where main.jsx lands somebody after sign-in, so putting it
// first IS the change; the order of the rest is untouched.
const WORKSPACES = {
  requester: {
    workspace: 'My deals',
    opensOn: 'Their engagements only, each with its stage, and what is waiting on them',
    tabs: [
      { key: 'home', label: 'today' },
      { key: 'my-deals',  label: 'my deals' },
      { key: 'intake',    label: 'intake' },
      { key: 'negotiate', label: 'negotiate' },
      // The deal room (0079, Mike 2026-08-09): the negotiation record with
      // the conversation beside it. One pane, three roles — the scoping is
      // the deal-room tables' own policies, so the same surface answers a
      // requester their rooms and Legal every room.
      { key: 'deal-room', label: 'deal room' },
      { key: 'obligations', label: 'obligations' },
      // ── Sourcing (SRC-1..SRC-4) ────────────────────────────────────
      // FOUR PACKAGES LANDED WITHOUT A SCREEN and this is it. The tab is
      // offered to every role that holds a grant on cw.sourcing_run, which
      // is five of the six — the pane's own scoping is the database's, so
      // each is answered what their policy admits from one render.
      //
      // A VIEWER IS DELIBERATELY ABSENT. 0096 grants them nothing here, and
      // a tab landing on a refusal is worse than no tab.
      { key: 'sourcing',  label: 'sourcing' },
      { key: 'competitions',  label: 'competitions' },
      // ── DEPARTURES FROM THE MASTER (0006/0012/0019, reached 2026-08-23) ──
      // The tab goes wherever the role holds a grant on what it shows, and
      // `0012` grants SELECT on the whole family to five of the six roles —
      // everybody but the Administrator, who holds no grant on it at all and
      // therefore gets no tab.
      //
      // NOT ONE READ AND NOT ONE WRITE EVER REACHED ANY OF IT. Six objects,
      // three writable tables with policies, an append-only guarantee, an audit
      // trigger, a required-approver check and a GATE that refuses a statement
      // of work contradicting its master without an authorised override. So a
      // legitimate departure was blocked and nobody could authorise it — a
      // locked door with no key, against this repository's own rule that every
      // gate needs a door that is not a lie.
      //
      // What differs between the five is the affordance: a requester may
      // propose on a statement of work they own and sign their own approval,
      // Legal may authorise, and the Auditor and the viewer are offered
      // nothing. Every one of those sentences is a policy in `0012`.
      { key: 'departures', label: 'departures' },
      { key: 'vendors',   label: 'vendors' },
      { key: 'suppliers', label: 'suppliers' },
      // ── THE READING ROOM, ON EVERY RAIL (2026-08-23) ─────────────────
      // The tab goes wherever the role holds a grant on what it shows, and
      // `0017` grants SELECT on cw.agreement_share and cw.reading_room to all
      // six roles BY NAME. It reached one of them.
      //
      // The two Legal rails are the sharp case: `legal_shares` and
      // `legal_unshares` name exactly these roles for the INSERT and the
      // UPDATE, and `POST /shares` / `POST /shares/revoke` have been dispatched
      // by the doorway since 0017 with no screen calling either. So the act
      // that puts a contract in front of the socialisation audience — the whole
      // reason ADR-0008 created the viewer — could not be performed by anybody.
      //
      // What each of the other rails gains is a question nothing answered: a
      // requester "who has been shown my contract", an auditor and an
      // administrator "who could see this, and who used to". None of them is
      // offered an act, because no policy names them for one.
      { key: 'reading-room', label: 'reading room' },
      // ── OUR OWN PAPER (NC-16, 0049 — reached 2026-08-24) ────────────────
      // The tab goes wherever the role holds a grant on what it shows, and
      // `0049` grants SELECT on the three portfolio views to five of the six
      // roles by name — everybody but the viewer. It reached NONE of them:
      // the two reads were the last pair in the doorway's table that
      // `api.jsx` could not call, built 2026-07-30 and unreachable for
      // twenty-five days.
      //
      // What each rail gains is one question: "which of our contracts carry
      // this clause", which is where every recall starts, and "where did the
      // library have no answer at all". A requester is fenced by the view's
      // own WHERE clause to runs they recorded or deals they own, and the
      // pane says so — so this tab is honest on their rail rather than a
      // company total they must not read as one.
      { key: 'portfolio', label: 'our own paper' },
      { key: 'my-record', label: 'my record' },
      { key: 'ai-use',   label: 'AI use' },
      { key: 'notices',  label: 'notices' },
    ],
  },
  legal_reviewer: {
    workspace: 'Review desk',
    opensOn: 'Everything waiting on Legal judgement, oldest first',
    tabs: [
      { key: 'home', label: 'today' },
      { key: 'contract-onboarding', label: 'contract onboarding' },
      { key: 'review-desk',  label: 'review desk' },
      { key: 'tickets',      label: 'tickets' },
      // THE LIBRARY BUILDER (ADR-0010, 0102). The tab goes wherever the role
      // holds a grant on what it shows, and cw.library_draft_register is
      // granted to this role: a reviewer decides AI candidates, so a reviewer
      // can see what the machine was shown when it wrote one. They also hold
      // the insert on cw.clause_draft, so the drafting form is drawn for them.
      { key: 'builder',      label: 'library builder' },
      { key: 'routing',      label: 'routing' },
      // 0044 answered WHICH LAWYER; this answers WHICH DISCIPLINE — the
      // half of open question #6 that was never built (ADR-0013).
      { key: 'consultations', label: 'consultations' },
      // THE TAB GOES WHEREVER THE ROLE HOLDS A GRANT ON WHAT IT SHOWS. `0009`
      // grants cw.library_origin_mix and cw.run_origin_mix to this role and to
      // the Legal admin, and the pane sat on the auditor's rail alone. Found
      // and deliberately left during #192; closed by #196.
      { key: 'origin-mix',   label: 'origin mix' },
      { key: 'ai-use',   label: 'AI use' },
      { key: 'approvals',    label: 'approvals' },
      { key: 'negotiations', label: 'negotiations' },
      { key: 'deal-room',    label: 'deal room' },
      { key: 'reading-room', label: 'reading room' },
      { key: 'departures', label: 'departures' },
      { key: 'suppliers', label: 'suppliers' },
      { key: 'holds',        label: 'holds' },
      // THE ROLE THE WAIVER POLICY NAMES, given the book it waives. `0050`'s
      // record_act admits a legal_reviewer to every obligation act including
      // 'waived' — Legal's hands, both of them — and this tab was on the
      // requester's and the Legal admin's rails only. So the reviewer held a
      // grant on an act whose subject they could not open. The rail reaches
      // eleven here for the reason the Legal admin's did: a capability the
      // database grants and the screen hides is the defect, and the length of
      // a rail is a layout question.
      { key: 'obligations',  label: 'obligations' },
      { key: 'sourcing',     label: 'sourcing' },
      { key: 'competitions',     label: 'competitions' },
      { key: 'portfolio', label: 'our own paper' },
      { key: 'notices',  label: 'notices' },
    ],
  },
  legal_admin: {
    workspace: 'The library',
    opensOn: 'The vault, the ladders and the rules — and everything a reviewer sees',
    tabs: [
      { key: 'home', label: 'today' },
      { key: 'contract-onboarding', label: 'contract onboarding' },
      { key: 'library',     label: 'the library' },
      { key: 'ladders',     label: 'ladders & rules' },
      // Next to the library it proposes into, because this role owns what the
      // company's contract language IS. Twelve deep now — a layout question,
      // and the note nine entries below already says it is not a reason to
      // withhold a screen the database grants.
      { key: 'builder',     label: 'library builder' },
      { key: 'governance',  label: 'governance' },
      { key: 'reading-room', label: 'reading room' },
      { key: 'departures', label: 'departures' },
      { key: 'suppliers', label: 'suppliers' },
      { key: 'retention',   label: 'holds & retention' },
      { key: 'obligations', label: 'obligations' },
      { key: 'review-desk', label: 'review desk' },
      // Amended 2026-08-24: `approvals` joins BOTH Legal rails. 0015 grants the
      // deciding act to cw_legal_admin exactly as to cw_legal_reviewer
      // (decide_override_finding, the legal_decides policy), and the Legal Home
      // desk — which this role reaches through `review desk` — carries an
      // "override decisions" figure that presses to #/approvals. Until today
      // that press answered a legal_admin "not part of your workspace": a
      // control leading somewhere the role could not go, on a decision the
      // database says is theirs. The tab goes where the grant is — the rule
      // `sourcing`, `obligations`, `library builder` and `suppliers` each
      // used before it.
      { key: 'approvals',   label: 'approvals' },
      // THE NINTH AREA, added 2026-08-05 by owner decision NI-1. It was put to
      // Mike with a recommendation to leave the Legal admin at eight and give
      // them the numbers through reporting; he asked for the desk itself. The
      // rail is now nine deep, which is past what a rail reads well at — a
      // layout question for when sourcing and obligations grow this workspace
      // further, and deliberately not a reason to withhold a decided screen.
      { key: 'negotiations', label: 'negotiations' },
      { key: 'deal-room',   label: 'deal room' },
      { key: 'routing',     label: 'routing' },
      // The discipline half of routing (ADR-0013). This takes the rail to
      // eleven, which is past what a rail reads well at — the note eight
      // entries above already says so, and it is a LAYOUT question rather
      // than a reason to withhold a decided screen. A Legal admin who
      // could not reach expert consultation would be a capability nobody
      // running Legal can use.
      { key: 'consultations', label: 'consultations' },
      { key: 'sourcing',    label: 'sourcing' },
      { key: 'competitions',    label: 'competitions' },
      { key: 'reporting',   label: 'reporting' },
      { key: 'portfolio', label: 'our own paper' },
      // Same rule and the same migration as the reviewer's entry above: `0009`
      // grants both origin-mix views to this role, and the screen was the
      // auditor's alone. Found and deliberately left during #192; closed by #196.
      { key: 'origin-mix',  label: 'origin mix' },
      { key: 'ai-use',   label: 'AI use' },
      { key: 'notices',  label: 'notices' },
    ],
  },
  auditor: {
    workspace: 'The record',
    opensOn: 'The full chain, verified status, and the history of who was granted what',
    tabs: [
      { key: 'home', label: 'today' },
      { key: 'contract-onboarding', label: 'contract onboarding' },
      { key: 'the-record',     label: 'the record' },
      { key: 'quality',        label: 'quality' },
      { key: 'departures', label: 'departures' },
      { key: 'suppliers', label: 'suppliers' },
      { key: 'origin-mix',     label: 'origin mix' },
      { key: 'ai-use',   label: 'AI use' },
      // 0102 grants the auditor cw.library_draft_register, and the reason is
      // the auditor's whole job: 'origin mix' says how much of the library
      // began as an AI draft, and this says WHICH drafts, what the model was
      // shown, and whether the lawyer changed a word. No act is offered here.
      { key: 'builder',        label: 'library builder' },
      { key: 'reading-room', label: 'reading room' },
      { key: 'access-history', label: 'access history' },
      // "READS EVERYTHING, CHANGES NOTHING" WAS HALF TRUE. 0038 grants the
      // auditor select on cw.obligation_state and on close eligibility, and
      // there was no tab — so the whole post-signature half of the product
      // was unreadable to the role whose job is reading all of it. The acts
      // are not offered here; the pane asks the role and says so.
      { key: 'obligations',    label: 'obligations' },
      { key: 'sourcing',       label: 'sourcing' },
      { key: 'competitions',       label: 'competitions' },
      { key: 'reporting',      label: 'reporting' },
      { key: 'portfolio', label: 'our own paper' },
      { key: 'notices',  label: 'notices' },
    ],
  },
  viewer: {
    workspace: 'Reading room',
    opensOn: 'Just the agreements shared with them',
    tabs: [
      { key: 'home', label: 'today' },
      { key: 'reading-room', label: 'reading room' },
      // A PANEL MEMBER IS A VIEWER HOLDING A SEAT, not a role of its own
      // (ADR-0013). Said "not a seventh role" until 0119, when ADR-0014 made
      // procurement the actual seventh — a different role for a different
      // reason, and no panel member is one. The tab is offered to every viewer because holding a
      // seat is not something the shell can see — cw.consultation's read
      // policy answers a viewer with no seat an empty list, which is the
      // honest render: an empty desk, not a hidden one.
      { key: 'departures', label: 'departures' },
      { key: 'consultations', label: 'consultations' },
      { key: 'notices',  label: 'notices' },
    ],
  },
  // ── THE SEVENTH RACK (0119, ADR-0014) ────────────────────────────────────
  // A role with no tabs is a role nobody can act as, which is why this lands in
  // the same pull request as the role itself rather than after it.
  //
  // FOUR TABS, AND THE LIST IS SHORT ON PURPOSE. Every tab here is one whose
  // reads procurement actually holds a grant on after 0119 — the library it now
  // owns, the documents built from that library, and the messages 0098 decided
  // every role may send and receive. A tab landing on a refusal is worse than
  // no tab (the viewer's absence from `sourcing` is the precedent).
  //
  // WHAT IS DELIBERATELY ABSENT: `intake`, `negotiate`, `deal-room`,
  // `obligations`, `reading-room`. Procurement curates HOW WE ASK the market;
  // it has no part in what we would sign, and 0119 grants it nothing there. If
  // somebody later adds one of these tabs, the grant has to move first.
  procurement: {
    workspace: 'The sourcing library',
    opensOn: 'The wording every supplier is sent, and the documents built from it',
    tabs: [
      { key: 'home', label: 'today' },
      // The library it owns, and the built documents ADR-0014 named as the
      // minimum workspace. One pane answers both: the pane's scoping is the
      // database's, and 0119 puts procurement on cw.sourcing_run's read policy
      // so this opens on every built document rather than on an empty list.
      { key: 'sourcing',  label: 'sourcing' },
      { key: 'competitions',  label: 'competitions' },
      // `suppliers` WAS HERE AND CAME OUT (2026-08-30). #235 added it on the
      // stated grounds that every tab on this rack opens on a read the role
      // holds — and the test written to prove that checked cw.supplier, which
      // procurement does read. GET /suppliers is not that: it joins
      // cw.agreement_supplier and cw.agreement for its deal counts, and
      // procurement holds neither, so the tab answered 403. Found by signing
      // in as the role and pressing it, which is the only thing that would have.
      //
      // The rule this rack states is that a tab landing on a refusal is worse
      // than no tab, so it is removed rather than left looking available. What
      // procurement actually needs of a supplier — choosing one to invite, and
      // keeping the people at it — is on the competitions pane, fed by
      // GET /suppliers/names and GET /suppliers/contacts, both of which it holds.
      { key: 'notices',   label: 'notices' },
    ],
  },
  administrator: {
    workspace: 'Administration',
    opensOn: 'People and access, and the health of the record',
    tabs: [
      { key: 'home', label: 'today' },
      { key: 'contract-onboarding', label: 'contract onboarding' },
      { key: 'people',   label: 'people & access' },
      { key: 'settings', label: 'settings' },
      { key: 'health',   label: 'system health' },
      { key: 'reading-room', label: 'reading room' },
      { key: 'watchers', label: 'watchers' },
      { key: 'ai-use',   label: 'AI use' },
      // The same grant the auditor holds (0038). The Administrator reads every
      // deal and decides nothing inside any workflow (U5) — which is exactly
      // what this pane offers them: the whole book, and no act on it.
      { key: 'obligations', label: 'obligations' },
      { key: 'suppliers', label: 'suppliers' },
      { key: 'sourcing', label: 'sourcing' },
      { key: 'competitions', label: 'competitions' },
      // Seating an expert is an ACCESS GRANT, which is why it is here and
      // not in Legal's workspace — and why the routing RULES are not on
      // this screen at all (U5: a steward decides nothing).
      { key: 'panel',    label: 'expert panel' },
      // Mike, 2026-08-22: "enable the Admin to have a user friendly UI for
      // designing custom workflow for their own company". The switches also
      // appear on `settings` as governance rows, because that is where they
      // are stored — this is where they are DESIGNED, which is a different
      // job: a grid showing which risks nobody refers anywhere, and each
      // switch in the words of its consequence rather than its key.
      // THE SAME READING THE OBLIGATIONS TAB IS HERE ON. `0049` names
      // cw_administrator in its grant, and U5 is not offended: this role
      // reads every deal and decides nothing inside any workflow, which is
      // exactly what this pane offers them — counts, and no act on any of
      // them. The reports on `reporting` are withheld from this rail because
      // the GRANT withholds them; this one is granted, so it is drawn.
      { key: 'portfolio', label: 'our own paper' },
      { key: 'workflow', label: 'workflow' },
      { key: 'onboarding', label: 'workflow setup' },
      { key: 'notices',  label: 'notices' },
    ],
  },
};

// ── Sign in ───────────────────────────────────────────────────────────────
// The front door, set as the registry-access sheet from the 2026-08-10
// design set: one portrait sheet dropped on the dark desk, the form a
// brighter card laid on it, the rules of the desk and the status marks on
// the furniture around it.
//
// ONE DELIBERATE DEPARTURE FROM THE PICTURE: the design shows a password
// field. There is no password yet, and dressing the development doorway up
// as authentication — a box that accepts anything — would be a screen
// claiming a control that does not exist, which is the failure class this
// rebuild is paying down. The sheet says so where the field would be.

// The engraved crest. Drawn in the ink of whatever it sits on.
function Crest({ size = 64 }) {
  return (
    <svg className="crest" width={size} height={size} viewBox="0 0 64 64"
         fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
      <path d="M32 8 L50 14 V32 C50 44 42 52 32 56 C22 52 14 44 14 32 V14 Z" />
      <path d="M32 8 V56 M14 24 H50" strokeWidth="1" opacity=".6" />
      <path d="M20 30 l6 8 M26 30 l-6 8" strokeWidth="1" opacity=".6" />
      <circle cx="41" cy="34" r="4" strokeWidth="1" opacity=".6" />
      <path d="M24 14.5 h16 M22 19 h20" strokeWidth="1" opacity=".6" />
      <path d="M26 4.5 L32 2 L38 4.5 L32 7 Z" strokeWidth="1.2" />
      <path d="M8 58 C18 54 46 54 56 58" strokeWidth="1" opacity=".5" />
    </svg>
  );
}

// The five marks, for the reference slip. A legend states meanings; it is
// not data, and it claims nothing about any record.
function StampLegend() {
  return (
    <div className="stamp-legend mt-6">
      <span className="section-label" style={{ fontSize: 9.5 }}>status marks</span>
      <span className="chip chip-ok">effective</span>
      <span className="chip chip-pending">pending</span>
      <span className="chip chip-unknown">never-ran</span>
      <span className="chip chip-gone">superseded</span>
      <span className="chip chip-err">error</span>
    </div>
  );
}

function SignIn({ onSignedIn }) {
  const [person, setPerson] = useState('');
  const [busy, setBusy] = useState(false);
  const [reason, setReason] = useState(null);
  const [auth, setAuth] = useState(null);

  useEffect(() => {
    let active = true;
    const load = async () => {
      const result = await API.authStatus();
      if (!active) return;
      if (result.ok && ['development', 'oidc'].includes(result.body?.mode)) {
        setAuth(result.body);
      } else {
        setReason(result.reason || 'sign-in configuration could not be read');
      }
    };
    const query = new URLSearchParams(window.location.search);
    const signInFailure = query.get('sign_in');
    if (['failed', 'unavailable'].includes(signInFailure)) {
      setReason(signInFailure === 'unavailable'
        ? 'Company sign-in is temporarily unavailable. Try again; if it continues, contact your administrator.'
        : 'Sign-in could not be completed. Try again; if it continues, contact your administrator.');
      query.delete('sign_in');
      const suffix = query.toString();
      window.history.replaceState({}, '', window.location.pathname + (suffix ? `?${suffix}` : '') + window.location.hash);
    }
    load();
    return () => { active = false; };
  }, []);

  const submit = async (e) => {
    e.preventDefault();
    if (!person.trim() || busy) return;
    setBusy(true); setReason(null);
    const r = await API.signIn(person.trim());
    setBusy(false);
    if (r.ok) onSignedIn(r.identity); else setReason(r.reason);
  };

  return (
    <div className="h-full flex flex-col items-center justify-center"
         style={{ background: 'var(--bg)', overflowY: 'auto' }}>
      <div className="signin-sheet">
        <div style={{ borderBottom: '3px double var(--ink)', paddingBottom: 16 }}>
          <Crest />
          <div className="signin-wordmark mt-2">Clausewerk</div>
          <div className="signin-sub">Contract lifecycle management system</div>
        </div>

        <form onSubmit={submit} className="registry-access mt-7">
          <div className="access-rule">Registry access</div>

          {auth?.mode === 'oidc' ? (
            <React.Fragment>
              <div className="caption mt-5" style={{ lineHeight: 1.6 }}>
                Continue through your company identity provider. Clausewerk uses
                the identity it verifies and applies only the role granted here.
              </div>
              <button type="button" className="btn btn-primary mt-5 w-full justify-center"
                      style={{ padding: '10px 12px', fontSize: 12 }}
                      onClick={() => window.location.assign(auth.authorization_path)}>
                continue with your company
              </button>
            </React.Fragment>
          ) : auth?.mode === 'development' ? (
            <React.Fragment>
              <label className="section-label block mt-5" htmlFor="person">User ID</label>
              <input
                id="person" className="mt-2 w-full" autoFocus autoComplete="off"
                placeholder="name@clausewerk" style={{ padding: '9px 11px' }}
                value={person} onChange={(e) => setPerson(e.target.value)}
              />
              <div className="mt-4 p-2.5" style={{ border: '1px dashed var(--line-2)' }}>
                <span className="tag">local development sign-in</span>
                <div className="caption mt-1" style={{ lineHeight: 1.55 }}>
                  This local doorway identifies a known test person without
                  authenticating them. It is unavailable in customer mode.
                </div>
              </div>
              <button type="submit" className="btn btn-primary mt-5 w-full justify-center"
                      style={{ padding: '10px 12px', fontSize: 12 }}
                      disabled={busy || !person.trim()}>
                {busy ? 'signing in…' : 'press to sign in'}
              </button>
            </React.Fragment>
          ) : (
            <div className="caption mt-5">Checking the sign-in service…</div>
          )}

          {reason && (
            <div className="mt-4 panel-2 p-3">
              <div className="tag" style={{ color: 'var(--accent-2)' }}>refused</div>
              {/* The service's own sentence, unchanged. It says whether the
                  account is unknown, revoked, or holding a Legal grant nobody
                  has countersigned — all three are actionable, and "sign-in
                  failed" is not. */}
              <div className="text-[12.5px] mt-1.5" style={{ color: 'var(--mute)' }}>{reason}</div>
            </div>
          )}
        </form>

        <div className="authorized-box mt-6">
          Authorized officers only
          <span className="fine">All access is recorded and traceable.</span>
        </div>

        <div className="mt-4 flex items-center justify-between gap-4">
          {/* The registry's mark — an object on the paper, like the wax seal:
              decorative red, not a status (brief §6). */}
          <span className="stamp stamp-err stamp-sm" style={{ '--rot': '-2.5deg', lineHeight: 1.5 }}>
            Confidential<br />official record<br />public trust
          </span>
          <div className="caption text-right" style={{ lineHeight: 1.6, maxWidth: 280 }}>
            Your workspace is built from what your role can actually read.
            Nobody chooses a role here — the system looks up the one you hold.
          </div>
        </div>
      </div>

      <StampLegend />

      {/* The desk's own small print — furniture, shown where the desk is wide
          enough to hold it. */}
      <div className="desk-corner rules-of-the-desk">
        <div className="rules-title">Rules of the desk</div>
        <ol>
          <li>Red means error only.</li>
          <li>Pending is amber and hatched.</li>
          <li>Never-ran is dashed and unfilled.</li>
          <li>Superseded is struck and kept.</li>
          <li>Nothing said by colour alone.</li>
          <li>Honest empty states.</li>
          <li>Show only what the role can do.</li>
        </ol>
      </div>
    </div>
  );
}

// ── The masthead ──────────────────────────────────────────────────────────
// The registry's nameplate on the wood, in the build's own voice: the
// engraved wordmark at the left, and at the right whose desk this is — the
// "acting as" answer, which in this system decides what can be fetched at
// all. It belongs on the nameplate rather than behind a menu.
function Masthead({ identity, onSignOut, signingOut = false, signOutFailure = null }) {
  const ws = WORKSPACES[identity.role];
  // WRAPS RATHER THAN OVERFLOWS. At 375px the right-hand group — "desk of
  // …" and LEAVE THE DESK — is 438px wide and carries `shrink-0`, so it ran
  // off the right edge of every screen in the application and the sign-out
  // control could not be reached at all. `height` became `minHeight` because
  // a fixed height clips whatever wraps onto the second line.
  //
  // This is not a claim to work on a phone. It is the smaller promise that
  // the chrome stays reachable at any width, which a system that records
  // every access and asks people to leave the desk ought to keep.
  //
  // THE LEFT-HAND GROUP NEEDED THE SAME TREATMENT AND DID NOT GET IT. Only
  // `.masthead-right` was repaired; the nameplate beside it went on running
  // 16px past the edge at 375px, on every screen, for the same reason in a
  // different costume. It is named `masthead-left` so the rule that wraps it
  // has something honest to hold on to.
  return (
    <React.Fragment>
    <div className="masthead flex items-center justify-between flex-wrap px-6 border-b hair"
         style={{ minHeight: 56, rowGap: 6, paddingTop: 6, paddingBottom: 6 }}>
      <div className="masthead-left flex items-baseline gap-4 min-w-0">
        <span className="registry-wordmark">⚖ Clausewerk Registry</span>
        <span className="registry-est shrink-0">{ws.workspace}</span>
        {/* `masthead-strapline` is ruled in registry.css: hidden below 768px,
            shown from 768px up. It used to wear a responsive `md:` utility,
            and no such rule exists anywhere in this build, so the sentence had
            never been shown to anybody at any width (issue #221) — a guard in
            shell.test.mjs now refuses those utilities outright. `truncate`
            inside the `min-w-0` parent is what lets it shrink instead of
            pushing the right-hand group off the edge. */}
        <span className="text-[12px] truncate masthead-strapline"
              style={{ color: '#a8946c', fontStyle: 'italic' }}>
          {ws.opensOn}
        </span>
      </div>

      <div className="masthead-right flex items-center gap-3 shrink-0">
        <span className="desk-of" data-testid="acting-as">
          desk of <b>{identity.display_name || identity.person}</b>
          {' — '}
          <span data-testid="acting-role">{identity.role}</span>'s grant
        </span>
        <button className="btn btn-sm" onClick={onSignOut} disabled={signingOut}
                style={{ color: '#d9c9a4', borderColor: '#a8946c' }}>
          {signingOut ? 'leaving…' : 'leave the desk'}
        </button>
      </div>
    </div>
    {signOutFailure && (
      <div role="alert" className="px-6 py-2 text-[12px]"
           style={{ background: 'var(--err-bg)', color: 'var(--err)' }}>
        Sign-out did not finish: {signOutFailure}. You remain signed in.
      </div>
    )}
    </React.Fragment>
  );
}

// ── The navigation rack ───────────────────────────────────────────────────
// ONE PLACE, EVERY ROLE, EVERY VIEW (Mike, 2026-08-10): navigation is the
// top rack under the masthead, always. The rail-for-six-or-more rule is
// retired — the 2026-08-10 design set draws the rack across the top of all
// six screens, and a control that moves between screens is a control
// somebody has to find again. What varies by role is only WHICH pigeonholes
// the rack offers.
//
// Only this role's tabs, because only this role's panes will load. Hiding
// the others is not the control — the endpoints are — but showing them
// would be a promise the system will not keep.
//
// Counts appear ONLY where the app actually knows one. A rack showing "0"
// for an area it has not measured would be stating a fact it does not hold,
// so an unmeasured area shows nothing at all, and the difference is visible.

// Which endpoint stands behind each area's number. An area is listed here ONLY
// where a read already exists whose row count is the honest answer to "how many
// are waiting on me here". Everything else is deliberately absent and shows no
// number at all — a rail that printed 0 for an area it never asked about would
// be stating a fact it does not hold.
//
// An entry is either the read itself, or `{ read, only }` where `only` narrows
// what the read already returned to the rows the AREA is about. The narrowing
// form arrived with the negotiations rail (2026-08-05): the positions read
// answers every contested point, and the number beside "negotiations" claims
// something narrower — what is waiting on Legal. Two facts, one read, and the
// pane narrows it identically, so the number and the queue behind it cannot
// disagree. NOTE WHAT THIS IS NOT: it is not fetching broadly and filtering for
// permission. What the read returned is already only what the caller may see.
const COUNT_SOURCE = {
  'my-deals':    (A) => A.deals(),
  'obligations': (A) => A.obligationsBook(),
  'review-desk': (A) => A.waitingTickets(),
  'tickets':     (A) => A.tickets(),
  // Repointed 2026-08-24. It counted the countersign queue, but the tab
  // renders OverridesPane — override requests decided finding by finding —
  // and countersigning lives on Legal Home and the console. The number and
  // the pane behind it must count the same thing, so this narrows the
  // overrides read exactly as the pane and the Legal Home figure do: a
  // request waits here once socialised, and not before.
  'approvals':   { read: (A) => A.overrides(),
                   only: (r) => r.state === 'socialised' },
  'holds':       (A) => A.holds(),
  // Repointed 2026-08-05. It was the whole concession list, which counts
  // something real but not the thing the rail claims.
  'negotiations':{ read: (A) => A.positions(),
                   only: (p) => p.state === 'escalated' },
  'library':     (A) => A.clauses(),
  'ladders':     (A) => A.ladders(),
  'retention':   (A) => A.retentionDue(),
  'people':      (A) => A.people(),
  'watchers':    (A) => A.watchers(),
  // WHAT IS WAITING ON *YOU*, not how many notices exist. The read answers
  // everything you may see — including what you raised, and, for Legal and
  // the Auditor, the whole record — so the bare row count would be a number
  // about the system rather than about the person reading it. `only` gets
  // the identity for exactly this: a notice reaches you by naming you OR by
  // naming your role, and both are addresses.
  'notices':     { read: (A) => A.notices(),
                   only: (n, me) => n.state === 'open'
                     && (n.to_person === me.person || n.to_role === me.role) },
};

// Counts are read once per workspace, through the same role-scoped endpoints
// the panes use. A read that refuses or fails leaves its area WITHOUT a number
// rather than with a zero — the difference between "none are waiting" and "we
// could not ask" is exactly the difference this product exists to keep.
function useRailCounts(me) {
  const role = me.role;
  const [counts, setCounts] = useState({});
  useEffect(() => {
    let live = true;
    const keys = WORKSPACES[role].tabs
      .map((t) => t.key)
      .filter((k) => COUNT_SOURCE[k]);
    if (!keys.length) { setCounts({}); return; }
    Promise.all(keys.map(async (k) => {
      try {
        const source = COUNT_SOURCE[k];
        const read = typeof source === 'function' ? source : source.read;
        const only = typeof source === 'function' ? null : source.only;
        const r = await read(API);
        if (r && r.ok && Array.isArray(r.rows)) {
          return [k, only ? r.rows.filter((row) => only(row, me)).length : r.rows.length];
        }
      } catch (_) { /* falls through to no number */ }
      return null;
    })).then((pairs) => {
      if (!live) return;
      setCounts(Object.fromEntries(pairs.filter(Boolean)));
    });
    return () => { live = false; };
  }, [role, me.person]);
  return counts;
}

// ── The compartments of the rack ──────────────────────────────────────────
//
// EIGHTEEN PIGEONHOLES IN ONE FLAT ROW is what this rack had become, and this
// file said so three times in its own comments before anybody acted on it —
// "nine deep, which is past what a rail reads well at", then eleven, then "the
// length of a rail is a layout question". Each time the layout question was
// deferred rather than used as a reason to withhold a screen the database
// grants, which was the right call. This is that question answered.
//
// Measured before the change, signed in as the Legal admin at 1280px: 18
// labels, two rows, 85px of chrome, and no order a person could learn.
//
// WHAT THIS IS NOT. It is not a sidebar — navigation stays the top rack under
// the masthead, every role, every view (Mike, 2026-08-10), because a control
// that moves between screens is a control somebody has to find again. It is
// the same rack with the joiner's compartments the design brief already draws
// it with (§6, "the pigeonhole").
//
// AND IT ADDS NOTHING AND REMOVES NOTHING. The tab sets in WORKSPACES above are
// untouched, in their order, `home` still first. A compartment holds exactly
// the labels the role already had; a compartment with nothing in it for this
// role is not drawn at all, and neither is its label — the same rule the rest
// of the shell keeps.
//
// ONE ENTRY PER TAB KEY, and `db/test/the-rack-is-grouped.test.mjs` fails by
// name when a workspace gains a tab this table cannot place. That guard is the
// whole point of the table: a rule with one site per tab is exactly the rule
// this repository has repeatedly kept at SOME of its sites.
//
// `home` is deliberately absent. It stands alone at the left, where a desk
// belongs, and a compartment of one labelled "today" over a plate labelled
// "today" would be furniture.
const AREA_GROUP = {
  // The pipeline — a purchase from the question to the paper.
  'my-deals':      'the pipeline',
  'intake':        'the pipeline',
  'sourcing':      'the pipeline',
  'competitions':  'the pipeline',
  'negotiate':     'the pipeline',
  'negotiations':  'the pipeline',
  'deal-room':     'the pipeline',
  'approvals':     'the pipeline',
  'holds':         'the pipeline',
  'departures':    'the pipeline',
  'vendors':       'the pipeline',
  'suppliers':     'the pipeline',
  // The Legal desk — what is waiting on a lawyer's judgement.
  'review-desk':   'the legal desk',
  'tickets':       'the legal desk',
  'routing':       'the legal desk',
  'consultations': 'the legal desk',
  'builder':       'the legal desk',
  // The library — what the company's contract language IS.
  'library':       'the library',
  'ladders':       'the library',
  'governance':    'the library',
  'retention':     'the library',
  // After signature — what was promised, and who may read it.
  'obligations':   'after signature',
  'reading-room':  'after signature',
  // The record — what happened, and what the figures say about it.
  'the-record':    'the record',
  'quality':       'the record',
  'origin-mix':    'the record',
  'access-history':'the record',
  'reporting':     'the record',
  'portfolio':     'the record',
  'my-record':     'the record',
  'ai-use':        'the record',
  // Administration — the machine, and no voice in what it holds.
  'people':        'administration',
  'settings':      'administration',
  'health':        'administration',
  'watchers':      'administration',
  'panel':         'administration',
  'workflow':      'administration',
  'onboarding':    'administration',
  'contract-onboarding': 'administration',
  // Messages — everybody can raise one and receive one (Mike, S346).
  'notices':       'messages',
};

// The order the compartments are hung in, left to right. Declared ONCE, and a
// compartment named here with no tabs for this role is skipped rather than
// drawn empty.
const RACK_ORDER = [
  'the pipeline',
  'the legal desk',
  'the library',
  'after signature',
  'the record',
  'administration',
  'messages',
];

// ── Jump to ───────────────────────────────────────────────────────────────
// The rack is grouped, which answers "where do I look"; this answers "where is
// the thing I can already name". It offers EXACTLY the areas this role's rack
// offers — it is built from `WORKSPACES[role].tabs`, not from a list of its
// own, because a third place tabs are listed is a third place they drift.
//
// The sentence beside each is the desk's own (`AREA_NOTE` in home.jsx), read
// rather than copied, so typing "who owes" finds obligations. The count is the
// rack's own count, which is the desk's, which is the notification digest's.
// Nothing here counts anything.
function JumpTo({ tabs, counts, hidden, onPick, onClose }) {
  const [q, setQ] = useState('');
  const [cursor, setCursor] = useState(0);
  const note = (k) =>
    (typeof AREA_NOTE === 'object' && AREA_NOTE && AREA_NOTE[k]) || '';

  const hay = (t) => `${t.label} ${AREA_GROUP[t.key] || ''} ${note(t.key)}`.toLowerCase();
  const shown = tabs.filter((t) => !q.trim() || hay(t).includes(q.trim().toLowerCase()));
  const at = Math.min(cursor, Math.max(0, shown.length - 1));

  const onKey = (e) => {
    if (e.key === 'Escape') { e.preventDefault(); onClose(); return; }
    // Moved with an UPDATER rather than from `at`, because two keystrokes
    // inside one React batch both read the same stale cursor and the second
    // one goes nowhere. Observed while driving this from the console; a person
    // typing would rarely hit it and would never be able to say why.
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      setCursor((c) => Math.min(Math.min(c, shown.length - 1) + 1, shown.length - 1));
      return;
    }
    if (e.key === 'ArrowUp') {
      e.preventDefault();
      setCursor((c) => Math.max(Math.min(c, shown.length - 1) - 1, 0));
      return;
    }
    if (e.key === 'Enter' && shown[at]) { e.preventDefault(); onPick(shown[at].key); }
  };

  return (
    <div className="jump-scrim" data-testid="jump-sheet"
         onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="jump-sheet" role="dialog" aria-label="jump to an area">
        <div className="jump-rule">Jump to an area of your workspace</div>
        <input
          autoFocus className="jump-input" data-testid="jump-input"
          placeholder="type part of a name, or part of what it is for"
          value={q}
          onChange={(e) => { setQ(e.target.value); setCursor(0); }}
          onKeyDown={onKey}
        />
        {shown.length === 0 ? (
          <div className="jump-none" data-testid="jump-none">
            Nothing in your workspace matches that. Your workspace holds
            {' '}{tabs.length} areas; another role's areas are not offered here,
            because they are not offered to you anywhere.
          </div>
        ) : (
          <ul className="jump-list">
            {shown.map((t, i) => {
              const n = counts ? counts[t.key] : undefined;
              return (
                <li key={t.key}>
                  <button
                    className={`jump-row${i === at ? ' at' : ''}`}
                    data-testid={`jump-${t.key}`}
                    onMouseEnter={() => setCursor(i)}
                    onClick={() => onPick(t.key)}
                  >
                    <span className="jump-label">{t.label}</span>
                    {/* PUT AWAY, NOT GONE. The jump box offers every area of
                        the workspace whatever the rack draws — that is what
                        makes hiding a tidy-up rather than a loss. */}
                    <span className="jump-bay">
                      {AREA_GROUP[t.key] || 'the desk'}
                      {hidden && hidden.has(t.key) ? ' · put away' : ''}
                    </span>
                    {n === undefined ? null : <span className="jump-n">{n}</span>}
                    <span className="jump-note">{note(t.key)}</span>
                  </button>
                </li>
              );
            })}
          </ul>
        )}
        <div className="jump-keys">
          <kbd>↑</kbd><kbd>↓</kbd> move · <kbd>enter</kbd> open · <kbd>esc</kbd> close
        </div>
      </div>
    </div>
  );
}

// ── Your own rack (0108) ──────────────────────────────────────────────────
//
// THREE CHOICES, AND NONE OF THEM IS A PERMISSION. Pinning draws an area
// first; hiding withholds its plate from the shelf; "land here" decides where
// sign-in sends you. What a person may SEE is the database's answer and this
// changes none of it — the rack is built from the tabs the ROLE holds, so a
// preference naming an area somebody does not hold has nothing to attach to.
//
// AND HIDING NEVER HIDES THE WORK. A hidden area is still counted by its
// compartment, still offered by the jump box, and still reachable at its
// address; the compartment says how many are hidden inside it. Without that
// rule somebody could hide `approvals` and never learn that three override
// requests were waiting, which would make a convenience into a trap.
//
// KEPT IN THE DATABASE, against the person, because a preference in the
// browser does not follow somebody to the laptop they use tomorrow and does
// leave a list of the screens they use on the disk of a shared desk —
// `common.jsx` refuses localStorage for the intake draft on the same ground.
function Customise({ me, tabs, rack, onChange, onClose }) {
  const [busy, setBusy] = useState(false);
  const [refused, setRefused] = useState(null);

  const pinned = new Set(rack.pinned || []);
  const hidden = new Set(rack.hidden || []);

  const save = async (next) => {
    setBusy(true); setRefused(null);
    const r = await API.setMyWorkspace({
      pinned: [...next.pinned].join(','),
      hidden: [...next.hidden].join(','),
      opens_on: next.opensOn || '',
    });
    setBusy(false);
    // THE DATABASE'S OWN SENTENCE, unreworded, exactly as every other refusal
    // in this application is rendered.
    if (!r.ok) { setRefused(r.reason); return; }
    onChange();
  };

  const toggle = (set, key) => {
    const next = new Set(set);
    if (next.has(key)) next.delete(key); else next.add(key);
    return next;
  };

  const state = () => ({ pinned, hidden, opensOn: rack.opens_on });

  return (
    <div className="jump-scrim" data-testid="customise-sheet"
         onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="jump-sheet" role="dialog" aria-label="your own rack">
        <div className="jump-rule">Your own rack</div>
        <p className="caption mt-3" style={{ lineHeight: 1.6 }}>
          Pin the areas you use, put away the ones you never open, and choose
          where you land. This is yours: nobody else can read it, and putting an
          area away never hides what is waiting in it — its compartment still
          counts it, the jump box still offers it, and its address still works.
        </p>

        {refused && (
          <div className="mt-3 panel-2 p-3">
            <div className="tag" style={{ color: 'var(--accent-2)' }}>refused</div>
            <div className="text-[12.5px] mt-1.5" style={{ color: 'var(--mute)' }}>{refused}</div>
          </div>
        )}

        <table className="w-full mt-4" data-testid="customise-table">
          <thead>
            <tr>
              <th className="section-label text-left">area</th>
              <th className="section-label">pinned</th>
              <th className="section-label">put away</th>
              <th className="section-label">land here</th>
            </tr>
          </thead>
          <tbody>
            {tabs.map((t) => (
              <tr key={t.key} data-testid={`customise-${t.key}`}>
                <td className="py-1.5">
                  <span className="jump-label">{t.label}</span>
                  <span className="jump-bay ml-2">{AREA_GROUP[t.key] || 'the desk'}</span>
                </td>
                <td className="text-center">
                  <input type="checkbox" data-testid={`pin-${t.key}`}
                    aria-label={`pin ${t.label}`}
                    disabled={busy} checked={pinned.has(t.key)}
                    onChange={() => save({ ...state(), pinned: toggle(pinned, t.key) })} />
                </td>
                <td className="text-center">
                  {/* THE DESK CANNOT BE PUT AWAY. It is where sign-in lands
                      anybody who has chosen nothing, and a rack whose fallback
                      can be hidden is a rack somebody can lock themselves out
                      of the front of. */}
                  {t.key === 'home' ? <span className="caption">—</span> : (
                    <input type="checkbox" data-testid={`hide-${t.key}`}
                      aria-label={`put away ${t.label}`}
                      disabled={busy} checked={hidden.has(t.key)}
                      onChange={() => save({ ...state(), hidden: toggle(hidden, t.key) })} />
                  )}
                </td>
                <td className="text-center">
                  <input type="radio" name="opens-on" data-testid={`opens-${t.key}`}
                    aria-label={`land on ${t.label}`}
                    disabled={busy}
                    checked={(rack.opens_on || 'home') === t.key}
                    onChange={() => save({ ...state(), opensOn: t.key })} />
                </td>
              </tr>
            ))}
          </tbody>
        </table>

        <div className="jump-keys">
          Saved as you choose. <button className="btn btn-sm ml-2"
            data-testid="customise-clear" disabled={busy}
            onClick={() => save({ pinned: new Set(), hidden: new Set(), opensOn: '' })}>
            put it all back
          </button>
        </div>
      </div>
    </div>
  );
}

function Nav({ me, active, onSelect, rack, onRackChange }) {
  const role = me.role;
  const counts = useRailCounts(me);
  const tabs = WORKSPACES[role].tabs;
  const [jumping, setJumping] = useState(false);
  const [customising, setCustomising] = useState(false);
  // A rack that has not arrived yet is the PLAIN rack, never a blank one.
  // The preference read is one small row and it is not what the rest of
  // the workspace waits on.
  const mine = rack || { pinned: [], hidden: [], opens_on: null };
  const pinned = new Set(mine.pinned || []);
  const hidden = new Set(mine.hidden || []);

  // Ctrl+K / ⌘K, on the window, because the point of it is to work from
  // wherever your hands already are. It toggles: the same keys that open it
  // close it again, so nobody has to find a way back out.
  useEffect(() => {
    const onKey = (e) => {
      if ((e.ctrlKey || e.metaKey) && !e.altKey && (e.key === 'k' || e.key === 'K')) {
        e.preventDefault();
        setJumping((open) => !open);
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);

  // The plate in a pigeonhole. UNCHANGED from the flat rack, deliberately:
  // `data-testid="tab-<key>"` is how five suites and the browser walk find a
  // control, and a layout change that renamed the handles would make every one
  // of them go quiet rather than red.
  const plate = (t) => {
    const n = counts ? counts[t.key] : undefined;
    return (
      <button
        key={t.key}
        data-testid={`tab-${t.key}`}
        className={`tab-btn${active === t.key ? ' active' : ''}`}
        onClick={() => onSelect(t.key)}
      >
        {t.label}
        {n === undefined
          ? null
          : <span className={`tab-n${n === 0 ? ' zero' : ''}`}>{n}</span>}
      </button>
    );
  };

  // The desk stands alone at the left; everything else hangs in a compartment.
  // The order INSIDE a compartment is the order in WORKSPACES — there is no
  // second ordering to keep in step with the first.
  const loose = tabs.filter((t) => !AREA_GROUP[t.key]);
  const bays = RACK_ORDER
    .map((bay) => [bay, tabs.filter((t) => AREA_GROUP[t.key] === bay)])
    .filter(([, held]) => held.length > 0);

  // WHAT THIS PERSON PINNED, in the order WORKSPACES declares rather than the
  // order they happened to press the boxes in — one ordering, and it is the
  // product's, so two people's pinned compartments read the same way.
  const pins = tabs.filter((t) => pinned.has(t.key) && AREA_GROUP[t.key]);

  // WHICH COMPARTMENT IS OPEN. It follows the screen you are on — open the
  // review desk and the legal desk is open around it — and a click on a
  // compartment opens it WITHOUT navigating, because "show me what is in here"
  // and "take me there" are different questions and only the second should
  // change what you are reading. Landing on the desk (`home`) opens none: the
  // desk already lists every area of the workspace with a sentence and a count,
  // so a second copy of that list four inches above it would be furniture.
  const [opened, setOpened] = useState(null);
  const open = opened || AREA_GROUP[active] || null;
  // Following the screen means forgetting a hand-opened compartment the moment
  // somebody actually goes somewhere. Without this, opening `the library` and
  // then jumping to the review desk leaves the library's plates on screen under
  // a rack that says the legal desk is where you are.
  useEffect(() => { setOpened(null); }, [active]);

  // WHAT A CLOSED COMPARTMENT MAY SAY. The rack has always printed a number
  // only where a read exists whose row count is the honest answer, and printed
  // NOTHING — never a nought — where the application has not measured. A
  // compartment adds the numbers the rack already holds for the areas inside
  // it, and areas with no number contribute nothing, exactly as they
  // contribute nothing to the desk. A compartment with no measured area at all
  // gets no number rather than a zero.
  //
  // AND IT SAYS WHAT IT ADDED UP. The title names every area that contributed
  // and every area in the compartment the application has not measured, so the
  // figure can never be read as "and nothing else is waiting in here".
  const bayCount = (held) => {
    const measured = held.filter((t) => counts && counts[t.key] !== undefined);
    if (measured.length === 0) return null;
    const unmeasured = held.filter((t) => !counts || counts[t.key] === undefined);
    // WHAT IS PUT AWAY IS STILL COUNTED. `held` is the compartment's whole
    // set — the role's tabs, not the visible ones — so hiding an area changes
    // what is DRAWN and never what is COUNTED, and the sentence below names
    // what is hidden inside. A count that dropped when somebody tidied their
    // rack would let them hide a queue and never hear about it again.
    const away = held.filter((t) => hidden.has(t.key));
    return {
      n: measured.reduce((sum, t) => sum + counts[t.key], 0),
      says: `${measured.map((t) => `${t.label} ${counts[t.key]}`).join(', ')}`
        + (unmeasured.length
          ? ` — and not counted here: ${unmeasured.map((t) => t.label).join(', ')}`
          : '')
        + (away.length
          ? ` — put away by you: ${away.map((t) => t.label).join(', ')}`
          : ''),
    };
  };

  // TWO TIERS, AND THE HEIGHT STOPS GROWING WITH THE PRODUCT. Eighteen plates
  // in one wrapping row was two rows of chrome and no order; the same eighteen
  // grouped into compartments but all drawn at once was THREE rows, because the
  // labels cost width too (measured, 1280px: 85px flat, 147px as columns, 108px
  // as one sequence). Drawn as compartments over the open compartment's plates
  // it is two rows at any number of areas, which is the property that matters —
  // this rack has gained an area in nine of the last ten cycles.
  //
  // NOTHING IS HIDDEN THAT WAS REACHABLE. Every area is still one click away
  // (its compartment, then it), still reachable by address, still on the desk
  // with its sentence, and now findable by typing part of what it is for.
  return (
    <div className="rack-wrap border-b hair" data-testid="tab-row">
      <nav className="rack flex items-center flex-wrap px-6"
           style={{ minHeight: 40, paddingTop: 5, paddingBottom: 3 }}
           data-testid="rack-compartments">
        {loose.map(plate)}
        {bays.map(([bay, held]) => {
          const c = bayCount(held);
          const here = open === bay;
          return (
            <button
              key={bay}
              data-testid={`bay-${bay.replace(/ /g, '-')}`}
              className={`bay-btn${here ? ' open' : ''}`
                + (AREA_GROUP[active] === bay ? ' current' : '')}
              title={c ? c.says : undefined}
              aria-expanded={here}
              onClick={() => setOpened(here ? '' : bay)}
            >
              {bay}
              {c === null
                ? null
                : <span className={`tab-n${c.n === 0 ? ' zero' : ''}`}>{c.n}</span>}
            </button>
          );
        })}
        <button className="jump-btn" data-testid="jump-to"
                onClick={() => setJumping(true)}>
          jump to…<kbd className="jump-kbd">ctrl K</kbd>
        </button>
        {/* ON THE RACK, NOT INSIDE A COMPARTMENT. Somebody who has put every
            compartment's contents away must still be able to get them back,
            and a control reached through the thing it configures is a door
            that can be shut from the inside.

            AND NOT FOR A VIEWER, because `0108` grants them nothing here. A
            viewer changes nothing anywhere, by doctrine (0064: they are
            outside the organisation's process entirely), so the grant stops at
            the five internal roles and this screen does not offer a control
            the database would refuse. The screen mirrors the database; that is
            the rule this whole file is built on. */}
        {role !== 'viewer' && (
          <button className="jump-btn" data-testid="customise"
                  onClick={() => setCustomising(true)}>
            your rack
          </button>
        )}
      </nav>
      {/* PINNED, ALWAYS OPEN, ALWAYS FIRST. It is not a compartment you choose
          between: it is the shelf somebody built for themselves, and it is
          drawn only when they have built one. */}
      {pins.length > 0 && (
        <div className="rack-shelf flex items-center flex-wrap px-6"
             data-testid="rack-pinned">
          <span className="shelf-label">pinned</span>
          {pins.map(plate)}
        </div>
      )}
      {open && (
        <div className="rack-shelf flex items-center flex-wrap px-6"
             data-testid="rack-shelf">
          <span className="shelf-label">{open}</span>
          {/* WHAT IS PUT AWAY IS NOT DRAWN HERE, and what is pinned is not
              drawn TWICE — it is on the pinned shelf above. Neither changes
              the compartment's count, which is computed over the role's whole
              set a few lines up. */}
          {(bays.find(([bay]) => bay === open) || [null, []])[1]
            .filter((t) => !hidden.has(t.key) && !pinned.has(t.key))
            .map(plate)}
          {(bays.find(([bay]) => bay === open) || [null, []])[1]
            .filter((t) => hidden.has(t.key)).length > 0 && (
            <span className="shelf-away" data-testid="shelf-away">
              {(bays.find(([bay]) => bay === open) || [null, []])[1]
                .filter((t) => hidden.has(t.key)).length} put away —
              {' '}<button className="link-btn" onClick={() => setCustomising(true)}>
                your rack
              </button>
            </span>
          )}
        </div>
      )}
      {jumping && (
        <JumpTo
          tabs={tabs} counts={counts} hidden={hidden}
          onPick={(k) => { setJumping(false); onSelect(k); }}
          onClose={() => setJumping(false)}
        />
      )}
      {customising && (
        <Customise
          me={me} tabs={tabs} rack={mine}
          onChange={onRackChange}
          onClose={() => setCustomising(false)}
        />
      )}
    </div>
  );
}

function Footer({ identity, note }) {
  return (
    <div className="footer-bar flex items-center justify-between px-6 border-t hair"
         style={{ height: 28, fontSize: 11, color: 'var(--mute-2)' }}>
      <div className="font-mono">{identity.person}{identity.unit ? ` · ${identity.unit}` : ''}</div>
      <div>{note}</div>
    </div>
  );
}
