Skip to content

docs(theme): add theme studio - #6675

Open
mikenewbon wants to merge 265 commits into
nuxt:v4from
mikenewbon:docs/theme-studio-plan
Open

docs(theme): add theme studio#6675
mikenewbon wants to merge 265 commits into
nuxt:v4from
mikenewbon:docs/theme-studio-plan

Conversation

@mikenewbon

@mikenewbon mikenewbon commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

This is a concept/prototype, not a finished feature — opening early for direction feedback.

What it is: a standalone /theme page (header nav) that turns the existing popover into a full theme studio. Everything is a view over one idea: a theme is a sparse set of overrides on the chain Tailwind palette → aliases → semantic tokens → component variants — so the CSS export stays minimal by construction, and the two files split correctly between main.css and app.config.ts.

docs: scope only — no library code touched.

Features

  • Palette editor — devtools-style bézier curves for lightness/chroma/hue per ramp; any existing palette reverse-fits into editable curves; per-stop pinning; taste modifiers (lightness/contrast/saturation/hue) over the fitted curve; live-streaming drag with a WCAG readout.
  • Style axes — one config-driven shadow (offset/blur/spread, opacity, colour) plus an independent inner shadow, and a border treatment (width, optional frame, colour) — each with per-mode neutral-shade sliders.
  • Semantic token shades — background/text/border tokens mapped to ramp stops, per light/dark mode.
  • Typography & scale — family, per-step weights, case/tracking/leading, separate heading treatment, radius, UI scale and spacing density.
  • 9 presets (Nuxt UI, shadcn, Neo-brutalist, Orchard, Marshmallow, 8-bit, Anthropic, Spotify, Bootstrap) as plain data docs, plus a seeded shuffle.
  • 10 preview surfaces — component grid, dashboard, chat, SaaS, landing, docs, portfolio, changelog, editor, and a contrast/a11y sheet.
  • Round-trip import — paste an exported main.css + app.config.ts back in and get the doc it came from; anything outside the grammar is surfaced rather than silently dropped.

Thinking

