fix(cards): make the feed card floating action bar accessible in both themes - #6398
Conversation
… themes The bar floats over a post's cover image and its fill was `--theme-blur-blur-bg` at 64% — pepper.90 in dark mode, white in light mode. At 64% the cover composites through and sets the contrast, so a single icon measured anywhere from 1.0:1 to 11:1 depending on the image, and in light mode over a bright cover the whole control dissolved (WCAG SC 1.4.11 wants >= 3:1 for icons and UI component boundaries). Fill goes to 90% via `color-mix(var(--theme-background-default), transparent 10%)`, which resolves per theme on its own: a dark bar in dark mode, a white one in light mode. The pill's inline 8% black tint is dropped, which matters as much as the opacity — it lifts the light surface to near-white and so raises every dark accent's ratio. Pressed accents are re-toned per theme. They come from `--button-pressed-color`, a raw palette hex from the ghost ladder in `tailwind/buttons.ts` (light .80 / dark .40), not from a `--theme-*` token, so they need explicit overrides. Light takes each ramp's darkest step; avocado, blueCheese and cheese are too luminous even at .90 and take the smallest mix with pepper.90 that clears 3:1. Dark moves downvote (ketchup.40 -> .10) and bookmark (bun.40 -> .20) up the ramp — a pressed downvote was 1.62:1 over a white cover, so this was never a light-mode-only bug. Worst cell after the change: 3.01:1 light, 3.67:1 dark, and the ratios barely move across covers instead of swinging by 10x. Also removes the bottom-left cover scrim, a leftover from when the bar was compact and hover-revealed; it is redundant now that the bar is always full width, and it was darkening the bar's left end. Known remaining gap: the pill's own outline. A near-white fill cannot carry the component boundary on a light cover, and `border-border-subtlest-tertiary` resolves to salt.90 @20% = 1.14:1 there. The border is deliberately left on the default token to keep the familiar look. The Storybook page "Components/Cards/Glass Actions Theme Compare" holds the record: a cover-colour stress test, an active/pressed state matrix, and a contrast audit that computes the WCAG ratios in-page. Its "after" columns render the live component with no overrides, so the page stays honest as the component changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
rebelchris
left a comment
There was a problem hiding this comment.
Solid, well-evidenced accessibility fix — the fill-opacity diagnosis is right and the per-theme accent re-toning is the correct layer to fix it at. One blocking gap on the auto (system) theme, plus a few non-blocking cleanups. Details are inline.
Verified: root/packages/shared AGENTS.md read, scope matches the task (all four coverScrim consumers removed, no stragglers), selector groups diffed against base.css, palette steps and the tertiary ghost ladder in tailwind/buttons.ts checked value by value, CI green (lint/tests/strict typecheck/build).
Not verified by me: the rendered ratios (I read the values, not the pixels) and extension/companion sanity, which the PR checklist also leaves unticked.
Reviewed by AI.
| html .invert .invert .feed-card-glass-actions, | ||
| #daily-companion-wrapper .feed-card-glass-actions, | ||
| #daily-companion-wrapper.light .invert .feed-card-glass-actions, | ||
| #daily-companion-wrapper .invert .invert .feed-card-glass-actions { |
There was a problem hiding this comment.
Blocking: the auto theme isn't covered, and it lands in the worst possible combination.
base.css handles three theme states, not two: the html.light / html groups here, plus html.auto (+ html.auto .invert) resolved through @media (prefers-color-scheme: …). auto is a real user setting (ThemeMode.Auto in SettingsContext, applied as a class on <html>).
For a user on Settings → theme: system with a light OS:
- the fill resolves correctly to white, because
--theme-background-defaultflips inside theprefers-color-scheme: lightmedia query; - but this dark group's first selector,
html .feed-card-glass-actions, still matcheshtml.auto, while the light group'shtml.light …does not.
So the accents stay on the dark values (avocado.40, blueCheese.40, cheese.40, …) over a near-white bar — exactly the 1.0–2.9:1 failure this PR is fixing, and arguably worse than before since the fill is now 90% opaque instead of 64%.
Mirroring base.css should fix it — add the html.auto selectors to each group inside the matching @media (prefers-color-scheme: …) blocks (and html.auto .invert to the opposite group).
Reviewed by AI.
There was a problem hiding this comment.
Confirmed and fixed in 0dbab49 — thanks, this was a real bug and your diagnosis of the mechanism was exactly right.
I reproduced it before fixing: with html.auto and a light OS, the fill resolved to rgba(255,255,255,0.9) while the accents stayed on #57E087 / #F57869. So the worst combination, as you said, and worse than before because of the higher opacity.
One extra wrinkle worth recording: adding the html.auto selectors to the existing groups was not enough on its own. The nested & .btn-tertiary-* rules were the reason. Per spec a nested & compiles to :is(<parent selector list>), and :is() takes the specificity of its most specific argument — so the #daily-companion-wrapper entries in those lists handed every nested rule ID-level specificity, which outranked the new html.auto media blocks and left them inert. My first attempt at this fix looked correct and changed nothing.
So the file is now restructured to avoid that class of bug entirely:
- The theme groups are flat and only assign
--glass-actions-*variables on the pill. Flat comma lists don't share specificity between their selectors, so an ID in one entry can't leak into the others. - One theme-agnostic block maps those variables onto
--button-pressed-color. It qualifies withhtmlplus a doubled class purely to outrank the DS's own.light .invert .btn-tertiary-*(0,3,0) without depending on import order. prefers-color-schemeblocks now mirrorbase.css, including the.invertinversions.
Verified by reading computed values back out of the browser across light / dark / auto × both OS preferences × .invert subtrees — in all of them the fill polarity and the accent polarity now match. Also grepped the compiled webapp and extension bundles to confirm the media queries and mixins survive both PostCSS pipelines.
| #daily-companion-wrapper .invert .invert .feed-card-glass-actions { | ||
| & .btn-tertiary-avocado { | ||
| --button-pressed-color: theme('colors.raw.avocado.40'); | ||
| --button-hover-color: theme('colors.raw.avocado.40'); |
There was a problem hiding this comment.
Non-blocking: six of these twelve rules are no-ops that restate tailwind/buttons.ts.
The tertiary ghost ladder resolves pressed (and hover) to palette[color]['40'] in dark for every colour except burger/onion, and to palette[color]['60'] in light for every colour outside the luminous set. So:
- dark:
avocado.40,blueCheese.40,cabbage.40,cheese.40are identical to what already ships — onlybun.20andketchup.10are actual deviations; - light:
ketchup.60andcabbage.60are identical — only bun/avocado/blueCheese/cheese actually move.
The cost isn't bytes, it's that the file reads as if all six hues were tuned, and a future change to the ghost ladder gets silently shadowed here for the four colours that were fine. Keeping only the real deviations (with the rest inheriting) makes the intent legible and keeps the component in step with the ladder.
Reviewed by AI.
There was a problem hiding this comment.
You're right on every value — I checked each one against the ladder before changing anything, and it came out exactly as you described:
| colour | dark ladder | mine | light ladder | mine |
|---|---|---|---|---|
| avocado | .40 | .40 — no-op | .80 | 83% mix — deviates |
| blueCheese | .40 | .40 — no-op | .80 | 90% mix — deviates |
| bun | .40 | .20 | .80 | .90 |
| ketchup | .40 | .10 | .60 | .60 — no-op |
| cabbage | .40 | .40 — no-op | .60 | .60 — no-op |
| cheese | .40 | .40 — no-op | .80 | 62% mix — deviates |
Fixed in 2305582:
copy(cabbage) dropped entirely — it was a no-op in both themes, so it now inherits the ladder outright. I checked it still clears 3:1 inherited, including the ladder's own active step which I wasn't overriding before: light .60/.70 → worst 3.96 / 4.47, dark .40/.30 → worst 3.79 / 4.29.- The four remaining ladder-equal pairs now reference
var(--theme-accent-<colour>-default)instead of repeating a palette step. That token is the ladder's value in that theme (accent-default is .40 in dark and .60 in light), so there's no longer a second constant to drift. I kept the mapping present for those colours because each of them genuinely deviates in the other theme, and avar()fallback can't express "don't override" without naming the value I'm trying not to duplicate. - Added a comment listing exactly which accents move, so the tuned set is legible without cross-referencing
buttons.ts.
One caveat I'd rather state than leave implicit: this does mean a change to the ladder's mapping (say dark hover moving .40 → .30) would flow straight through to the bar without anything failing, since there's no automated contrast test. The header comment now says the audited numbers only hold for the steps it names, so re-running the Storybook audit is the trigger. If you'd prefer that guarded properly, a test that asserts the computed ratios would be the way — happy to add it as a follow-up if you think it's worth the weight.
| var(--theme-background-default), | ||
| transparent 10% | ||
| ); | ||
| } |
There was a problem hiding this comment.
Non-blocking: --button-active-color isn't overridden alongside pressed/hover.
buttons.css uses a separate var for :active (--button-color: var(--button-active-color), and the .btn-quaternary label rule does the same), and the ladder's active step differs from hover/pressed — light avocado active is avocado.90, which by this PR's own measurement doesn't clear 3:1 (hence the 83% mix for pressed). So while the pointer/finger is held down the icon dips back under threshold.
It's transient and only during the press, so not a blocker, but it's one more line per rule if you want the state matrix to be internally consistent.
Reviewed by AI.
There was a problem hiding this comment.
Good catch, fixed in 2305582 — --button-active-color is now pinned alongside pressed and hover on all five overridden colours.
Your reasoning checks out: light avocado's active step is avocado.90, which measures 2.79:1 on the 90% white bar — that's precisely the value the 83% mix exists to get past, so holding the pointer down dropped the icon back under threshold. Transient, but it's a state the user is deliberately in while tapping, and it's a per-colour value so there was no reason to leave it inconsistent.
All three states now share one value per colour. There isn't contrast headroom for a distinct active shade here — the tuned mixes sit at 3.01–3.08:1 in light, so any further step along the ramp goes under — so a flat state matrix is the honest outcome rather than a shortcut. The pressed/hover/active distinction still reads through the filled-vs-outline glyph and the button's own background change.
copy (cabbage) is excluded from this because it's no longer overridden at all (see the other thread) — I verified its inherited active step clears 3:1 on its own: 4.47 light, 4.29 dark.
| 'feed-card-glass-actions', | ||
| 'pointer-events-auto flex h-10 w-full items-center justify-between overflow-hidden pl-1 pr-2.5', | ||
| 'rounded-12 border border-border-subtlest-tertiary', | ||
| 'text-text-primary backdrop-blur-xl backdrop-saturate-150', |
There was a problem hiding this comment.
Non-blocking / question: with the fill now at 90% and the 8% tint gone, backdrop-blur-xl backdrop-saturate-150 is doing very little visually while still forcing a backdrop-filter compositing layer on every card in the feed. Worth checking on a long feed whether dropping them (or stepping down to a cheaper blur) is visually detectable — this is a hot path.
Related product question: between the 90% fill and the removed scrim, the control is effectively opaque now rather than glass. Has design signed off on that look, or is the intent to keep the glass language and solve contrast some other way (e.g. carrying it on the outline instead)? That also ties into the known-gap outline note in the description — a 1.14:1 boundary is still an SC 1.4.11 miss, so it'd be good to know whether that's a follow-up ticket or accepted.
Reviewed by AI.
There was a problem hiding this comment.
Three separate things here — taking them in turn, and I've left the code unchanged on all three deliberately.
1. The blur. You're right that it's doing very little now. At 90% fill only a tenth of the cover reaches it, so the visible difference is a soft-vs-sharp ghost that's really only detectable on a hard edge (a split light/dark cover), and it still forces a backdrop-filter compositing layer per card in the feed. I haven't profiled a long feed, so I won't claim a verdict either way — I'd be guessing. Kept for now because the frosted read is the reason the bar isn't fully opaque: the 90% (rather than 100%) came from wanting a hint of the cover to show through, and dropping the filter while keeping the transparency gives the worst of both — a faint sharp ghost. If we want the perf back, the coherent move is 100% opaque and no filter, not 90% without it. Happy to do that, or step the blur down, on a word from design.
2. Design sign-off. To be accurate rather than reassuring: this wasn't signed off in a separate design review. The direction was set by @tsahi iteratively against the Storybook comparison page — dark-glass-in-light-mode was explicitly rejected as "too prominent in the entire field", then white, then flat/solid, then 90% and the default border. So it's a product decision made with the measured page in front of him, not a designer hand-off. Worth someone from design confirming the language shift (glass → near-opaque surface) applies to every card type, since the same bar renders on collection/freeform/poll/share/hero cards too.
3. The outline. Accepted, not overlooked — but you're right that it's still a miss, and I'd rather it be a ticket than a paragraph in a PR body. The boundary measures 1.14:1 on white, 1.26:1 on yellow, 2.02:1 on green, because border-border-subtlest-tertiary is salt.90 at 20% and a 90% white fill can't carry the edge either (white-on-white is 1.00:1). The fix is known and small: setting --theme-border-subtlest-tertiary to full-strength pepper.10 on the pill gives 7.13 / 5.51 / 3.26. It was left out only because the default border was an explicit request to keep the familiar look. I'll raise it as a follow-up so it isn't lost — and note the same gap exists on the shipped bar today (1.14 / 1.37 / 1.10), so it's pre-existing rather than introduced here.
Also flagging your "not verified by me" note: the rendered ratios are computed live in the Storybook audit page rather than asserted from my side either, so the pixels are worth a human look. Extension and companion sanity is genuinely unticked — the companion can't contain feed cards (it renders only its own components, no grid/card), so the #daily-companion-wrapper selectors are defensive, but I've only verified the extension via its production build and test suite, not by loading the new tab.
The per-theme accent rules missed `html.auto`, so a user on the Auto theme with a light OS preference got a white bar (the fill follows `--theme-background-default`, which the auto media queries do set) but DARK-mode accents on top of it — avocado.40 at roughly 1.4:1, i.e. the original bug, for exactly the users the fix was meant to cover. Two changes: - Add `prefers-color-scheme` blocks for `html.auto`, mirroring the ones in `base.css`, including the `.invert` inversions. - Split the file into flat "values" rules that only assign `--glass-actions-*` on the pill, plus one theme-agnostic block that maps those onto each button's `--button-pressed-color`. The previous version nested `& .btn-tertiary-*` inside the theme selector lists, and per spec a nested `&` is `:is(<parent list>)` whose specificity is that of its most specific argument — so the `#daily-companion-wrapper` entries gave every nested rule ID-level specificity, which outranked the `html.auto` blocks and made them silently inert. Flat comma lists don't share specificity between their selectors, so the theme groups now compete only on their own terms. Verified by reading computed values back out of the browser for light, dark and auto (under both OS preferences) and for `.invert` subtrees: in every combination the fill polarity and the accent polarity now match. Also confirmed the rules survive both PostCSS pipelines by grepping the compiled webapp and extension bundles. Adds a regression test pinning the `feed-card-glass-actions` class to the rendered pill: the entire fix is delivered through that class, and dropping it reverts the bar to the inaccessible styling without failing anything else. Confirmed the test fails when the class is removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e ladder Review feedback on #6398. Six of the twelve accent overrides restated what `tailwind/buttons.ts` already ships. The `tertiary` ghost ladder resolves pressed/hover to palette .40 in dark, and .80 in light for the luminous families or .60 for the rest, so `cabbage` matched in both themes, `avocado`/`blueCheese`/ `cheese` matched in dark, and `ketchup` matched in light. Restating them made the file read as if all six hues had been tuned, and would have silently shadowed any future change to the ladder for the four that were already fine. - `copy` (cabbage) is dropped entirely and now inherits the ladder. Verified it still clears 3:1 in both themes for pressed *and* the ladder's own active step (worst cell 3.79). - The four remaining theme/colour pairs that match the ladder now reference `var(--theme-accent-<colour>-default)` instead of repeating a palette step. That token is the ladder's value in that theme (accent-default is .40 in dark, .60 in light), so there is no second constant to keep in sync. - A comment now states exactly which accents deviate and why, so the tuned set is legible at a glance. Also pins `--button-active-color` alongside pressed/hover. `buttons.css` routes `:active` through its own var and the ladder's active step is one shade further along — light avocado active is avocado.90, which measures 2.79:1, so the icon dipped back under threshold for as long as the pointer was held. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks for the review — all four addressed, two commits. Blocking ( Ladder no-ops — verified value by value, you were right on all six. Fixed in 2305582:
Blur / design / outline — no code change, answered inline. Short version: the blur is kept because 90%-without-filter is the worst of both, and I haven't profiled a long feed so I won't claim a perf verdict; the look was directed by @tsahi against the Storybook audit page rather than signed off in a design review, so the language shift is worth a designer's eye; the outline gap is real, pre-existing on the shipped bar too, and I'll raise it as a follow-up rather than leave it as a PR-body note. Re-ran after the changes: shared lint, shared 30 suites / 217 tests, webapp 45 / 336, strict typecheck, extension production build, and grepped the compiled bundle to confirm the output. Your two "not verified" points stand — the rendered pixels and an extension new-tab load are still worth a human pass. |
Brings in #6398 (feed card glass action bar contrast), merged into the PR branch on GitHub while these fixes were in progress. No conflicts. Mockup-to-eng-pass: 1
Changes
The feed card floating action bar floats over a post's cover image, and its fill was
--theme-blur-blur-bgat 64% — pepper.90 in dark mode, white in light mode. At 64% the cover composites through and sets the contrast, so one icon measured anywhere from 1.0:1 to 11:1 depending on the image, and in light mode over a bright cover the whole control dissolved. WCAG SC 1.4.11 asks for ≥ 3:1 on icons and UI component boundaries.color-mix(in srgb, var(--theme-background-default), transparent 10%). One expression covers both themes, since that token already flips (pepper.90 dark / salt.0 light): a dark bar in dark mode, a flat white one in light mode. The pill's inline 8% black tint is dropped, which matters as much as the opacity — it lifts the light surface to near-white and so raises every dark accent's ratio.--button-pressed-color, a raw palette hex from the ghost ladder intailwind/buttons.ts(light.80/ dark.40), not from a--theme-*token, so they need explicit overrides. Light takes each ramp's darkest step; avocado, blueCheese and cheese are too luminous even at.90and take the smallest mix with pepper.90 that clears 3:1. Dark moves downvoteketchup.40 → .10and bookmarkbun.40 → .20.All of it lives in
styles/components/feedCardGlassActions.css(imported fromcomponents.css, scoped to afeed-card-glass-actionsclass). The theme blocks assign--glass-actions-*variables on the pill as flat rules, and one theme-agnostic block maps those onto each button's--button-pressed-color. That split matters: an earlier version nested& .btn-tertiary-*inside the theme selector lists, and per spec a nested&is:is(<parent list>)whose specificity is that of its most specific argument — so the#daily-companion-wrapperentries handed every nested rule ID-level specificity, which silently outranked thehtml.autoblocks. Flat comma lists don't share specificity between their selectors.Selector groups mirror
base.css, including theprefers-color-schemeblocks forhtml.autoand the.invertinversions, so every theme mode resolves with the fill and the accents in the same polarity. Verified by reading computed values out of the browser for light / dark / auto (under both OS preferences) and inside.invertsubtrees. It has to be a stylesheet rather than utilities because the two themes need genuinely different accent values and there's nolight:Tailwind variant here. No hex in TSX, no changes to global tokens inbase.css.Measured result
Worst cell across white / near-black / saturated-green / yellow covers, computed (not eyeballed):
A pressed downvote was 1.62:1 in dark mode over a white cover, so this was never a light-mode-only bug. The ratios now barely move across covers instead of swinging by 10×.
Known remaining gap
The pill's own outline. A near-white fill can't carry the component boundary on a light cover (white fill vs a white cover is 1.00:1), and
border-border-subtlest-tertiaryresolves to salt.90 @20% = 1.14:1 there. The border was deliberately left on the default token to keep the familiar look; full-strength pepper.10 would fix it (7.13 / 5.51 / 3.26 on white / yellow / green). Documented in the CSS header and flagged in the Storybook audit's Boundary row.Storybook
Components/Cards/Glass Actions Theme Compareis the record: a 10-cover colour stress test, an active/pressed state matrix, and a contrast audit that computes the WCAG ratios in-page. The "after" columns render the live component with no overrides, so the page stays honest as the component changes; the "before" columns restore the old values via scoped CSS.Events
No new tracking events.
Experiment
No new experiments. Rides the existing
feed_card_glass_actionsflag.Manual Testing
On those affected packages:
base.css's selector groups, including the#daily-companion-wrapperand.invertvariants, so the companion resolves the same wayAutomated: shared 301 suites / 2032 tests, webapp 45 / 336, extension 6 / 43,
lint, changed-file strict typecheck, and both production builds (webapp + extension) — all green. Compiled CSS grepped out of both bundles to confirm the mixins,theme()calls and media queries survive each PostCSS pipeline.A regression test pins the
feed-card-glass-actionsclass to the rendered pill — the whole fix is delivered through that class, so dropping it would revert the bar to the inaccessible styling with nothing else failing. Confirmed the test fails when the class is removed.🤖 Generated with Claude Code
Preview domain
https://claude-floating-action-bar-light.preview.app.daily.dev