|
<!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>
|