mirror of
https://github.com/we-promise/sure.git
synced 2026-07-16 14:55:23 +00:00
* feat(ds): one canonical focus ring across primitives (#2136) Replaces the grab-bag of per-primitive focus indicators (neutral ring-alpha-black/white, outline-gray-900/white, faint form-field ring-4) with a single recipe — the #1737 accessibility follow-up. - New --color-focus-ring token: blue-600 (light) / blue-500 (dark), >=4:1 against both surfaces. - Canonical .focus-ring / .focus-ring-within in components.css: a 2px outline + 2px offset on :focus-visible only. Outline (not a box-shadow ring) so the offset gap is transparent on any surface with no layout shift; :focus-visible so it never shows for mouse/touch. - Applied to every focusable DS primitive: Button (had none), Link, Disclosure summary, Tabs nav, MenuItem (replaces the browser-default box), SearchInput, Tooltip trigger, Popover trigger, Select panel (focus-within), Toggle (peer-driven outline-focus-ring). .form-field adopts it via :focus-within, replacing the ~1:1 ring-4. - Dialog close button is a DS::Button icon variant, so it inherits the focus-visible-only ring and keeps no resting border (fixes "stuck ring"). Verified in-browser, light+dark: focus-visible ring on button, input, and full-width menu row — consistent blue 2px+offset, legible on both surfaces. Remaining follow-up: >=44px touch targets (disclosure trigger, composer send); bespoke notification / account-new close buttons that still carry a permanent border. * fix(ds): #2136 interactive-state follow-ups — touch target + close-button chrome - Disclosure default trigger: add min-h-11 (44px) so the standalone disclosure summary clears the touch-target minimum (was px-3 py-2 ~36px). Composer send + the coming-soon icons are already DS::Button icon/md (w-11 h-11). - Notification close buttons (sync_toast, notice): drop the resting border-alpha-black-50 box ("frame shouts, glyph muted"); keep a bg-container + shadow-xs chip so the corner control stays visible over the page, and brighten the muted glyph on hover (text-subdued -> hover:text-primary). * refactor(ds): focus ring -> neutral hugging box-shadow (was blue outline) Per design feedback: the blue 2px outline + 2px offset read as a loud, detached frame on the otherwise-neutral UI. Switch the canonical .focus-ring to a soft box-shadow ring that hugs the control (follows border-radius, no gap), in the theme-aware neutral focus-ring token (alpha-black/white-400). Transparent outline kept as a forced-colors fallback; toggle peer-driver switched from outline-* to ring-* to match. Still one token, :focus-visible only. Strength is tunable (currently subtle ~1.5:1). * fix(ds): focus ring vanished on shadowed controls — outline, not box-shadow The neutral box-shadow ring lived in the components layer, so any utility-layer shadow-* (or .form-field's focus-within:shadow-none) on the same element overrode it and the ring silently disappeared on shadowed buttons/inputs. Draw the same subtle neutral ring with a hugging `outline` (outline-offset: 0) instead — a separate property with no box-shadow conflict, and it doubles as the forced-colors indicator. Toggle peer-driver switched ring-* -> outline-* to match. Look is unchanged (neutral, hugging, subtle); it just no longer vanishes. * fix(a11y): enlarge sync-toast close-button touch target (p-0.5 -> p-1.5) The hover-revealed close button had ~2px padding around a 20px icon (~24px total), at the WCAG 2.5.8 AAA boundary. p-1.5 brings the interactive area to ~32px. Addresses CodeRabbit review on #2140. * fix(ds): keep form-field's resting halo; stop the outline color flash Two testing findings: - .form-field reverts to its original always-on soft ring (focus-within ring-4 at low alpha, theme-aware) instead of adopting the keyboard-only outline. It's a resting decoration, not a focus indicator, and the lower-opacity halo was the better look. The canonical block's comment documents the deliberate opt-out. - .focus-ring/.focus-ring-within now carry a base transparent 2px outline so consumers with transition-all (form-field had it) animate transparent -> token on focus instead of passing through currentColor, which flashed as a black border appearing and then fading out. * feat(ds): focus-ring token clears WCAG 3:1 non-text contrast alpha-black-400 (20%) measured ~1.6:1 against white — visible but below the AA bar for focus indicators. Bump to the 700 stop (50%): ~3.95:1 on light containers, ~4.6:1 on dark. Recipe unchanged; one token edit via tokens:build. * fix(ds): ring the hand-rolled privacy toggle too The header pair showed two different focus treatments: panel-right (a DS::Button) got the new token ring while the hand-rolled privacy toggle next to it fell back to the browser-default ring — the sweep covered DS primitives but not bespoke buttons. Both privacy toggles (mobile + desktop) now carry .focus-ring. Also documents the transition interplay on the focused-state rule: consumers with transition-colors fade the ring in over 150ms because Tailwind v4's color transition list includes outline-color. Verified settled value at the intended 50% alpha via Playwright. * fix(ds): ring the sidebar and settings nav links The reshoot caught both nav species falling back to the browser's blue default ring — main sidebar items and settings nav items are bespoke link_to markup the primitive sweep missed, and they're the primary keyboard path in the app. Both adopt .focus-ring (main nav adds rounded-lg so the outline follows a shape). * fix(ds): retire the legacy base-layer button ring for the canonical outline The @layer base button rule still painted a ring-2 ring-offset-2 box-shadow on :focus-visible. Box-shadow and outline are independent properties, so .focus-ring (an outline) could never clear it and every button-tag primitive double-painted both indicators on keyboard focus. Apply the canonical recipe to the base button rule itself: every <button> now gets the transparent resting outline + focus-ring token on :focus-visible by default. Two bespoke buttons suppressed the outline with focus:outline-none and relied on the base ring for their keyboard indicator (category dropdown rows, the sign-up password toggle). Drop the suppression so they pick up the canonical outline — :focus-visible keeps it keyboard-only, which is what the suppression was protecting against anyway. * fix(ds): segmented control adopts the canonical focus recipe The segment rule inlined its own focus-visible outline (offset 2, alpha-400 colors) with a comment noting it was temporary until the canonical token landed — this branch is that token. Drop the inlined utilities: button segments get the outline from the base button rule, and link segments now carry .focus-ring.
285 lines
10 KiB
CSS
285 lines
10 KiB
CSS
@layer components {
|
|
/*
|
|
Canonical keyboard-focus indicator (#2136). One recipe for every focusable
|
|
primitive: a neutral 2px `outline` that hugs the control (`outline-offset: 0`
|
|
so it sits right at the edge and follows the border-radius), in the
|
|
theme-aware `focus-ring` token.
|
|
|
|
`outline` — NOT box-shadow — on purpose: a components-layer box-shadow loses
|
|
to any utility-layer `shadow-*` (or `focus-within:shadow-none` on the field)
|
|
on the same element, so the ring silently vanished on shadowed controls.
|
|
`outline` is a separate property with no such conflict, and it doubles as the
|
|
forced-colors (Windows high-contrast) indicator. `:focus-visible` only, so it
|
|
never shows for mouse/touch. `.focus-ring-within` mirrors it onto a wrapper
|
|
whose focus lands on a child input. `.form-field` deliberately does NOT
|
|
adopt it: form fields keep their original always-on soft halo (ring-4 at
|
|
low alpha) — a resting decoration, not a keyboard-only indicator.
|
|
*/
|
|
/* Base: an always-present transparent outline so consumers that carry
|
|
`transition-all` (or any transition touching outline-color) animate
|
|
transparent -> token instead of passing through `currentColor` — the
|
|
latter reads as a black border flashing in and out. */
|
|
.focus-ring,
|
|
.focus-ring-within {
|
|
outline: 2px solid transparent;
|
|
outline-offset: 0;
|
|
}
|
|
|
|
.focus-ring:focus-visible,
|
|
.focus-ring-within:focus-within {
|
|
/* Note: consumers carrying `transition-colors` animate this in over
|
|
150ms — Tailwind v4's color transition list includes outline-color.
|
|
Combined with the transparent base above, that reads as a quick
|
|
fade-in rather than a pop, never a flash through currentColor. */
|
|
outline-color: var(--color-focus-ring);
|
|
}
|
|
|
|
/* Forms */
|
|
.form-field {
|
|
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-container border-secondary shadow-xs w-full;
|
|
@apply focus-within:border-secondary focus-within:shadow-none focus-within:ring-4 focus-within:ring-alpha-black-200;
|
|
@apply transition-all duration-300;
|
|
|
|
@variant theme-dark {
|
|
@apply focus-within:ring-alpha-white-300;
|
|
}
|
|
|
|
/* Add styles for multiple select within form fields */
|
|
select[multiple] {
|
|
@apply py-2 pr-2 space-y-0.5 overflow-y-auto;
|
|
|
|
option {
|
|
@apply py-2 rounded-md;
|
|
}
|
|
|
|
option:checked {
|
|
@apply after:content-['\2713'] bg-container-inset after:text-gray-500 after:ml-2;
|
|
}
|
|
|
|
option:active,
|
|
option:focus {
|
|
@apply bg-container-inset;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* New form field structure components */
|
|
.form-field__header {
|
|
@apply flex items-center justify-between gap-2;
|
|
}
|
|
|
|
.form-field__body {
|
|
@apply flex flex-col gap-1;
|
|
}
|
|
|
|
.form-field__actions {
|
|
@apply flex items-center gap-1;
|
|
}
|
|
|
|
.form-field__label {
|
|
@apply block text-xs text-secondary peer-disabled:text-subdued;
|
|
}
|
|
|
|
.form-field__input {
|
|
@apply text-primary border-none bg-container text-sm opacity-100 w-full p-0;
|
|
@apply focus:opacity-100 focus:outline-hidden focus:ring-0;
|
|
@apply placeholder-shown:opacity-50;
|
|
@apply disabled:text-subdued;
|
|
@apply text-ellipsis overflow-hidden whitespace-nowrap;
|
|
@apply transition-opacity duration-300;
|
|
@apply placeholder:text-subdued;
|
|
|
|
@variant theme-dark {
|
|
&::-webkit-calendar-picker-indicator {
|
|
filter: invert(1);
|
|
cursor: pointer;
|
|
}
|
|
}
|
|
}
|
|
|
|
textarea.form-field__input {
|
|
@apply whitespace-normal overflow-auto;
|
|
text-overflow: clip;
|
|
}
|
|
|
|
.form-field__input--multiselect-trigger {
|
|
@apply whitespace-normal overflow-visible;
|
|
text-overflow: clip;
|
|
}
|
|
|
|
select.form-field__input,
|
|
button.form-field__input {
|
|
@apply pr-10 appearance-none;
|
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
|
background-position: right -0.15rem center;
|
|
background-repeat: no-repeat;
|
|
background-size: 1.25rem 1.25rem;
|
|
text-align: left;
|
|
}
|
|
|
|
.form-field__radio {
|
|
@apply text-primary;
|
|
}
|
|
|
|
.form-field__submit {
|
|
@apply cursor-pointer rounded-lg bg-surface p-3 text-center text-white hover:bg-surface-hover;
|
|
}
|
|
|
|
/* Checkboxes */
|
|
.checkbox {
|
|
&[type='checkbox'] {
|
|
@apply rounded-sm;
|
|
@apply transition-colors duration-300;
|
|
}
|
|
}
|
|
|
|
.checkbox--light {
|
|
&[type='checkbox'] {
|
|
@apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-300 hover:bg-gray-300;
|
|
}
|
|
|
|
&[type='checkbox']:disabled {
|
|
@apply cursor-not-allowed opacity-80 bg-gray-50 border-gray-200 checked:bg-gray-400 checked:ring-gray-400;
|
|
}
|
|
|
|
@variant theme-dark {
|
|
&[type='checkbox'] {
|
|
@apply ring-gray-900 border-alpha-white-300;
|
|
background-color: transparent;
|
|
}
|
|
|
|
&[type='checkbox']:disabled {
|
|
@apply cursor-not-allowed opacity-80 border-transparent;
|
|
background-color: var(--color-gray-700);
|
|
}
|
|
|
|
&[type='checkbox']:checked,
|
|
&[type='checkbox']:indeterminate {
|
|
@apply border-transparent;
|
|
background-color: var(--color-gray-100);
|
|
}
|
|
|
|
&[type='checkbox']:checked {
|
|
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23171717' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
|
}
|
|
|
|
&[type='checkbox']:indeterminate {
|
|
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23171717' xmlns='http://www.w3.org/2000/svg'%3e%3crect x='3.5' y='7' width='9' height='2' rx='1'/%3e%3c/svg%3e");
|
|
}
|
|
}
|
|
}
|
|
|
|
.checkbox--dark {
|
|
&[type='checkbox'] {
|
|
@apply ring-gray-900 checked:text-white;
|
|
}
|
|
|
|
&[type='checkbox']:disabled {
|
|
@apply cursor-not-allowed opacity-80 ring-gray-600;
|
|
}
|
|
|
|
&[type='checkbox']:checked {
|
|
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
|
}
|
|
}
|
|
|
|
/* Tooltips */
|
|
.tooltip {
|
|
@apply hidden absolute;
|
|
}
|
|
|
|
.qrcode svg path {
|
|
fill: var(--color-black);
|
|
@variant theme-dark {
|
|
fill: var(--color-white);
|
|
}
|
|
}
|
|
|
|
/*
|
|
Segmented control (#2137) — track + equal-footprint segments. The selected
|
|
state is a single `--active` class so an optional Stimulus controller can
|
|
toggle it as one unit. Values mirror the DS tab tokens (tab-bg-group /
|
|
tab-item-active / tab-item-hover) but are inlined with `@variant theme-dark`
|
|
because `@apply`-ing a custom utility drops its dark override. The near-black
|
|
dark track (alpha-black-700) is what makes the gray-700 selected pill read —
|
|
the bespoke controls used a too-light `container-inset` track, hence the
|
|
audit's "selected pill invisible on dark." Focus comes from the canonical
|
|
recipe (`focus-ring` on link segments, the base button rule on button
|
|
segments), not from this block.
|
|
*/
|
|
.segmented-control {
|
|
@apply inline-flex items-center gap-0.5 p-1 rounded-lg bg-gray-50;
|
|
@variant theme-dark {
|
|
@apply bg-alpha-black-700;
|
|
}
|
|
}
|
|
|
|
.segmented-control__segment {
|
|
@apply inline-flex items-center justify-center px-2 py-1 rounded-md whitespace-nowrap;
|
|
@apply text-sm font-medium text-secondary cursor-pointer transition-colors duration-200;
|
|
@apply hover:bg-gray-200;
|
|
@variant theme-dark {
|
|
@apply hover:bg-gray-800;
|
|
}
|
|
}
|
|
|
|
.segmented-control__segment--active {
|
|
@apply bg-white text-primary shadow-sm hover:bg-white;
|
|
@variant theme-dark {
|
|
@apply bg-gray-700 hover:bg-gray-700;
|
|
}
|
|
}
|
|
|
|
/*
|
|
Horizontally scrollable table wrapper (#2137). `overflow-x: auto` so wide
|
|
tables scroll instead of clipping (the LLM-usage table was `overflow-hidden`
|
|
and pushed columns off-screen) or wrapping money mid-digit. The pure-CSS
|
|
"scroll shadow" gives the missing affordance: the cover gradients
|
|
(`--table-scroll-bg`, default container-inset) scroll WITH the content
|
|
(`background-attachment: local`) and hide the fixed shadow gradients at the
|
|
edges, so a soft edge-shadow only appears when there is more to scroll.
|
|
Theme-aware via `--color-shadow`. Set `--table-scroll-bg` to match the wrapper.
|
|
*/
|
|
.table-scroll {
|
|
--table-scroll-bg: var(--color-container-inset);
|
|
overflow-x: auto;
|
|
background:
|
|
linear-gradient(to right, var(--table-scroll-bg) 30%, transparent),
|
|
linear-gradient(to left, var(--table-scroll-bg) 30%, transparent) 100% 0,
|
|
radial-gradient(farthest-side at 0 50%, var(--color-shadow), transparent),
|
|
radial-gradient(farthest-side at 100% 50%, var(--color-shadow), transparent) 100% 0;
|
|
background-repeat: no-repeat;
|
|
background-color: var(--table-scroll-bg);
|
|
background-size: 32px 100%, 32px 100%, 12px 100%, 12px 100%;
|
|
background-attachment: local, local, scroll, scroll;
|
|
}
|
|
|
|
/*
|
|
Chart hover tooltip surface (see utils/chart_tooltip.js for the JS-side
|
|
contract). Matches the design reference exactly: hairline border ring
|
|
composed with a soft 8/24 drop shadow (Tailwind shadow utilities don't
|
|
compose, hence the component class), 10px radius, 10x12 padding. Dark
|
|
mode swaps the ring to alpha-white; the drop shadow is near-invisible
|
|
there, which is fine — the ring carries the edge.
|
|
|
|
No position transition here on purpose: cursor-following tooltips
|
|
(sankey, time-series) update left/top every mousemove and a transition
|
|
makes them trail the pointer. Snap-positioned tooltips (goal projection,
|
|
which jumps between dates) opt into the 80ms glide via inline style.
|
|
*/
|
|
.chart-tooltip {
|
|
background: var(--color-container);
|
|
border-radius: 10px;
|
|
padding: 10px 12px;
|
|
box-shadow:
|
|
0 0 0 1px var(--color-alpha-black-50),
|
|
0 8px 24px rgba(11, 11, 11, 0.12);
|
|
|
|
@variant theme-dark {
|
|
box-shadow:
|
|
0 0 0 1px var(--color-alpha-white-50),
|
|
0 8px 24px rgba(11, 11, 11, 0.12);
|
|
}
|
|
}
|
|
}
|