SCR-004 — Order create · web (oms-web) · app/orders/new/page.tsx

The screen is unchanged in layout. What this ticket adds is a gate in front of it and a second, independent gate behind it: the route requires Orders:Create, and so does createOrder() in lib/actions/orders.ts — which today asserts only requireSession(). 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.

Artboard A draws the permitted state, which inverts SCR-003's choice — deliberately. SCR-003 drew its changed state because its unchanged state had nothing to review. Here the changed state has no pixels at all: a user without Orders:Create is redirected before a body is emitted, so there is no denied rendering of this route to draw. Pin 2 argues that in full. The frame therefore shows the minimum permission set that can reach the route — Supplier · Staff holding { Orders:View, Orders:Create } — which is also the set that makes the screen's real finding visible.

That finding is pin 4: 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. page.tsx runs all four active*() lookups in a Promise.all 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: Orders:Create granted without Orders:View (5), where two hardcoded /orders redirects send the user somewhere their own guard denies, and whether the guard can live in middleware at all (3) — it cannot, on today's session model.

Viewport 1440 × 900 (desktop only) · tokens from tailwind.config.ts + app/globals.css, cross-checked against the compiled .next/static/css/9273638c9655cca0.css · shell from app/layout.tsx + components/layout/Sidebar.tsx · page transcribed from app/orders/new/page.tsx with PageHeader/OrderForm/SelectField/Listbox/DatePicker/SubmitButton · option data from lib/queries/masters.ts (activeSuppliers/Projects/Items/Uoms), server action lib/actions/orders.ts:18 · fields["SCR-004"] is [] — no field checklist was handed to this node, so the form's fields are transcribed from the real OrderForm.tsx 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 (Listbox, DatePicker) are called out in 6.
Artboard A — 1440 × 900 · 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.

New Order

Meridian Steel Co.
Riverside Clinic Fit-out

Order Items

Add Row
M16 Hex Bolt — Gr 8.8
Piece (pcs)
2400
09/09/2026
Steel Beam 203×133
Metre (m)
180
12/09/2026
Galvanised Sheet 2mm
Sq Metre (sqm)
64.5
dd/mm/yyyy
Select...
 
 
dd/mm/yyyy
Cancel Generate Order

1What Artboard A asserts

Each row is a claim the frame makes that a reviewer can check against the running app. "Today" is what main ships right now; "Drawn" is what Artboard A shows for a user holding { Orders:View, Orders:Create }.

ElementPermissionToday (main)Drawn in Artboard A
Route /orders/new Orders:Create Any session renders it. middleware.ts checks the cookie only. 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 2.
Server action createOrder() Orders:Create lib/actions/orders.ts:19await requireSession() and nothing more. 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 3.
Sidebar — OMS group Orders:View All three entries hardcoded in NAV_GROUPS. Orders only. Expenses and Expense Summary absent (hidden, not disabled — SCR-000 pin 1 treatment A).
Sidebar — Admin group every item denied All four entries hardcoded. Whole group dropped, heading included (SCR-000 pin 1 treatment B). The 20px group gap collapses.
PageHeader action slot Not passed on this screen. Title-only. Unchanged in every permission state — SCR-004 adds no header action.
Supplier / Project SelectField none — see pin 4 Populated from activeSuppliers() / activeProjects(). Populated in full, for a user with neither Supplier:View nor Project:View. Drawn, and flagged.
Product Name / UOM per row none — see pin 4 Populated from activeItems() / activeUoms(). Same. The item and UOM masters are handed to a user who cannot open either admin screen.
Add Row / Remove row Pure client state (useState<Row[]>). 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 disabled (rows.length === 1) — four rows are drawn, so all four are live.
Cancel assumes Orders:View router.push("/orders"), hardcoded. Drawn enabled, on the assumption Orders:Create implies Orders:View. Pin 5.
Generate Order Orders:Create SubmitButtoncreateOrder. Drawn enabled. It is never conditionally hidden, because a user without the permission never reaches the form it lives in.

2Why Artboard A draws the permitted state — this screen's denied state has no pixels

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.

Consequence for W21_ui_verify: a screenshot diff of this route against today's build should show zero pixel change for a permitted user. 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.

3Where the gate lives — two layers, and why middleware cannot be one of them

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.

Middleware is not available as a guard layer, and that is a design constraint, not an oversight. middleware.ts already matches this route and 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 { u, exp } (lib/auth.ts:21). 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. SCR-004 is where that question stops being theoretical. 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.

4The form hands this user four master lists they are not permitted to browse not gated — drawn

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.

app/orders/new/page.tsx opens with a Promise.all over activeSuppliers(), activeProjects(), activeItems() and activeUoms(). Each returns every active row of its table (lib/queries/masters.ts:97–104 — no pagination, no scoping, id + name only). Those names are serialised into the initial HTML payload as Listbox options. The user drawn in Artboard A holds Orders:Create and nothing else in the master-data space: no Supplier:View, no Item:View, no Uom:View, no Project:View. Their sidebar has no Admin group at all. They still receive the complete supplier list, project list, item catalogue and UOM list.

