add shadcn compatibility layer and separate brand from accent colors (#39)

Split the semantic "accent" color into two concepts: brand (user-customizable
accent color) and accent (shadcn's neutral hover state). This enables using
shadcn/ui components with minimal modification while preserving the existing
theming system.
This commit is contained in:
morgmart
2026-03-31 21:44:13 -07:00
committed by GitHub
parent 3eff914e60
commit 3085e1558b
14 changed files with 105 additions and 26 deletions
+1 -1
View File
@@ -202,7 +202,7 @@ export function ChatInput({
{mentionPersonaId && (
<div className="mb-2 flex items-center gap-1.5">
<span className="inline-flex items-center gap-1 rounded-full bg-accent/10 px-2.5 py-0.5 text-[11px] font-medium text-accent">
<span className="inline-flex items-center gap-1 rounded-full bg-brand/10 px-2.5 py-0.5 text-[11px] font-medium text-brand">
@
{personas.find((p) => p.id === mentionPersonaId)?.displayName}
<button
@@ -140,7 +140,7 @@ export const MarkdownContent = memo(function MarkdownContent({
prose-pre:p-0 prose-pre:m-0 !p-0
prose-code:before:content-none prose-code:after:content-none
prose-code:break-all prose-code:whitespace-pre-wrap prose-code:font-mono
prose-a:break-all prose-a:text-accent prose-a:underline
prose-a:break-all prose-a:text-brand prose-a:underline
prose-table:table prose-table:w-full
prose-blockquote:text-inherit prose-blockquote:border-border
prose-td:border prose-td:border-border prose-td:p-2
@@ -114,7 +114,7 @@ function MentionAvatar({ persona }: { persona: Persona }) {
"flex h-7 w-7 items-center justify-center rounded-full",
persona.isBuiltin
? "bg-foreground/10 text-foreground"
: "bg-accent/10 text-accent",
: "bg-brand/10 text-brand",
)}
>
{persona.isBuiltin ? (
@@ -171,7 +171,7 @@ function PersonaAvatar({
"flex items-center justify-center rounded-full",
isBuiltin
? "bg-foreground/10 text-foreground"
: "bg-accent/10 text-accent",
: "bg-brand/10 text-brand",
)}
>
{isBuiltin ? (
@@ -158,7 +158,7 @@ function HomeInput({
{mentionPersonaId && (
<div className="mb-2 flex items-center gap-1.5">
<span className="inline-flex items-center gap-1 rounded-full bg-accent/10 px-2.5 py-0.5 text-[11px] font-medium text-accent">
<span className="inline-flex items-center gap-1 rounded-full bg-brand/10 px-2.5 py-0.5 text-[11px] font-medium text-brand">
@{personas.find((p) => p.id === mentionPersonaId)?.displayName}
<button
type="button"
@@ -316,7 +316,7 @@ export function CreateProjectDialog({
type="checkbox"
checked={useWorktrees}
onChange={(e) => setUseWorktrees(e.target.checked)}
className="h-4 w-4 rounded border-border accent-foreground"
className="h-4 w-4 rounded border-border accent-brand"
/>
<span className="text-xs font-medium text-foreground-secondary">
Use git worktrees for branch isolation
+46 -4
View File
@@ -97,9 +97,30 @@
--radius-xl: 16px;
--radius-full: 9999px;
--color-accent: #3b82f6;
--color-accent-foreground: #ffffff;
--color-accent: #f5f5f5;
--color-accent-foreground: #0a0a0a;
--color-brand: #3b82f6;
--color-brand-foreground: #ffffff;
--density-spacing: 1;
/* shadcn compatibility layer */
--background: var(--color-background-primary);
--foreground: var(--color-text-primary);
--card: var(--color-background-primary);
--card-foreground: var(--color-text-primary);
--popover: var(--color-background-primary);
--popover-foreground: var(--color-text-primary);
--primary: var(--color-background-inverse);
--primary-foreground: var(--color-text-inverse);
--secondary: var(--color-background-secondary);
--secondary-foreground: var(--color-text-primary);
--muted: var(--color-background-secondary);
--muted-foreground: var(--color-text-secondary);
--destructive: var(--color-background-danger);
--destructive-foreground: var(--color-text-inverse);
--border: var(--color-border-primary);
--input: var(--color-border-primary);
--ring: var(--color-ring-primary);
}
.dark {
@@ -156,9 +177,30 @@
--shadow-lg:
0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
--color-accent: #3b82f6;
--color-accent-foreground: #ffffff;
--color-accent: #262626;
--color-accent-foreground: #fafafa;
--color-brand: #3b82f6;
--color-brand-foreground: #ffffff;
--density-spacing: 1;
/* shadcn compatibility layer */
--background: var(--color-background-primary);
--foreground: var(--color-text-primary);
--card: var(--color-background-secondary);
--card-foreground: var(--color-text-primary);
--popover: var(--color-background-secondary);
--popover-foreground: var(--color-text-primary);
--primary: var(--color-background-inverse);
--primary-foreground: var(--color-text-inverse);
--secondary: var(--color-background-tertiary);
--secondary-foreground: var(--color-text-primary);
--muted: var(--color-background-tertiary);
--muted-foreground: var(--color-text-secondary);
--destructive: var(--color-background-danger);
--destructive-foreground: var(--color-text-inverse);
--border: var(--color-border-primary);
--input: var(--color-border-secondary);
--ring: var(--color-ring-primary);
}
}
+14 -2
View File
@@ -32,6 +32,15 @@ function resolveTheme(preference: ThemePreference): ResolvedTheme {
return preference;
}
function getContrastColor(hexColor: string): string {
const hex = hexColor.replace("#", "");
const r = Number.parseInt(hex.slice(0, 2), 16);
const g = Number.parseInt(hex.slice(2, 4), 16);
const b = Number.parseInt(hex.slice(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? "#000000" : "#ffffff";
}
export function ThemeProvider({
children,
defaultTheme = "system",
@@ -96,8 +105,11 @@ export function ThemeProvider({
React.useEffect(() => {
const root = window.document.documentElement;
root.style.setProperty("--color-accent", accentColor);
root.style.setProperty("--color-accent-foreground", "#ffffff");
root.style.setProperty("--color-brand", accentColor);
root.style.setProperty(
"--color-brand-foreground",
getContrastColor(accentColor),
);
const spacingScale: Record<Density, string> = {
compact: "0.75",
+2 -2
View File
@@ -20,7 +20,7 @@ export function SearchBar({
}: SearchBarProps) {
return (
<div className={cn("relative", className)}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-secondary/60" />
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="search"
autoComplete="off"
@@ -28,7 +28,7 @@ export function SearchBar({
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full pl-9 pr-3 py-2 text-sm bg-background-secondary border border-border-secondary/50 rounded-lg placeholder:text-foreground-secondary/40 focus:outline-none focus:ring-1 focus:ring-ring focus:border-border-primary transition-colors"
className="w-full rounded-lg border border-input bg-secondary py-2 pr-3 pl-9 text-sm placeholder:text-muted-foreground transition-colors focus:outline-none focus:ring-1 focus:ring-ring"
/>
</div>
);
+7 -7
View File
@@ -5,20 +5,20 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/lib/cn";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-background-inverse text-foreground-inverse shadow hover:bg-background-inverse/90",
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
destructive:
"bg-background-danger text-foreground-inverse shadow-sm hover:bg-background-danger/90",
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-border bg-background shadow-sm hover:bg-background-secondary hover:text-foreground",
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-background-secondary text-foreground shadow-sm hover:bg-background-secondary/80",
ghost: "hover:bg-background-secondary hover:text-foreground",
link: "text-foreground underline-offset-4 hover:underline",
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-brand underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
+3 -3
View File
@@ -18,7 +18,7 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-background p-1 shadow-md",
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
@@ -42,7 +42,7 @@ const DropdownMenuItem = React.forwardRef<
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors",
"focus:bg-background-secondary focus:text-foreground",
"focus:bg-accent focus:text-accent-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
@@ -61,7 +61,7 @@ const DropdownMenuLabel = React.forwardRef<
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-xs font-semibold text-foreground-secondary",
"px-2 py-1.5 text-xs font-semibold text-muted-foreground",
inset && "pl-8",
className,
)}
+1 -1
View File
@@ -16,7 +16,7 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border border-border bg-background p-4 shadow-md outline-none",
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
+1 -1
View File
@@ -15,7 +15,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-foreground px-3 py-1.5 text-xs text-background",
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground shadow-md",
"animate-in fade-in-0 zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
+25
View File
@@ -26,6 +26,27 @@ export default {
lg: "var(--shadow-lg)",
},
colors: {
card: "var(--card)",
"card-foreground": "var(--card-foreground)",
popover: "var(--popover)",
"popover-foreground": "var(--popover-foreground)",
primary: {
DEFAULT: "var(--primary)",
foreground: "var(--primary-foreground)",
},
secondary: {
DEFAULT: "var(--secondary)",
foreground: "var(--secondary-foreground)",
},
muted: {
DEFAULT: "var(--muted)",
foreground: "var(--muted-foreground)",
},
destructive: {
DEFAULT: "var(--destructive)",
foreground: "var(--destructive-foreground)",
},
input: "var(--input)",
background: {
DEFAULT: "var(--color-background-primary)",
primary: "var(--color-background-primary)",
@@ -79,6 +100,10 @@ export default {
DEFAULT: "var(--color-accent)",
foreground: "var(--color-accent-foreground)",
},
brand: {
DEFAULT: "var(--color-brand)",
foreground: "var(--color-brand-foreground)",
},
// Semantic status colors for inline use (status dots, badges)
green: { 500: "#91cb80" },
red: { 500: "#f94b4b" },