Project

General

Profile

Feature #3241 » SCR-004-web.html

Bot AI, 08/27/2026 02:05 PM

 
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>SCR-004 — web mockup — Order create</title>
<!--
SCR-004 · side: web (oms-web) · viewport: 1440 × 900 (desktop, the only breakpoint this
app is built for — app/layout.tsx is a fixed flex row with a w-64 sidebar and ships no
mobile nav).

Screen: app/orders/new/page.tsx — status "existing-modified". Inventory scope (verbatim):
"app/orders/new/page.tsx — gate on Orders:Create at route level AND in
lib/actions/orders.ts server action."
Inventory ambiguity: none declared (`ambiguous: false`). Three genuine open decisions
surfaced while drawing it anyway — the master-data handout through the form's four
dropdowns (pin 4), Orders:Create granted without Orders:View (pin 5), and whether the
guard can live in middleware at all (pin 3). All three are drawn with a defensible
default and recorded for N31 rather than treated as blockers.

Artboard A draws this screen in its PERMITTED state, which inverts SCR-003's choice.
SCR-003 drew the changed state because the unchanged state had nothing to review; here
the changed state has literally no pixels — SCR-004's gate is all-or-nothing, so a user
without Orders:Create never renders this route at all and is redirected before any body
is emitted. The only frame this screen can produce is the one a permitted user sees.
Pin 2 makes that argument in full; pin 1 states what the frame is therefore asserting.

The permission set drawn is the MINIMUM one that can reach this route:
Supplier · Staff holding { Orders:View, Orders:Create }. That choice is what makes the
frame worth looking at — the sidebar carries no Suppliers, Items, UOM or Projects entry,
while the form below it is populated with every active supplier, project, item and UOM
name in the database. The leak in pin 4 is visible in the pixels, not just argued in prose.

Every colour, radius, spacing and type size below is transcribed from this repo's real
tokens — tailwind.config.ts (theme.extend.colors) and app/globals.css (@layer components:
.btn* / .field-input / .field-control / .card / .field-label / .field-error / .link),
cross-checked against the COMPILED stylesheet (.next/static/css/9273638c9655cca0.css) so
the control heights in pin 6 are measured, not estimated. Nothing is invented. The ONLY
non-token styling in this file is the annotation chrome (dashed outlines, pin numbers,
the sheet below the artboard) — deliberately drawn in a colour that exists nowhere in the
design system so it can never be mistaken for a UI element, and living OUTSIDE the
1440×900 artboard so Artboard A stays a clean, diffable frame for W21_ui_verify.
-->
<style>
/* ---- design tokens: verbatim from tailwind.config.ts ---- */
:root {
--bg: #fcfcfd;
--surface: #ffffff;
--text: #302828;
--text-muted: #836f6f;
--border: #ede0ce;
--accent: #453028;
--accent-subtle: #f7f1e9;
--status-success: #1f8a4c;
--status-warning: #b8860b;
--status-danger: #c0392b;
--status-neutral: #6b7280;
--status-info: #2563eb;

/* annotation-only — NOT a design token, never ships */
--anno: #7c3aed;
}

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: #e9e7e4;
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
padding: 32px;
}

