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
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" />
Back to sign in

View File

@ -587,7 +587,7 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
{/* Canvas */}
<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">
<ResourcesProvider>
<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">
<Upload className="size-5 text-muted-foreground group-hover:text-muted-foreground" />
<Upload className="size-5 text-muted-foreground group-hover:text-foreground" />
</div>
<p className="text-sm text-muted-foreground">
Click to choose a CSV file

View File

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

View File

@ -207,7 +207,7 @@ function LineSvg({
x2={VB_W - PADDING.right}
y1={y}
y2={y}
stroke="rgb(30 41 59)"
stroke="var(--border)"
strokeDasharray="3 3"
/>
<text
@ -265,7 +265,7 @@ function LineSvg({
x2={hoverX}
y1={PADDING.top}
y2={PADDING.top + chartH}
stroke="rgb(71 85 105)"
stroke="var(--muted-foreground)"
strokeDasharray="3 3"
/>
<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">
<svg viewBox={`0 0 ${size} ${size}`} className="h-48 w-48" role="img" aria-label="Pipeline value by stage">
{/* 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) => (
<path
key={seg.id}

View File

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

View File

@ -14,25 +14,38 @@ interface ReplyQuoteProps {
/** Present → renders the composer-chip variant with an X button. Absent →
* renders the embedded-in-bubble variant. */
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({
authorLabel,
preview,
onDismiss,
onPrimary = false,
}: ReplyQuoteProps) {
const isChip = !!onDismiss;
return (
<div
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
? "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="truncate text-[11px] font-medium text-primary">
<div
className={cn(
"truncate text-[11px] font-medium",
onPrimary ? "text-primary-foreground" : "text-primary",
)}
>
{authorLabel}
</div>
{/* 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) {
.pipeline-scroll {
scrollbar-width: thin;
scrollbar-color: rgb(51 65 85) transparent;
scrollbar-color: var(--border) transparent;
}
.pipeline-scroll::-webkit-scrollbar {
height: 8px;
@ -173,11 +173,11 @@ export function PipelineBoard({
background: transparent;
}
.pipeline-scroll::-webkit-scrollbar-thumb {
background-color: rgb(51 65 85);
background-color: var(--border);
border-radius: 9999px;
}
.pipeline-scroll::-webkit-scrollbar-thumb:hover {
background-color: rgb(71 85 105);
background-color: var(--muted-foreground);
}
}
`}</style>

View File

@ -292,7 +292,9 @@ export function PipelineSettings({
style={{
backgroundColor: color,
borderColor:
newStageColor === color ? "white" : "transparent",
newStageColor === color
? "var(--foreground)"
: "transparent",
}}
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"
style={{
backgroundColor: c,
borderColor: c === value ? "white" : "transparent",
borderColor:
c === value ? "var(--foreground)" : "transparent",
}}
/>
))}

View File

@ -115,7 +115,9 @@ const ROLE_CHIP: Record<
viewer: {
icon: UserIcon,
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"
style={{
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}
/>

View File

@ -1,8 +1,23 @@
"use client";
import { useSyncExternalStore } from "react";
import { Toaster } from "sonner";
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.
@ -11,12 +26,18 @@ import { useTheme } from "@/hooks/use-theme";
* 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
* 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() {
const { mode } = useTheme();
const isClient = useIsClient();
return (
<Toaster
theme={mode}
theme={isClient ? mode : DEFAULT_MODE}
position="top-right"
toastOptions={{
style: {