Skip to content

Latest commit

 

History

History
343 lines (265 loc) · 15.4 KB

File metadata and controls

343 lines (265 loc) · 15.4 KB

CULL — UI Implementation Spec (Dark Studio)

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)


0. How to apply this

An implementing agent should produce two things that satisfy every section below:

  1. A top bar matching §5.
  2. 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.

1. Intent

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.


2. Tier tokens

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

3. Typography

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

4. Space · radius · elevation · motion

  • Radius: 6px controls · 4px image mount + thumbnails · 8px containers · 12px outer cards.
  • Padding: 16px panel interior · 10–14px header/footer bar · 11px button vertical.
  • Gaps: 8px between action buttons · 5px between thumbnails · 3px inside 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. Respect prefers-reduced-motion: reduce → disable all transitions.

5. Top bar (consolidated)

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         │
└──────────────────────────────────────────────────────────────────────┘

5.1 Brand block (left)

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>

5.2 Grouped toolbar (right)

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.

5.3 Search + favorite

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.

5.4 Segmented sort

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.

5.5 Toolbar action hooks (UI only)

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.

5.6 Tabler dependency

Load once in <head>:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">

6. Action row

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.


7. Viewer

  • 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: kept on --keep/--keep-on, rejected on --reject/--reject-on, hidden when undecided.
  • Flag mark top-center, --flag-active star, shown when flagged.
  • Zoom: hold Space (or click) → transform:scale(2) on the image, 90ms ease.

8. Filmstrip

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.


9. Progress + status

  • 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-muted with --keep/--reject on the counts.
  • Round every displayed number; group thousands (toLocaleString).

10. Keyboard model

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).


11. State model

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 to nextUndecided() (wrap-around scan for the next status===null; if none, clamp cur+1).
  • toggleFlag(): flip flagged; flag is orthogonal to keep/reject and never advances.
  • undo(): pop history → restore that item's prevStatus → set cur to that index.
  • move(±1): clamp navigate without changing status.
  • Every keep/reject auto-advances to the next undecided frame.

12. Frame source (UI contract)

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.


13. Accessibility floor

  • 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.

14. Responsive

  • ≤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.

15. Do / don't

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.


16. Acceptance checklist

  • 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, preventDefault applied.
  • 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-motion disables transitions; no text <11px.
  • No gradient, glow, or drop shadow on any chrome.