diff --git a/VERSION b/VERSION
index fb7385c1..8576367b 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.60.9
+0.60.10
diff --git a/frontend/src/components/ActiveFilterChips.tsx b/frontend/src/components/ActiveFilterChips.tsx
index 625f4235..a6429473 100644
--- a/frontend/src/components/ActiveFilterChips.tsx
+++ b/frontend/src/components/ActiveFilterChips.tsx
@@ -32,7 +32,7 @@ export default function ActiveFilterChips({
onClick={f.remove}
title={t("feed.chips.remove", { name: f.label })}
aria-label={t("feed.chips.remove", { name: f.label })}
- className="group inline-flex items-center gap-1 pl-2.5 pr-1.5 py-1 rounded-full border border-accent/40 bg-accent/10 text-accent text-xs hover:border-accent hover:bg-accent/20 transition"
+ className="chip group pl-2.5 pr-1.5 py-1 text-xs hover:border-accent hover:bg-accent/20 transition"
>
{f.label}
diff --git a/frontend/src/components/ChannelPicker.tsx b/frontend/src/components/ChannelPicker.tsx
index e2fd5225..e09f037b 100644
--- a/frontend/src/components/ChannelPicker.tsx
+++ b/frontend/src/components/ChannelPicker.tsx
@@ -52,7 +52,7 @@ export default function ChannelPicker({
key={id}
onClick={() => toggle(id)}
title={t("sidebar.channelRemove", { name: nameOf(id) })}
- className="inline-flex items-center gap-1 max-w-full pl-2.5 pr-1.5 py-1 rounded-full bg-accent text-accent-fg text-xs shadow-xs hover:opacity-90 transition"
+ className="chip-solid inline-flex items-center gap-1 max-w-full pl-2.5 pr-1.5 py-1 rounded-full text-xs shadow-xs hover:opacity-90 transition"
>
{nameOf(id)}
diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx
index ea6fa675..ee985a8d 100644
--- a/frontend/src/components/ChannelTags.tsx
+++ b/frontend/src/components/ChannelTags.tsx
@@ -22,8 +22,7 @@ interface TagChipProps {
}
function TagChip({ tag, onClick, title }: TagChipProps) {
- const cls =
- "shrink-0 text-3xs px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent max-w-36 truncate";
+ const cls = "chip-solid shrink-0 text-3xs px-1.5 py-0.5 rounded-full max-w-36 truncate";
return onClick ? (
@@ -622,11 +619,8 @@ export default function Channels() {
@@ -667,11 +661,7 @@ export default function Channels() {
setTagFilter((f) => (active ? f.filter((x) => x !== tg.id) : [...f, tg.id]))
}
aria-pressed={active}
- className={`text-xs px-2 py-1 rounded-full border transition ${
- active
- ? "bg-accent text-accent-fg border-accent"
- : "bg-card border-border text-muted hover:text-fg hover:border-accent"
- }`}
+ className="pill text-xs px-2 py-1 rounded-full border"
>
{tg.name}
diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx
index c5dd0c26..5d49bcf6 100644
--- a/frontend/src/components/Feed.tsx
+++ b/frontend/src/components/Feed.tsx
@@ -557,11 +557,7 @@ export default function Feed({
data-testid={`feed-show-${id}`}
onClick={() => setFilters({ ...filters, show: id })}
aria-pressed={filters.show === id}
- className={`text-xs px-3 py-1.5 rounded-full border transition ${
- filters.show === id
- ? "bg-accent text-accent-fg border-accent"
- : "border-border text-muted hover:text-fg hover:border-accent"
- }`}
+ className="pill text-xs px-3 py-1.5 rounded-full border"
>
{t("sidebar.show." + id)}
@@ -575,11 +571,7 @@ export default function Feed({
data-testid={`feed-content-${c.key}`}
onClick={() => setFilters({ ...filters, [c.key]: !on })}
aria-pressed={on}
- className={`text-xs px-3 py-1.5 rounded-full border transition ${
- on
- ? "bg-accent text-accent-fg border-accent"
- : "border-border text-muted hover:text-fg hover:border-accent"
- }`}
+ className="pill text-xs px-3 py-1.5 rounded-full border"
>
{t(c.label)}
diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx
index 92b54547..0f9642b7 100644
--- a/frontend/src/components/NavSidebar.tsx
+++ b/frontend/src/components/NavSidebar.tsx
@@ -193,9 +193,7 @@ export default function NavSidebar({
onClick={() => setPage(p)}
title={slim ? label : undefined}
aria-current={active ? "page" : undefined}
- className={`${rowBase} ${slim ? "justify-center px-0" : ""} relative ${
- active ? "bg-accent text-accent-fg" : "text-muted hover:text-fg hover:bg-card"
- }`}
+ className={`${rowBase} ${slim ? "justify-center px-0" : ""} relative is-current`}
>
@@ -308,11 +306,7 @@ export default function NavSidebar({
onClick={() => setPage("settings")}
title={slim ? t("header.account.settings") : undefined}
aria-current={page === "settings" ? "page" : undefined}
- className={`${rowBase} ${slim ? "justify-center px-0" : ""} ${
- page === "settings"
- ? "bg-accent text-accent-fg"
- : "text-muted hover:text-fg hover:bg-card"
- }`}
+ className={`${rowBase} ${slim ? "justify-center px-0" : ""} is-current`}
>
{!slim && {t("header.account.settings")}}
diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx
index d0daae57..60ca7dac 100644
--- a/frontend/src/components/PlexPlaylistView.tsx
+++ b/frontend/src/components/PlexPlaylistView.tsx
@@ -505,9 +505,8 @@ export default function PlexPlaylistView({
title={label}
aria-label={label}
aria-pressed={layout === v}
- className={`rounded-lg p-2 text-sm transition ${
- layout === v ? "bg-accent/15 text-accent" : "glass-card glass-hover text-muted"
- }`}
+ data-soft="true"
+ className={`pill rounded-lg p-2 text-sm ${layout === v ? "" : "glass-card glass-hover text-muted"}`}
>
{icon}
diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx
index c332e729..2b1ac4b9 100644
--- a/frontend/src/components/PlexSidebar.tsx
+++ b/frontend/src/components/PlexSidebar.tsx
@@ -59,9 +59,7 @@ function MatchToggle({
key={m}
onClick={() => onChange(m)}
aria-pressed={mode === m}
- className={`rounded-full px-2 py-0.5 transition ${
- mode === m ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
- }`}
+ className="pill rounded-full px-2 py-0.5"
>
{t(`plex.filter.match.${m}`)}
@@ -150,8 +148,9 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
@@ -281,11 +272,7 @@ export default function Sidebar({
key={m}
onClick={() => setFilters({ ...filters, tagMode: m })}
aria-pressed={filters.tagMode === m}
- className={`px-2 py-0.5 rounded-full transition ${
- filters.tagMode === m
- ? "bg-accent text-accent-fg"
- : "text-muted hover:text-fg"
- }`}
+ className="pill px-2 py-0.5 rounded-full"
>
{m === "or" ? t("sidebar.any") : t("sidebar.all")}
@@ -392,9 +379,7 @@ export default function Sidebar({
onClick={() => setFilters({ ...filters, scope: s })}
title={t("header.scope." + s + "Tip")}
aria-pressed={filters.scope === s}
- className={`flex-1 inline-flex items-center justify-center gap-1 px-2.5 py-1 rounded-full transition ${
- filters.scope === s ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
- }`}
+ className="pill flex-1 inline-flex items-center justify-center gap-1 px-2.5 py-1 rounded-full"
>
{s === "my" ? : }
{t("header.scope." + s)}
diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx
index a7d44392..59c4602e 100644
--- a/frontend/src/components/Stats.tsx
+++ b/frontend/src/components/Stats.tsx
@@ -35,11 +35,8 @@ export default function Stats() {
setTab(id)}
- className={`px-3 py-1.5 rounded-full text-sm border transition ${
- tab === id
- ? "bg-accent text-accent-fg border-accent"
- : "bg-card border-border text-muted hover:border-accent"
- }`}
+ aria-pressed={tab === id}
+ className="pill px-3 py-1.5 rounded-full text-sm border"
>
{t(`stats.tabs.${id}`)}
@@ -236,11 +233,8 @@ function SystemStats() {
setDays(d)}
- className={`px-3 py-1.5 rounded-full text-sm border transition ${
- days === d
- ? "bg-accent text-accent-fg border-accent"
- : "bg-card border-border text-muted hover:border-accent"
- }`}
+ aria-pressed={days === d}
+ className="pill px-3 py-1.5 rounded-full text-sm border"
>
{t("stats.rangeDays", { count: d })}
diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx
index fbf25b84..3154827b 100644
--- a/frontend/src/components/Tabs.tsx
+++ b/frontend/src/components/Tabs.tsx
@@ -31,10 +31,9 @@ export default function Tabs({
onChange(tb.id)}
- className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition ${
- on
- ? "bg-accent text-accent-fg font-medium shadow-md shadow-accent/20"
- : "text-muted hover:text-fg hover:bg-card/60"
+ aria-pressed={on}
+ className={`pill inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm ${
+ on ? "font-medium shadow-md shadow-accent/20" : "hover:bg-card/60"
}`}
>
{tb.label}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index c19ab2cf..3ae68bfb 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -418,6 +418,84 @@ body {
outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);
outline-offset: -2px;
}
+
+ /* ===== R10 S4 · Selection-paint recipes =====
+ The ~14 hand-rolled "this is the selected / current / active one" idioms all painted the SAME
+ bg-accent fill via an inline ternary (`sel ? "bg-accent text-accent-fg border-accent" : "…"`),
+ which drifted over time. These recipes OWN that paint, keyed on the semantic ARIA the control
+ already carries — the JSX just toggles `aria-pressed` (a segment/toggle) or `aria-current` (the
+ single current item) and the CSS paints, so one control's selected look can never diverge from
+ the next's. Geometry (padding / radius / border-width / font) stays a per-site utility since it
+ legitimately varies. NO focus-visible here on purpose: the `@layer base` rule above already rings
+ every button/a/[role]; re-adding it would duplicate that (an R9 review finding). Because these sit
+ in @layer components, a site's own utility still wins — so a MIGRATED site must DROP its bg/text/
+ border-color utilities (else a utility-layer `bg-*` beats the recipe's pressed fill). Add
+ data-soft="true" for the subtle tint some surfaces use instead of the solid fill. */
+ .pill,
+ .is-current {
+ color: var(--muted);
+ transition:
+ color 0.15s ease,
+ background-color 0.15s ease,
+ border-color 0.15s ease,
+ box-shadow 0.15s ease,
+ transform 0.15s ease; /* box-shadow: an on-state shadow (Tabs) fades; transform: an active:translate press eases */
+ }
+ /* Resting border colour for the plain pills. NOT applied to a glass-card-resting pill (`.pill.glass-card`),
+ whose own border — including its lighter top-edge specular highlight — must survive. Inert unless the
+ site also adds a border-width utility. */
+ .pill:not(.glass-card),
+ .is-current {
+ border-color: var(--border);
+ }
+ .pill:hover,
+ .is-current:hover {
+ color: var(--fg);
+ }
+ .pill:hover {
+ border-color: color-mix(in srgb, var(--accent) 55%, transparent);
+ }
+ .is-current:hover {
+ background-color: var(--card);
+ }
+ /* selected / current — SOLID accent fill (the dominant idiom). The `:not([aria-current="false"])`
+ guard matters: `.is-current` is a shared primitive and a consumer using the common
+ `aria-current={x ? "true" : "false"}` idiom would otherwise paint a NON-current row as current. */
+ .pill[aria-pressed="true"],
+ .is-current[aria-current]:not([aria-current="false"]) {
+ background-color: var(--accent);
+ color: var(--accent-fg);
+ border-color: var(--accent);
+ }
+ /* selected — SOFT tint variant (the plex layout toggle keeps a glass-card resting look, so its
+ "on" state is a subtle accent tint rather than the solid fill). */
+ .pill[aria-pressed="true"][data-soft="true"] {
+ background-color: color-mix(in srgb, var(--accent) 14%, transparent);
+ color: var(--accent);
+ border-color: color-mix(in srgb, var(--accent) 40%, transparent);
+ }
+
+ /* .chip — an applied tag / filter token (rounded, tinted, self-contained: display + geometry +
+ paint). The tint is the resting look (a filter is "on" whenever the chip exists); removable
+ variants add their own × button as a child.
+ .chip-solid is intentionally a PAINT-ONLY modifier (bg/text/border colour, no display or
+ geometry): it sits on a site's OWN chip geometry so a truncating single-element chip
+ (ChannelTags) isn't forced into inline-flex, which would break its text-overflow. Use it WITH a
+ site's own geometry (`rounded-full`, optional `border`) or on top of `.chip`, never bare. */
+ .chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ border-radius: 9999px;
+ border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
+ background-color: color-mix(in srgb, var(--accent) 10%, transparent);
+ color: var(--accent);
+ }
+ .chip-solid {
+ background-color: var(--accent);
+ color: var(--accent-fg);
+ border-color: var(--accent);
+ }
}
.glass-hover:hover {
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts
index fa827086..e8fabff9 100644
--- a/frontend/src/lib/releaseNotes.ts
+++ b/frontend/src/lib/releaseNotes.ts
@@ -14,6 +14,16 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
+ {
+ version: "0.60.10",
+ date: "2026-08-11",
+ fixes: [
+ "Selected filters, tabs and chips now share one consistent highlight, so the same kind of control looks the same everywhere — e.g. the channel manager's filter rows no longer differ from each other.",
+ ],
+ chores: [
+ "Unified the selected / current / active control styling onto shared recipes, which also gives filter pills proper keyboard-focus rings and screen-reader selected state (no behaviour change to what the controls do).",
+ ],
+ },
{
version: "0.60.9",
date: "2026-08-09",