panel chrome stops being black

Reported against a dark theme: the panel header's title, zoom readout and controls were
hardcoded `text-black`, `text-black/60`, `hover:bg-black/10` and `border-black/10`.
Legible on Duck's pale glass, invisible on every palette theme — which is the whole
class of bug the theme work exists to remove, sitting in the one component I moved
between packages and did not re-read.

Three derived tokens, so no theme has to think about them:

  --panel-header-fg-dim     the header's ink at 60%   (the zoom readout)
  --panel-header-border     the header's ink at 15%   (the rule under it)
  --panel-control-hover     the header's ink at 12%   (control hover)

The swap, drop-target and app-picker chrome used `duck-teal` and `text-white` directly.
Those now use `--panel-accent` and a new `--panel-accent-fg`, so a chip on the accent
stays readable whatever the accent is.

Verified across five themes rather than by reading:

  duck-dark          ink=rgb(248,250,252)
  duck-light         ink=rgb(2,8,23)
  rose-pine          ink=rgb(224,222,244)
  gruvbox            ink=rgb(235,219,178)
  catppuccin-latte   ink=rgb(76,79,105)

Two comments corrected while there: both asserted the chrome "is black throughout",
which was true when written and is now the opposite of true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 01:04:08 +00:00
co-authored by Claude Opus 5
parent fd78495b5a
commit 94a55fe679
3 changed files with 30 additions and 17 deletions
+10
View File
@@ -251,7 +251,17 @@
--panel-blur: 0px;
--panel-header-bg: color-mix(in srgb, var(--muted) 60%, transparent);
--panel-header-fg: var(--foreground);
/*
Panel chrome states. These were literal blacks in the component — `text-black/60` on the zoom
readout, `hover:bg-black/10` on every control, `border-black/10` under the header — which is legible
on Duck's pale glass and invisible on any dark theme. Derived from the header's own ink so they
follow whatever it is.
*/
--panel-header-fg-dim: color-mix(in srgb, var(--panel-header-fg) 60%, transparent);
--panel-header-border: color-mix(in srgb, var(--panel-header-fg) 15%, transparent);
--panel-control-hover: color-mix(in srgb, var(--panel-header-fg) 12%, transparent);
--panel-accent: var(--primary);
--panel-accent-fg: var(--primary-foreground);
--panel-grid-color: color-mix(in srgb, var(--border) 25%, transparent);
/* The header */
@@ -14,7 +14,7 @@ export const AppPicker = ({ registry, onSelect }: AppPickerProps) => {
<button
key={key}
type="button"
className="flex items-center gap-1.5 rounded-full border border-duck-teal/25 bg-background/80 backdrop-blur-sm px-3 py-1.5 text-duck-teal hover:border-duck-teal/40 hover:bg-background/90 transition-colors cursor-pointer"
className="flex items-center gap-1.5 rounded-full border border-[color-mix(in_srgb,var(--panel-accent)_25%,transparent)] bg-background/80 backdrop-blur-sm px-3 py-1.5 text-[var(--panel-accent)] hover:border-[color-mix(in_srgb,var(--panel-accent)_40%,transparent)] hover:bg-background/90 transition-colors cursor-pointer"
onClick={() => onSelect(key)}
>
<entry.icon className="h-3.5 w-3.5 shrink-0" />
@@ -89,12 +89,12 @@ const SwapOverlay = ({ panelId }: { panelId: string }) => {
return (
<div
className="absolute inset-0 z-20 flex items-center justify-center bg-duck-teal/10 hover:bg-duck-teal/20 transition-colors cursor-pointer"
className="absolute inset-0 z-20 flex items-center justify-center bg-[color-mix(in_srgb,var(--panel-accent)_10%,transparent)] hover:bg-[color-mix(in_srgb,var(--panel-accent)_20%,transparent)] transition-colors cursor-pointer"
onClick={() => onSwap(swapSourceId, panelId)}
>
<div className="flex items-center gap-2 rounded-lg bg-background/90 backdrop-blur-sm px-3 py-2 shadow-lg border border-duck-teal/30">
<ArrowLeftRight className="h-4 w-4 text-duck-teal" />
<span className="text-sm font-medium text-duck-teal">Swap here</span>
<div className="flex items-center gap-2 rounded-lg bg-background/90 backdrop-blur-sm px-3 py-2 shadow-lg border border-[color-mix(in_srgb,var(--panel-accent)_30%,transparent)]">
<ArrowLeftRight className="h-4 w-4 text-[var(--panel-accent)]" />
<span className="text-sm font-medium text-[var(--panel-accent)]">Swap here</span>
</div>
</div>
);
@@ -106,12 +106,12 @@ const SwapSourceIndicator = ({ panelId }: { panelId: string }) => {
if (swapSourceId !== panelId) return null;
return (
<div className="absolute inset-0 z-20 pointer-events-none ring-2 ring-inset ring-duck-teal/50 rounded-lg">
<div className="absolute inset-0 z-20 pointer-events-none ring-2 ring-inset ring-[color-mix(in_srgb,var(--panel-accent)_50%,transparent)] rounded-lg">
<div className="absolute top-2 left-2 pointer-events-auto">
<button
type="button"
onClick={() => setSwapSourceId(null)}
className="flex items-center gap-1.5 rounded-md bg-duck-teal/90 px-2.5 py-1 text-xs font-medium text-white shadow cursor-pointer hover:bg-duck-teal"
className="flex items-center gap-1.5 rounded-md bg-[color-mix(in_srgb,var(--panel-accent)_90%,transparent)] px-2.5 py-1 text-xs font-medium text-[var(--panel-accent-fg)] shadow cursor-pointer hover:bg-[var(--panel-accent)]"
>
<ArrowLeftRight className="h-3 w-3" />
Swapping... click to cancel
@@ -183,7 +183,7 @@ const FullscreenToggle = ({ panelId }: { panelId: string }) => {
type="button"
onClick={() => setFullscreenPanelId(isFullscreen ? null : panelId)}
title={isFullscreen ? 'Exit full screen' : 'Full screen'}
className={`cursor-pointer rounded p-1 transition-all hover:bg-black/10 focus-visible:opacity-100 ${isFullscreen ? '' : 'opacity-0 group-hover/header:opacity-100'}`}
className={`cursor-pointer rounded p-1 transition-all hover:bg-[var(--panel-control-hover)] focus-visible:opacity-100 ${isFullscreen ? '' : 'opacity-0 group-hover/header:opacity-100'}`}
>
<Icon className="h-3.5 w-3.5" />
</button>
@@ -217,7 +217,7 @@ const ZoomControls = ({ panelId, zoom }: { panelId: string; zoom: number }) => {
onClick={() => onSetZoom(panelId, zoom - ZOOM_STEP)}
disabled={zoom <= ZOOM_MIN}
title="Zoom out"
className="cursor-pointer rounded p-1 opacity-0 transition-all group-hover/header:opacity-100 hover:bg-black/10 focus-visible:opacity-100 disabled:cursor-default disabled:opacity-0 disabled:group-hover/header:opacity-30"
className="cursor-pointer rounded p-1 opacity-0 transition-all group-hover/header:opacity-100 hover:bg-[var(--panel-control-hover)] focus-visible:opacity-100 disabled:cursor-default disabled:opacity-0 disabled:group-hover/header:opacity-30"
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
@@ -225,7 +225,7 @@ const ZoomControls = ({ panelId, zoom }: { panelId: string; zoom: number }) => {
type="button"
onClick={() => onSetZoom(panelId, 1)}
title="Reset zoom"
className={`w-9 cursor-pointer rounded px-1 text-center text-[11px] tabular-nums text-black/60 transition-all hover:bg-black/10 hover:text-black ${atDefault ? 'opacity-0 group-hover/header:opacity-100' : ''}`}
className={`w-9 cursor-pointer rounded px-1 text-center text-[11px] tabular-nums text-[var(--panel-header-fg-dim)] transition-all hover:bg-[var(--panel-control-hover)] hover:text-[var(--panel-header-fg)] ${atDefault ? 'opacity-0 group-hover/header:opacity-100' : ''}`}
>
{Math.round(zoom * 100)}%
</button>
@@ -234,7 +234,7 @@ const ZoomControls = ({ panelId, zoom }: { panelId: string; zoom: number }) => {
onClick={() => onSetZoom(panelId, zoom + ZOOM_STEP)}
disabled={zoom >= ZOOM_MAX}
title="Zoom in"
className="cursor-pointer rounded p-1 opacity-0 transition-all group-hover/header:opacity-100 hover:bg-black/10 focus-visible:opacity-100 disabled:cursor-default disabled:opacity-0 disabled:group-hover/header:opacity-30"
className="cursor-pointer rounded p-1 opacity-0 transition-all group-hover/header:opacity-100 hover:bg-[var(--panel-control-hover)] focus-visible:opacity-100 disabled:cursor-default disabled:opacity-0 disabled:group-hover/header:opacity-30"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
@@ -383,7 +383,7 @@ export const PanelSlot = ({
if (!interactive || locked) {
return (
<div className="h-full w-full p-1">
<div className="h-full w-full rounded-lg border-3 border-duck-teal/50" />
<div className="h-full w-full rounded-lg border-3 border-[color-mix(in_srgb,var(--panel-accent)_50%,transparent)]" />
</div>
);
}
@@ -440,7 +440,7 @@ export const PanelSlot = ({
<button
type="button"
onClick={onMobileBack}
className="flex items-center gap-0.5 text-xs font-medium text-black/70 hover:text-black transition-colors cursor-pointer shrink-0"
className="flex items-center gap-0.5 text-xs font-medium text-[var(--panel-header-fg-dim)] hover:text-[var(--panel-header-fg)] transition-colors cursor-pointer shrink-0"
>
<ChevronLeft className="h-4 w-4" />
Back
@@ -454,11 +454,14 @@ export const PanelSlot = ({
const fullscreenToggle = interactive && !isMobile ? <FullscreenToggle panelId={panel.id} /> : null;
const headerContent = (
<div className="group/header shrink-0 flex items-center gap-2 px-3 py-1.5 border-b border-black/10 text-black font-semibold">
<div className="group/header shrink-0 flex items-center gap-2 px-3 py-1.5 border-b border-[var(--panel-header-border)] text-[var(--panel-header-fg)] font-semibold">
{mobileBackButton}
{header}
{!mobileBackButton && onClose && (
<button onClick={onClose} className="p-1 rounded hover:bg-black/10 transition-colors cursor-pointer">
<button
onClick={onClose}
className="p-1 rounded hover:bg-[var(--panel-control-hover)] transition-colors cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
)}
@@ -543,9 +546,9 @@ export const PanelSlot = ({
// is no app left to float above.
//
// Denser glass than a tiled panel, not a darker one. Panel chrome — header text, icons, the zoom
// control — is black throughout, so the old near-opaque dark fill left every one of them unreadable.
// control — takes the header's own ink, so a near-opaque dark fill left every one of them unreadable.
// This is the nav Header's exact recipe (`rgba(255,255,255,0.25)` over a 0.35 border), which is the
// app's established "raised above the rest" surface and is already proven to carry black text.
// app's established "raised above the rest" surface. Its ink follows `--panel-header-fg` like the rest.
// Fullscreen is DENSER glass, not darker: panel chrome is dark text throughout, and a heavy dark fill
// made every icon unreadable. A panel with no header takes a harder edge, having nothing else to
// separate it from what it sits on. Both were literal rgba pairs and are now the theme's to decide.