|
<!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"> </div>
|
|
</div>
|
|
<div>
|
|
<label class="field-label">Qty</label>
|
|
<div class="field-input"> </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<Row[]></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><input className="field-input" … disabled /></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><input></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>
|