
Web Taste
- 92 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
web-taste is a Claude Code skill for ai & agent building.
About
web-taste is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- web-taste
- AI & Agent Building
- AI-coding skill
Web Taste by the numbers
- 92 all-time installs (skills.sh)
- +7 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #4,749 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill web-tasteAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 92 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with ai & agent building tasks during AI-assisted development.?
Helps with ai & agent building tasks during AI-assisted development.
Who is it for?
Best when you're working on ai & agent building and need structured help with web taste.
Skip if: Teams with no ai & agent building needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with ai & agent building tasks during AI-assisted development., or when web-taste is a claude code skill for ai & agent building.
What you get
Structured output aligned to web-taste: web-taste, AI & Agent Building.
Files
Web Taste
Taste doesn't start at the pixel level. It starts at "who is this person and what do they need?" The visual refinement is the LAST step. The first step is understanding the user's world deeply enough that the interface design feels inevitable — like it couldn't have been designed any other way.
Your default mode skips straight to layout. It produces technically correct React that looks generic because it was never grounded in a real person's needs — a stack of cards, a sidebar, a settings list. This skill changes the order of operations: think like a designer first, then write code.
Phase 0: The 0.5-Second Test (ORIENT before everything)
Before designing anything, answer ONE question:
**What does the user SEE in the first half-second — before they
read a single word?**
This is not about content. It's about the SHAPE of the screen. Close your eyes and picture it. What dominates?
- A single hero number with a sparkline? → Analytics / metric dashboard
- A gradient card with bold white text? → Status / billing / hero page
- A table with sticky chrome and a search bar? → Data console / admin
- A grid of media cards? → Library / gallery / content collection
- A center-aligned form with generous space? → Sign-up / auth / single-action
- A two-pane list + detail? → Email / inbox / CRM
- A timeline of events? → Activity feed / audit log
If your answer is "a sidebar and some cards in a content area" → STOP. That's a CMS theme, not a product. Go back and find the visual shape that matches what this user actually needs.
Write the 0.5-second answer as the FIRST line of the experience brief, as a JSDoc comment on the route:
// 0.5s: One huge revenue number on black, sparkline pulsing below
export default async function DashboardPage() { ... }This single sentence anchors every decision that follows. If the code you write doesn't produce that shape, something went wrong.
Phase 1: Design Thinking (Before You Touch React)
Before writing a single line of JSX, answer these questions. Write the answers down as comments or in your thinking. If you skip this phase, your output will look like every other AI-generated UI — correct but soulless.
1. Who is the user?
Not "a SaaS admin." A real person with a context:
- What moment are they in when they open this page? (Triaging the
morning's alerts? Sharing a link in a meeting? Reviewing the quarter before a board call?)
- What did they just do before arriving here? (Clicked a notification
email? Searched? Followed a deep link from Slack?)
- What do they want to accomplish in under 10 seconds?
This shapes EVERYTHING. A user pasted into the page via a Slack deep-link needs a giant headline that reorients them. A user with the page open all day wants quiet chrome and big working space. A user evaluating during a demo wants the value proposition to be visible without scrolling.
2. What should they FEEL?
This is the question that separates designed products from CRUD admin panels. Linear doesn't show you a kanban — it makes shipping feel like an obsession. Stripe doesn't show you payment forms — it makes commerce feel like a solved problem. Every design choice serves that emotional goal.
Before choosing components, decide the emotional intent:
- Focused / Productive → muted palette, monochrome chrome, single
accent color, generous space, Inter or system-ui sans
- Confident / Trustworthy → deeper blues/greens, clear data
presentation, generous whitespace, classical proportions
- Delighted / Premium → unexpected microinteractions, rich
gradients, subtle 3D, considered typography (Söhne, Inter Display)
- Energetic / Bold → high saturation, large display type,
asymmetric layouts, strong photography
- Calm / Editorial → serif typography, narrow text columns, lots
of breathing room, restrained color
The emotional intent drives every visual decision downstream: color tokens, scale, spacing, whether data is listed or visualized, whether the page feels dense or spacious.
3. What are their goals and pain points?
For each page, identify:
- Primary goal — the ONE thing most users come here to do
- Secondary goals — things some users occasionally need
- Pain points — what frustrates users in this domain?
A billing page: the primary goal isn't "see all billing info." It's "change the payment method that just expired" or "download last month's invoice for expense reports." The pain point is wading through plan summaries and feature comparisons to find the one action that needs doing.
4. What features serve those goals?
Map goals to features. Not "what features could this page have?" but "what's the minimum set of features that makes the primary goal effortless?" Every feature that doesn't serve a goal is clutter.
Group features by priority:
- Must-have — blocks the primary goal without it
- Should-have — significantly improves the experience
- Could-have — nice but the user doesn't miss it if it's absent
5. How do features become routes?
This is information architecture — deciding what goes where in the App Router:
- One primary action per route. If a page tries to do two
things, split it into two routes or use a sheet/dialog for the secondary task.
- Group by user intent, not by data type. A user doesn't think
"I want to see my notification settings." They think "I want Slack to stop pinging me during deep work." Group features by the problem they solve, not by their technical category.
- Navigation follows the user's mental model.
/settings→
/settings/billing is obvious. /admin/organizations/{org}/members/{member}/preferences/notifications is six levels deep for something the user sets once. Use parallel routes (@modal) and intercepting routes for "open as overlay" patterns when full navigation is overkill.
6. What components serve each feature?
NOW you think about React — but through the lens of user intent:
- Server Component vs Client Component — RSC by default. Use
'use client' only when interactivity is required. Don't ship a hydration boundary for a list that doesn't need one.
- Dialog vs Popover vs Sheet vs Full page — see ux-modality.
Pick the lightest container that fits.
- Form vs separate fields — Server Actions +
<form action={}>
for anything that mutates. Standalone fields with useOptimistic for live-saving settings.
- Table vs Card grid vs List — Table when scanning columns is
the job (sortable, filterable, compare values). Card grid for heterogeneous browse. List for homogeneous rows with one primary identifier per row.
- shadcn/ui vs custom — shadcn/ui Radix-based primitives are
the default. Custom components only when no primitive fits.
The component choice IS the design. A <DataTable> with sticky header for a financial dashboard feels precise and bureaucratic. A gradient card grid for the same data feels exploratory and editorial. Neither is wrong — the right choice depends on who the user is and what moment they're in.
Phase 2: Visual Design
After Phase 1, you know who the user is, what they need, how they should feel, and what components serve those needs. Now make it beautiful. The emotional intent from Phase 1 drives every choice here.
1. Hierarchy Through Scale
Not just font weight — dramatic scale contrast. The most important thing on screen should be physically large, not just bold.
- Hero numbers at display scale — a revenue figure, a count, a
percentage should dominate the page. Use text-6xl or text-7xl with font-semibold tracking-tight tabular-nums. Linear's velocity charts use 64-72 px display numbers. Don't shrink important data into a text-sm row.
- Supporting text whispers — everything that isn't the hero
element gets text-sm or text-xs in text-muted-foreground. The contrast between the hero and the support IS the hierarchy.
- Space as luxury — leave empty areas. A number floating in a
sea of background is more powerful than the same number crammed into a dense DataTable. Space communicates importance.
// Hero metric — scale dominates, whisper labels
<section className="p-8">
<p className="text-sm text-muted-foreground">Net revenue · last 30 days</p>
<p className="mt-2 text-7xl font-semibold tracking-tight tabular-nums">
${(revenue / 100).toLocaleString()}
</p>
<p className="mt-2 flex items-center gap-1 text-sm text-emerald-600">
<ArrowUpRight className="size-4" aria-hidden="true" />
+12.4% vs prior period
</p>
</section>2. Color Is Math, Not Vibes
NEVER pick colors by hand. Color harmony is a solved mathematical problem. This skill bundles a palette generator that computes every color from a single seed hue — analogous harmony, WCAG contrast validated, light and dark mode variants emitted as CSS custom properties and a Tailwind config snippet.
Before writing any view code, run the palette generator:
python scripts/generate_palette.py \
--seed <hue-degrees> \
--mode both \
--items <collection-count> \
--app "App Name"Seed hue guide:
- 0–30° = warm (creative, social, dating, food)
- 30–60° = golden (finance, productivity, mail)
- 60–150° = green (health, fitness, sustainability)
- 150–210° = cyan/teal (developer tools, infra, cloud)
- 210–270° = blue (trust, fintech, enterprise SaaS)
- 270–330° = purple (creative, AI, premium)
- 330–360° = pink/red (energy, gaming, e-commerce)
Paste the generated :root and .dark CSS custom properties into app/globals.css, and use ONLY those tokens via Tailwind utilities (bg-primary, text-muted-foreground, border-border). The palette is computed — every color is mathematically related to the seed, contrast ratios are pre-validated, and light/dark variants are included.
Rules that never break:
- One seed hue per app. Everything derives from it.
- Collections use analogous variations (the
--itemsflag),
not random hues. They sit together because they're ±30° of seed.
- Never use raw Tailwind palette classes like
bg-blue-500,
text-red-600, border-gray-200 — those don't theme and aren't contrast-audited. Use semantic tokens.
- **Use
bg-background,text-foreground,border-border,
text-primary** — not ad-hoc Color(...) calls or arbitrary classes scattered through component code.
3. Show Data, Don't List It
When data is the content (analytics, financial stats, progress), VISUALIZE it instead of putting it in a LabeledRow:
- Sparklines and area charts for trends over time (Recharts,
Tremor, or hand-rolled SVG)
- Gauges and rings for progress toward a goal
- Large hero numbers with delta arrows and percentage chips
- Heatmaps for activity by time-of-day or by-day-of-week
- Color-coded bars for composition (revenue by source, time split)
A <dl><dt>Revenue</dt><dd>$8,432</dd></dl> is information. A large "$8,432" in text-6xl tabular-nums with a sparkline below it is an experience. The emotional intent from Phase 1 tells you which one to use.
4. Card-Based Composition
Don't default to a single bordered container for everything. Compose with rounded-xl border bg-card containers when the content is heterogeneous:
- Cards with
rounded-xl border bg-card text-card-foreground p-6 - Each card is a self-contained visual unit with its own hierarchy
- Cards can have gradient backgrounds for visual richness
(think Vercel deployment cards, Linear cycle cards)
- Use CSS grid with
auto-rows-frfor equal-height card grids
Tables are for homogeneous scannable data. Cards are for dashboards, overviews, and content-rich pages where each item has its own story.
5. Content Realism
The data IS the design. Every preview tells a coherent story:
- Real names ("Elena Marsh"), plausible numbers ("$47.83", "4.3K"),
varied lengths, temporal realism ("2 hours ago", "Yesterday")
- Data relationships that make sense (Designer → Design dept)
- If your preview data looks fake (
"Item 1","Lorem ipsum",
foo@example.com), your design looks fake
Generate seed data with a coherent narrative — even in fixtures.
6. Restraint
What you leave out defines taste. No instruction headers ("Welcome to your dashboard"). No uniform icon decorations on every row. No tutorial overlays. No demo naming. For every element, ask: "what happens if I remove this?" If nothing — remove it.
7. Craft
The invisible details that feel right:
tabular-numson changing numbers so digits don't reflowtracking-tighton display headingstransition-colors duration-150on every interactive elementhover:+focus-visible:+active:states on every buttonmotion-safe:on transforms (see acc-reduce-motion)truncate+line-clamp-Ninstead of overflow hidden + ellipsis JSfont-variant-numeric: tabular-numseverywhere numbers tick- Accessibility as design, not compliance (see web-rules)
8. Character
Each page has a distinct personality. Character comes from:
- Domain-appropriate color palettes (cool blue for fintech, warm
ochre for crafts, vivid green for sustainability)
- Content-specific typography pairings (a serif headline + Inter
body feels editorial; mono in the chrome feels developer-y)
- Cover the navigation bar — can you still tell what app this is?
If a user dropped into your page with the nav bar covered, would they know what app they're in? Linear's character is in its sharp chrome and cycle visualization. Stripe's is in its perfectly-aligned data tables and trust-blue accent. Find yours.
Applying Both Phases
When asked to build a route, layout, or component:
1. Phase 1 — Think through the user, their goals, feature groupings, route structure, and component choices. Write brief notes (as code comments or in your response) showing your design reasoning. This is not optional — it's what separates a designed experience from a decorated layout.
2. Phase 2 — Write the React/Next.js/Tailwind code with all eight fundamentals applied. Start with realistic data and seed fixtures. Build minimal, add only what earns its place, then polish with craft details.
3. Self-check — Before finishing, ask: "Would a real user using this product in the moment I identified in Phase 1 feel like this page was designed for them?" If not, something in Phase 1 was wrong — go back.
No Taste vs Taste — Concrete Examples
For full before/after code (a generic AI-looking dashboard vs a Stripe-inspired revenue page) and the Component Palette Quick Reference table that maps reflex choices to taste-driven ones, read references/code-examples.md.
The short version of the contrast: a "no taste" page jumps straight to layout — instruction header, numbered placeholders, text-gray-* and bg-blue-* hardcoded, generic component naming. A "taste" page opens with a design comment naming the user and emotional intent, puts the most important number at hero scale with tabular-nums, uses semantic tokens (text-muted-foreground, bg-card), and lets a sparkline support the hero rather than compete with it.
Reference apps worth studying: Linear, Stripe, Vercel, Notion, and high-craft consumer apps (Cash App, Things, Arc). Each has a distinct character driven by deliberate choices in chrome, typography, and data presentation.
The Screen Becomes the Content
Study Linear's issue detail view: the page isn't a form about an issue — the entire screen IS the issue. The title is the page heading at hero scale. The description IS the body. Actions live in chrome that fades away. There's no "Edit issue" page; editing happens in place.
This is the highest level of taste: the UI dissolves into the content. The page doesn't frame the data — it becomes the data.
Techniques for this:
- In-place editing —
contenteditableon the title and body so
the page IS the editor. Save on blur via Server Action.
- Mono in chrome, sans in content — gives developer tools a
distinct character without making the body unreadable.
- Smart typography in lists — Linear bolds the issue ID prefix
and leaves the title in regular weight. This tiny detail makes identifier-scanning dramatically faster. Find the equivalent typographic hierarchy for your domain.
- Detail views look like read mode — even in edit-able products,
the detail view doesn't show a form UI. The same content reads beautifully and edits in place.
Reference: Web Design DNA
When making specific design decisions, read references/web-design-dna.md in this skill's directory. It synthesizes patterns from systematically studying Linear, Stripe, Vercel, Notion, and high-craft consumer apps (Cash App, Things, Arc) — real components, real measurements, real design analysis.
Key sections to consult:
- Three modes of web app design — Dashboard (data-heavy, dark
chrome), Utility (CRUD-driven, light surfaces), Editorial (content- first, expressive typography)
- Universal measurements — card radius (12-16 px), header height
(56-64 px), content max-width (1024-1280 px)
- Onboarding templates — feature-list vs hero-illustration patterns
- Button hierarchy — filled primary, outline secondary, ghost
tertiary; destructive isolated by position not just color
- Detail-page-becomes-content — the Linear / Notion technique
- Empty states — show structure, not "no data" messages
The Mindset
You are not a developer who can also design. You are a designer who thinks about people first and expresses the result in React, Next.js, and Tailwind. The code is the medium. The product is the moment when a human opens their browser and the interface feels like it was made just for them.
Web Taste
This curated skill mirrors SKILL.md. When maintaining it, keep the trigger language focused on React 19 / Next.js 16 / Tailwind CSS user-facing interface work and keep supporting references in references/.
Sibling skill: skills/.experimental/web-rules/ — the strict-rules companion. web-taste answers what to build; web-rules enforces how to build it correctly.
{
"version": "1.0.1",
"organization": "dot-skills",
"technology": "React + Next.js + Tailwind",
"date": "May 2026",
"abstract": "Designs React 19 + Next.js 16 + Tailwind CSS experiences with real taste — starting from user goals, not pixels."
}
Web Taste — Code Examples & Component Palette
Concrete before/after examples and the component-choice reference table. Pulled out of SKILL.md to keep the main skill lean.
What "No Taste" Looks Like
// NO TASTE — jumped straight to layout, no user thinking
export default function DemoPage() {
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Dashboard</h1>
<p className="text-gray-600 mb-4">
Welcome to your dashboard! Here you can see all your data.
</p>
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="border rounded p-4">
<h2 className="font-semibold">Item {i}</h2>
<p className="text-sm text-gray-500">Description</p>
<button className="bg-blue-500 text-white px-3 py-1 rounded mt-2">
Action
</button>
</div>
))}
</div>
</div>
)
}No user thinking. No goals. Instruction header ("Welcome to your dashboard"). Numbered placeholders. Hardcoded text-gray-* and bg-blue-*. Generic naming. No character.
What Taste Looks Like
// GOLDEN — Stripe-inspired revenue overview
// User: Maya, finance ops, opens this every morning while drinking coffee
// Emotional intent: CONFIDENT — make the number trustworthy, the trend obvious
// Hero: net revenue dominating the top, sparkline whispering below
export default async function RevenuePage() {
const [revenue, trend, recent] = await Promise.all([
getNetRevenue(),
getTrend(),
getRecentPayments(),
])
return (
<main className="mx-auto max-w-6xl px-6 py-8 space-y-8">
<section>
<p className="text-sm text-muted-foreground tracking-wide uppercase">
Net revenue · last 30 days
</p>
<h1 className="mt-2 text-7xl font-semibold tracking-tight tabular-nums">
${(revenue / 100).toLocaleString()}
</h1>
<div className="mt-3 flex items-center gap-3">
<span className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium',
trend.delta >= 0
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400'
: 'bg-destructive/10 text-destructive'
)}>
{trend.delta >= 0 ? <ArrowUpRight className="size-3" /> : <ArrowDownRight className="size-3" />}
{Math.abs(trend.delta).toFixed(1)}%
</span>
<span className="text-sm text-muted-foreground">vs prior 30 days</span>
</div>
<RevenueSparkline data={trend.points} className="mt-6 h-16 w-full" />
</section>
<section aria-labelledby="recent-heading">
<h2 id="recent-heading" className="text-sm font-medium text-muted-foreground mb-3">
Recent payments
</h2>
<ul className="rounded-xl border bg-card divide-y">
{recent.map((p) => (
<li key={p.id} className="flex items-center justify-between px-4 h-14">
<div className="min-w-0">
<p className="font-medium truncate">{p.customer}</p>
<p className="text-xs text-muted-foreground">{p.email}</p>
</div>
<p className="font-mono tabular-nums">${(p.amount / 100).toLocaleString()}</p>
</li>
))}
</ul>
</section>
</main>
)
}Design comment explains user moment and emotional intent. Hero revenue number dominates the screen (not buried in a <dl>). Tabular-nums keeps digits from reflowing. Semantic tokens (text-muted-foreground, bg-card, border) — no raw grayscale. Sparkline supports the hero number, doesn't compete with it. You know this is a fintech product without reading the title.
Component Palette Quick Reference
When you instinctively reach for a tutorial component, STOP:
| NEVER (reflex) | GOLDEN (reach for this instead) |
|---|---|
<table> for everything | Cards for heterogeneous; table for sortable columns |
Form + Section boxes | Server Action + flat sections separated by space-y-8 |
<dl><dt><dd> for metrics | Hero typography text-6xl tabular-nums |
| Generic ProgressBar | Sparkline / area chart / radial gauge |
<Button>Save</Button> | Autosave with optimistic UI (see ux-settings) |
text-gray-500 | text-muted-foreground (semantic token) |
text-base for headings | text-3xl tracking-tight or larger |
| Default border | Tinted backgrounds, gradients, bg-card token |
confirm() for destructive | Typed confirmation OR Undo toast |
useState + useEffect for data | Server Component await getData() |
Modern stack idioms to reach for: Server Components by default, Server Actions for mutations, useOptimistic for live UI, <Suspense> for streaming, parallel routes (@modal) for overlays with their own loading states, intercepting routes for "open as modal with a real URL".
Reference apps to study: Linear (sharp chrome, cycle visualization, keyboard-first), Stripe (perfectly-aligned tables, trust-blue, generous typography), Vercel (gradient cards, mono in chrome, deployment cards), Notion (in-context editing, content-as-UI), Apple-fueled fintech UI (Cash App, Robinhood, Wealthfront).
Web Design DNA — Cross-Product Synthesis
Patterns extracted from studying Linear, Stripe, Vercel, Notion, Cash App, and Arc as of 2026. Sources: live product UI, public design system docs, component libraries (shadcn/ui, Radix, Tremor), and engineering blog deep-dives.
The Three Modes of Modern Web App Design
Mode 1: Console (Linear, Vercel, Sentry, Stripe Dashboard)
- Background: Near-black in dark mode (#0a0a0a / #131414), off-white
in light mode (#fafafa)
- Chrome: Minimal. Sidebar with subtle dividers, header with sticky
page actions, no big illustrations
- Content: Dense lists of issues / deployments / customers / events
- Data: Inline charts, sparklines, status pills, monospace identifiers
- Emotion: Focused, productive, control. The user is the operator.
- Typography: Inter / Geist / SF Pro — sans-serif workhorse. Monospace
for IDs and code (Geist Mono, JetBrains Mono).
- Navigation: Sidebar primary, keyboard-first (Cmd+K palette mandatory)
- Use when: The app's purpose is operating, monitoring, or managing
ongoing systems
Mode 2: Editorial (Notion, Stripe Marketing, Cash App, Things)
- Background: White or very light surface, generous max-width content
columns (640-720px for prose)
- Chrome: Disappears — only navigation chrome and one action button
- Content: Long-form text, headings, in-place editing, embedded media
- Data: Inline (the data IS the content)
- Emotion: Calm, considered, focused on the single thing you're making
- Typography: Often serif headlines (Tiempos, Söhne, Charter) paired
with sans-serif body. Larger sizes than typical SaaS.
- Navigation: Minimal. Sidebar that hides on focus, or top-only nav
- Use when: The app's purpose is creating, reading, or thinking
Mode 3: Spectacle (Stripe homepage, Linear marketing, Vercel deploys)
- Background: Animated or gradient — the page IS the experience
- Chrome: Almost none — full-bleed visuals, micro-interactions on
every element
- Content: One bold idea per screen; scroll reveals supporting
evidence
- Data: Decorative when present; always with motion
- Emotion: Premium, ambitious, "this product is serious"
- Typography: Very large display sizes (text-7xl, text-8xl),
optical letter-spacing, custom variable fonts
- Navigation: Top nav with scroll-aware blur/shrink
- Use when: Marketing pages, hero feature reveals, deployment
ceremonies (Vercel's "deploying" screen is theater)
Most products MIX modes: Linear is Console (the app) + Spectacle (the marketing site) + Editorial (the issue detail). Pick mode per route.
Universal Design Principles
1. The Page IS The Content
Linear's issue detail is the issue. Notion's page is the document. Stripe's invoice view is the invoice. In none of these does the UI "frame" the data — the content IS the UI surface. Look for opportunities to make the page disappear into its content.
Implementation in Next.js:
// Linear-style issue detail — no "edit" mode, the page IS the editor
'use client'
export function IssueDetail({ issue }: { issue: Issue }) {
return (
<article className="mx-auto max-w-3xl p-8 space-y-6">
<input
defaultValue={issue.title}
className="w-full text-3xl font-semibold tracking-tight bg-transparent border-none focus:outline-none"
onBlur={(e) => updateTitleAction(issue.id, e.target.value)}
/>
<RichTextEditor
defaultValue={issue.body}
onBlur={(body) => updateBodyAction(issue.id, body)}
/>
</article>
)
}2. One Accent Color, Used Sparingly
Linear uses a single brand purple (~#5e6ad2) in:
- Active sidebar item background
- Primary button fill
- Pull-quote borders
- Cycle progress
That's it. Four placements across the entire product.
Stripe uses indigo-blue (#635bff) similarly: primary CTA, link color, active state. Trust + simplicity.
Anti-pattern: rainbow palettes where every section has its own brand color. Use semantic tokens; reserve hue variation for content categorization (chart series, status pills), not for chrome.
3. Card vs Row Grammar
- Card: Used for dashboards, summaries, marketing grids. Self-
contained, rounded corners (12-16px), subtle border or shadow, generous padding (24px). Cards say "here's a snapshot — tap to dive deeper."
- Row: Used for lists where each item has the same shape (issues,
customers, deployments, files). Full-width, divider-separated, compact padding (8-12px vertical). Rows say "here's structured data — scan and act."
The transition from card -> row signals depth level. Dashboard cards link to row-based detail lists.
4. Onboarding: Three Templates
Feature List (Notion, Linear, Vercel):
[Icon — same color, same style for all rows] Bold Title
Description (1 sentence)
[Icon] Bold Title
Description
[Icon] Bold Title
Description
[=== Get started ===]Hero Illustration (Stripe, Cash App):
[Large custom illustration — brand character]
Bold Headline
Body subhead (centered)
[=== Primary CTA ===] Secondary linkEmpty Slate (Linear new workspace, Notion new page):
[Single brand symbol]
"You don't have any X yet"
"X are great for Y."
[=== Create your first X ===]Use Feature List for "here's what this product does" (3 screens max). Use Hero Illustration for marketing pages. Use Empty Slate for in-app first-run.
5. Button Hierarchy
- Primary: Filled with accent color, white text. One per page.
- Secondary: Outline / ghost in foreground color. Up to 3 per page.
- Tertiary: Text-only link in muted-foreground; underline on hover.
- Destructive: Filled destructive color, isolated by position
(rightmost in dialogs, bottom of "Danger zone" sections), never primary on a page.
shadcn/ui Button variants line up: default, outline, ghost, link, destructive. Use them; do not introduce new variants unless you've fully redesigned the hierarchy.
6. Empty States Show Structure, Not Absence
When there's no data:
- Show the skeleton of what would be there (a dotted card outline,
a faded sample row) so the user understands the shape
- Or show the first step (Linear's "Create your first issue")
- Never show "No data" alone
Stripe's empty payments table shows a faded sample row + arrow pointing to "Test mode" + how to send a test payment. Educational emptiness.
7. Glass-on-Gradient Detail Views
For high-craft detail screens (a deploy detail, a customer profile, a release post):
Layer 0: Full-bleed gradient or accent background
Layer 1: Frosted glass cards (backdrop-blur-xl bg-white/5 dark:bg-white/5)
Layer 2: Strong text contrast on the glassVercel's deploy detail does this (gradient + dashboard cards on glass). Cash App's send/receive screen does this (gradient background + frosted card with the amount).
Tailwind recipe:
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-violet-500/30 via-indigo-500/20 to-blue-500/30" />
<div className="relative z-10 rounded-2xl backdrop-blur-xl bg-white/40 dark:bg-black/40 border border-white/10 p-6">
{/* content */}
</div>
</div>8. Inline Data Enhancement
Instead of "View metrics →", Linear embeds a sparkline in the cycle card. Instead of "See deploys →", Vercel embeds a heatmap of deployments per day. The preview IS the data — no navigation needed for first-level insight.
Use Tremor, Recharts, or hand-rolled SVG with viewBox to fit the chart into a card. Keep them small (40-80px tall) — they're a hint, not the destination.
9. Keyboard-First Power Patterns
The hallmark of a "Console" app is keyboard mastery:
- Cmd+K palette — every action discoverable by typing. Use cmdk
(the library shadcn/ui ships) or kbar
- Jump nav — Cmd+1..9 for top-level sections
- Vim-style row navigation — j/k to move, Enter to open
- Selection state — keyboard arrows + Shift to multi-select
Linear is the gold standard. If you're building a Console app, ship Cmd+K on day one.
import { Command } from 'cmdk'
export function CommandPalette() {
return (
<Command.Dialog>
<Command.Input placeholder="Type a command or search..." />
<Command.List>
<Command.Group heading="Actions">
<Command.Item onSelect={() => createIssue()}>
<Plus className="size-4" /> New issue
<kbd className="ml-auto">⌘N</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Navigation">
<Command.Item onSelect={() => router.push('/inbox')}>Inbox</Command.Item>
</Command.Group>
</Command.List>
</Command.Dialog>
)
}10. Typography That Scans
Linear's issue list bolds the issue ID prefix (e.g., ENG-1432) and leaves the title in regular weight. Stripe's customer list bolds the email. Vercel's deploy list bolds the commit message. Each app identifies the MOST LIKELY scan pattern of its content and uses font weight to support it.
For your app: figure out what users scan FOR (a number, a name, a status?) and bold that — let everything else be regular weight.
11. Dark Mode Is the Default for Console, Light for Editorial
Linear, Vercel, Sentry, Datadog, GitHub: default dark. The operator mindset wants quiet chrome. Notion, Stripe Marketing, Substack: default light. The editorial mindset wants paper.
Always ship both. Let users override. Use next-themes with defaultTheme="system".
12. Optimistic UI Is Visible Polish
The difference between Linear's "instant" feel and a typical CRUD app is useOptimistic. Every interaction renders the expected result on the next paint (16ms), then reconciles. The server round- trip is hidden.
Use useOptimistic for:
- Toggling status (mark issue done, like post)
- Reordering (drag a row, drop in new position)
- Quick edits (rename inline, change priority)
'use client'
import { useOptimistic } from 'react'
export function StatusToggle({ issue }: { issue: Issue }) {
const [optimisticStatus, setStatus] = useOptimistic(issue.status)
return (
<button onClick={() => {
const next = optimisticStatus === 'done' ? 'open' : 'done'
setStatus(next)
startTransition(() => updateStatusAction(issue.id, next))
}}>
{optimisticStatus === 'done' ? <CheckCircle2 className="text-success" /> : <Circle />}
</button>
)
}Measurements Reference (the de-facto standards)
| Element | Size | Tailwind | Notes |
|---|---|---|---|
| Sidebar width | 240-280 px | w-60 / w-72 | Wider for long item names (Linear), narrower for icon-led (Vercel) |
| Top nav / header height | 56-64 px | h-14 / h-16 | h-14 for compact consoles, h-16 for editorial |
| Page content max-width | 1024-1280 px | max-w-5xl / max-w-6xl | 1280 for dashboards, 720 for prose |
| Card corner radius | 12-16 px | rounded-xl / rounded-2xl | rounded-xl is the safest default |
| Border color (light) | #e4e4e7 (zinc-200) | border-border | Use the token |
| Border color (dark) | #2a2a2a | border-border | Same token, different value |
| Input height | 36-44 px | h-9 / h-11 | h-11 for primary forms (touch targets), h-9 for dense filters |
| Button height | 32-40 px | h-8 / h-10 | h-10 default; h-8 for inline / row actions |
| Icon (in row) | 16 px | size-4 | Default |
| Icon (button) | 20 px | size-5 | Tap-target friendly |
| Avatar (row) | 32 px | size-8 | List density |
| Avatar (header) | 40 px | size-10 | Profile card |
| Avatar (detail) | 96-120 px | size-24 / size-28 | Profile poster |
| Stat card padding | 24 px | p-6 | Standard dashboard card |
| Row vertical padding | 8-12 px | py-2 / py-3 | Density depends on context |
| List row min height | 44 px | min-h-11 | WCAG touch-target — see inter-touch-targets |
| Hero number font | text-6xl - text-7xl | text-6xl / text-7xl | 60-72 px display values |
| Page heading font | text-2xl - text-3xl | text-2xl tracking-tight | Page H1s |
| Section heading font | text-sm font-medium uppercase | text-sm font-medium text-muted-foreground tracking-wide uppercase | Section labels |
| Body font | text-sm - text-base | text-sm | text-sm for dense, text-base for editorial |
| Code font | text-xs - text-sm mono | font-mono text-xs | IDs and code snippets |
Apps to Study by Pattern
| If you're building... | Study | What to copy |
|---|---|---|
| Issue / ticket tracker | Linear, GitHub Issues | Sidebar nav, Cmd+K, status pills, in-place edit |
| Analytics / metrics | Tremor demos, Vercel Analytics, Stripe Dashboard | Hero number, sparkline, time-range selector |
| CRM / customers | Stripe Customers, Pipedrive | Sticky filters, dense table, drawer for detail |
| Settings | Linear, Stripe Account | Grouped sections, autosave, account switcher |
| Auth / sign-up | Vercel, Stripe, Clerk | Single-action page, social + email, no chrome |
| Editor / canvas | Notion, Linear, Figma | The page IS the editor, slash commands, comments in margin |
| Marketing site | Stripe, Vercel, Linear | Display typography, gradient hero, scroll-reveal |
| Payment / fintech | Stripe Checkout, Cash App | One number dominates, trust signals, single CTA |
The Highest Form
The highest level of taste is when the chrome dissolves into the content. Linear's issue page doesn't show an issue — it IS the issue. Notion's page doesn't contain your writing — it IS your writing. Stripe Checkout doesn't frame the transaction — the transaction renders on plain trust-blue space.
This is the goal. Frames are a cost. The product is the moment when the user forgets the UI exists and is just doing the thing.
#!/usr/bin/env python3
"""
Generate a mathematically harmonious CSS custom-property palette from a single seed hue.
Outputs CSS that drops into Tailwind 4's `@theme` and `.dark` selectors, plus
a Tailwind config snippet showing how to reference the tokens.
Usage:
python generate_palette.py --seed 15 --mode both --items 6
python generate_palette.py --seed 210 --mode dark --items 0
python generate_palette.py --seed 120 --app "Fitness Tracker"
Arguments:
--seed Hue angle in degrees (0-360). Examples:
0-30 = warm (creative, social, food)
30-60 = golden (finance, productivity)
60-150 = green (health, fitness, sustainability)
150-210 = cyan/teal (dev tools, infra, cloud)
210-270 = blue (trust, fintech, enterprise SaaS)
270-330 = purple (creative, AI, premium)
330-360 = pink/red (energy, gaming, e-commerce)
--mode light, dark, or both (default: both)
--items Number of collection items needing distinct colors (default: 0)
--app Optional app name for the generated comment
Output: A complete CSS block ready to paste into `app/globals.css`, plus a
Tailwind v4 `@theme` snippet. All colors use HSB internally for harmony math;
output is hex (sRGB) for portability. Contrast ratios are validated.
"""
import argparse
import colorsys
def hsb_to_rgb(h: float, s: float, b: float) -> tuple[float, float, float]:
"""Convert HSB (h in 0-1, s in 0-1, b in 0-1) to RGB (0-1)."""
return colorsys.hsv_to_rgb(h, s, b)
def relative_luminance(r: float, g: float, b: float) -> float:
"""WCAG relative luminance from linear RGB."""
def linearize(c):
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
rl, gl, bl = linearize(r), linearize(g), linearize(b)
return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl
def contrast_ratio(lum1: float, lum2: float) -> float:
"""WCAG contrast ratio between two luminances."""
lighter = max(lum1, lum2)
darker = min(lum1, lum2)
return (lighter + 0.05) / (darker + 0.05)
def validate_text_on_bg(h: float, s: float, b: float, text_white: bool) -> bool:
"""Check if white/black text has >= 4.5:1 contrast on this HSB background."""
r, g, bl = hsb_to_rgb(h, s, b)
bg_lum = relative_luminance(r, g, bl)
text_lum = 1.0 if text_white else 0.0
return contrast_ratio(bg_lum, text_lum) >= 4.5
def adjust_for_contrast(h: float, s: float, b: float, text_white: bool) -> tuple[float, float, float]:
"""Adjust brightness to ensure WCAG 4.5:1 contrast with text color."""
if text_white:
while b > 0.1 and not validate_text_on_bg(h, s, b, True):
b -= 0.02
else:
while b < 0.99 and not validate_text_on_bg(h, s, b, False):
b += 0.02
s = max(0.02, s - 0.01)
return h, s, b
def hex_from_hsb(h: float, s: float, b: float) -> str:
r, g, bl = hsb_to_rgb(h % 1.0, s, b)
return f"#{int(round(r*255)):02x}{int(round(g*255)):02x}{int(round(bl*255)):02x}"
def generate_palette(seed_deg: int, mode: str, item_count: int, app_name: str) -> str:
"""Generate a complete CSS @theme + .dark palette block plus Tailwind snippet."""
seed = seed_deg / 360.0
out = []
out.append(f"/* Generated palette for {app_name or 'app'} - seed hue: {seed_deg}deg */")
out.append(f"/* Analogous harmony, WCAG contrast validated */")
out.append(f"/* Paste into app/globals.css after `@import \"tailwindcss\"` */")
out.append("")
# ---------- Compute core colors ----------
# Dark mode
dh_primary, ds_primary, db_primary = adjust_for_contrast(seed, 0.70, 0.85, True)
dh_secondary, ds_secondary, db_secondary = adjust_for_contrast(seed, 0.22, 0.65, True)
dh_accent, ds_accent, db_accent = adjust_for_contrast(seed + 0.417, 0.70, 0.85, True)
dark_primary = hex_from_hsb(dh_primary, ds_primary, db_primary)
dark_secondary = hex_from_hsb(dh_secondary, ds_secondary, db_secondary)
dark_accent = hex_from_hsb(dh_accent, ds_accent, db_accent)
dark_card = hex_from_hsb(seed, 0.12, 0.14)
dark_surface = hex_from_hsb(seed, 0.05, 0.08)
# Light mode
light_primary = hex_from_hsb(seed, 0.65, 0.55)
light_secondary = hex_from_hsb(seed, 0.10, 0.90)
light_accent = hex_from_hsb(seed + 0.417, 0.55, 0.60)
light_card = hex_from_hsb(seed, 0.04, 0.97)
light_surface = hex_from_hsb(seed, 0.02, 0.99)
# Collection item colors
items_dark = []
items_light = []
if item_count > 0:
base_spread = 0.167 # 60deg
hue_spread = min(base_spread + (item_count - 1) * 0.005, 0.333) # cap at 120deg
for i in range(item_count):
t = float(i) / max(1, item_count - 1) if item_count > 1 else 0.5
item_hue = seed + (t * hue_spread) - (hue_spread / 2)
tier = i % 3
if tier == 0:
sat_dark, bri_dark = 0.70, 0.55
sat_light, bri_light = 0.45, 0.82
elif tier == 1:
sat_dark, bri_dark = 0.40, 0.78
sat_light, bri_light = 0.20, 0.95
else:
sat_dark, bri_dark = 0.55, 0.68
sat_light, bri_light = 0.35, 0.88
ih, is_, ib = adjust_for_contrast(item_hue, sat_dark, bri_dark, True)
items_dark.append(hex_from_hsb(ih, is_, ib))
ih, is_, ib = adjust_for_contrast(item_hue, sat_light, bri_light, False)
items_light.append(hex_from_hsb(ih, is_, ib))
# ---------- Emit CSS ----------
if mode in ("light", "both"):
out.append("/* Light theme (default) */")
out.append(":root, .light {")
out.append(f" --color-background: {light_surface};")
out.append(f" --color-foreground: #1a1a1a;")
out.append(f" --color-card: {light_card};")
out.append(f" --color-card-foreground: #1a1a1a;")
out.append(f" --color-popover: {light_card};")
out.append(f" --color-popover-foreground:#1a1a1a;")
out.append(f" --color-primary: {light_primary};")
out.append(f" --color-primary-foreground:#ffffff;")
out.append(f" --color-secondary: {light_secondary};")
out.append(f" --color-secondary-foreground:#1a1a1a;")
out.append(f" --color-muted: {light_secondary};")
out.append(f" --color-muted-foreground: #73726f;")
out.append(f" --color-accent: {light_accent};")
out.append(f" --color-accent-foreground: #ffffff;")
out.append(f" --color-border: #e4e4e7;")
out.append(f" --color-input: #e4e4e7;")
out.append(f" --color-ring: {light_primary};")
out.append(f" --color-destructive: #c0392b;")
out.append(f" --color-destructive-foreground:#ffffff;")
out.append(f" --color-success: #2f8f3f;")
out.append(f" --color-warning: #b87b00;")
for i, hexv in enumerate(items_light):
out.append(f" --color-chart-{i + 1}: {hexv};")
out.append("}")
out.append("")
if mode in ("dark", "both"):
out.append("/* Dark theme */")
out.append(".dark {")
out.append(f" --color-background: {dark_surface};")
out.append(f" --color-foreground: #f5f5f5;")
out.append(f" --color-card: {dark_card};")
out.append(f" --color-card-foreground: #f5f5f5;")
out.append(f" --color-popover: {dark_card};")
out.append(f" --color-popover-foreground:#f5f5f5;")
out.append(f" --color-primary: {dark_primary};")
out.append(f" --color-primary-foreground:#0a0a0a;")
out.append(f" --color-secondary: {dark_secondary};")
out.append(f" --color-secondary-foreground:#f5f5f5;")
out.append(f" --color-muted: {dark_card};")
out.append(f" --color-muted-foreground: #a6a6a6;")
out.append(f" --color-accent: {dark_accent};")
out.append(f" --color-accent-foreground: #0a0a0a;")
out.append(f" --color-border: #2a2a2a;")
out.append(f" --color-input: #2a2a2a;")
out.append(f" --color-ring: {dark_primary};")
out.append(f" --color-destructive: #e06450;")
out.append(f" --color-destructive-foreground:#0a0a0a;")
out.append(f" --color-success: #56c46a;")
out.append(f" --color-warning: #e0b04a;")
for i, hexv in enumerate(items_dark):
out.append(f" --color-chart-{i + 1}: {hexv};")
out.append("}")
out.append("")
# ---------- Tailwind v4 @theme block ----------
out.append("/* Tailwind v4 @theme block (maps CSS vars to Tailwind utilities) */")
out.append("@theme inline {")
tokens = [
"background", "foreground",
"card", "card-foreground",
"popover", "popover-foreground",
"primary", "primary-foreground",
"secondary", "secondary-foreground",
"muted", "muted-foreground",
"accent", "accent-foreground",
"border", "input", "ring",
"destructive", "destructive-foreground",
"success", "warning",
]
for t in tokens:
out.append(f" --color-{t}: var(--color-{t});")
for i in range(item_count):
out.append(f" --color-chart-{i + 1}: var(--color-chart-{i + 1});")
out.append("}")
out.append("")
# ---------- Usage hint ----------
out.append("/* Usage in components: */")
out.append("/* bg-background, text-foreground, bg-card, text-muted-foreground */")
out.append("/* bg-primary text-primary-foreground (CTA buttons) */")
out.append("/* text-destructive (errors), text-success (confirmations) */")
if item_count > 0:
out.append(f"/* text-chart-1 .. text-chart-{item_count} (collection / categorical) */")
out.append("")
out.append(f"/* Seed: {seed_deg}deg | Mode: {mode} | Items: {item_count} */")
out.append(f"/* Harmony: analogous (+/-30deg) | Contrast: WCAG AA validated */")
out.append("")
return "\n".join(out)
def main():
parser = argparse.ArgumentParser(description="Generate a Tailwind/CSS color palette")
parser.add_argument("--seed", type=int, required=True, help="Seed hue in degrees (0-360)")
parser.add_argument("--mode", choices=["light", "dark", "both"], default="both")
parser.add_argument("--items", type=int, default=0, help="Number of collection item colors")
parser.add_argument("--app", type=str, default="", help="App name for comment")
args = parser.parse_args()
if not 0 <= args.seed <= 360:
parser.error("Seed must be 0-360")
if args.items > 12:
import sys
print(
f"Warning: {args.items} items requested. Perceptual distinguishability "
f"degrades above 12 items in an analogous palette. Consider 12 or fewer, "
f"or grouping items by category.",
file=sys.stderr,
)
print(generate_palette(args.seed, args.mode, min(args.items, 20), args.app))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Exhaustive verification of the palette generator.
Tests all 361 seed hues x 21 item counts = 7,581 combinations.
Verifies every output satisfies color theory invariants.
If this passes, the palette generator is proven correct by enumeration.
Usage:
python verify_palette.py # run full verification
python verify_palette.py --verbose # show each violation
python verify_palette.py --pair "#737373" "#ffffff" # ad-hoc contrast check
"""
import sys
import argparse
import math
import re
import colorsys
from generate_palette import generate_palette, relative_luminance, contrast_ratio
# ---------- CIE Lab conversion for perceptual distance ----------
def rgb_to_xyz(r: float, g: float, b: float) -> tuple[float, float, float]:
def linearize(c):
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
rl, gl, bl = linearize(r), linearize(g), linearize(b)
x = 0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl
y = 0.2126729 * rl + 0.7151522 * gl + 0.0721750 * bl
z = 0.0193339 * rl + 0.1191920 * gl + 0.9503041 * bl
return x, y, z
def xyz_to_lab(x: float, y: float, z: float) -> tuple[float, float, float]:
xn, yn, zn = 0.95047, 1.00000, 1.08883
def f(t):
return t ** (1 / 3) if t > 0.008856 else (7.787 * t) + (16 / 116)
fx, fy, fz = f(x / xn), f(y / yn), f(z / zn)
L = 116 * fy - 16
a = 500 * (fx - fy)
b = 200 * (fy - fz)
return L, a, b
def hex_to_rgb(h: str) -> tuple[float, float, float]:
h = h.lstrip("#")
return int(h[0:2], 16) / 255.0, int(h[2:4], 16) / 255.0, int(h[4:6], 16) / 255.0
def hex_to_lab(h: str) -> tuple[float, float, float]:
r, g, b = hex_to_rgb(h)
x, y, z = rgb_to_xyz(r, g, b)
return xyz_to_lab(x, y, z)
def hex_to_hue(h: str) -> float:
"""Return HSB hue in 0-1."""
r, g, b = hex_to_rgb(h)
hue, _s, _v = colorsys.rgb_to_hsv(r, g, b)
return hue
def delta_e(lab1: tuple, lab2: tuple) -> float:
return math.sqrt(sum((a - b) ** 2 for a, b in zip(lab1, lab2)))
def hue_distance(h1: float, h2: float) -> float:
d = abs(h1 - h2)
d = min(d, 1.0 - d)
return d * 360.0
# ---------- Parser for CSS output ----------
TOKEN_RE = re.compile(r"^\s*--color-([\w-]+):\s*(#[0-9a-fA-F]{6});", re.MULTILINE)
def parse_palette(output: str) -> dict[str, dict[str, str]]:
"""Return {'light': {token: hex}, 'dark': {token: hex}}."""
blocks = {"light": {}, "dark": {}}
current = None
for line in output.split("\n"):
if ":root" in line or ".light {" in line:
current = "light"
continue
if ".dark {" in line:
current = "dark"
continue
if line.strip() == "}":
current = None
continue
if current is None:
continue
m = TOKEN_RE.match(line)
if m:
blocks[current][m.group(1)] = m.group(2).lower()
return blocks
# ---------- Invariants ----------
def verify_invariants(seed_deg: int, item_count: int, verbose: bool = False) -> list[str]:
violations = []
output = generate_palette(seed_deg, "both", item_count, "test")
blocks = parse_palette(output)
seed_hue = seed_deg / 360.0
if not blocks["light"] or not blocks["dark"]:
violations.append(f"seed={seed_deg}, items={item_count}: no colors parsed")
return violations
# 1. Dark theme — white text contrast on key surfaces
text_white = relative_luminance(1, 1, 1)
for token in ("background", "card", "primary", "secondary", "accent"):
hexv = blocks["dark"].get(token)
if not hexv:
continue
r, g, b = hex_to_rgb(hexv)
bg_lum = relative_luminance(r, g, b)
cr = contrast_ratio(text_white, bg_lum)
if cr < 3.0:
violations.append(
f"DARK_CONTRAST: white-on-{token} = {cr:.2f}:1 < 3.0:1 (seed={seed_deg}, items={item_count})"
)
# 2. Light theme — dark text contrast on key surfaces
text_dark = relative_luminance(0.1, 0.1, 0.1)
for token in ("background", "card", "secondary", "muted"):
hexv = blocks["light"].get(token)
if not hexv:
continue
r, g, b = hex_to_rgb(hexv)
bg_lum = relative_luminance(r, g, b)
cr = contrast_ratio(bg_lum, text_dark)
if cr < 3.0:
violations.append(
f"LIGHT_CONTRAST: dark-text-on-{token} = {cr:.2f}:1 < 3.0:1 (seed={seed_deg}, items={item_count})"
)
# 3. Light background is bright; dark background is dark
light_bg = hex_to_rgb(blocks["light"].get("background", "#ffffff"))
if relative_luminance(*light_bg) < 0.80:
violations.append(f"LIGHT_BG_DIM: seed={seed_deg} (luminance {relative_luminance(*light_bg):.2f} < 0.80)")
dark_bg = hex_to_rgb(blocks["dark"].get("background", "#000000"))
if relative_luminance(*dark_bg) > 0.10:
violations.append(f"DARK_BG_BRIGHT: seed={seed_deg} (luminance {relative_luminance(*dark_bg):.2f} > 0.10)")
# 4. Collection items within harmonic range of seed
max_dist = 35.0 if item_count <= 6 else 65.0
chart_keys = [f"chart-{i + 1}" for i in range(item_count)]
for theme in ("dark", "light"):
for k in chart_keys:
hexv = blocks[theme].get(k)
if not hexv:
continue
h = hex_to_hue(hexv)
dist = hue_distance(h, seed_hue)
if dist > max_dist:
violations.append(
f"HARMONY: {theme}.{k}={hexv} is {dist:.1f}deg from seed {seed_deg}deg (>{max_dist:.0f}deg)"
)
# 5. Pairwise perceptual distance (ΔE) for collection
for theme in ("dark", "light"):
hexes = [blocks[theme].get(k) for k in chart_keys if blocks[theme].get(k)]
for i in range(len(hexes)):
for j in range(i + 1, len(hexes)):
de = delta_e(hex_to_lab(hexes[i]), hex_to_lab(hexes[j]))
if de < 4.0:
violations.append(
f"PERCEPTUAL: {theme} chart-{i+1} and chart-{j+1} ΔE*={de:.1f} < 4.0 (seed={seed_deg})"
)
# 6. Core palette colors (primary/secondary/accent) perceptually distinct
for theme in ("dark", "light"):
keys = ("primary", "secondary", "accent")
core = [(k, blocks[theme].get(k)) for k in keys if blocks[theme].get(k)]
for i in range(len(core)):
for j in range(i + 1, len(core)):
de = delta_e(hex_to_lab(core[i][1]), hex_to_lab(core[j][1]))
if de < 15.0:
violations.append(
f"CORE_SEPARATION: {theme}.{core[i][0]} vs {core[j][0]} ΔE*={de:.1f} < 15.0 (seed={seed_deg})"
)
# 7. Background and card layers visually distinct
for theme in ("dark", "light"):
bg = blocks[theme].get("background")
card = blocks[theme].get("card")
if bg and card:
de = delta_e(hex_to_lab(bg), hex_to_lab(card))
if de < 2.0:
violations.append(
f"BG_LAYERS: {theme} background vs card ΔE*={de:.1f} < 2.0 (seed={seed_deg})"
)
return violations
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--verbose", action="store_true")
parser.add_argument(
"--pair",
nargs=2,
metavar=("FG", "BG"),
help="Ad-hoc contrast check between two hex colors",
)
args = parser.parse_args()
if args.pair:
fg, bg = args.pair
fg_r, fg_g, fg_b = hex_to_rgb(fg)
bg_r, bg_g, bg_b = hex_to_rgb(bg)
cr = contrast_ratio(relative_luminance(fg_r, fg_g, fg_b), relative_luminance(bg_r, bg_g, bg_b))
status_body = "PASS body (>=4.5:1)" if cr >= 4.5 else "FAIL body"
status_large = "PASS large/UI (>=3:1)" if cr >= 3.0 else "FAIL large/UI"
print(f"{fg} on {bg} -> contrast {cr:.2f}:1 | {status_body}, {status_large}")
return
total_tests = 0
total_violations = 0
all_violations: list[str] = []
print("Exhaustive palette verification")
print("Testing 361 seed hues x 21 item counts = 7,581 combinations\n")
for seed in range(361):
for items in range(21):
total_tests += 1
violations = verify_invariants(seed, items, args.verbose)
if violations:
total_violations += len(violations)
all_violations.extend(violations)
if args.verbose:
for v in violations:
print(f" FAIL seed={seed:3d} items={items:2d}: {v}")
if seed % 36 == 0:
print(f" [{seed:3d}/360] {total_tests:,} tests, {total_violations} violations")
print(f"\n{'=' * 60}")
print(f"Total tests: {total_tests:,}")
print(f"Total violations: {total_violations}")
if total_violations == 0:
print("\n ALL INVARIANTS HOLD for every possible input.")
print(" The palette generator is proven correct by exhaustive enumeration.")
else:
types = set(v.split(":")[0] for v in all_violations)
print(f"\n Violation types: {', '.join(sorted(types))}")
print("\n First 10 violations:")
for v in all_violations[:10]:
print(f" {v}")
sys.exit(1)
if __name__ == "__main__":
main()
Related skills
FAQ
What does web-taste do?
web-taste is a Claude Code skill for ai & agent building.
When should I use web-taste?
When you need to helps with ai & agent building tasks during AI-assisted development., or when web-taste is a claude code skill for ai & agent building.
What are the main capabilities?
web-taste; AI & Agent Building; AI-coding skill.