QueryPermission that would gate itWhat the user gets without it
activeSuppliers()Supplier:ViewEvery active supplier name — the same list /admin/suppliers would show them, minus the columns.
activeProjects()Project:ViewEvery 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.
activeItems()Item:ViewThe full active item catalogue.
activeUoms()Uom:ViewThe UOM list. Low sensitivity, listed for completeness.

Why SCR-003's argument does not transfer. 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: revisit if activeSuppliers() can return suppliers in no visible row — which on today's query it can. On SCR-004 that condition is not a hypothetical, it is the normal case: the create form has no table beside it, so 100% of the names shown appear in no row this user can see. The redundancy that justified leaving SCR-003's dropdown open does not exist here.

Drawn anyway, and here is the defence. Creating an order is choosing a supplier, a project and some items. A gated dropdown makes Orders:Create 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:

Note this leak is not closed by either guard in pin 3. Both of those check Orders:Create, 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.

5Orders:Create without Orders:View — two hardcoded redirects that can bounce, or loop assumed away — flagged

Permissions in this design are independent flags, so { Orders:Create } without Orders:View 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 after a successful write.

Exit pathCodeWhat happens without Orders:View
Cancel button router.push("/orders")
OrderForm.tsx:114
Client navigation to a route whose own guard denies this user → bounced to the first permitted menu entry. Confusing, but recoverable; nothing was written.
Successful submit redirect("/orders")
lib/actions/orders.ts:43
The order is 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.

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 because Artboard A assumes the implication below — which is another way of saying the assumption is load-bearing, not decorative.

Drawn: Orders:Create implies Orders:View. 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:

These are not exclusive, and A alone leaves redirect("/orders") hardcoded for the next permission that does not imply Orders:View. Recommendation for N31: A now, B when the resolver from SCR-002 lands — but this is flagged, not decided.

6Approximations — flagged, not silently drawn

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 defects in the shipping app 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.

WhatKindDetail
Disabled UOM field has no disabled appearance defect OrderForm.tsx:79 renders <input className="field-input" … disabled />. globals.css defines no :disabled rule for .field-input (confirmed against the compiled CSS), and the class sets background-color and color explicitly, which overrides the browser's own disabled shading. The result: a field that looks fully editable and silently refuses input. Listbox and DatePicker both do style their disabled state (disabled:bg-accent-subtle disabled:text-text-muted), so the inconsistency is within the design system, not inherent to it. Drawn as it ships — white background, full-strength text. Not this ticket's to fix; flagged because a reviewer will otherwise read the frame as approving it.
Long UOM labels truncate in the row defect The Order Items grid is grid-cols-[2fr_1fr_1fr_1fr_auto] gap-3 inside a max-w-3xl form (768px) with p-6 card padding, leaving a 720px content box. After the auto trash column (34px) and four 12px gaps, the UOM track measures 118.3px on a row whose date is set and 122.6px 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 92–97px of text once .field-input's px-3 and borders are removed. The UOM value is not a bare symbol: page.tsx builds it as `${u.name} (${u.symbol})`, so realistic labels run past that: "Sq Metre (sqm)" is 107.7px and clips in either row state, while "Kilogram (kg)" at 94.8px 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 <input> it clips — no ellipsis, no wrap, no tooltip — and the user cannot scroll it because the field is disabled. Drawn clipped, as it ships. 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.
Three control heights in one row defect Measured off the compiled stylesheet, the Order Items row contains .field-input at 38px (20px line + 16px padding + 2px border, no min-height), .field-control at 42px (min-height: 2.625rem wins), and DatePicker at 46px (a .field-control wrapping a p-1.5 icon button, whose 28px content beats the min-height). items-end bottom-aligns them, so the four labels in a row sit at three different heights. The footer has the same problem in miniature: .btn-primary is 36px and .btn-secondary is 38px, side by side. Drawn at the real measurements — the misalignment in Artboard A is transcribed, not sloppy. Related to, but not the same as, the open P2 in verification/W15_DESIGN_CONSISTENCY_3238.md; the height mismatch is not recorded there.
Listbox and DatePicker approximation Both are custom popover components. Drawn as static .field-control boxes in their closed state at real geometry (1px border, 8×12px padding, real placeholders "Select..." / "dd/mm/yyyy", real ChevronDown/Calendar/X icon sizes and paddings). Open panels, keyboard focus rings and the border-accent ring-1 ring-accent open state are out of scope for a static artboard. Same approximation SCR-003 pin 7 made.
No order-date control accurate Called out because a reviewer will look for one. orderDate is a hidden input fixed to today (useMemo + format(new Date(), "yyyy-MM-dd"), OrderForm.tsx:37,50) and has never been user-editable. Its absence from the frame is the screen, not an omission. itemsJson is likewise hidden.
The denied state is not drawn deliberate It has no body to draw — see pin 2. Not an omission, and not a blocker.
Error states not drawn deliberate state.message (.field-error above the form), fieldErrors.supplierId/projectId (under each SelectField) and fieldErrors.items (under the Order Items heading) all exist and none are permission-sensitive. A clean frame diffs better.
SubmitButton pending copy inherited 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 pendingLabel prop on the shared component — an N31 call, and arguably outside this ticket.
Supplier / project / item / UOM names, quantities, dates sample data 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 `${u.name} (${u.symbol})` template.
Four rows, mixed fill states sample data The real form initialises with one 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 disabled at opacity: .5, which is not visible here.
Icon glyphs approximation Hand-transcribed lucide-react paths (ClipboardList, LogOut, Plus, Trash2, ChevronDown, Calendar, X) at real h-4 w-4. Sub-pixel differences from the real font-rendered icons are expected.
Sidebar shown filtered borrowed 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 4's whole point.
Hover / focus / open states not drawn hover:bg-accent-subtle, focus:ring-accent, focus-visible:ring-2, the Listbox's rotate-180 chevron. A static artboard has no pointer.

7Open decisions carried into N31 — and the default this mockup drew

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 ambiguous: false; the first three below are why that is optimistic.

QuestionDefault drawn, and what overturning it costs
Does Orders:Create hand over the supplier, project, item and UOM masters? Pin 4. Yes — not gated. 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 User.supplierId decision SCR-003 pin 5 raised for listOrders()decide the two together, they are one predicate. Option A costs only a line in the permission registry, but that line must exist or the grant is silently broader than its name.
Is Orders:Create without Orders:View grantable? Pin 5. Assumed no — Create implies View. If N31 says it is grantable, both OrderForm.tsx:114 and lib/actions/orders.ts:43 must route through resolveLandingPath(), and the post-create case is the urgent one: the write commits before the redirect, so the user gets no confirmation and resubmits.
Can the guard live in middleware.ts? Pin 3. Drawn as no — 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 (MAX_AGE_SECONDS).
Are the two guards both required, or is the route guard enough? Both — drawn as non-negotiable, not as a preference. 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.
Do updateFulfillment and cancelOrder get gated here? No — out of scope, flagged so the file is not swept. They live in the same lib/actions/orders.ts and share the same bare requireSession(), so an implementer opening that file will see three ungated actions and may gate all three under Orders:Create. They belong to SCR-005, whose own ambiguity (Approve vs Fulfil, one permission or two) is unresolved. Gating them under Orders:Create would be wrong and would look finished.
Should the frame have drawn a Manufacturer · Admin instead? No — minimum permission set drawn. Recorded because it is a real editorial choice: an Admin frame would be identical to today's page and would have concealed pin 4 entirely behind a full sidebar.
SCR-029 — no way to create the user this frame depicts. Dependency, not a design question. There is no User model and lib/auth.ts is a single shared credential, so "Supplier · Staff holding { Orders:View, Orders:Create }" cannot be instantiated to verify this frame end-to-end. Same note SCR-000/001/002/003 each carried.

8Tokens used — no new colour, radius or type scale is introduced

Every value in Artboard A resolves to tailwind.config.ts or a globals.css component class. SCR-004 adds zero new pixels 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 6 as missing from the design system rather than approximated into it.

bg
#fcfcfd
surface
#ffffff
text
#302828
text-muted
#836f6f
border
#ede0ce
accent
#453028
accent-subtle
#f7f1e9
status-danger
#c0392b
Token / classValueWhere it lands in Artboard A
bg#fcfcfdmain background
surface#ffffffsidebar, both cards, every field, .btn-secondary
text / text-muted#302828 / #836f6flabels and values / nav items, placeholders, field icons, sign-out
border#ede0cesidebar edge, card borders, every field border, .btn-secondary border
accent#453028Generate Order only — the single accent-filled element on the screen
accent-subtle#f7f1e9active Orders nav item
status-danger#c0392breserved, not painted.field-error; no error state is drawn (pin 6)
status-success/warning/neutral/infounused on this screen; it has no status badges and no links
rounded-md / rounded-lg / rounded-full6px / 8px / 9999pxfields + buttons / cards / DatePicker's calendar icon button
type scale18px·28 / 20px·28 / 14px·20text-lg brand / text-xl page title / text-sm everything else. text-xs 12px·16 for the nav group heading only.
spacing24 / 16 / 12 / 8 / 4pxp-6 cards + space-y-6 / gap-4 grid / gap-3 rows + mb-3 / gap-2 footer / mb-1 labels
annotation --anno#7c3aedthis sheet only — exists in no design token and appears nowhere inside the artboard