release: 0.60.0 (r9-focus-a11y)
This commit is contained in:
@@ -45,7 +45,7 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
tabIndex={0}
|
||||
className="block max-w-88 truncate text-muted cursor-help outline-hidden"
|
||||
className="block max-w-88 truncate text-muted cursor-help rounded-sm outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60 focus-visible:outline-offset-2"
|
||||
>
|
||||
{preview}
|
||||
</span>
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function AddToPlaylist({
|
||||
<ListPlus className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<PopoverSurface popover={pop} className="glass-menu w-60 rounded-xl p-2 shadow-2xl">
|
||||
<PopoverSurface popover={pop} className="glass-menu w-60 rounded-xl p-2">
|
||||
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
|
||||
would otherwise reach the feed card that hosts the trigger and start playback. */}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -250,11 +250,15 @@ type ItemRole = "menuitem" | "menuitemradio" | "menuitemcheckbox" | "option";
|
||||
* focus-visible heuristic, so a `:focus-visible` highlight can stay invisible until the first arrow
|
||||
* key. (Since the Tailwind v4 migration `bg-accent/NN` works — v4 does the opacity modifier via
|
||||
* color-mix — so the recipe no longer needs a hand-written color-mix arbitrary value.) */
|
||||
// Applied to EVERY PopoverItem so the roving-focus row is clearly visible across all schemes: an
|
||||
// accent fill plus a 1px inset accent ring for a defined edge. (The previous per-consumer 24% fill
|
||||
// alone read as barely-there — a real UAT finding, 2026-08-08.)
|
||||
const ACTIVE_HIGHLIGHT =
|
||||
// The roving-focus highlight, SPLIT by selection state (R9). A NON-selected row gets the familiar
|
||||
// accent fill + inset ring. A SELECTED/checked row gets ring-ONLY, so the consumer's own resting
|
||||
// selected paint (a bg-accent fill, text-accent, a checkbox) SURVIVES being highlighted instead of
|
||||
// being downgraded to a 35% tint or having its text recoloured (the R10-S0 review findings). The two
|
||||
// can't be merged onto one recipe: the fill and the selected paint both drive background/color on the
|
||||
// same element, so the highlight must yield to the selected state rather than overwrite it.
|
||||
const HIGHLIGHT_UNSELECTED =
|
||||
"data-active:bg-accent/35 data-active:text-fg data-active:ring-1 data-active:ring-inset data-active:ring-accent/60";
|
||||
const HIGHLIGHT_SELECTED = "data-active:ring-2 data-active:ring-inset data-active:ring-accent/70";
|
||||
|
||||
export function PopoverItem({
|
||||
children,
|
||||
@@ -323,7 +327,7 @@ export function PopoverItem({
|
||||
tabIndex={focusActive ? 0 : -1}
|
||||
data-active={visualActive ? "true" : undefined}
|
||||
disabled={disabled}
|
||||
className={`${ACTIVE_HIGHLIGHT}${className ? ` ${className}` : ""}`}
|
||||
className={`outline-hidden ${checked || selected ? HIGHLIGHT_SELECTED : HIGHLIGHT_UNSELECTED}${className ? ` ${className}` : ""}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
|
||||
return createPortal(
|
||||
<button
|
||||
onClick={toTop}
|
||||
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center w-16 h-16 rounded-full glass-card glass-hover text-fg shadow-lg hover:text-accent transition-all duration-200 ${
|
||||
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center w-16 h-16 rounded-full glass-card glass-hover text-fg hover:text-accent transition-all duration-200 ${
|
||||
show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function ChatDock({ meId }: { meId: number }) {
|
||||
function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] glass rounded-2xl shadow-2xl flex flex-col overflow-hidden">
|
||||
<div className="pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] glass rounded-2xl flex flex-col overflow-hidden">
|
||||
{/* One-shot attention flash on a new message; keyed by the counter so it replays each time. */}
|
||||
{chat.flash > 0 && (
|
||||
<div
|
||||
|
||||
@@ -649,7 +649,7 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
|
||||
className={inputCls}
|
||||
/>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu shadow-xl overflow-hidden">
|
||||
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
|
||||
{filtered.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
|
||||
@@ -284,7 +284,7 @@ export default function GlassTuner() {
|
||||
{open && (
|
||||
<div
|
||||
style={{ pointerEvents: "auto" }}
|
||||
className="glass-menu rounded-l-2xl rounded-r-none w-[300px] max-h-[82vh] flex flex-col overflow-hidden shadow-2xl"
|
||||
className="glass-menu rounded-l-2xl rounded-r-none w-[300px] max-h-[82vh] flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
|
||||
<span className="text-sm font-semibold text-fg">◐ Glass Tuner</span>
|
||||
|
||||
@@ -66,9 +66,7 @@ export default function Modal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl shadow-2xl`}
|
||||
>
|
||||
<div className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl`}>
|
||||
<div className="flex items-start justify-between gap-3 p-5 pb-3">
|
||||
<h2 className="text-lg font-semibold leading-snug">{title}</h2>
|
||||
<button
|
||||
|
||||
@@ -168,7 +168,8 @@ export default function NavSidebar({
|
||||
.filter((p) => sys.has(p))
|
||||
.map((p) => ({ page: p, ...META(p) }));
|
||||
|
||||
const rowBase = "w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
|
||||
const rowBase =
|
||||
"selectable w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
|
||||
const name = me.display_name ?? me.email.split("@")[0];
|
||||
// Role is surfaced as a plain title tooltip on the account row (no visual badge). Demo isn't a
|
||||
// real role (it's a flag on a `user` row), so surface it first.
|
||||
@@ -367,7 +368,7 @@ export default function NavSidebar({
|
||||
key={a.id}
|
||||
onClick={() => switchTo(a.id)}
|
||||
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
|
||||
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition"
|
||||
className="selectable w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition"
|
||||
>
|
||||
<AvatarImg
|
||||
src={a.avatar_url}
|
||||
@@ -393,14 +394,14 @@ export default function NavSidebar({
|
||||
clearActiveAccount();
|
||||
window.location.href = "/auth/login";
|
||||
}}
|
||||
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
{t("header.account.addAccount")}
|
||||
</button>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
{t("header.account.signOut")}
|
||||
|
||||
@@ -916,7 +916,7 @@ export default function PlayerModal({
|
||||
<div
|
||||
ref={cardRef}
|
||||
tabIndex={-1}
|
||||
className="glass-card relative flex-1 min-w-0 max-h-full overflow-y-auto rounded-2xl shadow-2xl outline-hidden"
|
||||
className="glass-card relative flex-1 min-w-0 max-h-full overflow-y-auto rounded-2xl outline-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -295,6 +295,7 @@ function ViewRow({
|
||||
<button
|
||||
onClick={onApply}
|
||||
title={t("views.apply")}
|
||||
aria-current={active ? "true" : undefined}
|
||||
className={`flex-1 min-w-0 text-left text-xs py-1 truncate ${
|
||||
active ? "text-accent font-medium" : "text-fg"
|
||||
}`}
|
||||
@@ -308,7 +309,7 @@ function ViewRow({
|
||||
className={`p-1 rounded transition ${
|
||||
view.is_default
|
||||
? "text-accent"
|
||||
: "text-muted opacity-0 group-hover:opacity-100 hover:text-accent"
|
||||
: "text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-accent"
|
||||
}`}
|
||||
>
|
||||
<Star className={`w-3.5 h-3.5 ${view.is_default ? "fill-current" : ""}`} />
|
||||
@@ -316,21 +317,21 @@ function ViewRow({
|
||||
<button
|
||||
onClick={onShare}
|
||||
title={t("views.share")}
|
||||
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 hover:text-fg transition"
|
||||
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-fg transition"
|
||||
>
|
||||
<Share2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onStartRename}
|
||||
title={t("views.rename")}
|
||||
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 hover:text-fg transition"
|
||||
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-fg transition"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
title={t("views.delete")}
|
||||
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 hover:text-red-400 transition"
|
||||
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-red-400 transition"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function SearchBar({
|
||||
if (onGo && !goDisabled) onGo();
|
||||
};
|
||||
return (
|
||||
<div className="pointer-events-auto flex-1 min-w-0 max-w-xl h-11 pl-3 pr-2 rounded-2xl glass-menu flex items-center gap-2">
|
||||
<div className="pointer-events-auto flex-1 min-w-0 max-w-xl h-11 pl-3 pr-2 rounded-2xl glass-menu flex items-center gap-2 focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
|
||||
<Search className="w-4 h-4 shrink-0 text-muted" />
|
||||
<input
|
||||
value={value}
|
||||
|
||||
@@ -61,7 +61,7 @@ function UserShare({ job }: { job: DownloadJob }) {
|
||||
className={inputCls}
|
||||
/>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu shadow-xl overflow-hidden">
|
||||
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
|
||||
{filtered.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
@@ -130,7 +130,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
||||
|
||||
return (
|
||||
<div className="rounded-lg glass-card p-2.5 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 rounded-md focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
|
||||
<Link2 className="w-4 h-4 text-muted shrink-0" />
|
||||
<input
|
||||
readOnly
|
||||
|
||||
@@ -158,7 +158,7 @@ function FilterFunnel<T>({
|
||||
checked={!filterOn}
|
||||
onSelect={() => onApply(col.key, "")}
|
||||
label={t("datatable.all")}
|
||||
className={`text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition data-active:ring-1 data-active:ring-accent ${
|
||||
className={`text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${
|
||||
!filterOn ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
@@ -171,7 +171,7 @@ function FilterFunnel<T>({
|
||||
checked={value === o.value}
|
||||
onSelect={() => onApply(col.key, o.value)}
|
||||
label={o.label}
|
||||
className={`text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition data-active:ring-1 data-active:ring-accent ${
|
||||
className={`text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${
|
||||
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function DraftSaveBar({
|
||||
const saving = state === "saving";
|
||||
const floating = variant === "floating";
|
||||
const wrap = floating
|
||||
? "fixed bottom-4 left-1/2 -translate-x-1/2 z-rail glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]"
|
||||
? "fixed bottom-4 left-1/2 -translate-x-1/2 z-rail glass rounded-2xl flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]"
|
||||
: "flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40";
|
||||
// The floating variant is fixed to the viewport but renders from a page inside the page
|
||||
// scroller, so it goes to <body> — otherwise the scroller's stacking context (its edge-fade
|
||||
|
||||
+98
-57
@@ -47,6 +47,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* R9 focus foundation: one accent focus ring for keyboard users on every interactive element that
|
||||
doesn't define its own. In @layer base so any component/utility focus style (.selectable, an input's
|
||||
focus:ring, a menu item's data-active highlight + outline-hidden) overrides it. Non-input controls
|
||||
app-wide had no visible keyboard focus before R9; this is the single biggest a11y win. */
|
||||
@layer base {
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
[role="button"]:focus-visible,
|
||||
[role="tab"]:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-scale: 1.06;
|
||||
|
||||
@@ -265,69 +280,95 @@ body {
|
||||
}
|
||||
|
||||
/* ===== Liquid-glass surface system (theme-aware, GPU-light) ===== */
|
||||
.glass {
|
||||
/* Frosted overlay surface: translucent enough that the blurred backdrop shows
|
||||
/* In @layer components so the recipe classes below (.selectable) compose with utilities instead of
|
||||
silently beating them the way these rules did while unlayered. (The ~9 dead `shadow-*` that used to
|
||||
sit on glass surfaces were REMOVED in R9, not activated: the tuned glass box-shadow — including its
|
||||
inset top-edge highlight — is the intended depth, and a shadow-* utility would atomically replace it
|
||||
and drop that highlight.) The perf-mode and :hover overrides below stay UNLAYERED on purpose. */
|
||||
@layer components {
|
||||
.glass {
|
||||
/* Frosted overlay surface: translucent enough that the blurred backdrop shows
|
||||
through (the glassy look), opaque enough that the background isn't distracting.
|
||||
The strong blur turns whatever is behind into soft colour, not a sharp image.
|
||||
Layer 1 (top) = the optional scrim; layer 2 = the translucent surface tint. */
|
||||
background:
|
||||
linear-gradient(
|
||||
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
|
||||
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
|
||||
),
|
||||
color-mix(in srgb, var(--surface) var(--glass-surface-alpha), transparent);
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
border: 1px solid color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent);
|
||||
border-top-color: color-mix(
|
||||
in srgb,
|
||||
#fff var(--glass-edge),
|
||||
color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, #fff var(--glass-inset), transparent),
|
||||
0 18px 44px -16px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.glass-card {
|
||||
/* No backdrop-filter here: cards render in bulk (feed grid) over a solid background
|
||||
background:
|
||||
linear-gradient(
|
||||
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
|
||||
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
|
||||
),
|
||||
color-mix(in srgb, var(--surface) var(--glass-surface-alpha), transparent);
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
border: 1px solid color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent);
|
||||
border-top-color: color-mix(
|
||||
in srgb,
|
||||
#fff var(--glass-edge),
|
||||
color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, #fff var(--glass-inset), transparent),
|
||||
0 18px 44px -16px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.glass-card {
|
||||
/* No backdrop-filter here: cards render in bulk (feed grid) over a solid background
|
||||
where blur adds almost nothing visually but is GPU-expensive and triggers reflow.
|
||||
Translucency + border + depth keep the look; blur is reserved for .glass overlays. */
|
||||
background:
|
||||
linear-gradient(
|
||||
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
|
||||
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
|
||||
),
|
||||
color-mix(in srgb, var(--card) var(--glass-card-alpha), transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
|
||||
border-top-color: color-mix(
|
||||
in srgb,
|
||||
#fff var(--glass-edge),
|
||||
color-mix(in srgb, var(--border) 65%, transparent)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) / 2), transparent),
|
||||
0 8px 22px -14px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.glass-menu {
|
||||
/* Floating menus/popovers hover over undimmed content (no backdrop scrim like dialogs),
|
||||
background:
|
||||
linear-gradient(
|
||||
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
|
||||
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
|
||||
),
|
||||
color-mix(in srgb, var(--card) var(--glass-card-alpha), transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
|
||||
border-top-color: color-mix(
|
||||
in srgb,
|
||||
#fff var(--glass-edge),
|
||||
color-mix(in srgb, var(--border) 65%, transparent)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) / 2), transparent),
|
||||
0 8px 22px -14px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.glass-menu {
|
||||
/* Floating menus/popovers hover over undimmed content (no backdrop scrim like dialogs),
|
||||
so they must be near-opaque to stay readable — only a hint of translucency + the blur. */
|
||||
background:
|
||||
linear-gradient(
|
||||
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
|
||||
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
|
||||
),
|
||||
color-mix(in srgb, var(--surface) var(--glass-menu-alpha), transparent);
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
|
||||
border-top-color: color-mix(
|
||||
in srgb,
|
||||
#fff var(--glass-edge),
|
||||
color-mix(in srgb, var(--border) 80%, transparent)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) - 2%), transparent),
|
||||
0 18px 44px -16px rgba(0, 0, 0, 0.6);
|
||||
background:
|
||||
linear-gradient(
|
||||
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
|
||||
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
|
||||
),
|
||||
color-mix(in srgb, var(--surface) var(--glass-menu-alpha), transparent);
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
|
||||
border-top-color: color-mix(
|
||||
in srgb,
|
||||
#fff var(--glass-edge),
|
||||
color-mix(in srgb, var(--border) 80%, transparent)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) - 2%), transparent),
|
||||
0 18px 44px -16px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* R9: standard interactive-row recipe. The hover tint menu/list rows already use, PLUS the
|
||||
app-wide focus-visible ring that was missing on non-input controls (buttons, rows, menu items) —
|
||||
the biggest single a11y win. In @layer components so a consumer's own utilities still win; the
|
||||
accent ring renders correctly under v4 (color-mix), matching the input ring in ui/form.tsx. */
|
||||
.selectable {
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
.selectable:hover {
|
||||
background-color: var(--card);
|
||||
}
|
||||
.selectable:focus-visible {
|
||||
/* outline (not a box-shadow ring) so it never fights a glass surface's box-shadow and never gets
|
||||
clipped by an overflow-hidden ancestor; inset offset keeps it inside tightly-packed rows. */
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
.glass-hover:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
|
||||
@@ -14,6 +14,24 @@ export interface ReleaseEntry {
|
||||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.60.0",
|
||||
date: "2026-08-08",
|
||||
summary:
|
||||
"Keyboard focus is now clearly visible across the whole app, menu highlights are steadier, and the styling engine was modernised for consistent theming.",
|
||||
features: [
|
||||
"Keyboard focus is now visible everywhere — buttons, links, menu items and inputs all show a clear accent outline when you move to them with Tab.",
|
||||
],
|
||||
fixes: [
|
||||
"A selected item in a menu keeps its highlight when you hover or arrow onto it, instead of fading to a faint tint.",
|
||||
"The main search box, the About tooltip and the share-link field now show a focus ring when reached by keyboard.",
|
||||
"A saved view's row actions (rename, share, delete) are now reachable by keyboard, not only on hover.",
|
||||
],
|
||||
chores: [
|
||||
"Migrated the styling engine to Tailwind v4, so theme colours and their opacities render consistently across every scheme and in both light and dark.",
|
||||
"Removed dead styles (unused shadows and animations) and consolidated focus-ring and menu-highlight styling into shared recipes.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.59.1",
|
||||
date: "2026-08-06",
|
||||
|
||||
Reference in New Issue
Block a user