Fix the broken input focus outline, unify input styling, tokenize scales

The root cause of 'some inputs show a focus outline, most don't': the
global input:focus/textarea:focus/select:focus rule (outline:none,
border-color: accent) has specificity (0,0,1,1). Every per-component
rule shaped '.wrapper input { border: 1px solid var(--border) }' (.form
input, .field-row input, .kanban__new input, .sidebar__select select,
.modal__field select) ties it exactly, and -- being unconditional --
silently won that tie by simply appearing later in the file, so the
input's border stayed var(--border) forever regardless of focus. Only
two controls escaped: .project-card__name-input (a class applied
directly to the element, (0,0,1,0), too low to ever win the tie) and
.card-row__text (has its own .card-row__text:focus, (0,0,2,0),
genuinely higher). Confirmed with a live computed-style test against
the real stylesheet before and after.

Fix: one canonical .field class (plus .field--compact for inline 'add'
rows and the sidebar, .field--autosize for the dashboard's JS-grown
textarea), applied directly to the <input>/<textarea>/<select> itself
in every view -- structurally immune to the same tie, since a bare
class can never out-specificity input:focus. This also collapses five
near-duplicate rules (each with its own padding/radius/font-size) into
one definition, and fixes .kanban__new input's stray
background: var(--surface) (every other field uses var(--bg); this one
nearly matched its own var(--surface) sidebar panel).

Also: .btn-danger hardcoded #b3261e/#fff instead of the already-
existing var(--error)/var(--accent-text) tokens, so danger buttons
didn't adapt in dark mode like every other error-coloured element;
now they do. Added .field:disabled styling (opacity/cursor), matching
buttons -- latent until now since no input bound :disabled yet.

Second pass, promoting repeated-but-consistent raw values to tokens:
- border-radius: 6/7/8/10/12/999px -- the stray 7px (.sidebar__link)
  folded into the 6px tier -- become --radius-sm/md/lg/xl/pill.
- disabled/ghost opacity: 0.6 was used for every disabled button
  except .status-row__delete's 0.4 (now unified) and drag-ghost states'
  0.5 (semantically different, kept separate) -- --opacity-disabled/
  --opacity-ghost.