/* ================= mockup document chrome (outside the artboard) ================= */
.doc-head { max-width: 1440px; margin: 0 auto 20px; }
.doc-head h1 { font-size: 20px; line-height: 28px; margin: 0 0 6px; font-weight: 600; }
.doc-head p { font-size: 13px; line-height: 20px; color: #4b4b4b; margin: 0 0 4px; max-width: 1100px; }
.doc-meta { font-size: 12px; color: #5c5c5c; margin-top: 8px; }
.doc-meta code { background: #fff; border: 1px solid #d8d5d1; border-radius: 4px; padding: 1px 5px; }
.frame-label {
max-width: 1440px; margin: 28px auto 8px; font-size: 12px; font-weight: 600;
text-transform: uppercase; letter-spacing: .06em; color: #4b4b4b;
}
.frame-label span { font-weight: 400; text-transform: none; letter-spacing: 0; color: #6b6b6b; }

/* ================= ARTBOARD A — exactly 1440 × 900, no annotation inside ================= */
.artboard {
width: 1440px; height: 900px; margin: 0 auto;
overflow: hidden; background: var(--bg);
outline: 1px solid #cfcbc6; /* outline, not border — does not alter the 1440×900 box */
box-shadow: 0 8px 24px rgba(0,0,0,.10);
}

/* ---- app/layout.tsx: body.flex.min-h-screen > Sidebar + main.flex-1.p-6 ---- */
.shell { display: flex; width: 100%; height: 100%; }
.main { flex: 1 1 auto; padding: 24px; min-width: 0; overflow: hidden; }

/* ---- components/layout/Sidebar.tsx: aside.w-64.shrink-0.border-r.bg-surface.flex.flex-col ---- */
.sidebar {
width: 256px; flex: 0 0 256px;
border-right: 1px solid var(--border); background: var(--surface);
display: flex; flex-direction: column;
}
.sidebar-brand { padding: 16px 20px; border-bottom: 1px solid var(--border); } /* px-5 py-4 */
.sidebar-brand span { font-size: 18px; line-height: 28px; font-weight: 600; color: var(--text); } /* text-lg */
.sidebar-nav { flex: 1 1 auto; overflow-y: auto; padding: 16px 0; } /* py-4 */
.nav-group { margin-bottom: 20px; padding: 0 12px; } /* mb-5 px-3 */
.nav-group-label {
padding: 0 8px; margin-bottom: 4px; /* px-2 mb-1 */
font-size: 12px; line-height: 16px; font-weight: 600; /* text-xs font-semibold */
text-transform: uppercase; letter-spacing: .025em; color: var(--text-muted);
}
.nav-list { list-style: none; margin: 0; padding: 0; }
.nav-list li + li { margin-top: 2px; } /* space-y-0.5 */
.nav-item {
display: flex; align-items: center; gap: 8px; /* gap-2 */
border-radius: 6px; padding: 6px 8px; /* rounded-md px-2 py-1.5 */
font-size: 14px; line-height: 20px; color: var(--text-muted); text-decoration: none;
}
.nav-item.active { background: var(--accent-subtle); color: var(--text); font-weight: 500; }
.nav-item svg { width: 16px; height: 16px; flex: 0 0 16px; } /* h-4 w-4 */
.sidebar-foot { border-top: 1px solid var(--border); padding-top: 12px; } /* pt-3 */
.signout {
display: flex; align-items: center; gap: 8px; margin: 0 12px 16px; /* px-3 pb-4 */
width: calc(100% - 24px);
border-radius: 6px; padding: 6px 8px; font-size: 14px; line-height: 20px;
color: var(--text-muted); background: none; border: 0; font-family: inherit; text-align: left;
}
.signout svg { width: 16px; height: 16px; flex: 0 0 16px; }

/* ---- app/globals.css @layer components ----
Heights below are MEASURED off the compiled stylesheet, not eyeballed. The three
control classes do not agree with each other (pin 6):
.field-input -> 20px line + 16px pad + 2px border = 38px
.field-control -> same 38px content, but min-height 2.625rem = 42px
DatePicker -> .field-control wrapping a p-1.5 icon button = 46px
and .btn-primary (no border) is 36px while .btn-secondary (1px border) is 38px. */
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; } /* rounded-lg */
.btn {
display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; /* rounded-md */
padding: 8px 16px; /* px-4 py-2 */
font-size: 14px; line-height: 20px; /* text-sm */
font-weight: 500; /* font-medium */
font-family: inherit; border: 0; cursor: default; text-decoration: none;
}
.btn-primary { background: var(--accent); color: #fff; } /* 36px tall */
.btn-secondary { background: var(--surface); border: 1px solid var(--border); color: var(--text); } /* 38px tall */
.btn-icon { padding: 8px; } /* Remove-row button: px-2 py-2 -> 34 × 34 */
.btn-dim { opacity: .5; } /* .btn disabled:opacity-50 */

/* Bare native input. NOTE: globals.css defines NO :disabled treatment for this class —
the disabled UOM box below is therefore drawn with the ordinary enabled appearance,
because that is exactly what ships. See pin 6. */
.field-input {
display: block; width: 100%; border: 1px solid var(--border); border-radius: 6px;
background: var(--surface); color: var(--text);
font-size: 14px; line-height: 20px; /* sm:text-sm */
padding: 8px 12px; font-family: inherit;
/* Every .field-input in this artboard stands in for a real single-line <input>
(the disabled UOM box and the numeric Qty box). A <div> would wrap where an
<input> clips, which silently invents a two-line control that cannot exist and
drags the row's items-end baseline out of line with its neighbours. nowrap +
overflow:hidden reproduces the native clipping instead. This is load-bearing:
the UOM track measures 118.3px on a row whose date is set and 122.6px on one
where it is not (2fr_1fr_1fr_1fr_auto over a 720px content box, gap-3), leaving
92.3px / 96.6px of text once px-3 and the 1px borders are removed. "Sq Metre
(sqm)" is 107.7px at 14px so the longest real UOM labels DO truncate in the
shipping app. Drawn truncated because that is what ships; called out in pin 6
as a pre-existing layout defect, not a mockup artefact. */
white-space: nowrap; overflow: hidden;
}
.field-control {
display: block; width: 100%; border: 1px solid var(--border); border-radius: 6px;
background: var(--surface); color: var(--text);
font-size: 14px; line-height: 20px; padding: 8px 12px; min-height: 42px; font-family: inherit;
}
.field-label { display: block; font-size: 14px; line-height: 20px; font-weight: 500; color: var(--text); margin-bottom: 4px; }
.field-error { margin-top: 4px; font-size: 14px; line-height: 20px; color: var(--status-danger); }
.link { color: var(--status-info); text-decoration: underline; }

/* ---- PageHeader.tsx: div.flex.items-center.justify-between.mb-6 ---- */
.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
.page-head h1 { font-size: 20px; line-height: 28px; font-weight: 600; color: var(--text); margin: 0; } /* text-xl */

/* ---- OrderForm.tsx: form.space-y-6.max-w-3xl ---- */
.order-form { max-width: 768px; } /* max-w-3xl = 48rem */
.order-form > * + * { margin-top: 24px; } /* space-y-6 */
.card-pad { padding: 24px; } /* p-6 */
.grid-2 { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 16px; } /* grid-cols-2 gap-4 */

/* Listbox.tsx trigger: button.field-control.flex.items-center.justify-between.text-left */
.listbox {
display: flex; align-items: center; justify-content: space-between; text-align: left;
gap: 8px; height: 42px;
}
.listbox .val { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.listbox .val.placeholder { color: var(--text-muted); }
.listbox svg { width: 16px; height: 16px; flex: 0 0 16px; color: var(--text-muted); } /* ChevronDown */

/* Order Items repeater row: div.grid.grid-cols-[2fr_1fr_1fr_1fr_auto].gap-3.items-end */
.items-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } /* mb-3 */
.items-head h2 { font-size: 14px; line-height: 20px; font-weight: 500; color: var(--text); margin: 0; } /* text-sm font-medium */
.items-head .btn svg { width: 16px; height: 16px; margin-right: 4px; } /* Plus, mr-1 */
.item-rows > * + * { margin-top: 12px; } /* space-y-3 */
.item-row {
display: grid;
/* grid-cols-[2fr_1fr_1fr_1fr_auto]. Bare `Nfr` (= minmax(auto, Nfr)) is correct and
load-bearing here do NOT "tidy" this into minmax(0, Nfr). The tracks in the real
app are NOT equal, and the reason is the content floor:
- The Listbox and DatePicker triggers wrap their text in a `truncate` span
(overflow:hidden), whose automatic minimum size is therefore ZERO so their
TEXT imposes no floor.
- But DatePicker also holds two icon buttons (clear + calendar) that are NOT
overflow:hidden, so those DO impose a floor, and the Due Date track is pushed
WIDER than its 1fr share at every other track's expense.
- <input> imposes no floor at all and shrinks freely, clipping its value.
Measured in-browser on a replica built from the real markup and the COMPILED
stylesheet. The clear (X) icon only renders when a date is set, so the floor
and therefore every track differs by row state:
date set Product 236.6 | UOM 118.3 | Qty 118.3 | Due Date 162.7 | trash 34.0
date empty Product 245.2 | UOM 122.6 | Qty 122.6 | Due Date 145.6 | trash 34.0
and at those widths the UOM input clips ("Sq Metre (sqm)": 107.7px of text in a
92.3/96.6px box pin 6) while the Due Date text does NOT truncate (80.7px).
Forcing minmax(0,) would flatten Due Date to an even share and make it ellipsise,
inventing a defect the app does not have. The one thing that must NOT reach the
track algorithm is the *text* of the .field-input stand-ins real inputs never
contribute theirs. See .item-row > div:has(> .field-input) below for where that
constraint has to live. */
grid-template-columns: 2fr 1fr 1fr 1fr auto;
gap: 12px; /* gap-3 */
align-items: end; /* items-end */
}
/* The grid items are the wrapper <div>s, not the controls inside them, so the
automatic minimum that matters is the WRAPPER's — and a wrapper's min-content is
the max of its children's. .field-input's own `overflow:hidden` zeroes its minimum
as a *grid item*, but it is a block child of a block wrapper here, so its nowrap
text still propagated up and inflated the wrapper's min-content. That is why the
constraint could not live on .field-input itself.

Left unconstrained it produced exactly the artefact the block above warns about:
row 3's UOM track rendered 133.7px against the other rows' 122.6px purely because
"Sq Metre (sqm)" is a long string. A real <input> never does this its intrinsic
contribution is fixed and independent of its value so the inflation was a
stand-in artefact, not a transcribed defect.

Scoped with :has() to the UOM and Qty wrappers ONLY. A blanket
`.item-row > * { min-width: 0 }` also zeroes the Listbox and DatePicker wrappers,
which flattens all four tracks to 254.4/127.2/127.2/127.2 the very minmax(0,)
outcome the block above rules out. Verified against a replica built from the real
markup and the COMPILED stylesheet (.next/static/css/9273638c9655cca0.css); with
this rule all four rows resolve to the real app's tracks:
rows 1-2 (date set, clear icon shown) 236.6 | 118.3 | 118.3 | 162.7 | 34
rows 3-4 (date empty, no clear icon) 245.2 | 122.6 | 122.6 | 145.6 | 34 */
.item-row > div:has(> .field-input) { min-width: 0; }
.item-row .btn-icon { margin-bottom: 2px; } /* mb-0.5 */
.item-row .btn-icon svg { width: 16px; height: 16px; } /* Trash2 */

/* DatePicker.tsx closed state: div.field-control.flex.items-center.justify-between.gap-1 */
.datepick { display: flex; align-items: center; justify-content: space-between; gap: 4px; }
.datepick .val { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.datepick .val.placeholder { color: var(--text-muted); }
.datepick .icons { display: flex; flex: 0 0 auto; align-items: center; }
.datepick .ic-clear { border-radius: 4px; padding: 4px; color: var(--text-muted); display: inline-flex; } /* rounded p-1 */
.datepick .ic-cal { border-radius: 9999px; padding: 6px; color: var(--text-muted); display: inline-flex; } /* rounded-full p-1.5 */
.datepick svg { width: 16px; height: 16px; }

.form-foot { display: flex; justify-content: flex-end; align-items: center; gap: 8px; } /* flex justify-end gap-2 */

/* ================= annotation sheet (never ships) ================= */
.sheet { max-width: 1440px; margin: 0 auto; }
.sheet section {
background: #fff; border: 1px solid #d8d5d1; border-radius: 8px;
padding: 20px 24px; margin-top: 16px;
}
.sheet h2 { font-size: 15px; margin: 0 0 4px; font-weight: 600; }
.sheet h2 .pin { margin-right: 8px; }
.sheet .lede { font-size: 13px; line-height: 20px; color: #4b4b4b; margin: 0 0 16px; max-width: 1180px; }
.pin {
display: inline-flex; align-items: center; justify-content: center;
width: 20px; height: 20px; border-radius: 9999px; background: var(--anno);
color: #fff; font-size: 11px; font-weight: 700; line-height: 20px; vertical-align: middle;
}
.anno-note { font-size: 12px; line-height: 18px; color: var(--anno); margin-top: 8px; }
.anno-box { outline: 2px dashed var(--anno); outline-offset: 3px; }
.sheet table.info { width: 100%; border-collapse: collapse; font-size: 13px; }
.sheet table.info thead th { border-bottom: 1px solid #d8d5d1; color: #4b4b4b; padding: 8px 10px; text-align: left; font-weight: 500; }
.sheet table.info tbody td { padding: 8px 10px; vertical-align: top; border-top: 1px solid #ece9e6; }
.sheet code { background: #f5f3f1; border-radius: 4px; padding: 1px 5px; font-size: 12px; }
.states { display: flex; gap: 28px; flex-wrap: wrap; align-items: flex-start; }
.state-cap { font-size: 12px; font-weight: 600; color: #3b3b3b; margin-bottom: 6px; }
.state-sub { font-size: 12px; line-height: 17px; color: #6b6b6b; margin-bottom: 10px; max-width: 400px; }
.mini-frame {
width: 400px; background: var(--bg); border: 1px solid #d8d5d1; border-radius: 8px; padding: 16px;
}
.swatches { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 6px; }
.sw { font-size: 11px; color: #4b4b4b; text-align: center; }
.sw i { display: block; width: 56px; height: 28px; border-radius: 4px; border: 1px solid #d8d5d1; margin-bottom: 4px; }
.decisions li { font-size: 13px; line-height: 20px; margin-bottom: 10px; }
.flag { color: var(--anno); font-weight: 600; }
.chip {
display: inline-block; font-size: 11px; font-weight: 600; letter-spacing: .04em;
text-transform: uppercase; border-radius: 4px; padding: 2px 6px; vertical-align: middle;
}
.chip-drawn { background: rgba(31,138,76,.12); color: var(--status-success); }
.chip-declined { background: rgba(192,57,43,.10); color: var(--status-danger); }
.chip-approx { background: rgba(184,134,11,.14); color: var(--status-warning); }
.ladder { list-style: none; margin: 0; padding: 0; }
.ladder li {
font-size: 13px; line-height: 20px; color: #3b3b3b;
border-left: 2px solid var(--anno); padding: 0 0 14px 14px; margin: 0; position: relative;
}
.ladder li:last-child { border-left-color: transparent; padding-bottom: 0; }
.ladder li::before {
content: ""; position: absolute; left: -5px; top: 6px; width: 8px; height: 8px;
border-radius: 9999px; background: var(--anno);
}
.ladder b { color: #1f1f1f; }
</style>
</head>
<body>

<div class="doc-head">
<h1>SCR-004 — Order create · web (oms-web) · <code>app/orders/new/page.tsx</code></h1>
<p>
The screen is unchanged in layout. What this ticket adds is a <em>gate in front of it</em> and a
<em>second, independent gate behind it</em>: the route requires <code>Orders:Create</code>, and so does
<code>createOrder()</code> in <code>lib/actions/orders.ts</code> — which today asserts only
<code>requireSession()</code>. Those two are the whole of SCR-004. Nothing inside the form is
individually permission-sensitive, because there is no partial state of this screen: you may create an
order or you may not.
</p>
<p>
<strong>Artboard A draws the permitted state, which inverts SCR-003's choice — deliberately.</strong>
SCR-003 drew its <em>changed</em> state because its unchanged state had nothing to review. Here the changed
state has <em>no pixels at all</em>: a user without <code>Orders:Create</code> is redirected before a body is
emitted, so there is no denied rendering of this route to draw. Pin <span class="pin">2</span> argues that in
full. The frame therefore shows the <em>minimum</em> permission set that can reach the route —
Supplier · Staff holding <code>{ Orders:View, Orders:Create }</code> — which is also the set that makes the
screen's real finding visible.
</p>
<p>
That finding is pin <span class="pin">4</span>: <strong>the sidebar carries no Suppliers, Items, UOM or
Projects entry, yet the form below it is populated with every active supplier, project, item and UOM name in
the database.</strong> <code>page.tsx</code> runs all four <code>active*()</code> lookups in a
<code>Promise.all</code> before rendering. SCR-003 pin 6 declined to gate its filter dropdowns on the grounds
that the same names already printed in the table's own columns — that argument does not survive on this
screen, and this is the case SCR-003 said to revisit. Two further decisions are flagged rather than silently
resolved: <strong><code>Orders:Create</code> granted without <code>Orders:View</code></strong>
(<span class="pin">5</span>), where two hardcoded <code>/orders</code> redirects send the user somewhere their
own guard denies, and <strong>whether the guard can live in middleware at all</strong>
(<span class="pin">3</span>) — it cannot, on today's session model.
</p>
<div class="doc-meta">
Viewport <code>1440 × 900</code> (desktop only) ·
tokens from <code>tailwind.config.ts</code> + <code>app/globals.css</code>, cross-checked against the compiled
<code>.next/static/css/9273638c9655cca0.css</code> ·
shell from <code>app/layout.tsx</code> + <code>components/layout/Sidebar.tsx</code> ·
page transcribed from <code>app/orders/new/page.tsx</code> with
<code>PageHeader</code>/<code>OrderForm</code>/<code>SelectField</code>/<code>Listbox</code>/<code>DatePicker</code>/<code>SubmitButton</code> ·
option data from <code>lib/queries/masters.ts</code> (<code>activeSuppliers/Projects/Items/Uoms</code>),
server action <code>lib/actions/orders.ts:18</code>
· <code>fields["SCR-004"]</code> is <code>[]</code> — no field checklist was handed to this node, so the form's
fields are transcribed from the real <code>OrderForm.tsx</code> instead of from a list. Every control drawn has
a real component behind it; the two that are drawn in a static approximation of a dynamic component
(<code>Listbox</code>, <code>DatePicker</code>) are called out in <span class="pin">6</span>.
</div>
</div>

<div class="frame-label">Artboard A — 1440 × 900 · <span>Supplier · Staff, permissions = { Orders:View, Orders:Create }. The minimum set that can reach this route. Clean frame, no annotation overlay — diffable by W21_ui_verify.</span></div>

<div class="artboard">
<div class="shell">

<!-- components/layout/Sidebar.tsx rendered from the resolved permission set (SCR-027).
This user holds Orders:View + Orders:Create only, so Expenses and Expense Summary are
absent from the OMS group and the whole Admin group is dropped (every item denied).
Hide, not disable — the default drawn in SCR-000 pin 5.

Note what this costs: the four master resources (Suppliers / Items / UOM / Projects)
are unreachable from the menu, and yet every one of their names is printed in the form
to the right. That is pin 4, and it is visible here rather than merely argued. -->
<aside class="sidebar">
<div class="sidebar-brand"><span>OMS</span></div>
<nav class="sidebar-nav">
<div class="nav-group">
<div class="nav-group-label">OMS</div>
<ul class="nav-list">
<li>
<a class="nav-item active" href="#">
<!-- lucide ClipboardList -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg>
Orders
</a>
</li>
</ul>
</div>
</nav>
<div class="sidebar-foot">
<button type="button" class="signout">
<!-- lucide LogOut -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></svg>
Sign out
</button>
</div>
</aside>

<!-- main.flex-1.p-6 rendering app/orders/new/page.tsx -->
<main class="main">

<!-- PageHeader title="New Order" — no `action` prop is passed on this screen today and
none is added by this ticket. The header is title-only in every permission state. -->
<div class="page-head">
<h1>New Order</h1>
</div>

<!-- form.space-y-6.max-w-3xl. Two hidden inputs precede the visible fields and have no
rendering: orderDate (set to today by useMemo, never shown as a control) and
itemsJson (the serialised row state). See pin 6 — the absent order-date control is
accurate, not an omission. -->
<form class="order-form">

<div class="card card-pad grid-2">
<div>
<label class="field-label">Supplier *</label>
<div class="field-control listbox">
<span class="val">Meridian Steel Co.</span>
<!-- lucide ChevronDown -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
</div>
<div>
<label class="field-label">Project *</label>
<div class="field-control listbox">
<span class="val">Riverside Clinic Fit-out</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
</div>
</div>

<div class="card card-pad">
<div class="items-head">
<h2>Order Items</h2>
<span class="btn btn-secondary">
<!-- lucide Plus -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
Add Row
</span>
</div>

<div class="item-rows">

<!-- Row 1 — fully populated. UOM is derived from the chosen item (uomByItem) and is
a DISABLED .field-input. globals.css gives .field-input no :disabled rule, so it
renders identically to an enabled field. Drawn as it ships. See pin 6. -->
<div class="item-row">
<div>
<label class="field-label">Product Name</label>
<div class="field-control listbox">
<span class="val">M16 Hex Bolt — Gr 8.8</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
</div>
<div>
<label class="field-label">UOM</label>
<div class="field-input">Piece (pcs)</div>
</div>
<div>
<label class="field-label">Qty</label>
<div class="field-input">2400</div>
</div>
<div>
<label class="field-label">Due Date</label>
<div class="field-control datepick">
<span class="val">09/09/2026</span>
<span class="icons">
<!-- lucide X — clear control, shown only when a value is set (clearable defaults true) -->
<span class="ic-clear"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></span>
<!-- lucide Calendar -->
<span class="ic-cal"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/></svg></span>
</span>
</div>
</div>
<span class="btn btn-secondary btn-icon">
<!-- lucide Trash2 -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
</span>
</div>

<!-- Row 2 — fully populated. -->
<div class="item-row">
<div>
<label class="field-label">Product Name</label>
<div class="field-control listbox">
<span class="val">Steel Beam 203×133</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
</div>
<div>
<label class="field-label">UOM</label>
<div class="field-input">Metre (m)</div>
</div>
<div>
<label class="field-label">Qty</label>
<div class="field-input">180</div>
</div>
<div>
<label class="field-label">Due Date</label>
<div class="field-control datepick">
<span class="val">12/09/2026</span>
<span class="icons">
<span class="ic-clear"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></span>
<span class="ic-cal"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/></svg></span>
</span>
</div>
</div>
<span class="btn btn-secondary btn-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
</span>
</div>

<!-- Row 3 — item chosen, due date left empty. DatePicker shows its placeholder and,
with no value set, no clear (X) control. -->
<div class="item-row">
<div>
<label class="field-label">Product Name</label>
<div class="field-control listbox">
<span class="val">Galvanised Sheet 2mm</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
</div>
<div>
<label class="field-label">UOM</label>
<div class="field-input">Sq Metre (sqm)</div>
</div>
<div>
<label class="field-label">Qty</label>
<div class="field-input">64.5</div>
</div>
<div>
<label class="field-label">Due Date</label>
<div class="field-control datepick">
<span class="val placeholder">dd/mm/yyyy</span>
<span class="icons">
<span class="ic-cal"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/></svg></span>
</span>
</div>
</div>
<span class="btn btn-secondary btn-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
</span>
</div>

<!-- Row 4 — the just-added empty row (emptyRow). Product Name shows Listbox's
"Select..." placeholder; UOM stays blank because it is derived from itemId. -->
<div class="item-row">
<div>
<label class="field-label">Product Name</label>
<div class="field-control listbox">
<span class="val placeholder">Select...</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
</div>
<div>
<label class="field-label">UOM</label>
<div class="field-input">&nbsp;</div>
</div>
<div>
<label class="field-label">Qty</label>
<div class="field-input">&nbsp;</div>
</div>
<div>
<label class="field-label">Due Date</label>
<div class="field-control datepick">
<span class="val placeholder">dd/mm/yyyy</span>
<span class="icons">
<span class="ic-cal"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/></svg></span>
</span>
</div>
</div>
<span class="btn btn-secondary btn-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
</span>
</div>

</div>
</div>

<!-- Cancel is router.push("/orders") — a hardcoded destination this user may not be
permitted to open. Generate Order is SubmitButton (btn-primary). Neither is
individually gated: reaching this frame already required Orders:Create. Pin 5. -->
<div class="form-foot">
<span class="btn btn-secondary">Cancel</span>
<span class="btn btn-primary">Generate Order</span>
</div>

</form>
</main>

</div>
</div>

<!-- ======================= annotation sheet — reference only, never ships ======================= -->
<div class="sheet">

<section>
<h2><span class="pin">1</span>What Artboard A asserts</h2>
<p class="lede">
Each row is a claim the frame makes that a reviewer can check against the running app. "Today" is what
<code>main</code> ships right now; "Drawn" is what Artboard A shows for a user holding
<code>{ Orders:View, Orders:Create }</code>.
</p>
<table class="info">
<thead>
<tr><th style="width:230px">Element</th><th style="width:170px">Permission</th><th style="width:300px">Today (main)</th><th>Drawn in Artboard A</th></tr>
</thead>
<tbody>
<tr>
<td>Route <code>/orders/new</code></td>
<td><code>Orders:Create</code></td>
<td>Any session renders it. <code>middleware.ts</code> checks the cookie only.</td>
<td>Rendered. This user holds the permission, so the frame is byte-identical to today's page — the gate's effect on a permitted user is nil, which is the point of pin <span class="pin">2</span>.</td>
</tr>
<tr>
<td>Server action <code>createOrder()</code></td>
<td><code>Orders:Create</code></td>
<td><code>lib/actions/orders.ts:19</code><code>await requireSession()</code> and nothing more.</td>
<td>No pixels. Listed because a mockup showing only the form would be read as the whole change; it is the opposite — this is the only layer with security value. Pin <span class="pin">3</span>.</td>
</tr>
<tr>
<td>Sidebar — OMS group</td>
<td><code>Orders:View</code></td>
<td>All three entries hardcoded in <code>NAV_GROUPS</code>.</td>
<td>Orders only. Expenses and Expense Summary absent (hidden, not disabled — SCR-000 pin 1 treatment A).</td>
</tr>
<tr>
<td>Sidebar — Admin group</td>
<td>every item denied</td>
<td>All four entries hardcoded.</td>
<td>Whole group dropped, heading included (SCR-000 pin 1 treatment B). The 20px group gap collapses.</td>
</tr>
<tr>
<td><code>PageHeader</code> action slot</td>
<td></td>
<td>Not passed on this screen.</td>
<td>Title-only. Unchanged in every permission state — SCR-004 adds no header action.</td>
</tr>
<tr>
<td>Supplier / Project <code>SelectField</code></td>
<td class="flag">none — see pin 4</td>
<td>Populated from <code>activeSuppliers()</code> / <code>activeProjects()</code>.</td>
<td>Populated in full, for a user with neither <code>Supplier:View</code> nor <code>Project:View</code>. Drawn, and flagged.</td>
</tr>
<tr>
<td>Product Name / UOM per row</td>
<td class="flag">none — see pin 4</td>
<td>Populated from <code>activeItems()</code> / <code>activeUoms()</code>.</td>
<td>Same. The item and UOM masters are handed to a user who cannot open either admin screen.</td>
</tr>
<tr>
<td>Add Row / Remove row</td>
<td></td>
<td>Pure client state (<code>useState&lt;Row[]&gt;</code>).</td>
<td>Drawn enabled. Nothing to gate: the rows never touch the server until submit, and submit is already gated. With a single row the Remove button is <code>disabled</code> (<code>rows.length === 1</code>) — four rows are drawn, so all four are live.</td>
</tr>
<tr>
<td>Cancel</td>
<td class="flag">assumes <code>Orders:View</code></td>
<td><code>router.push("/orders")</code>, hardcoded.</td>
<td>Drawn enabled, on the assumption <code>Orders:Create</code> implies <code>Orders:View</code>. Pin <span class="pin">5</span>.</td>
</tr>
<tr>
<td>Generate Order</td>
<td><code>Orders:Create</code></td>
<td><code>SubmitButton</code><code>createOrder</code>.</td>
<td>Drawn enabled. It is never conditionally hidden, because a user without the permission never reaches the form it lives in.</td>
</tr>
</tbody>
</table>
</section>

<section>
<h2><span class="pin">2</span>Why Artboard A draws the permitted state — this screen's denied state has no pixels</h2>
<p class="lede">
SCR-003 drew its changed state and explained that drawing the unchanged one "would produce a mockup with
nothing in it to review". SCR-004 reaches the opposite conclusion from the same principle, and the reason is
worth stating plainly so the inversion does not read as an inconsistency.
</p>
<ul class="ladder">
<li><b>SCR-003's gate is partial.</b> <code>Orders:View</code> without <code>Orders:Create</code> is a
real, renderable state: the page loads, and one button is missing from it. There are two frames, and the
delta between them is a picture.</li>
<li><b>SCR-004's gate is total.</b> There is no permission set that renders <em>part</em> of this screen.
Every control on it exists to compose one <code>createOrder</code> call. Gating any of them individually
would produce a form that cannot be submitted — worse than no form.</li>
<li><b>So the denied state is not a rendering.</b> Under the default carried from SCR-002/SCR-000, a denied
<code>GET /orders/new</code> resolves to <code>307 → </code>first permitted menu entry. The response has
no body. There is nothing to draw, and drawing a 403 page instead would pre-empt exactly the operator
decision SCR-028 exists to make.</li>
<li><b>Therefore the only frame this screen can produce is the permitted one</b> — and the useful choice is
<em>which</em> permitted user to draw. Artboard A draws the minimum set that can reach the route rather
than a Manufacturer · Admin, because the minimum set is the one that exposes pin
<span class="pin">4</span> in the pixels: a sidebar with no master-data entries above a form full of
master data.</li>
</ul>
<p class="anno-note">
Consequence for W21_ui_verify: <strong>a screenshot diff of this route against today's build should show
zero pixel change for a permitted user.</strong> That is the correct result, not a sign the gate is
missing. SCR-004 cannot be verified by looking at it — only by attempting the route and the action without
the permission. Whatever gate N31 approves needs a test, not a screenshot.
</p>
</section>

<section>
<h2><span class="pin">3</span>Where the gate lives — two layers, and why middleware cannot be one of them</h2>
<p class="lede">
The acceptance criterion is "the user should not be able to access the functionality directly through a
URL". On this screen there is no button to hide, so both layers below are load-bearing — unlike SCR-003,
where three of the four layers were cosmetic.
</p>
<ul class="ladder">
<li><b>1 · Route guard</b><code>app/orders/new/page.tsx</code> resolves the session's permission set and
denies without <code>Orders:Create</code>, <em>before</em> the four <code>active*()</code> lookups run.
Placement matters for the same reason SCR-003 pin 2 gave: the guard must sit above the query, not filter
its result. Here it matters more — the queries are the leak (pin <span class="pin">4</span>), so a guard
that runs after them has already loaded four master tables for a user being turned away.</li>
<li><b>2 · Server action</b><code>createOrder()</code> at <code>lib/actions/orders.ts:18</code> needs its
own <code>Orders:Create</code> assertion, and this is the only layer an attacker actually has to get past.
<strong>A Server Action is a POST to the same route URL, and the page component does not run for it.</strong>
Guarding <code>page.tsx</code> alone leaves <code>createOrder</code> fully reachable by anyone with a valid
session cookie. The two guards are not redundant; they cover disjoint request types.</li>
<li><b>3 · Menu (SCR-027) and the create entry point (SCR-003)</b> — both already own their half of this:
SCR-003 hides the "New Order" button when <code>Orders:Create</code> is absent. That is cosmetic and
prevents nothing. It is listed here only so SCR-004 is not read as duplicating it.</li>
</ul>
<p class="anno-note">
<strong>Middleware is not available as a guard layer, and that is a design constraint, not an oversight.</strong>
<code>middleware.ts</code> already matches this route <em>and</em> the action POST — it would be the natural
single choke point. It cannot be used: it runs on the Edge runtime, and the only thing it can read is the
session cookie, whose payload is <code>{ u, exp }</code> (<code>lib/auth.ts:21</code>). Checking a permission
there needs either a database read (Prisma is not available on Edge) or the permission set baked into the
cookie — which is precisely the "re-read per request, or frozen in the cookie?" question SCR-002 pin 6
recorded as out of scope. <em>SCR-004 is where that question stops being theoretical.</em> The default drawn
here assumes it stays unanswered: guard in the page component and in the action, both on the Node runtime,
both reading one registry.
</p>
</section>

<section>
<h2><span class="pin">4</span>The form hands this user four master lists they are not permitted to browse <span class="chip chip-drawn">not gated — drawn</span></h2>
<p class="lede">
This is the highest-value finding on the screen, it is visible in Artboard A, and it is the case SCR-003
pin 6 explicitly deferred to a screen like this one.
</p>
<p class="lede">
<code>app/orders/new/page.tsx</code> opens with a <code>Promise.all</code> over
<code>activeSuppliers()</code>, <code>activeProjects()</code>, <code>activeItems()</code> and
<code>activeUoms()</code>. Each returns <em>every</em> active row of its table
(<code>lib/queries/masters.ts:97–104</code> — no pagination, no scoping, id + name only). Those names are
serialised into the initial HTML payload as <code>Listbox</code> options. The user drawn in Artboard A holds
<code>Orders:Create</code> and nothing else in the master-data space: no <code>Supplier:View</code>, no
<code>Item:View</code>, no <code>Uom:View</code>, no <code>Project:View</code>. Their sidebar has no Admin
group at all. They still receive the complete supplier list, project list, item catalogue and UOM list.
</p>
<table class="info">
<thead><tr><th style="width:210px">Query</th><th style="width:200px">Permission that would gate it</th><th>What the user gets without it</th></tr></thead>
<tbody>
<tr><td><code>activeSuppliers()</code></td><td><code>Supplier:View</code></td><td>Every active supplier name — the same list <code>/admin/suppliers</code> would show them, minus the columns.</td></tr>
<tr><td><code>activeProjects()</code></td><td><code>Project:View</code></td><td>Every active project name. For a Supplier·Staff user this is arguably the most sensitive of the four: project names describe the manufacturer's client work.</td></tr>
<tr><td><code>activeItems()</code></td><td><code>Item:View</code></td><td>The full active item catalogue.</td></tr>
<tr><td><code>activeUoms()</code></td><td><code>Uom:View</code></td><td>The UOM list. Low sensitivity, listed for completeness.</td></tr>
</tbody>
</table>
<p class="lede" style="margin-top:16px">
<strong>Why SCR-003's argument does not transfer.</strong> SCR-003 declined to gate its filter dropdowns
because "the same names already print in the table's own columns… gating the dropdown while leaving the
column is the worst of both". It also wrote the escape clause: <em>revisit if <code>activeSuppliers()</code>
can return suppliers in no visible row — which on today's query it can.</em> On SCR-004 that condition is not
a hypothetical, it is the <em>normal</em> case: the create form has no table beside it, so <strong>100% of
the names shown appear in no row this user can see.</strong> The redundancy that justified leaving SCR-003's
dropdown open does not exist here.
</p>
<p class="lede">
<strong>Drawn anyway, and here is the defence.</strong> Creating an order <em>is</em> choosing a supplier, a
project and some items. A gated dropdown makes <code>Orders:Create</code> a permission that grants a form
which cannot be completed — the "denied user sees a control that will fail server-side" failure SCR-000
treatment D was written to prevent, just relocated. The coherent alternatives are all bigger than a mockup
decision, and all belong to N31:
</p>
<ul class="decisions">
<li><span class="flag">A · Accept it, and record the implication.</span> <code>Orders:Create</code> is
<em>defined</em> to include read access to the four master lists, and the permission registry says so out
loud. Cheapest, and honest — but an administrator granting "just Orders:Create" must be able to see that
it is not just that. <strong>This is what Artboard A draws.</strong></li>
<li><span class="flag">B · Scope the queries instead of gating them.</span> Return only the suppliers and
projects this user is attached to. This is the same <code>User.supplierId</code> predicate SCR-003 pin 5
flagged for the orders list — one decision, two screens, and it should be made once.</li>
<li><span class="flag">C · Gate per dropdown.</span> Rejected as drawn: it yields an unusable form. Recorded
so the option is on the record rather than absent from it.</li>
</ul>
<p class="anno-note">
Note this leak is <em>not</em> closed by either guard in pin <span class="pin">3</span>. Both of those check
<code>Orders:Create</code>, which this user has. The gate works exactly as specified and the data still
leaves. That is why it is drawn and flagged rather than treated as a bug the ticket already fixes.
</p>
</section>

<section>
<h2><span class="pin">5</span><code>Orders:Create</code> without <code>Orders:View</code> — two hardcoded redirects that can bounce, or loop <span class="chip chip-approx">assumed away — flagged</span></h2>
<p class="lede">
Permissions in this design are independent flags, so <code>{ Orders:Create }</code> without
<code>Orders:View</code> is grantable. Whether it should be is an operator call; what it does today is not
ambiguous, and it is worse than a cosmetic problem because one of the two failures happens
<em>after a successful write</em>.
</p>
<table class="info">
<thead><tr><th style="width:250px">Exit path</th><th style="width:260px">Code</th><th>What happens without <code>Orders:View</code></th></tr></thead>
<tbody>
<tr>
<td>Cancel button</td>
<td><code>router.push("/orders")</code><br><code>OrderForm.tsx:114</code></td>
<td>Client navigation to a route whose own guard denies this user → bounced to the first permitted menu
entry. Confusing, but recoverable; nothing was written.</td>
</tr>
<tr>
<td>Successful submit</td>
<td><code>redirect("/orders")</code><br><code>lib/actions/orders.ts:43</code></td>
<td class="flag">The order <em>is</em> created — the write commits before the redirect — and the user is
then thrown somewhere unrelated with no confirmation that it worked. They will reasonably submit again.
Duplicate orders are the realistic outcome.</td>
</tr>
</tbody>
</table>
<p class="lede" style="margin-top:16px">
There is also a loop hazard, and SCR-002 pin 2 already named it from the other end: if the first permitted
menu entry resolves back to a route this user cannot open, the bounce repeats. That cannot happen through
Orders here <em>because</em> Artboard A assumes the implication below — which is another way of saying the
assumption is load-bearing, not decorative.
</p>
<p class="lede">
<strong>Drawn:</strong> <code>Orders:Create</code> implies <code>Orders:View</code>. Artboard A shows the
Orders nav entry present and Cancel enabled, which is only coherent under that implication. Two ways to make
it true, both for N31:
</p>
<ul class="decisions">
<li><span class="flag">A · Enforce it in the registry</span><code>Orders:Create</code> declares
<code>Orders:View</code> as a prerequisite, and granting one grants the other. Makes the drawn frame
correct by construction and removes the case entirely. Simplest.</li>
<li><span class="flag">B · Route both exits through <code>resolveLandingPath(session)</code></span> — the
single resolver SCR-002 pin 1/pin 6 already requires to exist. Strictly more correct (it also fixes the
post-create case for any future permission shape) and strictly more work, since
<code>createOrder</code>'s redirect currently hardcodes a literal.</li>
</ul>
<p class="anno-note">
These are not exclusive, and A alone leaves <code>redirect("/orders")</code> hardcoded for the next
permission that does not imply <code>Orders:View</code>. Recommendation for N31: <strong>A now, B when the
resolver from SCR-002 lands</strong> — but this is flagged, not decided.
</p>
</section>

<section>
<h2><span class="pin">6</span>Approximations — flagged, not silently drawn</h2>
<p class="lede">
Per this node's instruction: anything drawn without a real control behind it is called out here. An
unflagged approximation is what gets built. Three entries below are <em>defects in the shipping app</em> that
this frame reproduces faithfully rather than quietly correcting — none of them is SCR-004's to fix, and all
three are listed so the frame is not mistaken for approving them.
</p>
<table class="info">
<thead><tr><th style="width:270px">What</th><th style="width:120px">Kind</th><th>Detail</th></tr></thead>
<tbody>
<tr>
<td>Disabled UOM field has no disabled appearance</td>
<td><span class="chip chip-declined">defect</span></td>
<td><code>OrderForm.tsx:79</code> renders <code>&lt;input className="field-input" … disabled /&gt;</code>.
<code>globals.css</code> defines <strong>no <code>:disabled</code> rule for <code>.field-input</code></strong>
(confirmed against the compiled CSS), and the class sets <code>background-color</code> and
<code>color</code> explicitly, which overrides the browser's own disabled shading. The result: a field
that looks fully editable and silently refuses input. <code>Listbox</code> and <code>DatePicker</code>
both <em>do</em> style their disabled state (<code>disabled:bg-accent-subtle disabled:text-text-muted</code>),
so the inconsistency is within the design system, not inherent to it. <strong>Drawn as it ships</strong>
— white background, full-strength text. Not this ticket's to fix; flagged because a reviewer will
otherwise read the frame as approving it.</td>
</tr>
<tr>
<td>Long UOM labels truncate in the row</td>
<td><span class="chip chip-declined">defect</span></td>
<td>The Order Items grid is <code>grid-cols-[2fr_1fr_1fr_1fr_auto] gap-3</code> inside a
<code>max-w-3xl</code> form (768px) with <code>p-6</code> card padding, leaving a 720px content box.
After the <code>auto</code> trash column (34px) and four 12px gaps, the UOM track measures
<strong>118.3px</strong> on a row whose date is set and <strong>122.6px</strong> on one where it is not
(the DatePicker's clear icon only exists in the first case, so it pushes the Due Date floor wider at the
other tracks' expense) — leaving about <strong>92–97px</strong> of text once
<code>.field-input</code>'s <code>px-3</code> and borders are removed. The UOM value is not a bare
symbol: <code>page.tsx</code> builds it as
<code>`${u.name} (${u.symbol})`</code>, so realistic labels run past that: "Sq Metre (sqm)" is
<strong>107.7px</strong> and clips in either row state, while "Kilogram (kg)" at
<strong>94.8px</strong> clips on a date-set row and only just fits on the others — the defect is
therefore row-state dependent, which makes it easy to miss in testing. Because the control is a real
<code>&lt;input&gt;</code> it
<strong>clips</strong> — no ellipsis, no wrap, no tooltip — and the user cannot scroll it because the
field is <code>disabled</code>. <strong>Drawn clipped, as it ships.</strong> The third row's
"Sq Metre (sqm)" is the deliberate demonstration. Note this compounds the row above: the field looks
editable, so a user who sees a cut-off unit has no affordance to reveal the rest. Pre-existing, not
introduced by SCR-004, and outside this ticket's permission scope — recorded so the frame is not read
as approving it.</td>
</tr>
<tr>
<td>Three control heights in one row</td>
<td><span class="chip chip-declined">defect</span></td>
<td>Measured off the compiled stylesheet, the Order Items row contains
<code>.field-input</code> at <strong>38px</strong> (20px line + 16px padding + 2px border, no
<code>min-height</code>), <code>.field-control</code> at <strong>42px</strong>
(<code>min-height: 2.625rem</code> wins), and <code>DatePicker</code> at <strong>46px</strong> (a
<code>.field-control</code> wrapping a <code>p-1.5</code> icon button, whose 28px content beats the
min-height). <code>items-end</code> bottom-aligns them, so the four labels in a row sit at three
different heights. The footer has the same problem in miniature: <code>.btn-primary</code> is 36px and
<code>.btn-secondary</code> is 38px, side by side. <strong>Drawn at the real measurements</strong>
the misalignment in Artboard A is transcribed, not sloppy. Related to, but not the same as, the open
P2 in <code>verification/W15_DESIGN_CONSISTENCY_3238.md</code>; the height mismatch is not recorded
there.</td>
</tr>
<tr>
<td><code>Listbox</code> and <code>DatePicker</code></td>
<td><span class="chip chip-approx">approximation</span></td>
<td>Both are custom popover components. Drawn as static <code>.field-control</code> boxes in their
<em>closed</em> state at real geometry (1px border, 8×12px padding, real placeholders "Select..." /
"dd/mm/yyyy", real <code>ChevronDown</code>/<code>Calendar</code>/<code>X</code> icon sizes and
paddings). Open panels, keyboard focus rings and the
<code>border-accent ring-1 ring-accent</code> open state are out of scope for a static artboard. Same
approximation SCR-003 pin 7 made.</td>
</tr>
<tr>
<td>No order-date control</td>
<td><span class="chip chip-drawn">accurate</span></td>
<td>Called out because a reviewer will look for one. <code>orderDate</code> is a hidden input fixed to
today (<code>useMemo</code> + <code>format(new Date(), "yyyy-MM-dd")</code>,
<code>OrderForm.tsx:37,50</code>) and has never been user-editable. Its absence from the frame is the
screen, not an omission. <code>itemsJson</code> is likewise hidden.</td>
</tr>
<tr>
<td>The denied state is not drawn</td>
<td><span class="chip chip-drawn">deliberate</span></td>
<td>It has no body to draw — see pin <span class="pin">2</span>. Not an omission, and not a blocker.</td>
</tr>
<tr>
<td>Error states not drawn</td>
<td><span class="chip chip-drawn">deliberate</span></td>
<td><code>state.message</code> (<code>.field-error</code> above the form),
<code>fieldErrors.supplierId</code>/<code>projectId</code> (under each <code>SelectField</code>) and
<code>fieldErrors.items</code> (under the Order Items heading) all exist and none are
permission-sensitive. A clean frame diffs better.</td>
</tr>
<tr>
<td><code>SubmitButton</code> pending copy</td>
<td><span class="chip chip-approx">inherited</span></td>
<td>Not drawn (the frame is not pending), but the same defect SCR-001 pin 4 flagged applies here and is
worse: the shared component hardcodes "Saving…", so "Generate Order" becomes "Saving…" rather than
"Generating…". Fix is a <code>pendingLabel</code> prop on the shared component — an N31 call, and
arguably outside this ticket.</td>
</tr>
<tr>
<td>Supplier / project / item / UOM names, quantities, dates</td>
<td><span class="chip chip-approx">sample data</span></td>
<td>Invented, but consistent with SCR-003's frame (Meridian Steel Co., Riverside Clinic Fit-out) so the
two mockups read as one system. UOM labels follow the real
<code>`${u.name} (${u.symbol})`</code> template.</td>
</tr>
<tr>
<td>Four rows, mixed fill states</td>
<td><span class="chip chip-approx">sample data</span></td>
<td>The real form initialises with <strong>one</strong> empty row. Four are drawn (two complete, one
missing its due date, one freshly added and empty) to show the repeater, the derived-UOM behaviour and
the DatePicker's with/without-value states in a single frame. A one-row frame would also show the
Remove button <code>disabled</code> at <code>opacity: .5</code>, which is not visible here.</td>
</tr>
<tr>
<td>Icon glyphs</td>
<td><span class="chip chip-approx">approximation</span></td>
<td>Hand-transcribed <code>lucide-react</code> paths (ClipboardList, LogOut, Plus, Trash2, ChevronDown,
Calendar, X) at real <code>h-4 w-4</code>. Sub-pixel differences from the real font-rendered icons are
expected.</td>
</tr>
<tr>
<td>Sidebar shown filtered</td>
<td><span class="chip chip-approx">borrowed</span></td>
<td>The permission-filtered nav is SCR-027's design, not this screen's. Drawn because the shell is
always on screen and an unfiltered sidebar would contradict the permission set in the frame label —
and, on this screen specifically, would hide pin <span class="pin">4</span>'s whole point.</td>
</tr>
<tr>
<td>Hover / focus / open states</td>
<td><span class="chip chip-approx">not drawn</span></td>
<td><code>hover:bg-accent-subtle</code>, <code>focus:ring-accent</code>,
<code>focus-visible:ring-2</code>, the Listbox's <code>rotate-180</code> chevron. A static artboard has
no pointer.</td>
</tr>
</tbody>
</table>
</section>

<section>
<h2><span class="pin">7</span>Open decisions carried into N31 — and the default this mockup drew</h2>
<p class="lede">
None of these blocked the mockup. A defensible default was drawn for each and is recorded here so the
reviewer overturns it explicitly rather than discovering it after it ships. The inventory marks SCR-004
<code>ambiguous: false</code>; the first three below are why that is optimistic.
</p>
<table class="info">
<thead><tr><th style="width:420px">Question</th><th>Default drawn, and what overturning it costs</th></tr></thead>
<tbody>
<tr>
<td><b>Does <code>Orders:Create</code> hand over the supplier, project, item and UOM masters?</b>
Pin <span class="pin">4</span>.</td>
<td><strong>Yes — not gated.</strong> Highest-severity item on this screen and, like SCR-003 pin 5,
invisible in the pixels while passing every permission check. Option B (scope the queries) is the same
<code>User.supplierId</code> decision SCR-003 pin 5 raised for <code>listOrders()</code>
<em>decide the two together, they are one predicate.</em> Option A costs only a line in the permission
registry, but that line must exist or the grant is silently broader than its name.</td>
</tr>
<tr>
<td><b>Is <code>Orders:Create</code> without <code>Orders:View</code> grantable?</b>
Pin <span class="pin">5</span>.</td>
<td><strong>Assumed no — Create implies View.</strong> If N31 says it is grantable, both
<code>OrderForm.tsx:114</code> and <code>lib/actions/orders.ts:43</code> must route through
<code>resolveLandingPath()</code>, and the post-create case is the urgent one: the write commits before
the redirect, so the user gets no confirmation and resubmits.</td>
</tr>
<tr>
<td><b>Can the guard live in <code>middleware.ts</code>?</b> Pin <span class="pin">3</span>.</td>
<td><strong>Drawn as no</strong> — guard in the page component and in the action, both Node-runtime.
This forces SCR-002 pin 6's deferred question ("permissions re-read per request, or frozen in the
cookie?") into the open, because putting the permission set in the cookie is the only thing that would
make an Edge guard possible — at the price of up to 7 days of stale authorization
(<code>MAX_AGE_SECONDS</code>).</td>
</tr>
<tr>
<td><b>Are the two guards both required, or is the route guard enough?</b></td>
<td><strong>Both — drawn as non-negotiable, not as a preference.</strong> A Server Action POST does not
execute the page component. This is the one item on this sheet recorded as a correctness constraint
rather than an open question; it is listed so it cannot be traded away as belt-and-braces.</td>
</tr>
<tr>
<td><b>Do <code>updateFulfillment</code> and <code>cancelOrder</code> get gated here?</b></td>
<td><strong>No — out of scope, flagged so the file is not swept.</strong> They live in the same
<code>lib/actions/orders.ts</code> and share the same bare <code>requireSession()</code>, so an
implementer opening that file will see three ungated actions and may gate all three under
<code>Orders:Create</code>. They belong to SCR-005, whose own ambiguity (Approve vs Fulfil, one
permission or two) is unresolved. Gating them under <code>Orders:Create</code> would be wrong and would
look finished.</td>
</tr>
<tr>
<td><b>Should the frame have drawn a Manufacturer · Admin instead?</b></td>
<td><strong>No — minimum permission set drawn.</strong> Recorded because it is a real editorial choice:
an Admin frame would be identical to today's page and would have concealed pin
<span class="pin">4</span> entirely behind a full sidebar.</td>
</tr>
<tr>
<td><b>SCR-029 — no way to create the user this frame depicts.</b></td>
<td>Dependency, not a design question. There is no <code>User</code> model and
<code>lib/auth.ts</code> is a single shared credential, so "Supplier · Staff holding
<code>{ Orders:View, Orders:Create }</code>" cannot be instantiated to verify this frame end-to-end.
Same note SCR-000/001/002/003 each carried.</td>
</tr>
</tbody>
</table>
</section>

<section>
<h2><span class="pin">8</span>Tokens used — no new colour, radius or type scale is introduced</h2>
<p class="lede">
Every value in Artboard A resolves to <code>tailwind.config.ts</code> or a <code>globals.css</code>
component class. SCR-004 adds <strong>zero new pixels</strong> to the shipping surface: the permitted frame
is byte-identical to today's page, and the denied state has no frame. Nothing on this screen needed an
invented control — the one thing that came close, a disabled-field treatment, is flagged in pin
<span class="pin">6</span> as missing from the design system rather than approximated into it.
</p>
<div class="swatches">
<span class="sw"><i style="background:#fcfcfd"></i>bg<br>#fcfcfd</span>
<span class="sw"><i style="background:#ffffff"></i>surface<br>#ffffff</span>
<span class="sw"><i style="background:#302828"></i>text<br>#302828</span>
<span class="sw"><i style="background:#836f6f"></i>text-muted<br>#836f6f</span>
<span class="sw"><i style="background:#ede0ce"></i>border<br>#ede0ce</span>
<span class="sw"><i style="background:#453028"></i>accent<br>#453028</span>
<span class="sw"><i style="background:#f7f1e9"></i>accent-subtle<br>#f7f1e9</span>
<span class="sw"><i style="background:#c0392b"></i>status-danger<br>#c0392b</span>
</div>
<table class="info" style="margin-top:16px">
<thead><tr><th style="width:200px">Token / class</th><th style="width:230px">Value</th><th>Where it lands in Artboard A</th></tr></thead>
<tbody>
<tr><td><code>bg</code></td><td>#fcfcfd</td><td><code>main</code> background</td></tr>
<tr><td><code>surface</code></td><td>#ffffff</td><td>sidebar, both cards, every field, <code>.btn-secondary</code></td></tr>
<tr><td><code>text</code> / <code>text-muted</code></td><td>#302828 / #836f6f</td><td>labels and values / nav items, placeholders, field icons, sign-out</td></tr>
<tr><td><code>border</code></td><td>#ede0ce</td><td>sidebar edge, card borders, every field border, <code>.btn-secondary</code> border</td></tr>
<tr><td><code>accent</code></td><td>#453028</td><td><strong>Generate Order</strong> only — the single accent-filled element on the screen</td></tr>
<tr><td><code>accent-subtle</code></td><td>#f7f1e9</td><td>active Orders nav item</td></tr>
<tr><td><code>status-danger</code></td><td>#c0392b</td><td><em>reserved, not painted</em><code>.field-error</code>; no error state is drawn (pin <span class="pin">6</span>)</td></tr>
<tr><td><code>status-success/warning/neutral/info</code></td><td></td><td>unused on this screen; it has no status badges and no links</td></tr>
<tr><td><code>rounded-md</code> / <code>rounded-lg</code> / <code>rounded-full</code></td><td>6px / 8px / 9999px</td><td>fields + buttons / cards / DatePicker's calendar icon button</td></tr>
<tr><td>type scale</td><td>18px·28 / 20px·28 / 14px·20</td><td><code>text-lg</code> brand / <code>text-xl</code> page title / <code>text-sm</code> everything else. <code>text-xs</code> 12px·16 for the nav group heading only.</td></tr>
<tr><td>spacing</td><td>24 / 16 / 12 / 8 / 4px</td><td><code>p-6</code> cards + <code>space-y-6</code> / <code>gap-4</code> grid / <code>gap-3</code> rows + <code>mb-3</code> / <code>gap-2</code> footer / <code>mb-1</code> labels</td></tr>
<tr><td class="flag">annotation <code>--anno</code></td><td class="flag">#7c3aed</td><td class="flag">this sheet only — exists in no design token and appears nowhere inside the artboard</td></tr>
</tbody>
</table>
</section>

</div>

</body>
</html>
(5-5/5)