The visual direction of Vinyl Collection Manager, approved 27 August 2026 and shipped over five phases.
This file records the decisions and the rules that follow from them. It is not
the source of truth for values — the tokens live in
app/assets/tailwind/application.css
and are the only place they are defined. What is here is the why, which the
code cannot tell you, plus a pointer to the test that keeps each rule honest.
The original proposal is kept as a dated snapshot in
crate-proposal.html — open it in a browser. Everything
it diagnoses has since been fixed, so read it as history, not as a to-do list.
The cover is the interface. A collector recognises a record by its sleeve, not its title. Artwork is the content; everything else is chrome and gets out of its way. This is why the grid puts art straight on the page ground with its own shadow instead of inside a card, and why the album page lets the blurred cover tint its own header.
Chrome is desaturated so the artwork carries the colour. Album covers are wildly colourful and all different. A saturated interface would fight every one of them. One muted brass accent, warm-biased neutrals, and nothing else.
Liner notes, not dashboard. Metadata reads like the back of a sleeve: condensed uppercase labels, values in tabular mono, hairline separators. No cards inside cards.
Square, like a sleeve. 3px radius (rounded-sleeve) everywhere. The object
this app catalogues has straight corners.
Defined once in application.css: the light palette on :root, dark
redefining values only under both [data-theme="dark"] and
prefers-color-scheme (the latter covers the no-JS case).
Views must use semantic utilities — bg-surface, text-fg-muted,
border-line — never bg-white or text-gray-700. A literal Tailwind colour
in a view means dark mode is broken there.
Guarded by
test/integration/theme_test.rb, which fails on any literal colour utility inapp/views.
Green and red are reserved for success and danger. They are never decoration. Blue is not in the palette at all; what used to be blue is the accent.
Contrast is measured, not assumed: 26 token pairs across both themes, all at or above their WCAG AA threshold. The tightest are subtle text at 3.66:1 (needs 3.0) and the accent on the page ground at 4.79:1 (needs 4.5). Changing a token means re-checking those two.
| Role | Face | Used for |
|---|---|---|
| Display | Archivo | Album titles, headings, the brand, condensed uppercase labels |
| Interface | Instrument Sans | Body, buttons, form labels, navigation |
| Data | IBM Plex Mono | Catalog numbers, years, track positions, durations, counts |
Anything that is a number and might line up in a column gets the mono face and
tabular-nums.
Component classes live in the @layer components block of application.css.
Use them instead of repeating utility strings — a button's intent belongs in
its class.
Buttons carry weight, not variety. One filled primary per view, an outlined secondary, and a destructive that looks like neither and sits away from both so it is never the accidental click.
.btn+.btn-primary— the one action the view is for.btn+.btn-secondary— everything else actionable.btn+.btn-danger— destructive, kept at a distance.btn+.btn-ghost— cancel and dismissals
The hierarchy on the album page is asserted in
test/integration/album_detail_test.rb.
Fields use .field, .field-label, .field-help.
These are commitments, not aspirations — each one has a test.
- 44px minimum touch target on everything tappable. Carried by
.btn,.field,.controland.control-iconrather than remembered per call site. For an inline link, grow the hit area with padding and an equal negative margin so the text does not move. →test/system/navigation_system_test.rb - State is never colour alone. The Discogs indicator is a dot and an icon
and words. →
test/integration/navigation_test.rb - Announcements reach screen readers. A notice is
role="status"and clears itself; an error isrole="alert"and stays, because it carries something still to act on. →test/integration/accessibility_test.rb - One focus ring for everything focusable, in the base layer. Never a
per-input
focus:utility. - Skip link and
aria-currenton every page. prefers-reduced-motionhonoured in the base layer.- Covers carry real alt text naming the record and artist.
Every user-facing string comes from config/locales/. English and Spanish are
at full parity — 169 keys, aligned interpolations.
A Stimulus controller cannot reach I18n, so any label a controller writes must be handed to it from the view as a Stimulus value, and those values must sit on the controller element, not on a target.
Guarded by
test/integration/locale_test.rb, which fails on any string a controller assigns totextContentor anaria-label.
The collection results sit in a turbo frame. The frame targets _top, so a
link inside it navigates the page normally and only pagination opts back in.
The toolbar deliberately lives outside the frame: replacing it on every
keystroke would pull the caret out of the search field.
A failed save must answer 422, or Turbo discards the response and the
validation errors never reach the page.
data-turbo-confirm goes through the app's own <dialog>, wired up in
app/javascript/confirm_dialog.js.
The test suite does not catch design regressions on its own. Every phase of this redesign turned up something only visible in a browser — a panel overflowing the viewport, a dialog stuck in a corner, an icon that never swapped, a confirmation that silently did nothing.
Run the app and look at it, at 1440, 768 and 390, in both themes.