Page specs — Redpoint Engine human surfaces
Companion to docs/DESIGN-BRIEF.md. The brief gives system-level direction
(personas, aesthetic, tokens, component list). This file gives the per-page
detail: every element, its exact data source in the current code, every state
it can show, and acceptance criteria. Design from both together.
Grounding. Every "← binding" in this file points to real code as of this
writing (src/app/**, src/lib/**, supabase/). Where the current scaffold
renders something poorly (raw JSON brief, raw enum status shown to clients), the
spec says so and specifies the intended design — those are marked elevate.
Two pages are not built yet (/workspace, /jobs/[id]/review); their
sections are marked NORMATIVE (unbuilt) and are grounded in BUILD-PLAN
Phases 5–6 and the transition machine (src/lib/transition-rules.ts).
Rule of precedence: where this file and the code disagree, the code wins — flag it, don't guess (per brief §intro). Where this file adds a page not yet built, the spec is the target.
No invented fields. If a datum is not in the code and not promised by the BUILD-PLAN, it is not in a spec. A short list of judgement calls I had to make is at the very end.
PART A — SHARED SYSTEMS
These are referenced by every page. Design them once.
A1. Client-facing status label map
Job status is an exact enum (src/lib/transition-rules.ts, JobStatus):
NEW, ASSIGNED, IN_PROGRESS, IN_REVIEW, QC_REJECTED, CLIENT_REVIEW, REVISION, APPROVED, DELIVERED, CANCELLED.
Admin (/ops) sees raw enum values. Editors (/workspace) see raw enum
values too — they operate the machine and the enum is their vocabulary.
Clients must never see the raw enum (brief §4). The current /jobs list and
/jobs/[id] detail render job.status raw to everyone — elevate: apply this
map on every client surface.
| Raw status | Client label | Chip tone | Notes |
|---|---|---|---|
NEW |
Order received | neutral | Job created; footage may still be arriving. |
ASSIGNED |
In production | info | Editor booked (client never sees who). |
IN_PROGRESS |
In production | info | Same client label as ASSIGNED — client needs no finer granularity. |
IN_REVIEW |
Quality check | info | Brief §4's named example. |
QC_REJECTED |
Quality check | info | Must NOT read as failure (brief §4). Deliberately shares the "Quality check" label — internal rework is invisible to the client. |
CLIENT_REVIEW |
Ready for your review | accent / action | The one status that asks the client to act. Loudest chip; may pair with a call-to-action. |
REVISION |
Revisions under way | info | After the client requested changes. |
APPROVED |
Approved | success | Payment captured, finals releasing. |
DELIVERED |
Delivered | success | Terminal, happy. |
CANCELLED |
Cancelled | muted | Terminal. Authorization voided. |
Design must expose this as a single lookup so a second tenant needs zero new copy. Every state must be conveyed by more than colour (brief §7): the label text carries the meaning; the chip tone reinforces it.
A2. Status-chip system
Chips are "the loudest visual element on any screen" (brief §6). Two chip families:
A2a. Job-status chip (10 states above)
- Tones: neutral (NEW), info (ASSIGNED/IN_PROGRESS/IN_REVIEW/ QC_REJECTED/REVISION), accent/action (CLIENT_REVIEW), success (APPROVED/DELIVERED), muted (CANCELLED).
- Admin/editor variant: same chip shell, raw enum text, plus admin extras —
RUSHmarker and revision counterr{n}(see below). Ops currently rendersRUSHas bold crimson text andr{revision_count}as grey suffix (src/app/ops/page.tsx) — design a proper chip/marker but keep the data. - Rush marker ←
jobs.is_rush(boolean). On client pages the current code shows· rush; design a discreet rush indicator (brief calls rush "tighter SLA", not an alarm for the client). - Revision counter (admin) ←
jobs.revision_count; shown only when> 0.
A2b. Ingest chip (6 states)
Source of truth src/app/jobs/[jobId]/upload/ingest-panel.tsx CHIP map and
src/lib/ingest.ts IngestStatus:
| Status | Chip label (exact) | Tone | Extra content |
|---|---|---|---|
queued |
queued |
info (indigo) | — |
fetching |
fetching… |
info (cyan) | appends humanized bytes_total when present, e.g. fetching… 3.2 GB |
done |
done |
success | appends · {files_imported} file(s) when files_imported > 0 |
failed |
failed |
error (red) | shows an ⓘ affordance; error string is the tooltip |
queued_manual |
our team will handle this |
warning (amber) | phrase as service, never error (brief §4) |
cancelled |
cancelled |
muted | — |
- Chip label maps 1:1 to status; do not invent statuses.
fetchingsize ←ingests.bytes_totalhumanized (B/KB/MB/GB/TB, 1 dp above bytes).donecount ←ingests.files_imported.- Live behaviour: the panel polls every 5 s only while any ingest is
queuedorfetching; the list settles (stops polling) otherwise. Design must make an in-flight chip feel live (subtle motion onfetching, 150–250 ms, per brief §6) without implying the client must wait on the page — pasted-link ingests are safe to close the tab (brief §4).
A3. SLA badge (admin only)
Source slaBadge() in src/app/ops/page.tsx. Input: jobs.internal_deadline
(nullable) + jobs.status.
- Null deadline, or status
DELIVERED/CANCELLED→ no badge (render an em-dash placeholder in table cells). ms = internal_deadline − now;hours = round(abs(ms) / 3.6e6).ms < 0→ breach: textbreach +{hours}h, white on red (#c0392b). "Impossible to miss" (brief §4). Highest-priority visual on the row.0 ≤ ms < 24h→ amber urgent: text{hours}h left, white on amber (#e67e22).ms ≥ 24h→ normal: text{hours}h left, quiet grey (#555).- Use tabular numerals (brief §6) so countdowns don't jitter column width.
- Ops list is sorted by
internal_deadlineascending, nulls last — breaches naturally float to the top; the badge must reward that scan.
A4. File-row component
Two live renderings today — unify them.
Client detail (/jobs/[id], table): columns Name · Kind · Size · Download.
Ops detail (/ops/jobs/[id], list): kind · filename · size · download.
- Filename ←
parseKey(files.r2_key).filename(client) /r2_key.split('/').pop()decoded (ops). Design: filename primary, monospace-friendly, truncates. - Kind ←
files.kindenum:original | draft | preview | final(0001_init.sql,file_kind). Design a small kind tag; kinds carry meaning (originals = client footage, draft/preview = editor output, final = released deliverable). - Size ←
files.size_byteshumanized (same B→TB scale as A2b). Nullable → em-dash. Tabular numerals. - Download ← link to
withBase('/api/files/{id}/download'). The route redirects to a short-lived presigned R2 GET (ResponseContentDisposition: attachment). Access is RLS-gated (participant-only; non-participant → 404). Design the link as a clear action; no spinner needed (browser handles the download). - Empty state: "No files yet." (both pages currently). See A9 voice.
A5. Form-field kit (brief-renderer input side)
Order form field types, from src/app/order/order-form.tsx + BriefField
(src/lib/billing.ts) + the seed template (supabase/seed.sql):
type |
Control | Value shape | Options source |
|---|---|---|---|
text (and any unknown type — fallback) |
single-line input | string | — |
textarea |
multi-line (min-height ~70px) | string | — |
select |
dropdown with leading — choose — empty option |
string | field.options[] |
multiselect |
inline checkboxes | string[] | field.options[] |
date |
date input | string (ISO date) | — — promised by brief §3; not in seed but the renderer must handle it |
- Every field: label + optional required asterisk. Required ←
field.required(renders a crimson*today). Design the required marker accessibly (not colour-only). - Validation errors ←
validateBrief(): message format is exactly"{label}" is required(curly quotes in code). Rendered as a list. The API returns them indata.details; the form shows them in a crimson<ul>. - Package radio group: one radio per package (see /order spec).
- Currency:
money(minor, currency)=Intl.NumberFormat('en-GB', {style: 'currency', currency}).format(minor/100). Prices are stored in minor units (pence/cents). Never divide twice.
Design the kit for mobile-first (brief §7, client pages 360px up): generous touch targets, one field per row, thumb-reachable submit.
A6. Brief renderer (read side) — elevate
Today the brief is dumped as raw JSON: /jobs/[id] in a <details><pre> and
/ops/jobs/[id] in a <pre>. Both must become a readable brief render
(brief §2, §3).
Inputs:
jobs.brief_data— a flat object of{ fieldKey: value }. Values: strings for text/textarea/select/date; string arrays for multiselect.- The field labels and types live in
brief_templates.schema.fields[](sameBriefFieldshape as the order form).brief_datastores keys, not labels.
Render spec:
- One labelled row per field, in template order: label (from the template) + value. textarea values may be multi-line — preserve line breaks. multiselect values render as a list/tag set. Empty optional fields render a quiet "—" or are omitted (designer's call; be consistent).
- Screen-reader friendly (brief §7): a definition-list (
<dl>) structure reads well. - Implementation note (normative): to show labels/types the page must load
the active
brief_templates.schemafor the job's tenant./orderalready loads it;/ops/jobs/[id]currently loads onlybrief_data. The renderer should degrade gracefully (humanize the raw key) if the template is unavailable, so a brief always renders.
A7. Events timeline (admin; also feeds editor/client note surfaces) — elevate
Source: events rows (type, payload, created_at), newest first, capped at 20
on /ops/jobs/[id]. Today rendered as type + JSON.stringify(payload).
Elevate to human sentences, screen-reader readable (brief §7).
Event types that exist in code (do not invent others):
type |
Written by | payload |
Suggested human line |
|---|---|---|---|
status_change |
DB trigger (log_job_status_change) |
{ from, to } |
"Status: {from} → {to}" (admin raw) / client-label mapped on client surfaces |
editor_assigned |
transitionJob on NEW→ASSIGNED |
{ editor_id, internal_deadline } |
"Editor assigned · deadline {date}" (admin only; never expose editor_id to clients) |
qc_rejected_note |
transitionJob on IN_REVIEW→QC_REJECTED |
{ note } |
"QC returned to editor: {note}" |
revision_requested |
transitionJob on CLIENT_REVIEW→REVISION (only when a note was given) |
{ note } |
"Revision requested: {note}" |
- Timestamp ←
created_at(localized). Tabular numerals for the time column. qc_rejected_noteandrevision_requestednotes are re-surfaced prominently on/workspaceand/jobs/[id]/reviewrespectively (see those pages). The timeline is the audit trail; those pages are the working views.- Privacy: the timeline is admin-only. Do not design a client- or
editor-visible timeline that leaks
editor_idor client identity.
A8. Theme tokens (tenant layer)
Client-facing surfaces consume tenant theme at runtime (brief §5). Actual token
keys today live on tenants.theme (supabase/seed.sql, Wakecut):
{ "primary": "#0B2A3C", "accent": "#C8A45C", "logo_url": null, "font": "system-ui" }
accent← used today for the /order heading rule and submit button (tenant.theme.accent, default#C8A45C). This is the brief's--brand-accent(Wakecut gold). Use sparingly: primary actions + focus only (brief §6).primary← brand foreground/dark (#0B2A3Cnavy). Maps to brief's--brand-fg/header colour.logo_url← nullable; text fallback =tenant.name(brief §5)./order,/loginalready fall back to the tenant name.font← tenant font family (defaultsystem-ui).- The engine chrome (spacing, greys, type scale, chip shells, states) stays constant across tenants and should "feel like discreet, expensive machinery" (brief §5). Only the tokens above vary per tenant.
/opsis NOT tenant-themed (brief §5) — it's Redpoint's own tool: neutral, utilitarian, dark-mode-eligible./workspaceis the editor's tool; treat it as engine-neutral too (it spans tenants).
A9. Voice (empty / loading / error)
Brief §4: "honest, specific, calm." The API already speaks this way — keep its concreteness, e.g. "Footage not ready (3 originals, 1 import pending)". Button labels are verbs, never "Submit" (brief §8). Reusable strings appear per page below.
PART B — BUILT PAGES (restyle; behaviour is fixed)
B1. /login
Purpose & user. All personas. Magic-link sign-in, tenant-branded.
(src/app/login/page.tsx, login-form.tsx.)
Layout. Single narrow centred column (currently max-width: 400px). Mobile-
first; this is the only unauthenticated page (brief §7 — no marketing furniture).
Tenant logo slot (A8) above the form; heading; email field; one primary button.
Elements & bindings.
- Heading ←
Sign in — {tenantName}when a tenant resolves (getTenant()), elseSign in. tenantName ←tenants.name. - Email input ← local state;
type=email, required, placeholderyou@company.com. - Primary button "Send magic link" →
supabase.auth.signInWithOtp({ email, options: { emailRedirectTo: origin + withBase('/auth/callback'), data: { tenant_slug } }}).tenant_slug← resolved tenant slug, attached as signup metadata (driveshandle_new_usertenant assignment) only for new users.
State matrix.
idle→ form visible.sent→ form replaced by "Check your email for the sign-in link." (calm, final).error→ error text shown below button ←error.messagefrom Supabase (crimson today). Keep specific but calm.
Interactions. Submit is async, no optimistic UI; on success swaps to sent.
No resend affordance exists today — may design one but do not assume a
backend for it beyond re-submitting the form.
Empty/loading/error. No list here. Loading = button busy state (design a disabled/spinner variant; code currently doesn't set one — safe to add visually). Error microcopy: keep the raw Supabase message but frame it calmly ("We couldn't send that link — check the address and try again." over the raw string is acceptable design polish).
Acceptance criteria.
- Tenant name (or logo) appears in the branded header; falls back to "Sign in" with no tenant.
- Email field is labelled and keyboard/AA accessible; visible focus ring in the accent colour.
sentstate fully replaces the form with the check-email message.errorstate is legible, calm, and not colour-only.- Uses tenant tokens (A8); no yacht-specific or hard-coded brand copy.
- Works at 360px width; single primary action is thumb-reachable.
B2. /order
Purpose & user. Client. Choose a package, fill the tenant brief, go to
payment. (src/app/order/page.tsx, order-form.tsx.)
Layout. Single column (currently max-width: 640px), mobile-first. Order top-
to-bottom: branded heading → package chooser → brief fields → rush toggle → VAT
block → error list → primary button. Desktop may widen but stays single-column
(brief §6: whitespace over widgets).
Elements & bindings.
- Heading ←
{tenant.name} — new brief, with an accent bottom-rule (tenant.theme.accent). elevate: the raw 3px border-bottom is a scaffold; design the branded header properly (A8 logo slot). - Package chooser ←
packages(RLS-scoped to the client's tenant, ordered byprice_gbpasc). Each option is a package card (brief §8), currently a radio row: name (bold), pricemoney(price_gbp|price_eur, currency), billing_mode tag, anddescription. For non-one_offmodes it shows "(retainer, uses entitlement)". First package pre-selected.- Price shown per package ← EUR price when currency is EUR and
price_eurpresent, else GBP (both minor units).
- Price shown per package ← EUR price when currency is EUR and
- Brief fields ←
template.schema.fields(active template, highest version). Rendered with the form-field kit (A5): text / textarea / select / multiselect / date, with required asterisks. - Rush toggle ← shown only when the selected package has
rush_available(pkg.rush_available). Label: "Rush (surcharge applies, tighter turnaround)". Surcharge amount is computed server-side (computePrice, A5) — elevate: the form does not preview the surcharge; design should show the rush delta (base × (multiplier−1)) as a line item so the client sees the new total before paying.rush_multiplier←billing_config(Wakecut 1.5). - VAT block ←
buyerCountry(2-letter, defaultGB) +buyerIsBusinesscheckbox ("This is a business purchase"). Recorded per job; no tax computed (ARCHITECTURE §5). - Primary button label ← dynamic:
Continue to payment — {money(total)}forone_off;Place orderfor entitlement modes (retainer/credits, no per-job checkout). Disabled whilebusyor no package selected.
State matrix (button + form).
- idle → button shows price/label per mode.
- busy → "Working…", disabled.
- validation/API error → crimson error list ←
data.details(fromvalidateBrief) ordata.error. - success →
one_off: browser redirects to Stripedata.checkoutUrl. entitlement: client-siderouter.push('/jobs/{jobId}').
Interactions.
- POST
withBase('/api/orders')with{ packageId, briefData, isRush, buyerCountry, buyerIsBusiness }. - Selecting a package updates price + whether the rush toggle appears.
- multiselect toggles append/remove from a string array.
- No optimistic navigation until the API responds (payment integrity — ARCHITECTURE §6: all money via verified webhooks).
Empty/loading/error.
- No packages (empty
packages): design a helpful empty state — "No packages available yet." (calm). Code currently would render an empty chooser; elevate. - No brief fields: the brief section is simply empty; that's valid (a tenant may have a minimal template).
- Error microcopy: keep API specificity; render
detailsas a checklist the client can fix.
Acceptance criteria.
- Package cards show name, description, price in the tenant currency, and a clear entitlement/one-off distinction.
- The rush toggle appears only for rush-eligible packages and, when on, the surcharge and new total are visible before the client commits.
- Brief fields render per type (text/textarea/select/multiselect/date) with accessible required markers.
- Required-field errors surface as a fixable list in calm voice, not colour-only.
- Primary button label reflects the billing mode (payment vs place-order) and shows the amount for one-off.
- Prices never mis-scale (minor units ÷ 100 exactly once) and use tabular numerals.
- Fully usable at 360px; single accent used only on the primary action + focus.
B3. /order/success
Purpose & user. Client. Post-payment reassurance. (src/app/order/success/page.tsx.)
Layout. Short centred column (currently max-width: 560px). Confirmation
headline → reassurance paragraph → next-step link.
Elements & bindings.
- Headline "Payment authorized ✓".
- Reassurance ← fixed copy: card was authorized, not charged; capture happens only on approval (ARCHITECTURE §5 manual capture). Keep this promise prominent — it is the trust anchor.
- Next step ← order row looked up by
stripe_checkout_session_id(from?session_id). Iforder.job_idpresent: "Your job is ready: open it →" (/jobs/{job_id}). Else (webhook not landed yet): "Setting up your job… this takes a moment. Refresh your jobs →" (/jobs).
State matrix.
- Job ready → direct link to the job.
- Job pending (webhook race) → soft "setting up" state with a refresh link. This is expected latency, not an error — design it calm, not alarming.
Empty/loading/error. No session_id → order is null → pending state shown.
That's acceptable. No hard error surface needed.
Acceptance criteria.
- The "authorized, not charged" promise is the visual focus.
- Both branches (job ready / job setting-up) render calmly with the right link.
- The setting-up branch never reads as failure.
- Tenant-themed; works at 360px.
B4. /jobs
Purpose & user. Client and editor (RLS scopes the list). Job index.
(src/app/jobs/page.tsx.) Admin also technically sees all here, but the admin's
real console is /ops.
Layout. Single column (currently max-width: 720px), mobile-first. "+ New
order" link → list of job rows.
Elements & bindings.
- Row ←
jobs(RLS: client sees own, editor sees assigned, admin sees all), orderedcreated_atdesc. Fields:id(short:id.slice(0,8), monospace, links to detail),status,is_rush,created_at. - elevate (critical): status is rendered as the raw enum today
(
<strong>{status}</strong>). For clients, apply the client label map (A1) and a status chip (A2a). For editors, raw enum + chip is fine. The same route serves both — the design must branch on role (the page knows the user; the label layer should key off role). - Rush ←
is_rushrenders "· rush" today; design a discreet marker (A2a). - Date ←
created_atlocalized; tabular numerals.
State matrix. Each row shows one job-status chip (A2a) mapped per role. Rush is an independent marker.
Interactions. Row → job detail. "+ New order" → /order. No inline actions.
Empty/loading/error.
- Empty ← current copy "No jobs visible to you." elevate: split by persona — client empty state should invite ("No jobs yet — start your first order →"); editor empty state should reassure ("No jobs assigned to you right now.").
- Loading: server-rendered; design a skeleton row set if a client-side variant is built.
Acceptance criteria.
- Clients see friendly labels (A1) + chips, never raw enum values.
- Editors see the raw enum + chip; the same page adapts by role.
- Short id is monospace/tabular and links to the detail page.
- Empty state is persona-appropriate and actionable, never blank.
- Rows are scannable at 360px; date and rush don't crowd the status chip.
B5. /jobs/[id] (client job detail)
Purpose & user. Client (also the editor's/admin's basic detail, but their
richer views are /workspace and /ops). Status, files, footage readiness,
cancel. (src/app/jobs/[jobId]/page.tsx.)
Layout. Single column (720px), mobile-first. Back link → title (short id) → status line → footage-readiness banner (NEW only) → brief (collapsible) → files table → upload link → cancel.
Elements & bindings.
- Title ←
Job {id.slice(0,8)}(monospace id). - Status line ←
job.status(elevate: client label + chip A1/A2a) + rush marker (is_rush). - Footage-readiness banner ← only when
status === 'NEW', fromgetFootageReadiness()→describeReadiness():- ready → "Footage ready", green tone (
#ecfdf5/#065f46). - originals 0 & pending 0 → "Waiting for footage", amber tone.
- pending > 0 → "Footage incoming — {n} import(s) pending", amber tone. This is the client-facing "2 imports pending — we'll start once everything lands" trust banner (brief §3/§4). Keep the concrete counts.
- ready → "Footage ready", green tone (
- Brief ←
job.brief_datain a<details><pre>JSON dump today. elevate: render via the brief renderer (A6). - Files ← file-row component (A4); kinds visible to the client will be
original(their own) and, post-Phase 6,final. Empty: "No files yet." - Upload link ← shown when the viewer may upload (
canUpload); label "Upload {kind} files →" where kind isoriginalfor clients (draftfor editors). Links to/jobs/{id}/upload. - Cancel ←
CancelJobButton, shown when viewer is client-or-admin and status ∈ CANCELLABLE = {NEW, ASSIGNED, IN_PROGRESS, IN_REVIEW, QC_REJECTED, CLIENT_REVIEW, REVISION} (pre-capture set).
State matrix.
- Readiness banner: 3 states above; present only in NEW.
- Cancel button:
idle→ "Cancel job (void authorization)";busy→ "Cancelling…";error→ inline crimson message ← APIerror. - Status chip: 10 states (A1/A2a).
Interactions.
- Cancel: destructive-confirm —
confirm("Cancel this job and void the payment authorization?")then POSTwithBase('/api/jobs/{id}/cancel'); on successrouter.refresh(). elevate: replace the nativeconfirm()with a designed confirmation (brief destructive pattern) — keep the "void the authorization" wording so the client understands no charge stands. - Download links → presigned R2 (A4).
- Upload link → upload page.
Empty/loading/error.
- No files → "No files yet." (calm).
- Cancel error → inline, specific (e.g. Stripe void failure surfaces the API message).
Acceptance criteria.
- Client sees a mapped status label + chip, never the raw enum.
- The footage-readiness banner appears only for NEW jobs and shows exact counts with a colour + text (not colour-only) ready/waiting distinction.
- Brief renders as labelled fields (A6), not raw JSON.
- File rows expose name, kind, size, and a working download; empty state present.
- Cancel is available exactly for the pre-capture statuses and uses a designed destructive confirm that names the authorization void.
- Upload entry point appears only when the viewer may upload.
- Mobile-first at 360px; single accent reserved for the primary action.
B6. /jobs/[id]/upload (THE critical client screen)
Purpose & user. Client (editors reuse it for drafts). Get multi-GB footage
in, three ways, with live trust. (src/app/jobs/[jobId]/upload/page.tsx,
uploader.tsx, ingest-panel.tsx.) Brief §3 calls this the critical screen.
Layout. Single column (720px), mobile-first. Back-to-job link → "Upload files" heading → Uppy Dashboard (drag-drop + "connect a service" pickers) → "Or paste a transfer link" ingest panel (originals only). Brief §3 describes "three side-by-side affordances" — on mobile they stack; on wider screens the drag-drop and connect-a-service live in the one Dashboard, with the paste-link panel below. Design the stack order so the simplest path (drag-drop) is first.
Elements & bindings.
- Kind ←
draftfor editors,originalfor clients; admin may override via?kind=. Dashboard note text:Uploading "{kind}" files — large files resume automatically after interruptions. - Uppy Dashboard ← third-party embedded UI (
@uppy/dashboard). Re-skin via CSS only (brief §7), do not replace. It provides drag-drop, file list, per- file progress, pause/resume. Uploads go browser→R2 via presigned multipart (/api/uploads/*); bytes never transit the server.- "Connect a service" pickers ← appear inside the Dashboard only when a
Companion URL is configured (
NEXT_PUBLIC_COMPANION_URL): sources Dropbox, Google Drive, OneDrive, Box, URL, plus Google Photos when a picker client id is set. Companion streams provider→R2 server-side. Design must not assume these are always present.
- "Connect a service" pickers ← appear inside the Dashboard only when a
Companion URL is configured (
- Ingest panel ("Or paste a transfer link") ← shown only when
kind === 'original'. Sub-copy (exact): "WeTransfer, SwissTransfer, iCloud shared albums, or any direct download link — if we can't fetch it automatically, our team will."- Input:
type=url, placeholderhttps://we.tl/…, required. Import button: idle "Import" / busy "Adding…"; disabled while busy or empty. - On submit POST
withBase('/api/ingests'){ jobId, url }. Server runs an SSRF pre-check (https-only, no localhost/private IPs) and classifies the link (classifyIngest) →queued(auto-resolvable) orqueued_manual. The new ingest is prepended optimistically to the list. - Ingest rows: ingest chip (A2b) + source display + cancel.
- Source display ←
ingests.source_display= redactedhost · filename; falls back to "external link".source_urlis column-revoked and never returned to the client (privacy; tokens live in query strings). Never design a surface that shows the raw pasted URL back. - Cancel button ← shown when status ∈ {queued, queued_manual}; POST
withBase('/api/ingests/{id}/cancel'), optimistic →cancelled. - Failed row ← ⓘ affordance with the
errorstring as tooltip.
- Source display ←
- Input:
- Footage-readiness / resumability trust (brief §3, §4):
- Browser uploads are NOT safe to close the tab — the Uppy session must stay open; resume is automatic within a session after interruption. Design must say so near the Dashboard.
- Pasted-link ingests ARE safe to close the tab — they run server-side. Design must say so near the ingest panel. This distinction is load-bearing (brief §4).
- A "we'll start once everything lands" readiness cue belongs here too; the
readiness data (
getFootageReadiness) is computed on/jobs/[id]— elevate: consider surfacing the same "N imports pending" line on this page so the client sees when footage is complete.
State matrix (ingest rows). Six chip states (A2b). Polling runs only while
any row is queued/fetching. fetching shows live bytes; done shows file
count; queued_manual is phrased as service; failed exposes the error; both
queued and queued_manual are cancellable.
Interactions.
- Drag-drop / pick → multipart upload to R2 (resumable, pause/resume in the Dashboard).
- Paste link → server ingest; live chips; cancel.
- All fetches use
withBase()(base-path safe — brief §7: relative nav only).
Empty/loading/error.
- No ingests yet → the ingest list is simply absent (panel shows input + copy). That's fine.
- Ingest request error ← inline crimson message ← API
error(e.g. SSRF reject: "internal hosts are not allowed", "only https links are accepted"). Keep these exact, calm messages. - Upload errors bubble inside the Uppy Dashboard; the CSS re-skin should keep Uppy's own error affordances legible in-theme.
Acceptance criteria.
- The three affordances (drag-drop, connect-a-service, paste-link) are all present and clearly separated; on mobile they stack simplest-first.
- The paste-link panel appears only for original uploads (not editor drafts).
- Ingest chips render all six states with exactly the code's labels, incl.
queued_manualas a service message and live bytes onfetching. - The pasted source is shown redacted (
host · filename); the raw URL is never displayed. - "Safe to close this tab" is shown for pasted-link ingests and not for browser uploads.
- Cancel appears only on
queued/queued_manualingests. - The Uppy Dashboard is CSS-reskinned in-theme, not replaced, and remains accessible.
- Connect-a-service sources appear only when Companion is configured — the design degrades gracefully without them.
- Works on a phone at 360px on flaky Wi-Fi; progress and resumability are legible.
B7. /ops (admin console list)
Purpose & user. Admin/operator. Every job across tenants, SLA-first.
(src/app/ops/page.tsx.) Not tenant-themed — Redpoint's own tool (brief §5):
neutral, dense, dark-mode-eligible. Desktop-first (1280px, usable at 1024 — brief
§7).
Layout. Wide container (currently 980px; design for 1280). Title + one-line subtitle → filter row(s) → dense table. Information-dense, sortable feel (brief §2 operator persona).
Elements & bindings.
- Access ← admin only; non-admin
redirect('/jobs'). - Filters:
- Status filter links:
Open(?status=open→ all statuses except DELIVERED/ CANCELLED),All(no param), plus one link per raw status. Active filter is bold. - Tenant filter links ← shown only when
> 1tenant exists; each sets?tenant={id}and preserves the status param.
- Status filter links:
- Table (sorted by
internal_deadlineasc nulls-last, thencreated_atdesc, limit 200). Columns exactly:- Job ← short id (monospace, links to
/ops/jobs/{id}) +RUSHmarker whenis_rush. - Tenant ←
tenants.slug. - Package ←
packages.name. - Client ←
client.company || client.name || '—'(admin sees real client identity; this is the operator surface). - Editor ←
editor.pseudonym || 'unassigned'(pseudonym only, even for the admin table). - Status ← raw enum (
<strong>), plusr{revision_count}when> 0. - SLA ← SLA badge (A3), or em-dash.
- Job ← short id (monospace, links to
State matrix. Each row: status (raw enum + revision marker), rush marker, SLA badge (breach / amber / normal / none). The breach state is the loudest thing on the page and must survive a fast scan.
Interactions. Filter links (server-rendered navigation). Row → ops job detail. No inline mutation here (mutations live on the detail page).
Empty/loading/error.
- No jobs match ← "No jobs match." elevate: design an operator-appropriate empty state per filter (e.g. "No open jobs — all clear.").
- This is
force-dynamic; consider a subtle loading affordance for slow tenant filters.
Acceptance criteria.
- Neutral, high-density, non-tenant-themed styling; dark-mode viable.
- All seven columns present with exact bindings; editor column shows pseudonym only.
- SLA badges (A3) render breach/amber/normal correctly and breaches dominate the scan; tabular numerals throughout.
- Filter row exposes Open/All/per-status and (multi-tenant only) tenant filters, with the active filter visibly marked.
- RUSH and revision counter are visible without shouting over SLA breaches.
- Rows are legible and sortable-feeling at 1024–1280px.
B8. /ops/jobs/[id] (admin job detail + action panel)
Purpose & user. Admin. The operator's control surface for one job: meta,
footage, brief, files, audit timeline, and every state transition.
(src/app/ops/jobs/[jobId]/page.tsx, ops-actions.tsx.) Not tenant-themed.
Layout. Single dense column (currently 780px). Back link → title (short id + raw status + RUSH) → meta table → action panel → brief → files → events. Desktop-first.
Elements & bindings.
- Title ←
Job {id.slice(0,8)} — {status}+ RUSH marker (is_rush). - Meta table (label/value rows):
- Tenant ←
tenants.name (slug). - Package ←
packages.name+ "(revisions: {revision_count}/{revision_limit})" when a limit exists. - Client ←
client.company || client.name+· {client.email}(admin sees email — the only surface that does). - Editor ←
editor.pseudonym || 'unassigned'. - Deadline ←
internal_deadlinelocalized, or "—". - Footage ←
describeReadiness(readiness)+ "({originals} original(s))". - Created ←
created_atlocalized.
- Tenant ←
- Action panel (
OpsActions) — the operator's fast state changes. Renders controls conditioned on current status; every button POSTs to the single transition APIwithBase('/api/jobs/{id}/transition'){ to, ...extra }thenrouter.refresh(). Panel-wideerrorline ← APIerror. Disabled-while-busy on all controls. Status-by-status:- NEW → assign: editor
<select>fromcandidates, adatetime-localdeadline, and an Assign button.- Candidate option label ←
{label} ({load}/{cap}{, unavailable | , at cap})where label ←pseudonym || name || id[:8], load ← count of that editor's jobs in EDITOR_ACTIVE states, cap ←max_concurrent_jobs. This is the brief's "Editor One (1/2)" display. - An option is disabled when
!available || load >= cap. - Candidates are only editors approved for this tenant (
editor_tenants) andvetting_status === 'approved'. - A footage-not-ready warning shows when
!footageReady: "Footage not ready — assignment is blocked until at least one original has landed and no import is in flight." - Assign is disabled until
editorIdanddeadlineandfootageReadyare all satisfied (as implemented). Server re-checks readiness, eligibility, availability, cap, and that the deadline is in the future — surface those API errors verbatim. - "No approved editors for this tenant yet (editor_tenants)." shows when
candidatesis empty.
- Candidate option label ←
- ASSIGNED: "Mark in progress" → IN_PROGRESS.
- IN_PROGRESS: "Mark submitted for QC" → IN_REVIEW.
- IN_REVIEW: "QC pass → client review" → CLIENT_REVIEW; and a required
note input + "QC reject" → QC_REJECTED. QC reject is disabled on an empty
note (as implemented; server also requires it). The note becomes a
qc_rejected_noteevent. - QC_REJECTED: "Back to editor (in progress)" → IN_PROGRESS.
- CLIENT_REVIEW: "Approve (admin override)" → APPROVED; "Request revision"
→ REVISION (optional note; when present →
revision_requestedevent; server enforces the per-package revision cap). - REVISION: "Back to editor (in progress)" → IN_PROGRESS.
- APPROVED: "Mark delivered" → DELIVERED.
- Cancel job ← shown whenever CANCELLED is a legal next status
(
nextStatuses.includes('CANCELLED'), i.e. any pre-capture state). Destructive-confirm viaconfirm("Cancel this job and void the payment authorization?"). elevate to a designed confirm (keep the wording).
- NEW → assign: editor
- Brief ← raw JSON
<pre>today → brief renderer (A6) (needs the tenant template; see A6 note). - Files ← file-row list (A4).
- Events ← events timeline (A7); today raw
type+JSON.stringify(payload), last 20 desc → human sentences, admin-only, screen-reader friendly.
State matrix. The action panel is a state-driven form: it shows a different control set for each of the 8 actionable statuses (DELIVERED/CANCELLED show only whatever remains, effectively none but Cancel where legal). Each mutating control has idle/busy states and shares one error line. The disabled conditions above are the exact implemented gates — the design must represent them (greyed options, disabled Assign until three preconditions, disabled QC-reject on empty note).
Interactions.
- All transitions go through the one API (never raw updates — ARCHITECTURE §4).
- Optimistic? No — the panel waits for the API then
router.refresh()s the server component. Design the busy state accordingly (buttons showwaitcursor today). - Assignment writes an
editor_assignedevent; QC/revision notes write their events — these appear in the timeline immediately after refresh.
Empty/loading/error.
- No files → "No files yet."
- No candidates (NEW) → the empty-editors message above.
- API errors (cap reached, footage not ready, void failed, deadline in past) surface verbatim on the panel error line — keep them; they are the concrete, calm voice the brief wants.
Acceptance criteria.
- Meta table shows tenant, package (+revision count/limit), client (+email), pseudonymous editor, deadline, footage readiness with counts, created — all bound as above.
- The action panel shows exactly the controls legal for the current status and nothing more.
- Assign shows each editor's
load/capand disables over-cap/unavailable editors; the Assign button is disabled until editor + deadline + footage-ready. - The footage-not-ready warning and the empty-editors message appear under their real conditions.
- QC reject is disabled with an empty note; QC pass and reject route to the right statuses.
- Cancel appears only in pre-capture states and uses a designed destructive confirm that names the authorization void.
- Brief renders readably (A6) and the events timeline reads as human sentences (A7), admin-only, without leaking client/editor identity across the boundary.
- Neutral non-tenant-themed styling; usable at 1024–1280px.
PART C — UNBUILT PAGES (NORMATIVE)
These pages do not exist in the code yet. Their specs are the target for
BUILD-PLAN Phases 5–6. Every transition named below is checked against the real
permission matrix (src/lib/transition-rules.ts PERMISSIONS). Where the
current matrix and the Phase-6 intent differ, it is called out explicitly.
C1. /workspace (editor) — NORMATIVE (unbuilt), BUILD-PLAN Phase 5
Purpose & user. Editor (freelance professional, desktop, many jobs). Their whole job: see assigned work, read the brief, pull originals, upload a draft, ship it — with zero ceremony (brief §2). Editors see only their assigned jobs (RLS). Pseudonymous to clients; never juxtapose client and editor identity (brief §2, ARCHITECTURE §8).
Layout. Desktop-first (1280px, usable at 1024). Two-pane feel: a left assigned-jobs list and a right job detail; on narrower widths, list collapses above detail. Engine-neutral styling (spans tenants — not tenant-themed; A8). Dense but scannable (brief §2 editor persona).
Assigned-jobs list
- Source ←
jobswhere the editor is the assigned editor (RLS already scopes the editor to assigned jobs;jobs.editor_id = auth.uid()). Ordered byinternal_deadlineasc (soonest first) is the sensible default (matches ops). - Row: short id (monospace), raw status enum + chip (editors get the raw vocabulary, A1), package name, deadline countdown. An editor-facing deadline cue may reuse the SLA math (A3) but without the admin's breach styling being alarmist — an editor wants "due in 6h", framed as focus not panic.
- Rush marker ←
is_rush(tighter turnaround; the editor should notice it). - Only these statuses are actionable by an editor (see permission table below): ASSIGNED, IN_PROGRESS, QC_REJECTED, REVISION. IN_REVIEW/CLIENT_REVIEW jobs are visible (they occupy the editor's cap) but read-only for the editor.
Job detail (right pane)
- Brief ← brief renderer (A6) from
jobs.brief_data+ the tenant template. This is the editor's primary reference; make it the most legible thing on the page (brief §2 "where's the brief"). Client identity limited to brief-level info only — no client email, no company beyond what the brief carries (ARCHITECTURE §8; the admin email surface in B8 must NOT be reproduced here). - Originals download list ← files where
kind === 'original', via the file-row component (A4) and/api/files/{id}/download(RLS-gated; the editor is a participant on assigned jobs). This is the "originals download (Phase 2 reuse)" of Phase 5. - Draft upload ← reuse the uploader (B6) with
kind='draft'. The existing upload page already setskind='draft'for the editor role, so/workspacecan link to/jobs/{id}/uploador embed the Uploader directly. Editors do not get the paste-a-link ingest panel (it's originals-only, B6). Until the Phase-9 transcoder exists, the process rule (ARCHITECTURE §6) is that the editor uploads a watermarked preview export alongside the draft — the design should make "upload your draft + its watermarked preview" a clear, single task, not two mysterious slots. - QC-rejection note (prominent) ← the most recent
qc_rejected_noteevent'spayload.notefor this job (A7). When a job is in QC_REJECTED (or freshly back in IN_PROGRESS after a rejection), display this note prominently at the top of the detail — it is the editor's instruction for rework (brief §3: "QC- rejection notes displayed prominently"). Calm, specific voice; it is internal and professional, not a reprimand. - Revision notes ← the most recent
revision_requestedevent'spayload.note(A7), surfaced the same way when the job is in REVISION / back to IN_PROGRESS after a client revision. Distinguish visually from QC notes (QC = internal operator; revision = client-driven) but treat both as actionable instructions.
Editor actions (grounded in PERMISSIONS, adminOrAssignedEditor)
The editor may perform exactly these edges (from the matrix):
| Button (proposed) | Edge | Shown when status is | Notes |
|---|---|---|---|
| Start work | ASSIGNED → IN_PROGRESS | ASSIGNED | begins the job |
| Submit for QC | IN_PROGRESS → IN_REVIEW | IN_PROGRESS | hands the draft to the operator; gate on ≥1 draft file present — see below |
| Resume work | QC_REJECTED → IN_PROGRESS | QC_REJECTED | after a QC rejection; pairs with the QC note |
| Resume work | REVISION → IN_PROGRESS | REVISION | after a client revision request; pairs with the revision note |
- The editor may NOT: assign, QC pass/reject, move to CLIENT_REVIEW, approve, deliver, or cancel (cancel is client/admin only). Do not design those controls into the workspace.
- All buttons POST to the same transition API
{ to }(no extra payload needed for these edges) and then refresh — same optimistic-off pattern as B8. - Submit-for-QC gating (normative): the code does not yet enforce a draft-
present precondition for IN_PROGRESS→IN_REVIEW (the transition machine allows
it unconditionally for the assigned editor). The design should disable
"Submit for QC" until at least one
draftfile exists to prevent empty submissions — mark this as a normative UI gate (the server does not enforce it today; do not claim it does). - Concurrent-job cap (Phase 5 gate) is enforced server-side at assignment (B8), not here; the workspace just shows the editor's current load implicitly by listing their jobs. No cap control belongs on this page.
State matrix.
- Job status chip: raw enum + chip (A1/A2a), editor variant.
- Action button set: one of the four rows above per status; read-only for IN_REVIEW/CLIENT_REVIEW/APPROVED/DELIVERED/CANCELLED.
- QC/revision note block: present when a note exists for the current rework cycle; absent otherwise.
- Draft-upload state: reuse the uploader's states (B6).
Empty/loading/error.
- No assigned jobs → reassuring empty state: "No jobs assigned to you right now." (not a dead end — the editor waits for dispatch).
- No originals yet → "Footage isn't here yet." (an assigned job should have footage — this is an edge case; keep it calm).
- No draft uploaded, Submit-for-QC disabled → helper text: "Upload your draft and its watermarked preview to submit."
- Transition error → inline, verbatim API message.
Acceptance criteria.
- Editor sees only assigned jobs; nothing from other editors or unassigned work; no client email/identity beyond brief-level info.
- The brief renders readably (A6) and is the page's most legible element.
- Originals are downloadable; drafts are uploadable via the reused uploader with
kind='draft'; no paste-link ingest panel appears. - Exactly the four editor-legal transitions are offered, each under its correct status; no assign/QC/approve/cancel controls exist.
- QC-rejection notes and revision notes surface prominently and are visually distinguished, in calm professional voice.
- "Submit for QC" is disabled until a draft file exists (normative UI gate), with helper copy explaining the watermarked-preview requirement.
- Editor status labels are the raw enum + chip; deadlines read as focus, not alarm.
- Engine-neutral (non-tenant) styling; dense and keyboard-friendly at 1024–1280px.
C2. /jobs/[id]/review (client) — NORMATIVE (unbuilt), BUILD-PLAN Phase 6
Purpose & user. Client. Watch the watermarked draft, then Approve (→ payment capture — a deliberate, confident moment) or Request changes (with a note and a remaining-revisions count). After approval, download finals. Mobile-first, non-technical, calm (brief §2, §3).
Layout. Single column, mobile-first (360px up). Order: branded header → status line → preview player frame → decision block (Approve / Request changes) → (post-approval) finals section. Whitespace over boxes (brief §6); tenant-themed (A8).
Preview player frame
- Source ← files where
kind === 'preview'(watermarked) — the client review asset. ARCHITECTURE §6: drafts are shown to clients as watermarked previews; until the Phase-9 transcoder, the editor uploads a watermarked preview alongside the draft (C1). The review page therefore plays thepreviewfile, not the rawdraft. - Design the frame, not the guts (brief §9): a standard HTML5/embedded video player sits inside a designed frame (title, watermark-present reassurance, poster). Do not spec player internals.
- If multiple previews exist (multi-deliverable), present them as a small selectable set; single preview is the common case.
- A visible "watermarked preview" label sets expectations that finals are clean and arrive on approval.
Decision block (only in CLIENT_REVIEW)
Grounded in the state machine: from CLIENT_REVIEW the client-relevant edges are
APPROVED and REVISION.
- Approve →
CLIENT_REVIEW → APPROVED.- Deliberate, confident confirm (brief §3): this captures payment (ARCHITECTURE §5) and triggers the Connect payout + finals release. Design a high-intent confirmation — a clear modal/step that states, calmly, "Approving releases payment and your final files." Not a scary warning; a confident, premium moment. Use the accent for this primary action.
- Permission reality (must flag): in the current matrix
CLIENT_REVIEW→APPROVEDis admin-only (admin), because capture+payout isn't wired to the client action until Phase 6. Phase 6 changes this edge to allow the owner-client (the natural extension ofadminOrOwnerClientseen on the REVISION edge). The design is normative for Phase 6; the button is the client's, and the confirm copy above assumes the Phase-6 capture wiring. Do not ship the client Approve before that wiring exists.
- Request changes →
CLIENT_REVIEW → REVISION(adminOrOwnerClient— the client may already do this per the matrix).- Requires a note (the change request). The API records it as a
revision_requestedevent when present. Design the note as required from the client's side (server currently treats it as optional on this edge — make the UI require it so the editor gets instructions; normative UI gate). - Remaining-revisions count ←
packages.revision_limit − jobs.revision_count. Display as "N of M revisions left" (brief §3 "1 of 2 revisions left"): M =revision_limit, used =revision_count, remaining = M − used. - At the cap (remaining ≤ 0): the server rejects the REVISION edge ("revision cap reached … paid revision or admin override (Phase 6)"). Design the over-cap state: explain calmly that included revisions are used up and offer the paid-revision / contact-operator path (Phase 6 decides the exact mechanism — design the message, not a new billing flow).
- Requires a note (the change request). The API records it as a
Finals section (post-APPROVED)
- Shown once status is
APPROVED(andDELIVERED). Source ← files wherekind === 'final', via the file-row component (A4) and the download route. - Finals are released only at approval/capture (ARCHITECTURE §6). Normative
note: the current download route (
/api/files/{id}/download) gates on RLS participation only — it does not yet check status orkind. Phase 6 must gate final delivery on APPROVED; the design assumes finals appear only after approval and should not show a finals section (or download affordance) before then. - Post-approval header copy: confident and complete — "Approved — your final files are ready." Finals are clean (no watermark); make that clear versus the preview.
State matrix (by job status, client-mapped label A1).
CLIENT_REVIEW("Ready for your review") → player + decision block (Approve / Request changes + remaining count).REVISION("Revisions under way") → player may remain (last preview) but decision block is replaced by a calm "Your changes are with the editor" state; the revision note the client sent may be echoed back for reassurance.APPROVED("Approved") → decision block gone; finals section present; capture-done reassurance.DELIVERED("Delivered") → finals section; terminal happy state.- Pre-
CLIENT_REVIEWstatuses → this page should not offer review actions; if reached early, show a calm "Not ready for review yet — we'll email you when your edit is ready." (No draft to show.)
Interactions.
- Approve / Request-changes POST to the transition API (
{ to }, plusnotefor REVISION) then refresh — same pattern as B8/C1. - Approve carries the deliberate confirm; Request-changes requires the note (UI-enforced).
- Downloads → presigned R2 (A4).
Empty/loading/error.
- No preview yet (shouldn't happen in CLIENT_REVIEW, but guard): "Your preview is being prepared." calm.
- Revision note empty → disable Request-changes with helper: "Tell the editor what to change."
- Over-cap → the calm paid-revision message above.
- Transition/capture error → verbatim, calm; never lose the client's typed note on error.
Acceptance criteria.
- The page plays the watermarked preview (
kind='preview') inside a designed frame, clearly labelled as a preview, with player internals left standard. - Approve is a deliberate, confident, accent-led confirmation whose copy states that approval releases payment and finals (Phase-6 capture assumed).
- Request-changes requires a note (UI-enforced) and shows the correct
"remaining of total revisions" count from
revision_limit − revision_count. - The over-cap state is handled calmly with a paid-revision/contact path, not a dead error.
- Finals (
kind='final') appear only after APPROVED, clearly distinguished from the watermarked preview, downloadable via the standard file row. - Every job status maps to the right client label (A1) and the right action set; pre-review and revision states are calm, never blank.
- Mobile-first at 360px; tenant-themed; single accent on the Approve action; all states conveyed by more than colour.
Judgement calls I had to make (flagged explicitly)
These are decisions taken because the brief asked for pixel-completeness while the code left a gap. Each is defensible and grounded, but a reviewer should confirm:
- Client status label map (A1). The brief mandates a mapping but only gives the IN_REVIEW example and the "QC_REJECTED must not read as failure" rule. I mapped both IN_REVIEW and QC_REJECTED to "Quality check" (so rework is invisible to clients), and collapsed ASSIGNED+IN_PROGRESS to "In production." The exact words are proposals; the rules they satisfy are the brief's.
- Editors see raw enum, clients see mapped labels. The brief says admin sees raw; it's silent on editors. I put editors on the raw enum (they operate the machine). If the operator wants editor-friendly labels, that's a small change.
- Brief renderer needs the template on
/ops/jobs/[id]and/workspace.brief_datastores keys, not labels/types; the ops page currently loads onlybrief_data. I specced the renderer to consumebrief_templates.schemaand degrade to key-humanization. This implies a small data-loading addition on those pages (normative). /jobs/[id]/reviewApprove is admin-only in the current matrix. I specced the client Approve button as Phase-6 normative and flagged that shipping it requires the capture+payout wiring that flipsCLIENT_REVIEW→APPROVEDto allow the owner-client. The design is correct for Phase 6; it must not ship before.- UI-only gates the server doesn't enforce yet: (a) editor "Submit for QC"
disabled until a
draftfile exists; (b) client "Request changes" note required. Both are normative UI gates; I explicitly did not claim the server enforces them (it doesn't today). - Finals gating. The download route gates on RLS participation only, not on
APPROVED/
kind. I specced the review page to reveal finals only post-APPROVED (per ARCHITECTURE §6) and flagged that Phase 6 must add the server-side gate — the design assumes it. - Preview vs draft on the review page. ARCHITECTURE §6 says clients see
watermarked previews; the editor uploads a
previewalongside thedraftuntil the Phase-9 transcoder. I specced the player to bind tokind='preview', notdraft. If the operator prefers to showdraftdirectly pre-transcoder, that's a one-line change — but it would show unwatermarked footage, which the architecture forbids. - Theme token names. The brief proposes
--brand-accentetc.; the actualtenants.themekeys areprimary/accent/logo_url/font. I documented the real keys and mapped them to the brief's intended token roles so the design and the data agree.