Summary
Deterministic theme extraction reads shadcn's token vocabulary and only shadcn's. A Tailwind v4 @theme block named the Tailwind way extracts almost nothing, silently, and the palette falls through to the consent-gated model pass — so a host with no model key gets the neutral blue default from a design-token sheet that declares every color it needs.
A Next 16 app whose entire palette lives in one @theme block produced this:
// .vendo/theme.extracted.json
{ "format": "vendo/theme-extracted@1", "slots": { "border": "#ece9e3" } }
One slot out of twelve — and border only matched by accident.
The obvious diagnosis is wrong
My first conclusion was "the extractor doesn't parse @theme". It does, and stating this saves the next person the same wrong turn:
parseCssVars (packages/vendo/src/cli/theme/css-vars.ts) is a brace-depth scanner. It is at-rule agnostic and reads declarations inside @theme exactly as it reads :root.
lastLightDecl (extract-theme.ts:282) already tries every allowlisted name in its Tailwind-v4 namespaced spelling: --border → also --color-border.
That second point is precisely why border came through. The @theme block is read correctly. It is the vocabulary that misses.
The actual gap
EXACT_COLOR_TOKENS (extract-theme.ts:259):
| Vendo slot |
Accepted names |
| background |
--background / --color-background |
| text |
--foreground / --color-foreground |
| surface |
--card / --color-card |
| accent |
--primary / --color-primary |
| mutedText |
--muted-foreground / --color-muted-foreground |
| border |
--border / --color-border |
| danger |
--destructive / --color-destructive |
The app names its tokens the way Tailwind's own docs name them: --color-bg, --color-ink, --color-surface, --color-accent, --color-muted, --color-neg. Nothing matches.
The sharpest edge: Vendo's own slot names are not accepted as token names. A host that writes --color-surface and --color-accent — the two slots Vendo literally calls surface and accent — gets neither, because the allowlist maps those slots to shadcn's --card and --primary. Naming your tokens after the thing you are configuring is the most natural guess available, and it is the one that fails.
examples/demo-bank is the control and shows the same shape:
// examples/demo-bank/.vendo/theme.extracted.json
{ "slots": { "border": "#ecebe8", "fontFamily": "Inter, sans-serif", "headingFamily": "Inter, sans-serif" } }
Same one color slot. Its committed theme.json colors (#FBFBFA, #111111, …) are not in that record — they came from the model pass. The two font slots came from deriveBodyFontStack reading its next/font import, not from CSS.
Why it lands hard
The file's own doc comment sets the expectation correctly: the exact pass is "fully deterministic — no model call, no network, no credential", and everything it leaves unfilled "rides init's consent-gated model step". Both halves work as designed. The problem is that the allowlist is narrow enough that a well-organized Tailwind v4 host lands entirely in the second half — so theme extraction reads as "needs a model key" when for a shadcn app it is free and exact.
And the miss is quiet. defaulted is tracked, but a newcomer seeing a blue theme has no way to reach "my token names aren't on a list I've never seen". The natural conclusion is that Vendo cannot read their CSS.
Affected
packages/vendo/src/cli/theme/extract-theme.ts at main (v0.8.0). Any host using Tailwind v4 @theme without shadcn's variable names.
Suggested fix
- Accept Vendo's own slot names as additional spellings:
--background, --surface, --text, --muted-text, --accent, --border, --danger, each also in its --color-* form. Purely additive — no scoring, no inference, no new machinery, just more entries in EXACT_COLOR_TOKENS. It turns "name your tokens after the slots" into a documented deterministic path instead of a coincidence.
- Report the miss usefully. When brand slots default and the host has a CSS custom-property sheet, name a few of the unmatched properties: "found
--color-bg, --color-ink, --color-surface — none are recognized token names; see ". The information is already in hand at that point.
- Document the vocabulary. Nothing currently lists which token names extract deterministically. The list is seven names long and is the difference between a free, exact palette and a model call.
Found while building a Next 16 order-ops app on v0.8.0.
Summary
Deterministic theme extraction reads shadcn's token vocabulary and only shadcn's. A Tailwind v4
@themeblock named the Tailwind way extracts almost nothing, silently, and the palette falls through to the consent-gated model pass — so a host with no model key gets the neutral blue default from a design-token sheet that declares every color it needs.A Next 16 app whose entire palette lives in one
@themeblock produced this:One slot out of twelve — and
borderonly matched by accident.The obvious diagnosis is wrong
My first conclusion was "the extractor doesn't parse
@theme". It does, and stating this saves the next person the same wrong turn:parseCssVars(packages/vendo/src/cli/theme/css-vars.ts) is a brace-depth scanner. It is at-rule agnostic and reads declarations inside@themeexactly as it reads:root.lastLightDecl(extract-theme.ts:282) already tries every allowlisted name in its Tailwind-v4 namespaced spelling:--border→ also--color-border.That second point is precisely why
bordercame through. The@themeblock is read correctly. It is the vocabulary that misses.The actual gap
EXACT_COLOR_TOKENS(extract-theme.ts:259):--background/--color-background--foreground/--color-foreground--card/--color-card--primary/--color-primary--muted-foreground/--color-muted-foreground--border/--color-border--destructive/--color-destructiveThe app names its tokens the way Tailwind's own docs name them:
--color-bg,--color-ink,--color-surface,--color-accent,--color-muted,--color-neg. Nothing matches.The sharpest edge: Vendo's own slot names are not accepted as token names. A host that writes
--color-surfaceand--color-accent— the two slots Vendo literally callssurfaceandaccent— gets neither, because the allowlist maps those slots to shadcn's--cardand--primary. Naming your tokens after the thing you are configuring is the most natural guess available, and it is the one that fails.examples/demo-bankis the control and shows the same shape:Same one color slot. Its committed
theme.jsoncolors (#FBFBFA,#111111, …) are not in that record — they came from the model pass. The two font slots came fromderiveBodyFontStackreading itsnext/fontimport, not from CSS.Why it lands hard
The file's own doc comment sets the expectation correctly: the exact pass is "fully deterministic — no model call, no network, no credential", and everything it leaves unfilled "rides init's consent-gated model step". Both halves work as designed. The problem is that the allowlist is narrow enough that a well-organized Tailwind v4 host lands entirely in the second half — so theme extraction reads as "needs a model key" when for a shadcn app it is free and exact.
And the miss is quiet.
defaultedis tracked, but a newcomer seeing a blue theme has no way to reach "my token names aren't on a list I've never seen". The natural conclusion is that Vendo cannot read their CSS.Affected
packages/vendo/src/cli/theme/extract-theme.tsat main (v0.8.0). Any host using Tailwind v4@themewithout shadcn's variable names.Suggested fix
--background,--surface,--text,--muted-text,--accent,--border,--danger, each also in its--color-*form. Purely additive — no scoring, no inference, no new machinery, just more entries inEXACT_COLOR_TOKENS. It turns "name your tokens after the slots" into a documented deterministic path instead of a coincidence.--color-bg,--color-ink,--color-surface— none are recognized token names; see ". The information is already in hand at that point.Found while building a Next 16 order-ops app on v0.8.0.