fix(theme): light-mode polish from review pass

Self-review of the light/dark sweep surfaced neutral colors that lived
in raw inline-style / <style jsx> / gradient values (so the className
sweep correctly skipped them) and a few contrast/affordance nits:

- reply-quote: adapt to the outbound (primary) bubble via onPrimary so
  the quote/author stay legible on the violet fill in light mode; wire
  it from message-bubble.
- dashboard SVG chrome -> tokens: conversations-chart gridline/crosshair,
  pipeline-donut background ring, automation-builder dot-grid gradient.
- members-tab viewer chip: bg-card -> bg-transparent (outline) so it
  doesn't blend into a card surface in light mode.
- tag-manager / pipeline-settings color-swatch selection rings: raw
  slate/white -> var(--popover)/var(--foreground) so they flip.
- pipeline-board scrollbar (<style jsx>): slate rgb -> var tokens.
- activity-feed zebra stripe: bg-card/40 -> bg-muted/40 (survives light).
- forgot-password / import-modal: restore real hover (dead no-op hovers).
- themed-toaster: gate theme via useSyncExternalStore to avoid a
  hydration mismatch on sonner's theme attribute (no setState-in-effect).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-06-17 09:28:22 +03:00
parent 4aa329b527
commit 1474657d03
No known key found for this signature in database
13 changed files with 65 additions and 21 deletions

View File

@ -119,7 +119,7 @@ export default function ForgotPasswordPage() {
<Link <Link
href="/login" href="/login"
className="mt-6 flex items-center justify-center gap-2 text-sm text-muted-foreground hover:text-muted-foreground" className="mt-6 flex items-center justify-center gap-2 text-sm text-muted-foreground hover:text-foreground"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Back to sign in Back to sign in

View File

@ -587,7 +587,7 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
{/* Canvas */} {/* Canvas */}
<div className="relative flex-1 overflow-y-auto"> <div className="relative flex-1 overflow-y-auto">
<div className="absolute inset-0 bg-[radial-gradient(circle,#1e293b_1px,transparent_1px)] [background-size:20px_20px] pointer-events-none" /> <div className="absolute inset-0 bg-[radial-gradient(circle,var(--border)_1px,transparent_1px)] [background-size:20px_20px] pointer-events-none" />
<div className="relative mx-auto flex max-w-2xl flex-col items-center gap-0 px-4 py-10"> <div className="relative mx-auto flex max-w-2xl flex-col items-center gap-0 px-4 py-10">
<ResourcesProvider> <ResourcesProvider>
<TriggerCard <TriggerCard

View File

@ -463,7 +463,7 @@ export function ImportModal({
) : ( ) : (
<> <>
<div className="flex size-10 items-center justify-center rounded-lg bg-muted/80 ring-1 ring-border/80 transition-colors group-hover:bg-muted"> <div className="flex size-10 items-center justify-center rounded-lg bg-muted/80 ring-1 ring-border/80 transition-colors group-hover:bg-muted">
<Upload className="size-5 text-muted-foreground group-hover:text-muted-foreground" /> <Upload className="size-5 text-muted-foreground group-hover:text-foreground" />
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Click to choose a CSV file Click to choose a CSV file

View File

@ -85,9 +85,10 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
{visible.map((it, i) => { {visible.map((it, i) => {
const theme = KIND_THEME[it.kind] const theme = KIND_THEME[it.kind]
const Icon = theme.icon const Icon = theme.icon
// Alternating row background for scanability — dark-theme // Alternating row background for scanability. bg-muted/40
// translation of the spec's white / #f9fafb stripes. // keeps the stripe visible in both light and dark modes
const stripe = i % 2 === 0 ? 'bg-transparent' : 'bg-card/40' // (bg-card/40 vanishes against a white card surface in light).
const stripe = i % 2 === 0 ? 'bg-transparent' : 'bg-muted/40'
const row = ( const row = (
<div className="flex items-center gap-3 px-5 py-2.5"> <div className="flex items-center gap-3 px-5 py-2.5">
<span <span

View File

@ -207,7 +207,7 @@ function LineSvg({
x2={VB_W - PADDING.right} x2={VB_W - PADDING.right}
y1={y} y1={y}
y2={y} y2={y}
stroke="rgb(30 41 59)" stroke="var(--border)"
strokeDasharray="3 3" strokeDasharray="3 3"
/> />
<text <text
@ -265,7 +265,7 @@ function LineSvg({
x2={hoverX} x2={hoverX}
y1={PADDING.top} y1={PADDING.top}
y2={PADDING.top + chartH} y2={PADDING.top + chartH}
stroke="rgb(71 85 105)" stroke="var(--muted-foreground)"
strokeDasharray="3 3" strokeDasharray="3 3"
/> />
<circle cx={hoverX} cy={yFor(data[hover.idx].incoming)} r={3.5} fill="#3b82f6" /> <circle cx={hoverX} cy={yFor(data[hover.idx].incoming)} r={3.5} fill="#3b82f6" />

View File

@ -98,7 +98,7 @@ function Donut({ data, currency }: { data: PipelineDonutData; currency: string }
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<svg viewBox={`0 0 ${size} ${size}`} className="h-48 w-48" role="img" aria-label="Pipeline value by stage"> <svg viewBox={`0 0 ${size} ${size}`} className="h-48 w-48" role="img" aria-label="Pipeline value by stage">
{/* background ring */} {/* background ring */}
<circle cx={cx} cy={cy} r={r} fill="none" stroke="rgb(30 41 59)" strokeWidth={ringWidth} /> <circle cx={cx} cy={cy} r={r} fill="none" stroke="var(--muted)" strokeWidth={ringWidth} />
{segments.map((seg) => ( {segments.map((seg) => (
<path <path
key={seg.id} key={seg.id}

View File

@ -269,7 +269,11 @@ export function MessageBubble({
)} )}
> >
{reply && ( {reply && (
<ReplyQuote authorLabel={reply.authorLabel} preview={reply.preview} /> <ReplyQuote
authorLabel={reply.authorLabel}
preview={reply.preview}
onPrimary={isAgent}
/>
)} )}
<MessageContent message={message} /> <MessageContent message={message} />
<div <div

View File

@ -14,25 +14,38 @@ interface ReplyQuoteProps {
/** Present → renders the composer-chip variant with an X button. Absent → /** Present → renders the composer-chip variant with an X button. Absent →
* renders the embedded-in-bubble variant. */ * renders the embedded-in-bubble variant. */
onDismiss?: () => void; onDismiss?: () => void;
/** True when embedded inside an outbound (primary-filled) bubble, so the
* quote must read against the primary surface rather than the neutral
* foreground — otherwise it goes low-contrast in light mode. */
onPrimary?: boolean;
} }
export function ReplyQuote({ export function ReplyQuote({
authorLabel, authorLabel,
preview, preview,
onDismiss, onDismiss,
onPrimary = false,
}: ReplyQuoteProps) { }: ReplyQuoteProps) {
const isChip = !!onDismiss; const isChip = !!onDismiss;
return ( return (
<div <div
className={cn( className={cn(
"flex items-start gap-2 border-l-2 border-primary px-2 py-1", "flex items-start gap-2 border-l-2 px-2 py-1",
onPrimary ? "border-primary-foreground/50" : "border-primary",
isChip isChip
? "rounded-md bg-muted/80" ? "rounded-md bg-muted/80"
: "mb-1.5 rounded-md bg-background/20", : onPrimary
? "mb-1.5 rounded-md bg-primary-foreground/15"
: "mb-1.5 rounded-md bg-background/20",
)} )}
> >
<div className="min-w-0 flex-1 overflow-hidden"> <div className="min-w-0 flex-1 overflow-hidden">
<div className="truncate text-[11px] font-medium text-primary"> <div
className={cn(
"truncate text-[11px] font-medium",
onPrimary ? "text-primary-foreground" : "text-primary",
)}
>
{authorLabel} {authorLabel}
</div> </div>
{/* Wrap the preview instead of truncating to a single line. {/* Wrap the preview instead of truncating to a single line.

View File

@ -164,7 +164,7 @@ export function PipelineBoard({
@media (hover: hover) and (pointer: fine) { @media (hover: hover) and (pointer: fine) {
.pipeline-scroll { .pipeline-scroll {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgb(51 65 85) transparent; scrollbar-color: var(--border) transparent;
} }
.pipeline-scroll::-webkit-scrollbar { .pipeline-scroll::-webkit-scrollbar {
height: 8px; height: 8px;
@ -173,11 +173,11 @@ export function PipelineBoard({
background: transparent; background: transparent;
} }
.pipeline-scroll::-webkit-scrollbar-thumb { .pipeline-scroll::-webkit-scrollbar-thumb {
background-color: rgb(51 65 85); background-color: var(--border);
border-radius: 9999px; border-radius: 9999px;
} }
.pipeline-scroll::-webkit-scrollbar-thumb:hover { .pipeline-scroll::-webkit-scrollbar-thumb:hover {
background-color: rgb(71 85 105); background-color: var(--muted-foreground);
} }
} }
`}</style> `}</style>

View File

@ -292,7 +292,9 @@ export function PipelineSettings({
style={{ style={{
backgroundColor: color, backgroundColor: color,
borderColor: borderColor:
newStageColor === color ? "white" : "transparent", newStageColor === color
? "var(--foreground)"
: "transparent",
}} }}
aria-label={`Pick color ${color}`} aria-label={`Pick color ${color}`}
/> />
@ -450,7 +452,8 @@ function ColorSwatch({
className="h-5 w-5 rounded-full border-2 transition-transform hover:scale-110" className="h-5 w-5 rounded-full border-2 transition-transform hover:scale-110"
style={{ style={{
backgroundColor: c, backgroundColor: c,
borderColor: c === value ? "white" : "transparent", borderColor:
c === value ? "var(--foreground)" : "transparent",
}} }}
/> />
))} ))}

View File

@ -115,7 +115,9 @@ const ROLE_CHIP: Record<
viewer: { viewer: {
icon: UserIcon, icon: UserIcon,
label: 'Viewer', label: 'Viewer',
className: 'border-border bg-card text-muted-foreground', // Outline-only so it stays quieter than the filled Agent chip in
// both modes — bg-card would blend into a card surface in light mode.
className: 'border-border bg-transparent text-muted-foreground',
}, },
}; };

View File

@ -241,7 +241,7 @@ export function TagManager() {
className="relative size-8 rounded-full transition-transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-popover" className="relative size-8 rounded-full transition-transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-popover"
style={{ style={{
backgroundColor: color.value, backgroundColor: color.value,
boxShadow: selectedColor === color.value ? `0 0 0 2px rgb(15 23 42), 0 0 0 4px ${color.value}` : 'none', boxShadow: selectedColor === color.value ? `0 0 0 2px var(--popover), 0 0 0 4px ${color.value}` : 'none',
}} }}
title={color.name} title={color.name}
/> />

View File

@ -1,8 +1,23 @@
"use client"; "use client";
import { useSyncExternalStore } from "react";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { useTheme } from "@/hooks/use-theme"; import { useTheme } from "@/hooks/use-theme";
import { DEFAULT_MODE } from "@/lib/themes";
// Returns false during SSR and the first hydration render, true after —
// the sanctioned (warning-free, no setState-in-effect) way to diverge
// server vs client. Lets us match the server-rendered default on first
// paint, then adopt the real mode.
const noopSubscribe = () => () => {};
function useIsClient() {
return useSyncExternalStore(
noopSubscribe,
() => true,
() => false,
);
}
/** /**
* Toaster wrapper that tracks the active light/dark mode. * Toaster wrapper that tracks the active light/dark mode.
@ -11,12 +26,18 @@ import { useTheme } from "@/hooks/use-theme";
* current mode and hand it to sonner. Colors are driven off the same * current mode and hand it to sonner. Colors are driven off the same
* CSS tokens as the rest of the app, so a toast looks at home in * CSS tokens as the rest of the app, so a toast looks at home in
* either mode without a second palette to maintain. * either mode without a second palette to maintain.
*
* The theme is gated behind `useIsClient`: the server renders
* DEFAULT_MODE, so first client paint must too, otherwise a light-mode
* user hydrates with a different sonner `theme` attribute than the
* server emitted and React logs a hydration mismatch.
*/ */
export function ThemedToaster() { export function ThemedToaster() {
const { mode } = useTheme(); const { mode } = useTheme();
const isClient = useIsClient();
return ( return (
<Toaster <Toaster
theme={mode} theme={isClient ? mode : DEFAULT_MODE}
position="top-right" position="top-right"
toastOptions={{ toastOptions={{
style: { style: {