Adopt WorkClaw's shadcn/ui Neutral token contract (Tailwind v4)

Replace the hand-rolled @theme color block with a real shadcn init shape:
bare :root (light) + .dark (dark) tokens mapped to --color-* via
@theme inline, plus the missing semantic/chart/radius tokens. --accent
is now neutral (#262626) to match WorkClaw; the ~16 coral-via-accent
usages migrate to the dedicated `coral` brand token so they stay coral.

Adds cn() (clsx + tailwind-merge) and components.json so future
`npx shadcn add` works. Clawmates-specific tokens (coral brand,
marketing palette, measured shadows, motion, layout, type scale) are
preserved verbatim in a separate @theme block. Root <html> gains `dark`
(app/login read .dark; marketing stays light via explicit colors).

We do NOT add shadcn's Button/Card/etc. — they'd conflict with the
existing measured-to-WorkClaw components.

Verified value-preserving: typecheck/lint/86 unit/31 E2E all green, and
all 6 visual baselines pass with no diff. Compiled + live CSS confirm
bg-accent→#262626 (neutral), text-coral→#f96565, chart tokens present.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-11 22:00:06 -05:00
co-authored by Claude Opus 4.8
parent e6f50c7ecb
commit 5ff2b2ca33
15 changed files with 198 additions and 45 deletions
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
+34
View File
@@ -10,12 +10,15 @@
"dependencies": { "dependencies": {
"@clerk/nextjs": "^7.5.0", "@clerk/nextjs": "^7.5.0",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"geist": "^1.7.2", "geist": "^1.7.2",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"next": "16.2.9", "next": "16.2.9",
"nuqs": "^2.8.9", "nuqs": "^2.8.9",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
@@ -3480,12 +3483,33 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"license": "Apache-2.0",
"dependencies": {
"clsx": "^2.1.1"
},
"funding": {
"url": "https://polar.sh/cva"
}
},
"node_modules/client-only": { "node_modules/client-only": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -7355,6 +7379,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/tailwind-merge": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
"integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
+3
View File
@@ -13,12 +13,15 @@
"dependencies": { "dependencies": {
"@clerk/nextjs": "^7.5.0", "@clerk/nextjs": "^7.5.0",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"geist": "^1.7.2", "geist": "^1.7.2",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"next": "16.2.9", "next": "16.2.9",
"nuqs": "^2.8.9", "nuqs": "^2.8.9",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
+109 -25
View File
@@ -1,47 +1,132 @@
@import "tailwindcss"; @import "tailwindcss";
@import "../styles/motion.css"; @import "../styles/motion.css";
/* Design tokens — merged from the reverse-engineered reference system /* shadcn/ui "dark" class variant (the app runs under .dark; marketing is a
(docs/tokens.css, measured from live DOM) on top of spec §2. Dark-only separate explicit-light scope). */
app scheme; coral brand accent. Values are spec-verbatim; aliases keep @custom-variant dark (&:is(.dark *));
existing component class names stable. */
/* ───────────────────────────────────────────────────────────────────────
shadcn/ui Neutral base preset (bare tokens). The app reads `.dark`; the
light `:root` palette is kept for contract parity. `@theme inline` below
maps these to the --color-* utilities (bg-background, text-accent, …).
--accent is the neutral subtle surface (the coral brand lives in the
separate --color-coral* tokens).
─────────────────────────────────────────────────────────────────────── */
:root {
--radius: 0.5rem;
--background: #fff;
--foreground: #0a0a0a;
--card: #fff;
--card-foreground: #0a0a0a;
--popover: #fff;
--popover-foreground: #0a0a0a;
--primary: #171717;
--primary-foreground: #fff;
--secondary: #f5f5f5;
--secondary-foreground: #171717;
--muted: #f5f5f5;
--muted-foreground: #737373;
--accent: #f5f5f5;
--accent-foreground: #171717;
--destructive: #ef4444;
--destructive-foreground: #fafafa;
--border: #e5e5e5;
--input: #e5e5e5;
--ring: #171717;
--chart-1: #3c83f6;
--chart-2: #2a9d90;
--chart-3: #274754;
--chart-4: #e8c468;
--chart-5: #f4a462;
}
.dark {
--background: #0a0a0a;
--foreground: #fafafa;
--card: #0a0a0a;
--card-foreground: #fafafa;
--popover: #0a0a0a;
--popover-foreground: #fafafa;
--primary: #fafafa;
--primary-foreground: #171717;
--secondary: #262626;
--secondary-foreground: #fafafa;
--muted: #262626;
--muted-foreground: #a3a3a3;
--accent: #262626;
--accent-foreground: #fafafa;
--destructive: #7f1d1d;
--destructive-foreground: #fafafa;
--border: #262626;
--input: #262626;
--ring: #fafafa;
--chart-1: #2662d9;
--chart-2: #2eb88a;
--chart-3: #e88c30;
--chart-4: #af57db;
--chart-5: #e23670;
}
/* Map the bare tokens into Tailwind's --color-* / radius utilities. */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
/* ───────────────────────────────────────────────────────────────────────
Clawmates-specific tokens (not part of the shadcn contract): the coral
brand, raised surfaces, marketing light palette, measured shadows, motion
curves, layout spacing, type scale, custom radii, fonts. Values verbatim
from the reverse-engineered reference system.
─────────────────────────────────────────────────────────────────────── */
@theme { @theme {
/* ---- Color: function-grouped ---- */ /* raised surfaces + extra foregrounds */
--color-background: #0a0a0a;
--color-foreground: #fafafa;
--color-card: #0a0a0a;
--color-popover: #0a0a0a;
--color-surface-warm: #141414; /* raised tier 1 */ --color-surface-warm: #141414; /* raised tier 1 */
--color-surface-warm-muted: #1a1a1a; /* raised tier 2 */ --color-surface-warm-muted: #1a1a1a; /* raised tier 2 */
--color-subtle: #141414; --color-subtle: #141414;
--color-hover-bg: #171717; /* generic hover fill */ --color-hover-bg: #171717; /* generic hover fill */
--color-divider-subtle: #1a1a1a; --color-divider-subtle: #1a1a1a;
--color-border: #262626;
--color-input: #262626;
--color-muted: #262626;
--color-secondary: #262626;
--color-muted-foreground: #a3a3a3;
--color-subtle-foreground: #737373; --color-subtle-foreground: #737373;
/* brand accent (coral) + variants; --color-accent is our legacy alias */ /* brand accent (coral) + variants */
--color-coral: #f96565; --color-coral: #f96565;
--color-accent: #f96565;
--color-coral-light: #fa7575; --color-coral-light: #fa7575;
--color-coral-dark: #e85555; --color-coral-dark: #e85555;
--color-cream: #f5f5f5; /* cream primary button fill */ --color-cream: #f5f5f5; /* cream primary button fill */
--color-primary: #fafafa;
--color-primary-foreground: #171717;
--color-ring: #fafafa;
--color-focus-ring: #f5f5f5; --color-focus-ring: #f5f5f5;
/* chat bubbles (measured) */ /* chat bubbles (measured) */
--color-bubble-user: #1a1a1a; --color-bubble-user: #1a1a1a;
--color-bubble-user-text: #d4d4d4; --color-bubble-user-text: #d4d4d4;
/* semantic */
--color-destructive: #7f1d1d;
--color-destructive-foreground: #fafafa;
/* gradient stops (coral tile) */ /* gradient stops (coral tile) */
--color-gradient-coral-top: #fa7575; --color-gradient-coral-top: #fa7575;
--color-gradient-coral-bottom: #e85555; --color-gradient-coral-bottom: #e85555;
@@ -54,8 +139,7 @@
--text-xxxl: 36px; --text-xxxl: 36px;
--text-xxxl--line-height: 44px; --text-xxxl--line-height: 44px;
/* ---- Radius scale ---- */ /* ---- Custom radii ---- */
--radius: 0.5rem;
--radius-squircle: 17px; /* 48px claw avatars */ --radius-squircle: 17px; /* 48px claw avatars */
--radius-button: 9999px; /* pill buttons + nav items */ --radius-button: 9999px; /* pill buttons + nav items */
--radius-toggle: 8px; --radius-toggle: 8px;
+4 -1
View File
@@ -43,7 +43,10 @@ export default function RootLayout({
children, children,
}: Readonly<{ children: React.ReactNode }>) { }: Readonly<{ children: React.ReactNode }>) {
return ( return (
<html lang="en" className={`${geist.variable} ${geistMono.variable} h-full`}> <html
lang="en"
className={`dark ${geist.variable} ${geistMono.variable} h-full`}
>
{/* No `antialiased` (-webkit-font-smoothing: antialiased) — it inherits {/* No `antialiased` (-webkit-font-smoothing: antialiased) — it inherits
everywhere and renders text thinner/lighter than the reference. The everywhere and renders text thinner/lighter than the reference. The
browser default (`auto`) gives the crisper/heavier weight WorkClaw browser default (`auto`) gives the crisper/heavier weight WorkClaw
+4 -4
View File
@@ -44,7 +44,7 @@ export function LoginForm() {
type="email" type="email"
required required
autoComplete="email" autoComplete="email"
className="rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-accent" className="rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-coral"
/> />
</label> </label>
<label className="flex flex-col gap-1 text-xs text-muted-foreground"> <label className="flex flex-col gap-1 text-xs text-muted-foreground">
@@ -54,18 +54,18 @@ export function LoginForm() {
type="password" type="password"
required required
autoComplete="current-password" autoComplete="current-password"
className="rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-accent" className="rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-coral"
/> />
</label> </label>
{failed && ( {failed && (
<p role="alert" className="text-xs text-accent"> <p role="alert" className="text-xs text-coral">
Invalid email or password. Invalid email or password.
</p> </p>
)} )}
<button <button
type="submit" type="submit"
disabled={pending} disabled={pending}
className="mt-1 rounded-(--radius-button) bg-accent px-4 py-2 text-sm font-medium text-background shadow-(--shadow-cta) hover:bg-coral-light disabled:opacity-50" className="mt-1 rounded-(--radius-button) bg-coral px-4 py-2 text-sm font-medium text-background shadow-(--shadow-cta) hover:bg-coral-light disabled:opacity-50"
> >
{pending ? "Signing in…" : "Sign in"} {pending ? "Signing in…" : "Sign in"}
</button> </button>
+2 -2
View File
@@ -51,12 +51,12 @@ function AgentMessage({
{message.status === "streaming" && ( {message.status === "streaming" && (
<span <span
aria-hidden aria-hidden
className="ml-0.5 inline-block h-4 w-0.5 translate-y-0.5 bg-accent motion-safe:animate-[caret-blink_1s_steps(1)_infinite]" className="ml-0.5 inline-block h-4 w-0.5 translate-y-0.5 bg-coral motion-safe:animate-[caret-blink_1s_steps(1)_infinite]"
/> />
)} )}
</p> </p>
{message.status === "error" && ( {message.status === "error" && (
<p role="alert" className="pt-1 text-xs text-accent"> <p role="alert" className="pt-1 text-xs text-coral">
Something went wrong with this reply. Something went wrong with this reply.
</p> </p>
)} )}
@@ -33,7 +33,7 @@ export function StepTrace({ steps }: { steps: UiStep[] }) {
<span <span
className={ className={
step.status === "error" step.status === "error"
? "text-accent" ? "text-coral"
: step.status === "running" : step.status === "running"
? "text-muted-foreground motion-safe:animate-[pulse_1.5s_ease-in-out_infinite]" ? "text-muted-foreground motion-safe:animate-[pulse_1.5s_ease-in-out_infinite]"
: "text-foreground" : "text-foreground"
@@ -39,12 +39,12 @@ export function PromoRedeem() {
required required
aria-label="Promo code" aria-label="Promo code"
placeholder="PROMOCODE" placeholder="PROMOCODE"
className="min-w-0 flex-1 rounded-(--radius) border border-input bg-background px-3 py-1.5 font-mono text-xs uppercase outline-none focus:border-accent" className="min-w-0 flex-1 rounded-(--radius) border border-input bg-background px-3 py-1.5 font-mono text-xs uppercase outline-none focus:border-coral"
/> />
<button <button
type="submit" type="submit"
disabled={pending} disabled={pending}
className="rounded-(--radius-button) bg-accent px-4 py-1.5 text-xs font-medium text-background hover:bg-coral-light disabled:opacity-50" className="rounded-(--radius-button) bg-coral px-4 py-1.5 text-xs font-medium text-background hover:bg-coral-light disabled:opacity-50"
> >
Redeem Redeem
</button> </button>
@@ -43,9 +43,9 @@ export function ApprovalCard({ approval, onDecide, agentName }: ApprovalCardProp
return ( return (
<section <section
aria-label="Review and approve" aria-label="Review and approve"
className="mt-2 max-w-md rounded-(--radius) border border-accent/40 bg-surface-warm p-3 shadow-(--shadow-card) motion-safe:animate-[scale-in_var(--duration-fast)_var(--ease-app)]" className="mt-2 max-w-md rounded-(--radius) border border-coral/40 bg-surface-warm p-3 shadow-(--shadow-card) motion-safe:animate-[scale-in_var(--duration-fast)_var(--ease-app)]"
> >
<p className="text-xs font-semibold uppercase tracking-wide text-accent"> <p className="text-xs font-semibold uppercase tracking-wide text-coral">
Review &amp; approve Review &amp; approve
</p> </p>
<p className="pt-1 text-sm"> <p className="pt-1 text-sm">
@@ -76,7 +76,7 @@ export function ApprovalCard({ approval, onDecide, agentName }: ApprovalCardProp
type="button" type="button"
disabled={pending} disabled={pending}
onClick={() => decide("approve")} onClick={() => decide("approve")}
className="rounded-(--radius-button) bg-accent px-4 py-1.5 text-xs font-medium text-background shadow-(--shadow-cta) hover:bg-coral-light disabled:opacity-50" className="rounded-(--radius-button) bg-coral px-4 py-1.5 text-xs font-medium text-background shadow-(--shadow-cta) hover:bg-coral-light disabled:opacity-50"
> >
Approve Approve
</button> </button>
@@ -33,7 +33,7 @@ export function SessionsColumn({
placeholder="Search" placeholder="Search"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-(--radius) border border-input bg-background px-2 py-1.5 text-xs outline-none focus:border-accent" className="w-full rounded-(--radius) border border-input bg-background px-2 py-1.5 text-xs outline-none focus:border-coral"
/> />
</div> </div>
<ul aria-label="Sessions" className="flex-1 overflow-y-auto px-2"> <ul aria-label="Sessions" className="flex-1 overflow-y-auto px-2">
@@ -46,7 +46,7 @@ export function SessionsColumn({
aria-current={active ? "true" : undefined} aria-current={active ? "true" : undefined}
onClick={() => onOpen(session)} onClick={() => onOpen(session)}
className={`w-full rounded-(--radius) border-l-2 px-2 py-2 text-left hover:bg-surface-warm ${ className={`w-full rounded-(--radius) border-l-2 px-2 py-2 text-left hover:bg-surface-warm ${
active ? "border-accent bg-surface-warm" : "border-transparent" active ? "border-coral bg-surface-warm" : "border-transparent"
}`} }`}
> >
<span className="block truncate text-xs text-foreground"> <span className="block truncate text-xs text-foreground">
@@ -64,7 +64,7 @@ export function SessionsColumn({
<button <button
type="button" type="button"
onClick={onNewSession} onClick={onNewSession}
className="w-full rounded-(--radius-button) border border-border px-3 py-1.5 text-xs text-muted-foreground hover:border-accent hover:text-foreground" className="w-full rounded-(--radius-button) border border-border px-3 py-1.5 text-xs text-muted-foreground hover:border-coral hover:text-foreground"
> >
+ New session + New session
</button> </button>
+1 -1
View File
@@ -50,7 +50,7 @@ export function Avatar({
<span <span
aria-hidden aria-hidden
className={`inline-flex items-center justify-center font-medium text-background ${radius} ${SIZES[size]}`} className={`inline-flex items-center justify-center font-medium text-background ${radius} ${SIZES[size]}`}
style={{ backgroundColor: accent || "var(--color-accent)" }} style={{ backgroundColor: accent || "var(--color-coral)" }}
> >
{initials} {initials}
</span> </span>
@@ -272,7 +272,7 @@ export function CreateClawForm() {
)} )}
{step === "slack" && confirming && ( {step === "slack" && confirming && (
<div className="rounded-(--radius) border border-accent/40 bg-surface-warm p-4"> <div className="rounded-(--radius) border border-coral/40 bg-surface-warm p-4">
<p className="text-sm font-medium">Create {draft.name}?</p> <p className="text-sm font-medium">Create {draft.name}?</p>
<p className="pt-1 text-xs text-muted-foreground"> <p className="pt-1 text-xs text-muted-foreground">
{draft.jobTitle} ·{" "} {draft.jobTitle} ·{" "}
+8
View File
@@ -0,0 +1,8 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** shadcn's class-merge helper: clsx for conditionals, tailwind-merge to
* dedupe conflicting Tailwind utilities. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+2 -2
View File
@@ -78,8 +78,8 @@
to { transform: translateX(0); opacity: 1; } to { transform: translateX(0); opacity: 1; }
} }
@keyframes toolAccentPulse { @keyframes toolAccentPulse {
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-accent) 45%, transparent); } 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-coral) 45%, transparent); }
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-accent) 12%, transparent); } 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-coral) 12%, transparent); }
} }
/* Provisioning (new-agent spin-up), spec §3. */ /* Provisioning (new-agent spin-up), spec §3. */