Skip to content

Feat/tailor themes#222

Open
itsprade wants to merge 16 commits into
mainfrom
feat/tailor-themes
Open

Feat/tailor themes#222
itsprade wants to merge 16 commits into
mainfrom
feat/tailor-themes

Conversation

@itsprade
Copy link
Copy Markdown
Contributor

@itsprade itsprade commented Apr 29, 2026

Summary

Introduces the Tailor brand appearance for AppShell — two new color palettes (cream, bloom), an independent font axis (geist / inter), a ready-made ThemeSwitcher, a pre-paint helper that prevents FOUC + hydration warnings, and a handful of cross-theme chrome refactors.

bloom is the new defaultTheme. Stored tailor-light / tailor-bloom / tailor-dark ids are migrated to cream / bloom / dark on first read.

See .changeset/tailor-theme-palettes.md for the consolidated changeset.

What's new

Color palettes

  • cream (off-white shell, light-violet accents) and bloom (lavender shell, white accent surfaces) on top of light, dark, system.
  • Cream/Bloom paint a fixed multi-stop vertical gradient on <html> (light tint at top → white in the bottom ~30%) and use squircle corners where the browser supports corner-shape.
  • New tokens in theme.css: --shell-gradient-{start,end}, --status-{default,neutral,completed,attention,danger}, --semantic-shadow-{xs,sm,md,lg} mapped through @theme.
  • Active sidebar row gets a hairline outline (var(--border)) + --semantic-shadow-xs to read as elevated.

Font axis

  • Independent of the color theme. setFont("inter") via the new useFont hook, or <AppShell defaultFont="inter">.
  • AppShell no longer fetches fonts at runtime — the eight cdn.jsdelivr.net @imports in globals.css are gone. Consumers pick a loading strategy:
    • Zero-config: @import "@tailor-platform/app-shell/fonts" ships self-hosted variable Geist + Inter (one variable font per family, ~30KB each, lockfile-pinned via @fontsource-variable/*).
    • Next.js: next/font/google with the conventional family name — the family chain "Geist Variable", "Geist Sans", … catches it automatically.
    • BYO font: override font-family on body in your own CSS; AppShell's data-font attribute still drives the axis.

ThemeSwitcher + SidebarLayout.themeSwitcher

  • New ThemeSwitcher component (exported from @tailor-platform/app-shell and @tailor-platform/app-shell/sidebar) renders a two-axis appearance menu.
  • SidebarLayout now mounts it in the header by default, replacing the old SunIcon light/dark toggle.
  • Override with themeSwitcher={<MyComponent />} or hide with themeSwitcher={null}.

getInitialAppearanceScript()

  • New public helper returning the source of a tiny IIFE consumers inline in <head>. Reads localStorage, runs legacy-id migration, resolves system via matchMedia, sets data-theme / data-font / class="light"|"dark" — all before first paint. Closes the FOUC + hydration-warning gap that ThemeProvider's post-mount effect leaves on SSR'd apps.
  • Wired into the Next.js example via app/layout.tsx. Documented in docs/api/use-theme.md for Next.js + Vite.

Cross-theme refactors (visible on all palettes, not just cream/bloom)

These are intentional but worth calling out for downstream apps:

  • Badge.neutral uses literal bg-neutral-200 / dark:bg-neutral-800 (was bg-secondary, which would render light-violet on cream/bloom).
  • Table.Row hover is now bg-muted (was bg-muted/50 — twice as opaque on every DataTable).
  • Dialog.Close is wrapped with <Button variant="ghost" size="icon"> — inherits standard button accessibility / keyboard handling.
  • Inputs (Input, Select.Trigger, Combobox.Input/Chips, Autocomplete.Input, Field.Control) flip to bg-transparent + dark:bg-input/30 so they pick up the surface behind them.
  • Outline Button on cream/bloom is transparent so the shell gradient shows through; hover restores the accent fill.

API additions

useFont · THEME_OPTIONS · FONT_OPTIONS · ThemeSwitcher · getInitialAppearanceScript · types Theme, ResolvedTheme, ThemeOption, Font, FontOption.

AppShell props: defaultTheme, defaultFont. SidebarLayout prop: themeSwitcher.

Implementation quality

  • ThemeProvider value is useMemo'd; setTheme / setFont are useCallback'd. Hooks return memoized projections so consumer identity-equality stays stable across unrelated re-renders.
  • ThemeProviderContext defaults to undefined; useTheme / useFont throw outside a provider rather than silently returning no-op setters.
  • Cream/Bloom transparent body + sidebar overrides live in @layer utilities, no !important needed (only the WebKit/Firefox autofill rule retains it — documented browser-override territory).
  • ThemeSwitcher: shared radioItemClasses(active) helper for the color and font grids; no inline style={{…}} duplicates of Tailwind utilities.

Tests

  • 4 ThemeSwitcher UI tests (menu list, system trigger title, theme apply, font apply).
  • 8 new ThemeProvider unit tests: legacy tailor-{light,bloom,dark} migration; unrecognized theme/font fallback; systemdark / light via mocked matchMedia; useTheme / useFont guard throws outside a provider.
  • 7 new getInitialAppearanceScript tests covering the pre-paint script's full behavior: theme + font apply, legacy migration, system resolution, custom storage keys, default fallbacks, and silent error handling.

The pre-existing snapshot failures in menu / select / combobox / autocomplete are inherited from main (base-ui inline-style ordering); not introduced or worsened by this PR.

Test plan

  • Toggle the appearance menu — verify color and font axes work independently and persist across reloads.
  • Cycle lightdarkcreambloom with both fonts.
  • Stored tailor-light migrates to cream (DevTools → Application → Local Storage).
  • Cream + Bloom: shell gradient fades smoothly to white in the bottom ~30%.
  • Cream: hover / active sidebar text is near-black (no leftover deep-green pairing).
  • Sidebar active row shows a hairline outline + drop shadow across all four palettes.
  • Next.js demo: no FOUC on hard reload; no React hydration warning in the console.
  • Vite demo: @import "@tailor-platform/app-shell/fonts" loads Geist Variable / Inter Variable.
  • pnpm type-check, pnpm lint, pnpm fmt: clean.

🤖 Generated with Claude Code

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 15, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@tailor-platform/app-shell@222
npm i https://pkg.pr.new/@tailor-platform/app-shell-sdk-plugin@222
npm i https://pkg.pr.new/@tailor-platform/app-shell-vite-plugin@222

commit: 99c2541

itsprade and others added 8 commits May 15, 2026 14:35
Rename theme API and html[data-theme] from tailor-* to short names.
LocalStorage values tailor-light, tailor-bloom, tailor-dark map to cream,
bloom, deep-dark on read. deep-dark keeps Tailwind dark class.

Includes palette CSS, shell gradients, tactile globals, docs, and changesets.
Checkpoint: cream + bloom + deep-dark brand themes alongside default light/dark.

Made-with: Cursor
- Add ThemeSwitcher (grid) and Tailor palettes (cream, bloom, deep-dark); wire sidebar slot; export THEMES/options.
- Theme context: bloom label, resolver updates; muted token comments aligned.
- Globals: tactile Cream/Bloom/deep-dark rules for composed controls (dialog-close/trigger slots); table layout fix.
- Dialog: card surface for content; footer/close compose with branded buttons.
- Vite example: CSS load order note (Tailwind before app-shell styles).
- Docs: use-theme and sidebar-layout; styling-theming palettes table.

Made-with: Cursor
After rebase onto latest main, restore dev/docs and nextjs-app local
config to match origin/main so theme work stays separate from dev
ergonomics (turbopack root, extra scripts).

Made-with: Cursor
Align snapshot output with upstream DataTable/menu DOM after merging main.

Made-with: Cursor
…sidebar chrome

- Remove deep-dark palette + types + switcher entry + globals selectors; dark covers the same need.
- Default theme now bloom (existing localStorage choices preserved); legacy tailor-dark id remaps to dark.
- Strip tactile button overrides; cream/bloom/dark buttons inherit default shadcn Button styling.
- Drop sidebar right-edge divider (border-r/border-l and inset-variant border-x).
- Dark --sidebar matches --background; sidebar blends with app surface.
- Unify --destructive at #dc2626 across light/cream/bloom for brand-consistent red.
- Neutral badge uses Tailwind neutral palette so lavender --secondary doesn't bleed in.
- Outline button transparent on cream/bloom only (rule sits in @layer utilities to beat
  Tailwind's astw:bg-background which also lives in utilities).
- Secondary button hover now brightness-based (visible regardless of token value).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…om shell

- Font axis (Geist default, Inter): `useFont` hook + `data-font` attr,
  added to ThemeSwitcher as a second axis alongside the color palette.
- Cream theme drops the dark-green text-on-violet pairing; secondary /
  accent / sidebar-accent foregrounds now use the same near-black as bloom.
- Cream/Bloom shell gradient: multi-stop fade with pure white covering the
  bottom 30% for a softer, more blended feel.
- Sidebar active item: hairline outline (`var(--border)`) + `--semantic-shadow-xs`
  drop so the selected row reads as elevated. Rule lives in `@layer utilities`
  so it wins against Tailwind's `outline-hidden` utility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

itsprade and others added 8 commits May 15, 2026 15:26
- Wrap `value` in `useMemo`; wrap `setTheme`/`setFont` in `useCallback` so
  consumers don't re-render when ThemeProvider re-renders for an unrelated
  reason and identity-comparison code (effect deps, memo selectors) stays
  stable across renders.
- `useTheme` / `useFont` return a memoized projection of the context so the
  hook return identity matches the underlying state slice.
- Change `ThemeProviderContext` default to `undefined` so the
  `useTheme must be used within a ThemeProvider` runtime guard actually fires
  instead of silently returning no-op setters from a fallback `initialState`.
- Drop redundant `defaultTheme ?? "bloom"` / `defaultFont ?? "geist"` in
  `AppShell` — `ThemeProvider` already has the same defaults; single source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the cream/bloom `body` and sidebar-chrome transparency rules from
`@layer base` to `@layer utilities` so they win the cascade against
Tailwind utilities (`bg-sidebar*` on sidebar slots) on layer order, and
drop the `!important` declarations — selector specificity now suffices.

`!important` is retained only on the WebKit/Firefox autofill rule, where
it's the documented way to defeat browser default styling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… fallbacks

- Extract `radioItemClasses(active)` shared by the color and font grids;
  the two grids were carrying byte-identical 7-line class strings.
- Remove the inline `style={{ display: "grid", gridTemplateColumns: ..., gap: ... }}`
  fallbacks on `RadioGroup` and the matching `display: flex` block on
  `ThemePreviewSwatches`. The Tailwind `astw:grid astw:grid-cols-N astw:gap-2`
  utilities cover the same layout and are now the single source of truth, in
  line with the project rule that components must be self-contained / portable
  (`.agents/skills/add-component/SKILL.md`).
- Drop the now-obsolete "inline fallback" note from the vite example's
  `index.css`; the load-order rationale remains.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…/fonts subpath

Remove the eight runtime `@import url("https://cdn.jsdelivr.net/...")` lines
from `globals.css`. The default `@import "@tailor-platform/app-shell/styles"`
is now font-free, eliminating the third-party CDN dependency (supply-chain
risk, request privacy, render-blocking imports, no offline support, not
lockfile-pinned).

In its place, AppShell ships a separate, opt-in stylesheet at
`@tailor-platform/app-shell/fonts` which pulls in `@fontsource-variable/geist`
and `@fontsource-variable/inter` — self-hosted, lockfile-pinned, single
variable font per family (~30KB each vs. 4× static weights).

Consumers pick a loading strategy:

  1. `@import "@tailor-platform/app-shell/fonts"` — zero-config.
  2. `next/font/google` with the conventional family name (`Geist`,
     `Inter`) — the new `font-family` fallback chain
     `"Geist Variable", "Geist Sans", …` catches it.
  3. Bring their own font; AppShell's `data-font` attribute still drives
     which axis is active.

Wiring:
- New `packages/core/src/assets/fonts.css` (copied to `dist/fonts.css` by
  `publicDir` during build).
- New `"./fonts": "./dist/fonts.css"` entry in `package.json` exports.
- `@fontsource-variable/{geist,inter}` added to core deps.
- Example apps updated to import the subpath so demos still render Geist.
- `docs/concepts/styling-theming.md` documents the three loading patterns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tion

Add a public helper that returns the source of a tiny IIFE consumers inline
in `<head>` so the stored theme + font are applied **before** React mounts.
Closes the FOUC / hydration-warning gap left by `ThemeProvider`'s post-mount
effect.

The returned script reads `localStorage`, runs the same legacy-id migration
as `parseStoredTheme`, resolves `system` via `matchMedia`, and writes
`data-theme`, `data-font`, and `class="light"|"dark"` on `<html>`.

Wired into the Next.js example via a `<script dangerouslySetInnerHTML>` in
`app/layout.tsx`. Documented in `docs/api/use-theme.md` with usage for
Next.js App Router and Vite/static HTML.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… script

- `theme-context.test.tsx`: legacy `tailor-{light,bloom,dark}` ids map to
  `{cream,bloom,dark}` on first render; unrecognized theme / font fall back
  to `defaultTheme` / `defaultFont`; `system` resolves to `dark` / `light`
  via a mocked `matchMedia`; `useTheme` / `useFont` throw outside a
  `ThemeProvider` (now that the context default is `undefined`).
- `initial-appearance.test.ts`: the script returned by
  `getInitialAppearanceScript()` writes `data-theme` / `data-font` /
  `class="light"|"dark"` correctly, applies legacy migration, resolves
  `system`, honors custom storage keys, and silently swallows storage
  errors so it never blocks paint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Remove the stale `bloom-tailor-theme.md` (Bloom is now the default, not
  additive; covered in the consolidated entry).
- Rewrite `tailor-theme-palettes.md` to reflect what actually ships:
  cream/bloom palettes, independent font axis, ThemeSwitcher slot, the new
  `getInitialAppearanceScript()` helper, the cross-theme refactors (Badge
  neutral, Table.Row hover, Dialog.Close button wrap, transparent inputs,
  outline button on cream/bloom), and the full set of new public exports.

Also pick up oxfmt's trailing reformatting of the new theme-context /
initial-appearance tests and theme-switcher's `radioItemClasses` call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…earanceScript`

The Next.js example's `app/layout.tsx` is a Server Component. Importing
`getInitialAppearanceScript` from the package root pulled in the full
client barrel, transitively `react-router`, which exports `useRouteError`
that doesn't exist in the React Server Components build — Turbopack RSC
bailed with "Export useRouteError doesn't exist in target module".

Fix: ship `getInitialAppearanceScript` as a leaf subpath
`@tailor-platform/app-shell/initial-appearance`. The source already has no
React / react-router imports (only `type` imports from `theme-context`
that are erased at compile time), so it's a ~1KB zero-runtime-dep entry —
safe to call from any RSC layout. Added to `vite.config.ts` build entries
and `package.json` exports. The top-level export remains for non-RSC
consumers; docs and the Next.js example use the leaf subpath.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@itsprade itsprade force-pushed the feat/tailor-themes branch from 762041e to 99c2541 Compare May 15, 2026 11:08
@itsprade itsprade requested a review from IzumiSy May 15, 2026 11:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants