Project

General

Profile

Feature #3241 » SCR-002-web.html

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

 
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>SCR-002 — web mockup — Root landing redirect (/)</title>
<!--
SCR-002 · 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/page.tsx — status "existing-modified". Inventory scope:
"app/page.tsx hard-redirects to /orders. Under permissions a Staff user without
Orders:View lands on a denial; landing target must become permission-derived
(first allowed menu)."
Inventory ambiguity (verbatim):
"Unspecified: redirect to first-permitted menu, or render a denial? Operator
decision — affects whether SCR-028 is needed."

READ THIS BEFORE READING THE ARTBOARD.
SCR-002 HAS NO PIXELS OF ITS OWN. app/page.tsx is five lines that call redirect();
it renders no markup, mounts no component, and is never painted. There is therefore no
honest way to draw "SCR-002" as a frame. What Artboard A draws instead is the OBSERVABLE
RESULT of the redirect — the screen a permission-limited user actually ends up looking at
after typing the app's bare origin — because that outcome is the entire design decision
this node has to commit to. Annotation 1 states this explicitly so the artboard is never
mistaken for a new screen; annotation 3 draws the two rejected alternatives.

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).
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; width: 100%; 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 ---- */
.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; }
.btn-secondary { background: var(--surface); border: 1px solid var(--border); color: var(--text); }
.btn-sm { padding: 4px 12px; } /* Pagination's px-3 py-1 */
.btn-dim { opacity: .4; } /* Pagination disabled edge: opacity-40 */
.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;
}
.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 */

/* ---- list toolbar: div.flex.items-center.justify-between.mb-4.gap-4 ---- */
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.search-wrap { position: relative; width: 100%; max-width: 320px; } /* max-w-xs */
.search-wrap svg { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; }
.search-wrap .field-input { padding-left: 32px; } /* pl-8 */
.search-wrap .placeholder {
position: absolute; left: 32px; top: 50%; transform: translateY(-50%);
font-size: 14px; line-height: 20px; color: var(--text-muted); pointer-events: none;
}
.filters-btn svg { width: 16px; height: 16px; margin-right: 6px; } /* mr-1.5 */

/* ---- DataTable.tsx ---- */
.table-card { overflow: hidden; }
table.dt { width: 100%; border-collapse: collapse; font-size: 14px; line-height: 20px; } /* text-sm */
table.dt thead th {
padding: 12px 16px; text-align: left; font-weight: 500; /* px-4 py-3 font-medium */
color: var(--text-muted); white-space: nowrap;
}
table.dt thead th .sort { display: inline-flex; align-items: center; gap: 4px; color: inherit; text-decoration: none; }
table.dt thead th .sort svg { width: 14px; height: 14px; } /* h-3.5 w-3.5 */
table.dt tbody tr { border-top: 1px solid var(--border); } /* divide-y divide-border */
table.dt thead tr { border-bottom: 1px solid var(--border); }
table.dt tbody td { padding: 12px 16px; vertical-align: middle; white-space: nowrap; color: var(--text); }

/* ---- StatusBadge.tsx: rounded-full px-2 py-0.5 text-xs font-medium, tone at /10 ---- */
.badge { display: inline-flex; align-items: center; border-radius: 9999px; padding: 2px 8px; font-size: 12px; line-height: 16px; font-weight: 500; }
.badge-warning { background: rgba(184,134,11,.1); color: var(--status-warning); }
.badge-success { background: rgba(31,138,76,.1); color: var(--status-success); }
.badge-danger { background: rgba(192,57,43,.1); color: var(--status-danger); }

/* ---- Pagination.tsx: flex justify-between mt-4 text-sm text-text-muted ---- */
.pager { display: flex; align-items: center; justify-content: space-between; margin-top: 16px; font-size: 14px; line-height: 20px; color: var(--text-muted); gap: 12px; }
.pager .right { display: flex; align-items: center; gap: 12px; }
.pager label { display: flex; align-items: center; gap: 8px; }
.pager select.field-input { width: auto; padding: 4px 28px 4px 12px; } /* py-1 pr-7 w-auto */

/* ================= 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: 330px; }
.mini-frame {
width: 330px; 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; counter-reset: step; }
.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; }
.ghost { opacity: .45; }
.strike { text-decoration: line-through; }
</style>
</head>
<body>

<div class="doc-head">
<h1>SCR-002 — Root landing redirect (/) · web (oms-web) · <code>app/page.tsx</code></h1>
<p>
<strong>This screen has no markup.</strong> <code>app/page.tsx</code> is a five-line Server Component whose entire
body is <code>redirect("/orders")</code> — it paints nothing, ever. The design work here is not a layout, it is a
<em>rule</em>: what does the bare origin resolve to once the menu is permission-derived and
<code>/orders</code> may be denied to the person asking?
</p>
<p>
<strong>Drawn default: redirect to the first permitted menu item.</strong> Artboard A is therefore a picture of
<em>where a permission-limited user ends up</em> — the Expenses list (SCR-006), reached by a Staff user who holds
<code>Expenses:View</code>/<code>Expenses:Create</code> and nothing else. That is the honest, verifiable output of
this node: the rule, plus the frame it produces. Annotation <span class="pin">1</span> makes the
"no pixels of its own" point non-negotiable so nobody reads Artboard A as a new screen;
<span class="pin">3</span> draws the two rejected alternatives (403 page, <code>notFound()</code>) so N31 can
overturn the default explicitly; <span class="pin">4</span> is the degenerate case where the default runs out of
road; <span class="pin">5</span> lists the approximations, including one real bug this frame exposes in an
<em>existing</em> file.
</p>
<div class="doc-meta">
Viewport <code>1440 × 900</code> (desktop only) ·
tokens from <code>tailwind.config.ts</code> + <code>app/globals.css</code> ·
shell mirrored from <code>app/layout.tsx</code> + <code>components/layout/Sidebar.tsx</code> ·
landing frame mirrored from <code>app/expenses/page.tsx</code> with
<code>PageHeader</code>/<code>SearchInput</code>/<code>FilterBar</code>/<code>DataTable</code>/<code>StatusBadge</code>/<code>Pagination</code> ·
redirect behaviour read from <code>app/page.tsx</code>, <code>middleware.ts</code>, <code>lib/actions/auth.ts</code>
· <code>fields["SCR-002"]</code> is <code>[]</code> — correctly so, this screen has no form controls at all
</div>
</div>

<div class="frame-label">Artboard A — 1440 × 900 · <span>The resolved landing for a Staff user without Orders:View. What <code>/</code> actually produces. 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 Expenses:View + Expenses:Create only, so: Orders and Expense Summary
are absent from the OMS group, and the whole Admin group is dropped (every item denied). -->
<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 Receipt -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1Z"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 17.5v-11"/></svg>
Expenses
</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/expenses/page.tsx -->
<main class="main">
<div class="page-head">
<h1>Expenses</h1>
<div><span class="btn btn-primary">New Expense</span></div>
</div>

<div class="toolbar">
<div class="search-wrap">
<!-- lucide Search -->
<svg viewBox="0 0 24 24" fill="none" stroke="#836f6f" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<input class="field-input" type="search" value="" />
<span class="placeholder">Search expenses...</span>
</div>
<span class="btn btn-secondary filters-btn">
<!-- lucide SlidersHorizontal -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 4H14"/><path d="M10 4H3"/><path d="M21 12H12"/><path d="M8 12H3"/><path d="M21 20H16"/><path d="M12 20H3"/><path d="M14 2v4"/><path d="M8 10v4"/><path d="M16 18v4"/></svg>
Filters
</span>
</div>

<div class="card table-card">
<table class="dt">
<thead>
<tr>
<th style="width:140px">
<span class="sort">Date
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 16-4 4-4-4"/><path d="M17 20V4"/><path d="m3 8 4-4 4 4"/><path d="M7 4v16"/></svg>
</span>
</th>
<th>Project Name</th>
<th style="width:150px">Expense Items</th>
<th style="width:170px">Total Amount</th>
<th style="width:150px">
<span class="sort">Status
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 16-4 4-4-4"/><path d="M17 20V4"/><path d="m3 8 4-4 4 4"/><path d="M7 4v16"/></svg>
</span>
</th>
<th style="width:110px">Actions</th>
</tr>
</thead>
<tbody>
<tr><td>18/08/2026</td><td>Riverside Clinic Fit-out</td><td>4</td><td>18,420.00</td><td><span class="badge badge-warning">Pending</span></td><td><a class="link" href="#">Edit</a></td></tr>
<tr><td>17/08/2026</td><td>Northgate Warehouse</td><td>2</td><td>3,150.00</td><td><span class="badge badge-success">Approved</span></td><td><a class="link" href="#">Edit</a></td></tr>
<tr><td>15/08/2026</td><td>Riverside Clinic Fit-out</td><td>7</td><td>26,905.50</td><td><span class="badge badge-success">Approved</span></td><td><a class="link" href="#">Edit</a></td></tr>
<tr><td>14/08/2026</td><td>Depot Cold Storage</td><td>1</td><td>780.00</td><td><span class="badge badge-danger">Rejected</span></td><td><a class="link" href="#">Edit</a></td></tr>
<tr><td>12/08/2026</td><td>Northgate Warehouse</td><td>5</td><td>9,640.25</td><td><span class="badge badge-warning">Pending</span></td><td><a class="link" href="#">Edit</a></td></tr>
<tr><td>11/08/2026</td><td>Harbour Point Retrofit</td><td>3</td><td>12,300.00</td><td><span class="badge badge-success">Approved</span></td><td><a class="link" href="#">Edit</a></td></tr>
<tr><td>08/08/2026</td><td>Depot Cold Storage</td><td>6</td><td>7,455.80</td><td><span class="badge badge-warning">Pending</span></td><td><a class="link" href="#">Edit</a></td></tr>
<tr><td>05/08/2026</td><td>Harbour Point Retrofit</td><td>2</td><td>1,990.00</td><td><span class="badge badge-success">Approved</span></td><td><a class="link" href="#">Edit</a></td></tr>
<tr><td>04/08/2026</td><td>Riverside Clinic Fit-out</td><td>9</td><td>31,275.00</td><td><span class="badge badge-warning">Pending</span></td><td><a class="link" href="#">Edit</a></td></tr>
</tbody>
</table>
</div>

<div class="pager">
<div>Showing 1–9 of 9</div>
<div class="right">
<label>Rows
<select class="field-input"><option>10</option></select>
</label>
<div style="display:flex; align-items:center; gap:4px;">
<span class="btn btn-secondary btn-sm btn-dim">Prev</span>
<span style="padding:0 8px;">Page 1 of 1</span>
<span class="btn btn-secondary btn-sm btn-dim">Next</span>
</div>
</div>
</div>
</main>

</div>
</div>

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

<section>
<h2><span class="pin">1</span>What SCR-002 is, and what Artboard A is a picture of</h2>
<p class="lede">
Stated plainly so it cannot be misread downstream: <strong>SCR-002 renders nothing.</strong> The frame above is
SCR-006 (Expenses list) shown as the <em>destination</em> of SCR-002's rule, under a permission set that makes
the rule's effect visible. If Artboard A is fed to a UI-diff gate, it is diffing the Expenses list under a
filtered sidebar — which is exactly the observable acceptance criterion for this screen, and there is no other
pixel evidence SCR-002 could ever produce.
</p>
<table class="info">
<thead>
<tr>
<th style="width:180px">Concern</th>
<th style="width:330px">Today</th>
<th style="width:360px">Under permissions</th>
<th>Visual consequence</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>The route body</strong></td>
<td><code>app/page.tsx</code>: <code>redirect("/orders")</code>, unconditional, no session read
(5 lines, no JSX).</td>
<td>Read the session, resolve the permission set, redirect to the first menu entry that set permits.
Still no JSX.</td>
<td><strong>None on <code>/</code> itself</strong> — it is never painted. All consequence is at the
destination.</td>
</tr>
<tr>
<td><strong>Who reaches it</strong></td>
<td>Anyone; <code>middleware.ts</code>'s matcher
<code>/((?!login|_next/static|_next/image|favicon.ico).*)</code> does match <code>/</code>, so an
unauthenticated visitor is bounced to <code>/login?next=/</code> first.</td>
<td>Unchanged. SCR-002 only ever runs for an <em>authenticated</em> user — which is why the question here is
authorization, never authentication.</td>
<td>None. The signed-out path is already correct and is not touched.</td>
</tr>
<tr>
<td><strong>Rendering model</strong></td>
<td>Server Component. <code>redirect()</code> throws before any response body is produced — the browser
receives a 307 and never paints an intermediate frame.</td>
<td>Unchanged, and worth protecting: resolving the target server-side means <strong>no flash, no spinner,
no "Redirecting…" screen</strong>.</td>
<td><strong>Do not build a loading state for this screen.</strong> There is no moment at which one could be
shown.</td>
</tr>
<tr>
<td><strong>Failure today</strong></td>
<td>A Staff user without <code>Orders:View</code> types the origin → lands on <code>/orders</code>
gets denied. The app's own front door is a dead end for them.</td>
<td>Landing target is derived from what the user may actually see.</td>
<td>Artboard A. The user lands somewhere they are allowed to be, with a menu that matches.</td>
</tr>
<tr>
<td><strong>Duplicate of this rule</strong></td>
<td><code>lib/actions/auth.ts</code> also hardcodes <code>redirect("/orders")</code> after a successful
login (SCR-001).</td>
<td>Both call points must share <strong>one</strong> resolver
(e.g. <code>resolveLandingPath(session)</code>), not two copies of the same list order.</td>
<td>None visible — but two implementations <em>will</em> drift, and the drift is invisible until a user with
an unusual permission set hits one path and not the other.</td>
</tr>
</tbody>
</table>
</section>

<section>
<h2><span class="pin">2</span>The resolution rule, and what it produces for real permission sets</h2>
<p class="lede">
The rule drawn is deliberately boring: <em>flatten the menu in its declared order, take the first entry the user
is permitted to open, redirect there.</em> "First" needs a defined ordering, and this app already has one —
<code>NAV_GROUPS</code> in <code>components/layout/Sidebar.tsx</code> is an ordered literal
(OMS: Orders → Expenses → Expense Summary; Admin: Suppliers → Items → UOM → Projects). That order is reused
rather than a new priority field being invented.
</p>
<ul class="ladder">
<li><b>1 · Authenticated?</b> No → <code>middleware.ts</code> already redirected to <code>/login?next=/</code>.
SCR-002 never runs. Unchanged behaviour.</li>
<li><b>2 · Flatten the menu</b> in <code>NAV_GROUPS</code> order into a single ordered list of
<code>{ href, permission }</code>. This is the same registry SCR-027 renders the sidebar from — not a second
list.</li>
<li><b>3 · Take the first entry the permission set permits</b> and <code>redirect()</code> to its
<code>href</code>.</li>
<li><b>4 · No entry is permitted</b> → the degenerate case. See <span class="pin">4</span>; the drawn position is
that this is unreachable because SCR-001 fails closed at login, with a fail-safe behind it.</li>
</ul>
<p class="anno-note" style="margin-bottom:16px">
<strong>Loop hazard — the one thing that can actually break this screen.</strong> The permission consulted in
step 3 must be the <em>same</em> permission the destination route's own guard checks. If the menu says a user may
see <code>/expenses</code> under <code>Expenses:View</code> but the route guard demands something else,
<code>/</code> redirects into a denial which (under option C in <span class="pin">3</span>) may redirect back to
<code>/</code> — an infinite loop, served as a browser error page, with no server exception to alert on. Both
must read one registry. This is an implementation constraint, not a design preference.
</p>
<table class="info">
<thead>
<tr>
<th style="width:230px">Example user</th>
<th style="width:330px">Permission set (illustrative)</th>
<th style="width:180px"><code>/</code> resolves to</th>
<th>Note</th>
</tr>
</thead>
<tbody>
<tr>
<td>Manufacturer · Admin</td>
<td>All</td>
<td><code>/orders</code></td>
<td>Identical to today's hardcoded target. <strong>No behaviour change for the privileged case</strong>,
which is why this default is low-risk to ship.</td>
</tr>
<tr>
<td>Supplier · Staff <span class="chip chip-drawn">Artboard A</span></td>
<td><code>Expenses:View</code>, <code>Expenses:Create</code></td>
<td><code>/expenses</code></td>
<td>Orders is first in the menu but denied, so it is skipped. Admin group is entirely denied and is dropped
from the sidebar rather than rendered empty.</td>
</tr>
<tr>
<td>Manufacturer · Staff (masters only)</td>
<td><code>Item:View</code>, <code>Uom:View</code></td>
<td><code>/admin/items</code></td>
<td>The whole OMS group is skipped; landing is inside Admin. Nothing about the rule special-cases groups —
it is one flat ordered walk.</td>
</tr>
<tr>
<td>Reporting-only Staff</td>
<td><code>ExpenseSummary:View</code></td>
<td><code>/expense-summary</code></td>
<td>Depends on SCR-009's open question (is reporting its own resource?). If reporting collapses into
<code>Expenses:View</code>, this user stops existing — the rule is unaffected either way.</td>
</tr>
<tr>
<td>Zero-permission account</td>
<td>— (empty role, or all permissions revoked)</td>
<td><em>nothing to redirect to</em></td>
<td>The degenerate case. <span class="pin">4</span>.</td>
</tr>
</tbody>
</table>
<p class="anno-note">
<strong>Ordering is a product decision wearing implementation clothes.</strong> Reusing <code>NAV_GROUPS</code>
order means the landing page for a limited user is "the topmost thing they can see", not "the most useful thing
they can see". For a Supplier Staff user whose actual job is Orders, that is fine; for one whose job is
Expenses but who also happens to hold a stray <code>Orders:View</code>, they land on Orders. An explicit
per-role <code>defaultLanding</code> would fix that and costs one nullable column — recorded in
<span class="pin">6</span>, not drawn, because nothing in the acceptance criteria asks for it and it adds a
second thing that can be misconfigured.
</p>
</section>

<section>
<h2><span class="pin">3</span>The operator decision: redirect, or deny? <span class="chip chip-drawn">A drawn</span> <span class="chip chip-declined">B, C declined</span></h2>
<p class="lede">
This is the ambiguity the inventory flags — <em>"redirect to first-permitted menu, or render a denial?"</em>
and it is explicitly noted as affecting whether SCR-028 is needed. All three answers are drawn so the choice is
visible rather than implied. <strong>A is what Artboard A commits to.</strong>
</p>
<div class="states">

<div>
<div class="state-cap">A — redirect to first permitted menu <span class="chip chip-drawn">drawn</span></div>
<div class="state-sub">
The user never sees that <code>/orders</code> was denied; they simply arrive somewhere they belong. No new
screen, no new component, no new string.
</div>
<div class="mini-frame">
<div style="font-size:12px; line-height:20px; color:#3b3b3b;">
<div><code>GET /</code></div>
<div style="color:var(--text-muted)">↓ session → permission set</div>
<div style="color:var(--text-muted)">↓ first permitted: <code>/expenses</code></div>
<div><code>307 → /expenses</code></div>
<div style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<span class="badge badge-success">renders</span>
<span style="margin-left:6px;">Expenses list + filtered sidebar</span>
</div>
</div>
</div>
</div>

<div>
<div class="state-cap">B — render a 403 denial page <span class="chip chip-declined">declined</span></div>
<div class="state-sub">
Honest about what happened, but requires <strong>a new screen</strong> — the one thing the requirement
forbids verbatim: <em>"Dont create any screen we dont want any screen for this rereuiment".</em>
</div>
<div class="mini-frame anno-box">
<div style="display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; padding:20px 8px;">
<div style="font-size:24px; line-height:32px; font-weight:600; color:var(--text); margin-bottom:8px;">403</div>
<div style="font-size:13px; line-height:20px; color:var(--text-muted); margin-bottom:16px;">
You don't have permission to view this page.
</div>
<span class="btn btn-primary">Back</span>
</div>
</div>
</div>

<div>
<div class="state-cap">C — <code>notFound()</code>, reusing <code>app/not-found.tsx</code> <span class="chip chip-declined">declined</span></div>
<div class="state-sub">
No new screen (the file exists), and it leaks nothing about what does or doesn't exist. But it tells the user
the front door is broken, which for a legitimate account is a lie.
</div>
<div class="mini-frame">
<div style="display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; padding:20px 8px;">
<div style="font-size:24px; line-height:32px; font-weight:600; color:var(--text); margin-bottom:8px;">404</div>
<div style="font-size:13px; line-height:20px; color:var(--text-muted); margin-bottom:16px;">
The page or record you're looking for doesn't exist.
</div>
<span class="btn btn-primary">Back to Orders</span>
</div>
<p class="anno-note" style="margin-top:10px;">
Note the CTA. See <span class="pin">5</span>.
</p>
</div>
</div>

<div style="max-width: 400px;">
<div class="state-cap">Why A</div>
<ul style="font-size:13px; line-height:20px; color:#4b4b4b; margin:0; padding-left:18px;">
<li><strong>It is the only option with no new surface.</strong> B is a new screen (forbidden). C reuses an
existing one but repurposes it to mean something it doesn't say.</li>
<li><strong><code>/</code> is not a resource.</strong> It is a doorway. Denying a doorway is a category
error — the user was not asking for anything privileged, they typed the app's address. Denial is the right
answer for <code>/admin/suppliers</code> (SCR-028's actual job); it is the wrong answer for
<code>/</code>.</li>
<li><strong>It keeps SCR-028 narrow.</strong> If <code>/</code> is a redirect, SCR-028 stays purely about
<em>direct URL access to a specific denied route by a user who has some permissions</em> — a much cleaner
question for the operator than one entangled with the landing rule.</li>
<li><strong>It matches the sibling mockups.</strong> SCR-000 drew a Staff user landing on <code>/orders</code>
and SCR-001 drew post-login landing as "first permitted menu". Choosing B or C here contradicts both and
re-cuts three artboards, not one.</li>
<li><strong>Symmetry with login.</strong> Whatever <code>/</code> does, <code>lib/actions/auth.ts</code> must
do — a user should not land in one place by logging in and a different place by typing the origin.
A is the only option that is sane to apply at both call sites.</li>
</ul>
<p class="anno-note">
<strong>The counter-argument, recorded.</strong> A is silent: a user who bookmarked <code>/orders</code> and
has just lost that permission gets moved without explanation, and an admin debugging "why does this user see
Expenses first?" has nothing on screen to read. C at least says <em>something</em> happened. This mockup
accepts the silence because the alternative costs a screen the requirement forbids — but it is a genuine
trade, not an obvious win, and it is precisely the kind of call N31 exists to make.
</p>
</div>

</div>
</section>

<section>
<h2><span class="pin">4</span>Degenerate case: no permitted menu item exists</h2>
<p class="lede">
Option A's rule has one hole: "redirect to the first permitted entry" has no answer when there are none. This is
where SCR-002, SCR-001 and SCR-000 are genuinely coupled, and the three mockups take one coherent position.
</p>
<div class="states">
<div style="max-width: 560px;">
<div class="state-cap">Drawn position <span class="chip chip-drawn">drawn</span></div>
<ul style="font-size:13px; line-height:20px; color:#4b4b4b; margin:0 0 10px; padding-left:18px;">
<li><strong>Primary: it cannot happen.</strong> SCR-001's mockup fails closed at login — an account with an
empty permission set never receives a session cookie and never reaches <code>/</code>. Handling the
degenerate case at authentication time is strictly cheaper than handling it at every route.</li>
<li><strong>Fail-safe behind it: <code>notFound()</code>.</strong> A permission set can be emptied
<em>mid-session</em> (a role edited while the user is logged in), so the primary defence is not sufficient
on its own. If the walk in <span class="pin">2</span> finds nothing, call <code>notFound()</code> — it
reuses <code>app/not-found.tsx</code>, so it is still no new screen.</li>
<li><strong>Not a redirect loop.</strong> The fail-safe must terminate rendering, not redirect anywhere.
Sending this user to <code>/login</code> would loop (their cookie is valid); sending them to any route
loops (they may open none).</li>
</ul>
<p class="anno-note">
<strong>Coupling, stated for N31.</strong> If the reviewer overturns SCR-001's fail-closed and lets
zero-permission users in with an empty shell, then this fail-safe becomes the <em>primary</em> path, it will
be hit routinely rather than exceptionally, and SCR-000's flagged "No modules available for this account"
empty-nav box comes back into scope — at which point <code>notFound()</code> is the wrong treatment and a
real empty state is needed. <strong>Decide SCR-000 <span class="pin">2</span>, SCR-001
<span class="pin">5</span> and this box together, or they will disagree.</strong>
</p>
</div>
<div>
<div class="state-cap">Mid-session revocation — the sequence</div>
<div class="state-sub">Why the fail-safe is not redundant with SCR-001's fail-closed login.</div>
<div class="mini-frame">
<div style="font-size:12px; line-height:20px; color:#3b3b3b;">
<div>1 · user signs in, holds <code>Expenses:View</code></div>
<div>2 · admin empties that role</div>
<div>3 · user's cookie is still valid</div>
<div>4 · user navigates to <code>/</code></div>
<div style="color:var(--anno)">5 · menu walk returns nothing</div>
<div style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border);">
<code>notFound()</code> — terminate, don't redirect
</div>
</div>
</div>
<p class="anno-note" style="max-width:330px;">
Assumes permissions are re-read per request rather than trusted from the cookie payload. If the session
cookie carries a frozen permission list (SCR-001 <span class="pin">1</span> leaves that open), step 5 never
fires until the cookie's 7-day expiry — a stale-authorization window that is a real security property, and
a question for whoever specifies the session payload.
</p>
</div>
</div>
</section>

<section>
<h2><span class="pin">5</span>Approximations and one live defect — 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.
</p>
<table class="info">
<thead>
<tr><th style="width:250px">Item</th><th style="width:110px">Kind</th><th>Detail</th></tr>
</thead>
<tbody>
<tr>
<td><strong>Artboard A is not this screen</strong></td>
<td><span class="chip chip-approx">approximation</span></td>
<td>The frame is SCR-006 (Expenses list) standing in for SCR-002's <em>outcome</em>. SCR-002 has no
renderable form of its own, so there is no non-approximate frame available. Any UI-diff gate consuming
this artboard is diffing the Expenses list under a filtered sidebar, which is the correct observable
assertion but is not "the SCR-002 screen" — because there isn't one.</td>
</tr>
<tr>
<td><code>app/not-found.tsx</code> CTA reads
<strong>"Back to Orders"</strong></td>
<td><span class="chip chip-declined">defect</span></td>
<td><strong>This is a real bug in an existing file, exposed by this screen's rule, not a design question.</strong>
<code>app/not-found.tsx:8</code> hardcodes <code>href="/orders"</code>. Option C in
<span class="pin">3</span> and the fail-safe in <span class="pin">4</span> both route
permission-limited users to that page — where the only escape hatch points at the one route they are most
likely to be denied. It must become the same resolved landing path from <span class="pin">2</span>
(making <code>not-found.tsx</code> a Server Component that reads the session), or the button must be
dropped. Applies to every 404 in the app, not just this path.</td>
</tr>
<tr>
<td>Menu ordering as landing priority</td>
<td><span class="chip chip-approx">approximation</span></td>
<td>No <code>defaultLanding</code> / priority field exists anywhere in the codebase, so
<code>NAV_GROUPS</code>' array order is used as the ordering authority. It is a real order and a
defensible one, but it was authored as a <em>menu layout</em>, never as a statement about which screen
matters most to which role. Adding a priority field later changes no pixels.</td>
</tr>
<tr>
<td>Sidebar shown filtered</td>
<td><span class="chip chip-approx">borrowed</span></td>
<td>The permission-filtered sidebar in Artboard A is SCR-027's design, not this screen's, and is drawn here
only because a landing frame with today's full hardcoded menu would be incoherent — it would show Orders
in the nav for a user who was just redirected away from it. If SCR-027's hide-vs-disable decision changes
(SCR-000 <span class="pin">6</span> drew <em>hide</em>), this frame is re-cut with it.</td>
</tr>
<tr>
<td>Expense rows, project names, amounts</td>
<td><span class="chip chip-approx">sample data</span></td>
<td>Invented content at realistic shape/length. Column set, formatting
(<code>toLocaleDateString()</code>, <code>toFixed(2)</code>) and the <code>StatusBadge</code> tones are
transcribed from <code>app/expenses/page.tsx</code>.</td>
</tr>
<tr>
<td>Icon glyphs</td>
<td><span class="chip chip-approx">approximation</span></td>
<td>Inline SVG traces of the <code>lucide-react</code> icons the app imports (Receipt, LogOut, Search,
SlidersHorizontal, ArrowUpDown) at their real <code>h-4 w-4</code> / <code>h-3.5 w-3.5</code> sizes.
Same glyphs, hand-transcribed paths — sub-pixel differences from the shipped icons are expected.</td>
</tr>
<tr>
<td>No loading / transition state drawn</td>
<td><span class="chip chip-drawn">deliberate</span></td>
<td>Not an omission. <code>redirect()</code> in a Server Component emits a 307 before any body; there is no
frame in which a spinner could appear. Drawing one would invite someone to build it.</td>
</tr>
</tbody>
</table>
</section>

<section>
<h2><span class="pin">6</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.
</p>
<ul class="decisions">
<li>
<span class="flag">Redirect vs denial at <code>/</code> — the inventory's stated ambiguity.</span>
<strong>Drawn: redirect to first permitted menu item (option A, <span class="pin">3</span>).</strong>
Consequence if overturned: option B creates the new screen the requirement forbids and pulls SCR-028 forward;
option C repurposes <code>app/not-found.tsx</code> and makes its "Back to Orders" defect blocking.
</li>
<li>
<span class="flag">Ordering authority for "first permitted".</span>
<strong>Drawn: <code>NAV_GROUPS</code> array order</strong> (OMS: Orders → Expenses → Expense Summary;
Admin: Suppliers → Items → UOM → Projects). Alternative is an explicit per-role
<code>defaultLanding</code>. Not drawn — no acceptance criterion needs it, and it is a second thing to
misconfigure.
</li>
<li>
<span class="flag">No-permitted-menu fallback.</span>
<strong>Drawn: unreachable by design (SCR-001 fails closed at login), with <code>notFound()</code> as the
mid-session fail-safe.</strong> Coupled to SCR-000 <span class="pin">2</span> and SCR-001
<span class="pin">5</span> — decide all three together.
</li>
<li>
<span class="flag">One resolver or two.</span> <strong>Drawn: one.</strong> <code>app/page.tsx</code> and
<code>lib/actions/auth.ts</code> must call the same <code>resolveLandingPath(session)</code>. Recorded as a
design constraint rather than left to implementation, because two copies of an ordered list is exactly the
kind of duplication that passes review and drifts a month later.
</li>
<li>
<span class="flag">Are permissions re-read per request, or frozen in the cookie?</span>
<strong>Not drawn — genuinely out of this screen's scope</strong>, but SCR-002 is where the difference first
becomes observable (see <span class="pin">4</span>). Frozen means up to 7 days of stale authorization; per-request
means a query on every navigation. SCR-001 owns the session payload; flagging it here so it is not lost.
</li>
<li>
<span class="flag"><code>app/not-found.tsx</code>'s hardcoded "Back to Orders".</span>
<strong>Not fixed in this mockup, flagged as a defect (<span class="pin">5</span>).</strong> It is a
one-line change in a file this ticket otherwise doesn't touch, and it affects every 404 in the app — so it
wants an explicit yes, not an incidental edit.
</li>
<li>
<span class="flag">SCR-029 dependency.</span> <code>prisma/schema.prisma</code> has no <code>User</code>
model and <code>lib/auth.ts</code> is a single shared credential, so no user with a partial permission set can
exist yet. Every frame here presumes seeded users with types, roles and permissions. Not a design question —
but it is why SCR-002 cannot be verified end-to-end until the operator rules on how users get created.
</li>
</ul>
</section>

<section>
<h2><span class="pin">7</span>Tokens used — no new colour, radius or type scale is introduced</h2>
<div class="swatches">
<div class="sw"><i style="background:#fcfcfd"></i>bg #fcfcfd</div>
<div class="sw"><i style="background:#ffffff"></i>surface #ffffff</div>
<div class="sw"><i style="background:#302828"></i>text #302828</div>
<div class="sw"><i style="background:#836f6f"></i>text-muted #836f6f</div>
<div class="sw"><i style="background:#ede0ce"></i>border #ede0ce</div>
<div class="sw"><i style="background:#453028"></i>accent #453028</div>
<div class="sw"><i style="background:#f7f1e9"></i>accent-subtle<br/>#f7f1e9</div>
<div class="sw"><i style="background:#1f8a4c"></i>success #1f8a4c</div>
<div class="sw"><i style="background:#b8860b"></i>warning #b8860b</div>
<div class="sw"><i style="background:#c0392b"></i>danger #c0392b</div>
<div class="sw"><i style="background:#2563eb"></i>info #2563eb</div>
<div class="sw"><i style="background:#7c3aed"></i>annotation<br/>(NOT a token)</div>
</div>
<p class="anno-note">
Classes reproduced verbatim: <code>.card</code> (surface + <code>--border</code> + <code>rounded-lg</code> 8px),
<code>.btn-primary</code> (<code>--accent</code>, white text, <code>px-4 py-2</code>, <code>rounded-md</code> 6px,
14px/500), <code>.btn-secondary</code> (surface + 1px <code>--border</code>), <code>.field-input</code>
(<code>@tailwindcss/forms</code> base geometry: 1px border, 8px×12px padding, 14px), <code>.link</code>
(<code>--status-info</code> + persistent underline — the fix from the "orders can't be clicked" report).
Layout: sidebar <code>w-64</code> = 256px with <code>px-5 py-4</code> brand and <code>px-3</code>/<code>mb-5</code>
nav groups; <code>main.flex-1.p-6</code> = 24px gutter; <code>PageHeader</code> <code>mb-6</code> = 24px;
toolbar <code>mb-4</code> = 16px; <code>DataTable</code> cells <code>px-4 py-3</code>;
<code>Pagination</code> <code>mt-4</code>. <code>StatusBadge</code> tones are the token colour at
<code>/10</code> alpha on a <code>rounded-full px-2 py-0.5 text-xs</code> pill, exactly as
<code>components/ui/StatusBadge.tsx</code> defines them.
</p>
<p class="anno-note">
The only token not exercised on this frame is <code>status-neutral</code> (no Inactive/Cancelled rows in the
sample). Nothing is added: this screen introduces <strong>zero new components, zero new strings and zero new
tokens</strong> — which is the strongest argument for the default drawn in <span class="pin">3</span>, since
both alternatives introduce at least one of the three.
</p>
</section>

</div>

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