Status: apply-ready · Scope: front-end UI only (persistence out of scope)
Target: self-hosted image cull app · Tiers: Studio Charcoal (default) + Studio Black (OLED)
Source files: image-cull.html (app shell), cull-topbar.html (header)
An implementing agent should produce two things that satisfy every section below:
- A top bar matching §5.
- A cull workspace (viewer + actions + filmstrip + progress) matching §6–§10.
All color/type/space values come from the tier token set in §2. Never hardcode a raw hex in a component — reference a token. Verify against the checklist in §16.
Non-negotiables
- Keep is green, Reject is red, both solid filled, equal visual weight.
- Action order, left→right: Reject · Flag · Undo · Keep.
- Green is used for the keep decision only — never for selection, hover, or accents.
- No gradients, glow, or drop shadows on chrome. Flat surfaces, hairline borders.
- Image is never tinted or bordered in a decision color. Neutral mount only.
Culling is a throughput task, measured in images per minute. Chrome recedes; the image carries the frame. The only saturated pixels on screen are the two decisions. Latency between a keypress and the next frame is the metric that matters — keep every transition under 120ms or cut it.
Declare as CSS custom properties. Switch the whole UI by setting data-tier on the
root element (<html data-tier="charcoal"> / "black"). Decision accents are shared
and constant across both tiers so the decision language never shifts.
:root {
--keep:#2f7d52; --keep-hover:#379161; --keep-on:#eafff2;
--reject:#b23b3b; --reject-hover:#c74848; --reject-on:#ffecec;
--flag:#d4a94e; --flag-active:#e6bf5f;
--radius:6px;
}
:root[data-tier="charcoal"]{
--bg-0:#101012; --bg-1:#171719; --bg-2:#1e1e21; --surface:#252528;
--border:#2a2a2d; --border-strong:#38383c;
--text-primary:#e8e8ea; --text-secondary:#8a8a90; --text-muted:#5a5a60;
--ring-neutral:#6a6a70;
}
:root[data-tier="black"]{
--bg-0:#000000; --bg-1:#050505; --bg-2:#0e0e0e; --surface:#141414;
--border:#1c1c1c; --border-strong:#262626;
--text-primary:#e8e8ea; --text-secondary:#7a7a7a; --text-muted:#4a4a4a;
--ring-neutral:#5a5a5a;
}| Token | Role |
|---|---|
--bg-0 |
App chrome — header, footer |
--bg-1 |
Page ground |
--bg-2 |
Image mount |
--surface |
Thumbnails, tertiary buttons, inputs, containers |
--border |
Hairline dividers |
--border-strong |
Button borders, hover/focus edges |
--text-primary |
Active labels, primary icons |
--text-secondary |
Section labels, meta, muted-tier icons |
--text-muted |
Counts, placeholders, hints, disabled |
--ring-neutral |
Selection ring, progress fill |
Single sans family (system stack is fine). UI only — no serif. Two weights: 400, 500.
| Level | Size | Weight | Color | Notes |
|---|---|---|---|---|
| Wordmark | 14px | 500 | --text-primary |
letter-spacing 2px |
| Section / tier label | 11–12px | 500 | --text-secondary |
letter-spacing 0.5px, may uppercase |
| Button label | 13px | 500 | on-accent / --text-primary |
sentence case |
| Meta / count / % | 11–12px | 400 | --text-muted |
round + group thousands |
| Overlay (filename, exif) | 11px | 400 | --text-secondary |
no background chip |
- Radius:
6pxcontrols ·4pximage mount + thumbnails ·8pxcontainers ·12pxouter cards. - Padding:
16pxpanel interior ·10–14pxheader/footer bar ·11pxbutton vertical. - Gaps:
8pxbetween action buttons ·5pxbetween thumbnails ·3pxinside grouped toolbars. - Elevation: none. Depth comes from the surface ramp (
--bg-1→--bg-2→--surface), not shadow. Focus rings are the only permitted box-shadow. - Motion: decision→advance 90–120ms crossfade or hard cut; button press
scale(0.98)60ms; nothing animates the image. Respectprefers-reduced-motion: reduce→ disable all transitions.
Six previously-loose buttons collapse into one bordered toolbar, split by hairline dividers into three frequency tiers. Three rows total.
┌──────────────────────────────────────────────────────────────────────┐
│ [◧] CULL (25+) [ ⌘ | ⧉ △ ⌗ | ↻ ⚙ ] ← grouped tbar │
├──────────────────────────────────────────────────────────────────────┤
│ [🔍 Search prompt, tags, model… ★ ] │
├──────────────────────────────────────────────────────────────────────┤
│ [ A → Z | Newest | Indexed ] ← single segmented control │
└──────────────────────────────────────────────────────────────────────┘
Logo mark + wordmark CULL + count pill. Count pill: --surface bg, --text-secondary,
border-radius:20px, padding:2px 8px.
Logo mark (inline SVG, fill var(--text-primary) so the flat-black mark reads on
dark chrome — do not use pure black): rounded-square container, one tall left panel,
two stacked right panels.
<svg width="22" height="22" viewBox="0 0 22 22" aria-hidden="true">
<rect x="0.5" y="0.5" width="21" height="21" rx="5" fill="none" stroke="var(--text-primary)" stroke-width="1"/>
<rect x="4" y="4" width="6" height="14" rx="1.5" fill="var(--text-primary)"/>
<rect x="12" y="4" width="6" height="6" rx="1.5" fill="var(--text-primary)"/>
<rect x="12" y="12" width="6" height="6" rx="1.5" fill="var(--text-primary)"/>
</svg>One container: --bg-1 bg, 0.5px --border, border-radius:8px, padding:3px.
Buttons are 34×32px, transparent, border-radius:6px, icon 18px, hover --surface.
Groups separated by a 0.5px --border vertical divider (height:18px, margin:0 3px).
| Order | Group (frequency) | Button | Icon (Tabler outline) | Icon color |
|---|---|---|---|---|
| 1 | Scope | Folders | ti-folders |
--text-primary |
| — | divider | |||
| 2 | Analysis | Duplicates | ti-copy |
--text-primary |
| 3 | Analysis | Anomalies | ti-alert-triangle |
--text-primary |
| 4 | Analysis | Tagger | ti-tags |
--text-primary |
| — | divider | |||
| 5 | Maintenance | Re-index | ti-refresh |
--text-secondary (muted) |
| 6 | Maintenance | Settings | ti-settings |
--text-secondary (muted) |
Maintenance-tier buttons render one step dimmer (--text-secondary) so infrequent
actions recede without hiding. Every icon button needs title + aria-label.
Optional — anomalies badge: ti-alert-triangle reads as "warning" and pulls the
eye even when clean. If desired, dim it to --text-secondary by default and show an
amber dot (--flag-active) only when the anomaly count is nonzero.
Full-width field: --bg-1 bg, 0.5px --border, border-radius:8px, height 40px,
leading ti-search (16px, --text-muted), placeholder --text-muted. Favorite toggle
lives inside the field trailing edge — 30px button, ti-star, --text-muted default,
--flag-active when aria-pressed="true". :focus-within → border --border-strong.
Three previously-separate pills become one segmented control: --bg-1 bg, 0.5px --border,
border-radius:8px, padding:3px, gap:3px. Segments flex:1. Active segment:
--surface bg, --text-primary, weight 500, aria-pressed="true". Inactive: transparent,
--text-secondary, weight 400. Options: A → Z (default), Newest, Indexed.
Wire these to existing app handlers; persistence and behavior are out of scope here:
openFolders(), runDuplicates(), runAnomalies(), runTagger(), reindex(),
openSettings(), onSearch(value), sort key az|newest|indexed.
Load once in <head>:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">Order left→right: Reject · Flag · Undo · Keep. Reject and Keep bookend the row at
equal weight (flex:1); Flag and Undo are fixed-width tertiary buttons in the middle.
<div class="actions">
<button class="btn reject" onclick="decide('reject')"><span class="ico">✕</span>Reject<span class="kbd">X</span></button>
<button class="btn flag" id="btn-flag" aria-label="Flag" onclick="toggleFlag()"><span class="ico">★</span></button>
<button class="btn undo" aria-label="Undo" onclick="undo()"><span class="ico">⤾</span></button>
<button class="btn keep" onclick="decide('keep')"><span class="ico">✓</span>Keep<span class="kbd">K</span></button>
</div>| Button | Fill | Text/icon | Border | Width | Icon |
|---|---|---|---|---|---|
| Reject | --reject (hover --reject-hover) |
--reject-on |
none | flex:1 |
✕ / ti-x |
| Flag | --surface |
--flag (--flag-active when set) |
0.5px --border-strong (--flag when set) |
auto | ★ / ti-star |
| Undo | --surface |
--text-secondary |
0.5px --border-strong |
auto | ⤾ / ti-arrow-back-up |
| Keep | --keep (hover --keep-hover) |
--keep-on |
none | flex:1 |
✓ / ti-check |
States (all): rest → hover → active scale(0.98) → focus-visible ring → disabled
(--text-muted on --surface; avoid disabling — respond on use instead).
Focus rings: 2px inset-then-color. Reject → --reject, Keep → --keep, tertiary → --ring-neutral.
.btn:focus-visible{outline:none;box-shadow:0 0 0 2px var(--bg-1),0 0 0 4px var(--ring-neutral);}
.btn.keep:focus-visible{box-shadow:0 0 0 2px var(--bg-1),0 0 0 4px var(--keep);}
.btn.reject:focus-visible{box-shadow:0 0 0 2px var(--bg-1),0 0 0 4px var(--reject);}Weighting rule: Keep and Reject stay equal. Do not make Keep a dominant CTA — a cull is binary; biasing toward keep depresses the rejection rate, which defeats the tool.
- Mount
--bg-2,border-radius:4px, no border, no decision tint. In Black tier the mount and ground converge — that is the intended OLED look. In Charcoal, letterbox with--bg-2(not pure black) so the mount reads as intentional surface. - Filename overlay top-left, 11px
--text-secondary, no chip. - EXIF overlay bottom-right, same treatment, off by default (toggle).
- Decision state overlay top-right, uppercase pill:
kepton--keep/--keep-on,rejectedon--reject/--reject-on, hidden when undecided. - Flag mark top-center,
--flag-activestar, shown when flagged. - Zoom: hold
Space(or click) →transform:scale(2)on the image, 90ms ease.
Horizontal strip under the action row. Thumbs ~56×38px, --surface, border-radius:3px,
gap:5px, object-fit:cover, auto-scroll current into center.
| State | Treatment |
|---|---|
| Current | 2px --ring-neutral outline, offset 1px — neutral, never green |
| Kept | corner check badge on --keep/--keep-on |
| Rejected | opacity ~0.45, no badge |
| Flagged | --flag-active dot bottom-left |
| Untouched | plain --surface |
States stack (a thumb can be current + flagged). Selection ring is neutral so green stays exclusive to the keep decision.
- Footer bar: 4px track
--surface, fill--ring-neutral,border-radius:2px, width = % complete, 180ms ease. - Percent readout right, 11px
--text-muted. - Header meta:
total · N kept · N rejected,--text-mutedwith--keep/--rejecton the counts. - Round every displayed number; group thousands (
toLocaleString).
Cull speed lives on the keyboard; buttons mirror keys.
| Key | Action |
|---|---|
K / Enter |
Keep + advance |
X / Delete / Backspace |
Reject + advance |
F |
Toggle flag (no advance) |
← / → |
Previous / next without deciding |
Space (hold) |
Zoom to 100% |
Z |
Undo last decision |
e.preventDefault() on handled keys. Ignore auto-repeat except for Space (hold-zoom).
item = { src, name, exif, status: null|'keep'|'reject', flagged: bool }
cur : index of the current frame
history : stack of { index, prevStatus } for undo
- decide(status): push
{cur, prevStatus}to history → set status → advance tonextUndecided()(wrap-around scan for the nextstatus===null; if none, clampcur+1). - toggleFlag(): flip
flagged; flag is orthogonal to keep/reject and never advances. - undo(): pop history → restore that item's
prevStatus→ setcurto that index. - move(±1): clamp navigate without changing status.
- Every keep/reject auto-advances to the next undecided frame.
Frames arrive as an array; the UI is agnostic to origin. Replace any demo generator
with the real manifest, then call init().
// [{ src, name, exif }, ...]
fetch('/api/frames').then(r => r.json()).then(f => { FRAMES = f; init(); });Writing decisions back to disk/DB is out of scope for this spec.
- Every icon-only button:
aria-label+title. - Visible keyboard focus on all interactive elements (rings per §6).
- Toggle buttons expose
aria-pressed. - Respect
prefers-reduced-motion. - No text below 11px. Text on colored fills uses the on-accent tokens, never raw black.
- ≤480px: hide wordmark and header meta; keep logo, toolbar, search, sort.
- Action buttons stay one row; labels may drop to icon-only under extreme narrow.
- Filmstrip scrolls horizontally; thumbs fixed size.
Do — hold green/red to decisions only · carry depth with the surface ramp · ship both tiers from one token set · round + group all numbers · keep transitions <120ms. Don't — tint or border the image in a decision color · make Keep heavier than Reject · use green for selection or hover · add shadows/gradients/glow to chrome · go below 11px.
-
data-tier="charcoal"and"black"both render correctly from one token set. - Action order is Reject · Flag · Undo · Keep; Reject and Keep are equal-weight and bookend.
- Keep green / Reject red, solid; green appears nowhere else.
- Top bar: brand+count, one grouped toolbar (3 groups, 2 dividers, correct icons, maintenance tier muted), search with inline favorite, one segmented sort.
- All six toolbar icons match §5.2; every icon button has
aria-label+title. - Keyboard: K/Enter, X/Del/Backspace, F, ←/→, hold-Space zoom, Z undo — all working,
preventDefaultapplied. - Keep/Reject auto-advance to next undecided; undo restores status and position.
- Filmstrip current ring is neutral; kept badge, rejected dim, flag dot correct.
- Progress + counts update and round; thousands grouped.
- Focus rings visible;
prefers-reduced-motiondisables transitions; no text <11px. - No gradient, glow, or drop shadow on any chrome.