Font-size and spacing values also repeat but don't reduce to a clean
scale without arbitrary judgement calls either way -- left alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 22:07:35 +01:00
co-authored by Claude Sonnet 5
parent a85bb182c7
commit a3462af005
8 changed files with 145 additions and 113 deletions
+38
View File
@@ -74,6 +74,44 @@ dashboard; the grid and the sidebar dropdown both pick it up via the shared
`projects` store). Signed-out routes
(`/login`, `/verify-email`) render without the sidebar.
## Styling
`style.css` is one global stylesheet (no scoped/component styles) with a
handful of conventions worth knowing before adding to it:
- **Tokens** (`:root` custom properties): colours (`--bg`, `--surface`,
`--border`, `--text`, `--muted`, `--accent`(-text), `--error`, `--warn-bg`/
`-border`), a border-radius scale (`--radius-sm` 6px compact controls,
`--radius-md` 8px buttons/inputs, `--radius-lg` 10px tiles, `--radius-xl`
12px panels, `--radius-pill`), and two opacity values (`--opacity-disabled`
0.6, `--opacity-ghost` 0.5 for a dragged item's placeholder). Reach for one
of these before hand-writing a value that's really just "the same grey
border again" or "the same rounding as every other button."
- **`.field`** is the one themed `<input>`/`<textarea>`/`<select>` look, used
everywhere from the login form to the sidebar's project switcher.
`.field--compact` is the same thing smaller, for a control that's a flex
child beside a button (an inline "add" row) or squeezed into the sidebar.
`.field--autosize` adds the `resize:none; overflow:hidden` the dashboard's
JS-driven auto-growing textarea needs. Crucially, **`.field` is applied
directly to the control**, not to a wrapper (`.form input` no longer
exists) -- see the next point for why that's load-bearing, not just style.
- **Put component classes on the control itself, not a wrapping element**,
for anything that needs a `:focus` state. `input:focus`/`textarea:focus`/
`select:focus` (global, removes the default outline and colours the border
with `--accent` instead) has specificity `(0,0,1,1)` -- one pseudo-class,
one element. A rule shaped `.wrapper input { border-color: var(--border) }`
ties it exactly, and being unconditional, silently wins that tie by simply
appearing later in the file -- the input keeps `var(--border)` forever,
focused or not, no matter what `:focus` says. `.field`, applied straight to
the element, is `(0,0,1,0)` -- strictly lower, so it can never win that
tie regardless of source order. (This bit a real shipped version of the
app: half the inputs had a working focus ring and half silently didn't,
purely from which pattern each one happened to use.) A component that
needs its own more elaborate `:focus` state (`.card-row__text`, background
swap included) writes `.card-row__text:focus` explicitly -- specificity
`(0,0,2,0)`, genuinely higher, wins outright rather than by luck of
ordering.
## Inbox
A card with no project lives in the caller's inbox (`useInboxStore`), rendered
+2 -1
View File
@@ -129,7 +129,7 @@ async function onInboxChange(change: ColumnChange) {
<rect x="6.25" y="2" width="3.5" height="9" rx="1" />
<rect x="11.5" y="2" width="3.5" height="6" rx="1" />
</svg>
<select v-model="selectedProjectId" aria-label="Go to project">
<select v-model="selectedProjectId" class="field field--compact" aria-label="Go to project">
<option value="" disabled>Choose a project</option>
<option v-for="project in projects.projects" :key="project.id" :value="project.id">
{{ project.title }}
@@ -167,6 +167,7 @@ async function onInboxChange(change: ColumnChange) {
<form class="kanban__new" @submit.prevent="onCreateInboxCard">
<input
v-model="newInboxText"
class="field field--compact"
type="text"
maxlength="1000"
required
+97 -106
View File
@@ -14,6 +14,19 @@
/* Kanban inbox column: a touch lighter than a status column (var(--bg)). */
--inbox-bg: #fcfcfd;
color-scheme: light dark;
/* Same radius used consistently by everything of that kind -- not a full
scale, just names for the handful of values already in use. */
--radius-sm: 6px; /* compact controls: chips, small icon buttons, .field--compact */
--radius-md: 8px; /* buttons, .field, dropdown menus */
--radius-lg: 10px; /* tiles: project cards, kanban columns */
--radius-xl: 12px; /* panels: .card, .sidebar, .modal__dialog */
--radius-pill: 999px; /* status chip */
/* Both used consistently for their one meaning each -- not "disabled at
0.6, ghost at 0.5" by convention, just what those states are. */
--opacity-disabled: 0.6;
--opacity-ghost: 0.5;
}
@media (prefers-color-scheme: dark) {
@@ -36,7 +49,14 @@
/* No browser focus ring on form controls -- the border colour change is
highlight enough (components with more elaborate focus styles, e.g.
.card-row__text, override this with their own higher-specificity rule). */
.card-row__text, override this with their own higher-specificity rule).
Specificity is a single pseudo-class + element, (0,0,1,1) -- .field below
is deliberately a single class with no element in the selector, (0,0,1,0),
so it can never win a specificity tie against this regardless of source
order (a wrapper-class + descendant-element rule, e.g. the old ".form
input", ties with this rule instead and -- being unconditional -- can
silently beat it by simply appearing later in the file, permanently
hiding the focus state; that was a real, previously-shipped bug). */
input:focus,
textarea:focus,
select:focus {
@@ -44,6 +64,47 @@ select:focus {
border-color: var(--accent);
}
/* The one themed text input/select/textarea, applied directly to the
control itself everywhere -- login, dashboard, project, configuration,
profile, sidebar. See the note above for why "directly to the control"
(a single class, no wrapping selector) matters here, not just for
consistency. */
.field {
display: block;
width: 100%;
box-sizing: border-box;
padding: 0.55rem 0.7rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 1rem;
}
.field:disabled {
opacity: var(--opacity-disabled);
cursor: not-allowed;
}
/* Same control, smaller -- inline "add" rows and the sidebar, where it's a
flex child beside a button or icon rather than filling a form row. */
.field--compact {
flex: 1;
width: auto;
min-width: 0;
padding: 0.4rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.9rem;
}
/* The dashboard's project-name field: grown by JS to fit its content, so no
manual resize handle or scrollbar. */
.field--autosize {
resize: none;
overflow: hidden;
}
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
@@ -81,7 +142,7 @@ body {
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
border-radius: 8px;
border-radius: var(--radius-md);
padding: 0.35rem 0.55rem;
font-size: 1rem;
line-height: 1;
@@ -140,7 +201,7 @@ body {
overflow-y: auto;
padding: 1rem 0.75rem 1.5rem;
border: 1px solid var(--border);
border-radius: 12px;
border-radius: var(--radius-xl);
background: var(--surface);
}
@@ -157,7 +218,7 @@ body {
cursor: pointer;
font-size: 1rem;
padding: 0.3rem 0.5rem;
border-radius: 6px;
border-radius: var(--radius-sm);
}
.sidebar__close:hover {
@@ -219,7 +280,7 @@ body {
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0.6rem;
border-radius: 7px;
border-radius: var(--radius-sm);
color: var(--text);
text-decoration: none;
font-size: 0.9rem;
@@ -272,18 +333,6 @@ body {
padding: 0.2rem 0.6rem;
}
.sidebar__select select {
flex: 1;
min-width: 0;
font: inherit;
font-size: 0.9rem;
padding: 0.35rem 0.4rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
}
.sidebar__inbox-cards {
max-height: 40vh;
overflow-y: auto;
@@ -298,7 +347,7 @@ body {
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
border-radius: var(--radius-xl);
padding: 1.75rem;
}
@@ -325,7 +374,7 @@ h1 {
padding: 0.75rem 1rem;
background: var(--warn-bg);
border: 1px solid var(--warn-border);
border-radius: 8px;
border-radius: var(--radius-md);
font-size: 0.9rem;
}
@@ -372,7 +421,7 @@ h1 {
cursor: pointer;
font-size: 0.9rem;
padding: 0.2rem 0.4rem;
border-radius: 6px;
border-radius: var(--radius-sm);
}
.passkey-notice__dismiss:hover {
@@ -397,7 +446,7 @@ h1 {
gap: 0.75rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
border-radius: var(--radius-md);
background: var(--bg);
}
@@ -424,7 +473,7 @@ h1 {
cursor: pointer;
font-size: 0.85rem;
padding: 0.3rem 0.5rem;
border-radius: 6px;
border-radius: var(--radius-sm);
}
.passkeys__remove:hover {
@@ -450,7 +499,7 @@ h1 {
padding: 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
border-radius: var(--radius-lg);
color: inherit;
text-decoration: none;
}
@@ -481,22 +530,6 @@ h1 {
font-size: 0.9rem;
}
.project-card__name-input {
display: block;
width: 100%;
box-sizing: border-box;
padding: 0.55rem 0.7rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 1rem;
/* Grown in JS to fit its content -- no manual resize handle, no scrollbar. */
resize: none;
overflow: hidden;
}
/* --- project detail: cards ------------------------------------------ */
.cards {
@@ -512,7 +545,7 @@ h1 {
align-items: center;
gap: 0.5rem;
border: 1px solid var(--border);
border-radius: 8px;
border-radius: var(--radius-md);
padding: 0.4rem 0.55rem;
background: var(--surface);
}
@@ -520,7 +553,7 @@ h1 {
.card-row__status {
flex: none;
padding: 0.15rem 0.55rem;
border-radius: 999px;
border-radius: var(--radius-pill);
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
@@ -538,7 +571,7 @@ h1 {
flex: 1;
min-width: 0;
border: 1px solid transparent;
border-radius: 6px;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
font-size: 1rem;
@@ -563,7 +596,7 @@ h1 {
cursor: pointer;
font-size: 1rem;
padding: 0.2rem 0.4rem;
border-radius: 6px;
border-radius: var(--radius-sm);
}
.card-row__delete:hover {
@@ -625,7 +658,7 @@ h1 {
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
border-radius: 8px;
border-radius: var(--radius-md);
padding: 0.35rem 0.7rem;
font: inherit;
font-size: 0.9rem;
@@ -653,7 +686,7 @@ h1 {
list-style: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
border-radius: var(--radius-md);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
}
@@ -667,7 +700,7 @@ h1 {
color: var(--text);
font: inherit;
padding: 0.45rem 0.6rem;
border-radius: 6px;
border-radius: var(--radius-sm);
cursor: pointer;
}
@@ -733,14 +766,14 @@ h1 {
align-items: center;
gap: 0.5rem;
border: 1px solid var(--border);
border-radius: 8px;
border-radius: var(--radius-md);
padding: 0.5rem 0.6rem;
background: var(--surface);
cursor: grab;
}
.status-row--ghost {
opacity: 0.5;
opacity: var(--opacity-ghost);
}
.status-row__name {
@@ -757,7 +790,7 @@ h1 {
cursor: pointer;
font-size: 1rem;
padding: 0.2rem 0.4rem;
border-radius: 6px;
border-radius: var(--radius-sm);
}
.status-row__delete:hover:not(:disabled) {
@@ -765,7 +798,7 @@ h1 {
}
.status-row__delete:disabled {
opacity: 0.4;
opacity: var(--opacity-disabled);
cursor: not-allowed;
}
@@ -778,23 +811,11 @@ h1 {
margin-top: 0.75rem;
}
.field-row input {
flex: 1;
min-width: 0;
font: inherit;
font-size: 0.9rem;
padding: 0.4rem 0.5rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
}
.field-row button[type='submit'] {
flex: none;
padding: 0.4rem 0.7rem;
font-size: 0.9rem;
border-radius: 6px;
border-radius: var(--radius-sm);
}
.modal__field {
@@ -804,15 +825,6 @@ h1 {
margin-top: 0.75rem;
}
.modal__field select {
padding: 0.5rem 0.6rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font: inherit;
}
/* --- kanban board ---------------------------------------------------- */
.kanban {
@@ -827,7 +839,7 @@ h1 {
flex: 0 0 16rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
border-radius: var(--radius-lg);
padding: 0.75rem;
}
@@ -862,7 +874,7 @@ h1 {
ghost placeholder while something is dragged over it. */
.kanban__cards:empty {
border: 1px dashed var(--border);
border-radius: 8px;
border-radius: var(--radius-md);
}
.kanban__cards:empty::before {
@@ -879,14 +891,14 @@ h1 {
.kanban-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
border-radius: var(--radius-md);
padding: 0.55rem 0.65rem;
font-size: 0.9rem;
cursor: grab;
}
.kanban-card--ghost {
opacity: 0.5;
opacity: var(--opacity-ghost);
}
.kanban__new {
@@ -895,23 +907,11 @@ h1 {
margin-top: 0.6rem;
}
.kanban__new input {
flex: 1;
min-width: 0;
font: inherit;
font-size: 0.9rem;
padding: 0.4rem 0.5rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--text);
}
.kanban__new button[type="submit"] {
flex: none;
padding: 0.4rem 0.7rem;
font-size: 0.9rem;
border-radius: 6px;
border-radius: var(--radius-sm);
}
/* --- confirmation modal --------------------------------------------------- */
@@ -939,7 +939,7 @@ h1 {
max-width: 24rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
border-radius: var(--radius-xl);
padding: 1.5rem;
}
@@ -958,7 +958,7 @@ h1 {
.btn-secondary,
.btn-danger {
border: 1px solid var(--border);
border-radius: 8px;
border-radius: var(--radius-md);
padding: 0.5rem 0.9rem;
font: inherit;
font-size: 0.95rem;
@@ -971,14 +971,14 @@ h1 {
}
.btn-danger {
background: #b3261e;
border-color: #b3261e;
color: #fff;
background: var(--error);
border-color: var(--error);
color: var(--accent-text);
}
.btn-secondary:disabled,
.btn-danger:disabled {
opacity: 0.6;
opacity: var(--opacity-disabled);
cursor: default;
}
@@ -1000,20 +1000,11 @@ h1 {
font-size: 0.9rem;
}
.form input {
padding: 0.55rem 0.7rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font-size: 1rem;
}
button[type="submit"],
.btn-primary {
padding: 0.6rem 1rem;
border: none;
border-radius: 8px;
border-radius: var(--radius-md);
background: var(--accent);
color: var(--accent-text);
font-size: 1rem;
@@ -1028,7 +1019,7 @@ button[type="submit"],
button[type="submit"]:disabled,
.btn-primary:disabled {
opacity: 0.6;
opacity: var(--opacity-disabled);
cursor: progress;
}
+1 -1
View File
@@ -85,7 +85,7 @@ async function onCreate() {
<textarea
ref="titleInput"
v-model="title"
class="project-card__name-input"
class="field field--autosize"
rows="1"
maxlength="255"
placeholder="Name"
+1 -1
View File
@@ -70,7 +70,7 @@ async function onPasskeyLogin() {
<form v-else class="form" @submit.prevent="onSubmit">
<label>
<span>Email</span>
<input v-model="email" type="email" autocomplete="email" required />
<input v-model="email" class="field" type="email" autocomplete="email" required />
<small v-if="error?.fieldError('email')" class="field-error">
{{ error.fieldError('email') }}
</small>
+2 -2
View File
@@ -141,7 +141,7 @@ async function onRemovePasskey(passkey: Passkey) {
<form class="form" @submit.prevent="onChangeEmail">
<label>
<span>New email</span>
<input v-model="newEmail" type="email" maxlength="255" required />
<input v-model="newEmail" class="field" type="email" maxlength="255" required />
<small v-if="changeError?.fieldError('email')" class="field-error">
{{ changeError.fieldError('email') }}
</small>
@@ -197,7 +197,7 @@ async function onRemovePasskey(passkey: Passkey) {
<form class="form form--new-card" @submit.prevent="onAddPasskey">
<label>
<span>Label</span>
<input v-model="newLabel" type="text" maxlength="100" />
<input v-model="newLabel" class="field" type="text" maxlength="100" />
</label>
<p v-if="addError" class="form-error">{{ addError }}</p>
<button type="submit" :disabled="adding">
+3 -1
View File
@@ -201,6 +201,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
<form class="field-row" @submit.prevent="onRenameProject">
<input
v-model="nameDraft"
class="field field--compact"
type="text"
maxlength="255"
required
@@ -251,6 +252,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
<form class="field-row" @submit.prevent="onAddStatus">
<input
v-model="newStatusName"
class="field field--compact"
type="text"
maxlength="100"
required
@@ -282,7 +284,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
<label class="modal__field">
<span>Move cards to</span>
<select v-model="reassignTo">
<select v-model="reassignTo" class="field">
<option value="" disabled>Choose a status</option>
<option v-for="s in reassignOptions" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
+1 -1
View File
@@ -194,7 +194,7 @@ async function onCreate() {
<form class="form form--new-card" @submit.prevent="onCreate">
<label>
<span>New card</span>
<input v-model="newText" type="text" maxlength="1000" required />
<input v-model="newText" class="field" type="text" maxlength="1000" required />
</label>
<button type="submit" :disabled="submitting">
{{ submitting ? 'Adding…' : 'Add card' }}