The theming primitives in v4 are already excellent — this makes them visible and manipulable, Blender-style, without inventing a parallel system. Along the way it surfaces concrete core gaps, which are now tracked as open questions 5 and 6 on the v5 roadmap (#6610): a runtime global defaultVariants.variant, and whether light --ui-bg should follow the neutral ramp.

Known limitations & open questions

These are the parts I'd most like direction on, since several depend on decisions that aren't mine to make:

  • The shadow/border axes expand to static per-component class bundles, because there are no semantic --ui-shadow-* / border-width tokens to drive yet. Epic A2 of v5 #6610 proposes exactly that scale — when it lands, most of theme-engine/styles.ts should collapse into consuming it. Until then a bold preset's app.config.ts is verbose: 8-bit is ~590 lines, neo-brutalist ~630 (the CSS half stays small, 80–90 lines). I've folded compound entries that select the same variants to cut that ~10%, but the real fix is the token.
  • Exported tabs overrides embed a library-internal selector. Stripping the pill tabs' pre-hydration shadow requires repeating the ssr() modifier chain from src/theme/tabs.ts verbatim, so it lands in the user's app.config.ts. If that internal changes — Epic C mentions possibly namespacing data-slot — those overrides silently go stale. Worth a public hook, or accepting that tabs overrides are provisional.
  • Style class bundles restore post-hydration, so a reload briefly shows the unstyled treatment before the persisted bundle reapplies.
  • Engine tests sit in the library test tree (test/utils/theme-*.spec.ts, 290 tests) and import across the root tsconfig.json exclude: ["docs"] boundary. They'd be better as a docs-scoped vitest project; happy to move them.
  • Palette fitting is ~13ms per click, fine interactively but not free.
  • Scope. This is deliberately one branch for review, but it splits cleanly if you'd prefer that: the pure engine (theme-engine/ + its tests), the runtime plumbing (useTheme/plugins/theme), the studio shell, the palette/curve editors, and the preview surfaces are all separable. Say the word and I'll break it up.

Built with Claude Code 🧵

mikenewbon and others added 6 commits July 5, 2026 03:43
Replaces the fixed dark/medium gray options (clustered at the dark end)
with a 'Neutral shade' mode: two sliders choose any of the 11 neutral
ramp shades independently for light and dark mode, with live swatches.
Two single sliders rather than one dual-thumb range because the two
values are independent and may cross — a range slider enforces ordering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e style offsets

Border color gains the same per-mode neutral-shade slider pair as
shadows, and a new Background section drives --ui-bg per mode the same
way (a token shorthand on the style axis until a full tokens editor
exists). The palette editor's trigger moves into the section header as a
Custom button, and 'Use this palette' is replaced by a style-offset
dropdown (Fitted/Pastel/Muted/Vivid/Dazzling) applying taste transforms
from the fitted base so repeated picks never compound.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Page layout: the toolbar spans full width with the sidebar toggle next
to the title (badge now 'Concept'), and the settings panel sits under
the toolbar row. Every sidebar section collapses via its header.

Fixes the preset regression: setStyle only removes the tokens the
previous style emitted instead of blanket-clearing shared variable
names, so a preset's --ui-bg survives style clicks; and applyDoc now
derives bgShade from preset tokens where they are neutral-ramp refs, so
the background sliders reflect the preset instead of stale defaults.
Also restores the neutral palette editor's lost open binding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Background pattern generalizes into a whitelist-driven tokenShades
map: Inverted, Highlighted, Muted and Dimmed each get their own
collapsible section with per-mode neutral-shade sliders (Background
open by default, the rest collapsed). Presets hydrate all of them via
the same token parser, and the legacy bgShade field folds in for
persisted sessions.

Pastel offset redefined per feedback: compress the lightness range from
both ends (light end down, dark end up) while RAISING chroma — candy
pastels instead of washed-out tints. Vivid/dazzling rebalanced, and the
lightness remap preserves the fitted curve's shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Correctness:
- Style class bundles move to their own channel (nuxt-ui-style-ui) and
  compose with preset/AI overrides via class-concatenating mergeUi —
  restyling can no longer destroy a preset's or the AI's component
  overrides, and doc components + style expansion now merge identically
  in live apply and exports (spread was dropping one side).
- resetTheme() clears ALL studio state (style prefs, style bundle,
  palette params/prev, active preset) so the popover and chat reset
  buttons no longer leave orphaned styles that resurrect on the next
  click or leak invisible bundles into exports.
- tokenShades are per-mode optional: hydrating a dark-only preset token
  no longer invents a light override that leaks into exports untouched.
- currentDoc references palettes by the alias's current value, so a
  custom ramp shadowing a default name ('green') still exports.
- applyThemeSettings guards the neutral alias against the SANITIZED
  palette set (a rejected palette could previously still be selected,
  bricking every neutral token), and takes a track option so slider
  drags stop emitting ~16 analytics events per second.
- Removing a custom palette restores the palette that preceded it
  instead of the hardcoded default; neutral remaps skip tokens the
  shade sliders own (no more two-writer flip-flops on --ui-bg).
- Palette editor: style offset resets to Fitted on reseed, watchIgnorable
  scopes seed suppression to its own writes, self-applies no longer
  round-trip through the echo watcher; theme-color meta follows the
  neutral-50 baseline in light mode.

Perf: shade-only style edits skip the 7-component teardown/re-merge.

Cleanup: shared ThemeStudioShadeSlider component, SHADES/defaults single
sources, shared parseUiColorRef, token-keyed sections, dead exports and
never-shipped legacy paths (bgShade, anchor/vibrance) deleted, FOUC
plugin restores the style bundle with the same merge as the live path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mikenewbon and others added 2 commits July 5, 2026 04:40
New settings:
- Defaults section: app-wide default variant and size, expanded into
  per-component defaultVariants (the runtime channel Nuxt UI already
  honors) only where the component supports the value — form fields
  have no solid variant and keep their own default.
- Shadow geometry sliders (offset X/Y, blur, spread) driving CSS
  variables referenced by one static class shape, plus an opacity
  slider folded into the shadow color via color-mix with per-treatment
  fallbacks (solid for custom, 25% for soft). 'Hard' renamed 'Custom'.
- Soft shadows now respect the color/shade options — tailwind's stock
  shadow color is hardcoded black, and the bare shadow-(--var) form
  parses as a shadow SIZE var; shadow-(color:--var) disambiguates.
- Sidebar groups (Colors/Style/Tokens/General) become a UAccordion with
  leading chevrons, built-in separators and unpadded panels; the shade
  option in both color selects is now labeled 'Custom…'.

Fixes:
- Studio prefs and palette params are restored on load again — the
  useState conversion (for popover-reset clearing) lost the client
  restore that useLocalStorage had provided; the FOUC plugin now
  rehydrates both keys. The persisted style class bundle also
  self-heals when the generator changes, so stale expanded classes
  can't outlive their source.
- Layout: toolbar returns beside the sidebar atop the preview column
  (with the sidebar toggle and Concept badge kept).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion headers

Color mode moves out of the accordion to UTabs pinned at the top of the
sidebar. Group panels are a single-open UAccordion (one section of the
studio in focus at a time), and the sub-section collapsibles become
ghost buttons with leading rotating chevrons. Shade options in the
color selects read 'Custom…'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
track() accepts primitive property values only; the style object now
carries nested geometry/token maps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mikenewbon

mikenewbon commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

Planned next (roadmap for this concept):

  • Primary-ramp shade options for shadow/border colors (currently neutral shades only)
  • Better palette modifiers — pastel/tinted need tuning; likely an effect-strength slider to scale any modifier up or down
  • Split default-variant selection per component group (buttons vs cards vs inputs)
  • Improved presets, and more of them
  • Template-scale previews (SaaS / Dashboard / Chat) alongside the component bento
  • Richer border options and controls
  • Shuffle coverage for the newer axes (style treatments, palettes)
  • More neutral and primary palettes in the pickers

Demo videos coming soon.

@pkg-pr-new

pkg-pr-new Bot commented Jul 5, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/@nuxt/ui@6675

commit: 5d8f4a5

mikenewbon and others added 3 commits July 5, 2026 05:33
- five studio palettes beyond tailwind (sand/sage/ash neutrals,
  cocoa/marine primaries) defined in @theme static; exports inline
  their ramps since consumer builds can't resolve them
- shuffle now rolls frames, border/shadow colors and app-wide
  default variants at tasteful weights
- palette offsets get an effect-strength slider (0-200%) that lerps
  between the fitted base and the offset target, extrapolating past
  100% for stronger takes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… per-group default variants

- Tokens gains a Primary section: light/dark sliders remapping
  --ui-primary along the primary ramp (library defaults 500/400 added
  to LIBRARY_TOKEN_DEFAULTS so at-default positions export nothing)
- shadow/border color selects gain 'Primary shade…' — the existing
  per-mode shade sliders walk the primary ramp instead of neutral
- default variant select split per component group (Buttons/Cards/
  Inputs); the app-wide value from presets/shuffle shows through as
  the fallback until a group chooses its own

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both inline their ramps from CUSTOM_PALETTES so exports stay
self-contained, and double as living tests of the new axes:
per-group button defaults (Cabin) and primary-shade shadows with a
dark-mode primary shade remap (Harbor).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@benjamincanac

Copy link
Copy Markdown
Member

No way @mikenewbon this is awesome! 🔥 I was planning to build this for v5 but let's go!!! Since this replaces the ThemePicker from the header I think we could use a different layout like HeroUI does: https://heroui.com/en/themes

Will add more to the review tomorrow 😊

mikenewbon and others added 3 commits July 5, 2026 22:18
…t oklch, not hex

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… and spacing knobs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cising the new token axes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mikenewbon and others added 6 commits July 5, 2026 23:55
… sidebar sections

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mantic colors

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ipeline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed with their selects

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…variant set

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…at layouts, presets in sidebar

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reverts collateral edits that were out of scope for a docs branch: the
em dash replacements across src/runtime/types, test/bench and the AI
prompt text, the devtools toggle and the .gitignore entry.

Also fixes the ring-N scanner in the theme importer, whose consumed
separator hid every second adjacent token, and stops the theme engine
specs running in both vitest projects when they need neither.
Shadcn, Bootstrap, Anthropic and Spotify become Mono, Cobalt, Parchment
and Signal, with generic lucide glyphs. Cobalt's palettes were named
bs-blue/bs-gray, which put the brand in every exported main.css, so they
are renamed too.
Both pickers are already popovers, so holding them in a second one was a
popover inside a popover. Drops the headings treatment controls with it.
A renamed preset leaves an id naming nothing while the theme it applied
still sits in the other storage keys, so the pickers read Custom against
a page no preset can name.
Every setting owned a localStorage key, sixteen of them, and two of those
were derived caches of the others. Restores could interleave, so the derived
stores each needed a self-heal to reconcile, and eight FOUC scripts each
re-read their own key and re-derived a slice of useTheme's style tags.

One key writes atomically, so those states cannot disagree. The style class
bundle and the custom-palette ramps are rebuilt from their source on load
instead of stored, which removes two of the three heals outright. Reads and
writes both live in the plugin, so no composable touches localStorage.

The nine keys the shipped picker wrote migrate once per browser.

Also groups the theme utils under utils/theme/, with the 646-line theme.ts
split by concern and theme-engine moved to utils/theme/engine.
They only ever existed as static per-component class bundles, including a
verbatim copy of the private `ssr()` modifier chain from `src/theme/tabs.ts`,
plus site-wide `--shadow-*` and `--default-border-width` overrides in
`main.css`. They come back in v5 on real tokens. An 8-bit export drops from
389 config lines to 68.

Fonts become three independent stacks rather than a family plus a heading
treatment. Preflight already wires `--font-sans` and `--font-mono`, so only
the serif needs an element rule, and it is emitted only when one is set. The
two runtime channels behind them collapse into one, and the engine's nine
modules into five.
Carbon, Orchard and 8-bit all set a border darker than the surface it sits
on, the opposite of the stock convention. That read as deliberate while the
borders were 2px with hard shadows behind them; at a stock 1px hairline they
became muddy seams, and 8-bit's literal black on a 21% background vanished.
Light keeps the ink, which is those themes' whole identity.
Every picker was a hand-rolled popover, listbox and search input. USelectMenu
already does grouped items, an async-friendly search and custom rows, so that
component is gone and both callers use it directly. Typography collapses into
one panel with the three stacks grouped and offering every category, so a mono
heading is possible; radius and the default variants each get their own
control. `--spacing` is dropped entirely, exposing tailwind's base unit as a
theme knob was never good practice.

Fullscreen goes too. Its job was hiding the site chrome, which the page now
does unconditionally, so what remained was a mousemove listener and a manual
Esc handler buying edge-to-edge width. Controls report divergence from the
preset by turning primary instead of carrying a chip, gated on mount because
the theme is restored before first render and hydration adopts the server's
markup without patching.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v4 #4488

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants