Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 53 additions & 8 deletions docs/api/use-theme.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ React hook to access and control the current appearance theme.
Exported types:

```typescript
export type Theme = "light" | "dark" | "deep-dark" | "cream" | "bloom" | "system";
export type Theme = "light" | "dark" | "cream" | "bloom" | "system";

export type ResolvedTheme = "light" | "dark" | "deep-dark" | "cream" | "bloom";
export type ResolvedTheme = "light" | "dark" | "cream" | "bloom";

export type ThemeOption = { readonly value: Theme; readonly label: string };

Expand All @@ -37,12 +37,12 @@ const useTheme: () => {
### `theme`

- **Type:** `Theme`
- **Description:** Stored theme preference. **`system`** means “follow OS light/dark” for the **default** light/dark palettes only (not cream, bloom, or deep-dark).
- **Description:** Stored theme preference. **`system`** means “follow OS light/dark” for the **default** light/dark palettes only (not cream or bloom).

### `resolvedTheme`

- **Type:** `ResolvedTheme`
- **Description:** Concrete palette after resolving **`system`** to **`light`** or **`dark`**. **`cream`**, **`bloom`**, and **`deep-dark`** are never produced by **`system`**; pick them explicitly with **`setTheme`**.
- **Description:** Concrete palette after resolving **`system`** to **`light`** or **`dark`**. **`cream`** and **`bloom`** are never produced by **`system`**; pick them explicitly with **`setTheme`**.

When **`resolvedTheme`** changes, **`document.documentElement`** gets **`data-theme`** set to this value and a **`light`** / **`dark`** class for Tailwind **`dark`** variant compatibility.

Expand All @@ -51,7 +51,7 @@ When **`resolvedTheme`** changes, **`document.documentElement`** gets **`data-th
- **Type:** `(theme: Theme) => void`
- **Description:** Set the theme. Persisted to **`localStorage`** (key **`appshell-ui-theme`** when using AppShell’s built-in provider).

Previously saved ids **`tailor-light`**, **`tailor-bloom`**, **`tailor-dark`** are mapped on read to **`cream`**, **`bloom`**, **`deep-dark`** (see **`LEGACY_THEME_IDS`** in **`theme-context.tsx`**).
Previously saved ids **`tailor-light`**, **`tailor-bloom`**, **`tailor-dark`** are mapped on read to **`cream`**, **`bloom`**, **`dark`** (see **`LEGACY_THEME_IDS`** in **`theme-context.tsx`**).

## Usage

Expand Down Expand Up @@ -114,13 +114,12 @@ function CustomThemeList() {

### Logo or assets by lightness

Use **`resolvedTheme`** and treat **`light`**, **`cream`**, and **`bloom`** like “light” palettes and **`dark`** and **`deep-dark`** like “dark” for monochrome assets:
Use **`resolvedTheme`** and treat **`light`**, **`cream`**, and **`bloom`** like “light” palettes and **`dark`** like “dark” for monochrome assets:

```typescript
function Logo() {
const { resolvedTheme } = useTheme();
const darkish =
resolvedTheme === "dark" || resolvedTheme === "deep-dark";
const darkish = resolvedTheme === "dark";

return <img src={darkish ? "/logo-dark.svg" : "/logo-light.svg"} alt="Logo" />;
}
Expand All @@ -132,6 +131,52 @@ The built-in **`ThemeProvider`** (used inside **`AppShell`**) persists the **`th

Use **`AppShell`**’s **`defaultTheme`** prop for the initial value when nothing is stored.

# useFont

Sibling hook to **`useTheme`** that controls the **font axis**, independent of color theme. Any color theme works with either font; users pick them separately in **`ThemeSwitcher`**.

## Signature

```typescript
export type Font = "inter" | "geist";

export type FontOption = { readonly value: Font; readonly label: string };

export const FONT_OPTIONS: readonly FontOption[];
```

```typescript
const useFont: () => {
font: Font;
setFont: (font: Font) => void;
};
```

## Return value

### `font`

- **Type:** `Font`
- **Description:** Stored font preference. Applied to **`<html>`** as **`data-font`**, which the package CSS uses to set **`font-family`** on **`body`** and headings.

### `setFont(font)`

- **Type:** `(font: Font) => void`
- **Description:** Set the font. Persisted to **`localStorage`** (key **`appshell-ui-font`** when using AppShell’s built-in provider).

## Usage

```typescript
import { useFont } from "@tailor-platform/app-shell";

function UseInter() {
const { setFont } = useFont();
return <button onClick={() => setFont("inter")}>Inter</button>;
}
```

Use **`AppShell`**’s **`defaultFont`** prop for the initial value when nothing is stored. Default is **`"geist"`**.

## Related

- [Styling & Theming](../concepts/styling-theming.md)
14 changes: 12 additions & 2 deletions docs/concepts/styling-theming.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,22 @@ AppShell ships **five** named semantic palettes controlled by **`data-theme`** o
| `dark` | Default neutral dark |
| `cream` | Tailor brand — cream shell, violet accents |
| `bloom` | Lavender shell (**Bloom**) and cream accents |
| `deep-dark` | Tailor brand — near-black tuning surface |

**`system`** resolves to **`light`** or **`dark`** only — not **`cream`**, **`bloom`**, or **`deep-dark`**. Set those with **`setTheme`** or **`AppShell`** **`defaultTheme`**.
**`system`** resolves to **`light`** or **`dark`** only — not **`cream`** or **`bloom`**. Set those with **`setTheme`** or **`AppShell`** **`defaultTheme`**.

Semantic tokens (**`--background`**, **`--primary`**, **`--border`**, sidebar tokens, **`--semantic-shadow-*`**, statuses, **`--radius`**, …) live in **`theme.css`**; override there or in host CSS keyed off **`html[data-theme='…']`**.

### Font axis

Font is an independent axis from color theme — any palette works with either face.

| Font | When you'd pick it |
| ------- | -------------------------------------------------------- |
| `inter` | Neutral workhorse; matches most ERP / dashboard UIs |
| `geist` | Vercel-flavoured display sans; default for Tailor themes |

Selected font is applied to **`<html>`** as **`data-font`**. CSS in **`globals.css`** sets **`font-family`** on **`body`** and headings off that attribute. Persisted to **`localStorage`** under **`appshell-ui-font`**; pick via **`setFont`** (see **`useFont`**) or **`AppShell`**'s **`defaultFont`** prop (default **`"geist"`**).

## A note on AppShell component class names

AppShell components use Tailwind utility classes for their styling. Tailwind classes are generated at build-time, so stylesheet for AppShell components is already built and is separate to the Tailwind stylesheet generated for your application.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ exports[`Button > snapshots > link variant 1`] = `"<button type="button" data-sl

exports[`Button > snapshots > outline variant 1`] = `"<button type="button" data-slot="button" class="astw:inline-flex astw:items-center astw:justify-center astw:gap-2 astw:whitespace-nowrap astw:rounded-md astw:text-sm astw:font-medium astw:transition-all astw:disabled:pointer-events-none astw:disabled:opacity-50 astw:[&amp;_svg]:pointer-events-none astw:[&amp;_svg:not([class*='size-'])]:size-4 astw:shrink-0 astw:[&amp;_svg]:shrink-0 astw:outline-none astw:focus-visible:border-ring astw:focus-visible:ring-ring/50 astw:focus-visible:ring-[3px] astw:aria-invalid:ring-destructive/20 astw:dark:aria-invalid:ring-destructive/40 astw:aria-invalid:border-destructive astw:border astw:bg-background astw:shadow-xs astw:hover:bg-accent astw:hover:text-accent-foreground astw:dark:bg-input/30 astw:dark:border-input astw:dark:hover:bg-input/50 astw:dark:hover:text-foreground astw:h-9 astw:px-4 astw:py-2 astw:has-[>svg]:px-3">Outline</button>"`;

exports[`Button > snapshots > secondary variant 1`] = `"<button type="button" data-slot="button" class="astw:inline-flex astw:items-center astw:justify-center astw:gap-2 astw:whitespace-nowrap astw:rounded-md astw:text-sm astw:font-medium astw:transition-all astw:disabled:pointer-events-none astw:disabled:opacity-50 astw:[&amp;_svg]:pointer-events-none astw:[&amp;_svg:not([class*='size-'])]:size-4 astw:shrink-0 astw:[&amp;_svg]:shrink-0 astw:outline-none astw:focus-visible:border-ring astw:focus-visible:ring-ring/50 astw:focus-visible:ring-[3px] astw:aria-invalid:ring-destructive/20 astw:dark:aria-invalid:ring-destructive/40 astw:aria-invalid:border-destructive astw:bg-secondary astw:text-secondary-foreground astw:shadow-xs astw:hover:bg-secondary/80 astw:h-9 astw:px-4 astw:py-2 astw:has-[>svg]:px-3">Secondary</button>"`;
exports[`Button > snapshots > secondary variant 1`] = `"<button type="button" data-slot="button" class="astw:inline-flex astw:items-center astw:justify-center astw:gap-2 astw:whitespace-nowrap astw:rounded-md astw:text-sm astw:font-medium astw:transition-all astw:disabled:pointer-events-none astw:disabled:opacity-50 astw:[&amp;_svg]:pointer-events-none astw:[&amp;_svg:not([class*='size-'])]:size-4 astw:shrink-0 astw:[&amp;_svg]:shrink-0 astw:outline-none astw:focus-visible:border-ring astw:focus-visible:ring-ring/50 astw:focus-visible:ring-[3px] astw:aria-invalid:ring-destructive/20 astw:dark:aria-invalid:ring-destructive/40 astw:aria-invalid:border-destructive astw:bg-secondary astw:text-secondary-foreground astw:shadow-xs astw:hover:brightness-95 astw:dark:hover:brightness-110 astw:h-9 astw:px-4 astw:py-2 astw:has-[>svg]:px-3">Secondary</button>"`;

exports[`Button > snapshots > small size 1`] = `"<button type="button" data-slot="button" class="astw:inline-flex astw:items-center astw:justify-center astw:whitespace-nowrap astw:text-sm astw:font-medium astw:transition-all astw:disabled:pointer-events-none astw:disabled:opacity-50 astw:[&amp;_svg]:pointer-events-none astw:[&amp;_svg:not([class*='size-'])]:size-4 astw:shrink-0 astw:[&amp;_svg]:shrink-0 astw:outline-none astw:focus-visible:border-ring astw:focus-visible:ring-ring/50 astw:focus-visible:ring-[3px] astw:aria-invalid:ring-destructive/20 astw:dark:aria-invalid:ring-destructive/40 astw:aria-invalid:border-destructive astw:bg-primary astw:text-primary-foreground astw:shadow-xs astw:hover:bg-primary/90 astw:h-8 astw:rounded-md astw:gap-1.5 astw:px-3 astw:has-[>svg]:px-2.5">Small</button>"`;

Expand Down
90 changes: 20 additions & 70 deletions packages/core/src/assets/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@
--chart-3: rgba(245, 158, 11, 1);
--chart-4: rgba(168, 85, 247, 1);
--chart-5: rgba(244, 63, 94, 1);
--sidebar: rgba(23, 23, 23, 1);
/* Match --background so sidebar blends with the app surface. */
--sidebar: rgba(10, 10, 10, 1);
--sidebar-foreground: rgba(250, 250, 250, 1);
--sidebar-primary: rgba(29, 78, 216, 1);
--sidebar-primary-foreground: rgba(250, 250, 250, 1);
Expand All @@ -96,24 +97,26 @@ html[data-theme="cream"] {
color-scheme: light;
/* Off-white app shell; white cards for elevation */
--background: rgba(248, 243, 228, 1);
/* Bottom stop for fixed shell gradient (globals.css); same cream family, lighter toward white. */
--shell-gradient-end: color-mix(in srgb, var(--background) 40%, rgb(255, 255, 255));
/* Shell gradient stops (globals.css): light cream tint at top → white at bottom. */
--shell-gradient-start: color-mix(in srgb, var(--background) 55%, rgb(255, 255, 255));
--shell-gradient-end: rgb(255, 255, 255);
--foreground: rgba(16, 18, 43, 1);
--card: rgba(255, 255, 255, 1);
--card-foreground: rgba(16, 18, 43, 1);
--popover: rgba(255, 255, 255, 1);
--popover-foreground: rgba(16, 18, 43, 1);
--primary: rgba(83, 90, 232, 1);
--primary-foreground: rgba(255, 255, 255, 1);
/* Light violet surfaces; dark green text (brand text-on-violet pairing) */
/* Light violet surfaces; near-black text (match bloom; drop the brand dark-green pairing). */
--secondary: rgba(226, 212, 254, 1);
--secondary-foreground: rgba(1, 55, 66, 1);
--secondary-foreground: rgba(16, 18, 43, 1);
/* Row hovers / `bg-muted`: subtle neutral lift (same alpha family as `--border`), not a second brand tint. */
--muted: rgba(0, 0, 0, 0.08);
--muted-foreground: rgba(16, 18, 43, 0.72);
--accent: rgba(226, 212, 254, 1);
--accent-foreground: rgba(1, 55, 66, 1);
--destructive: rgba(185, 28, 28, 1);
--accent-foreground: rgba(16, 18, 43, 1);
/* Destructive matches default light theme so the brand red stays consistent across light/cream/bloom. */
--destructive: rgba(220, 38, 38, 1);
--destructive-foreground: rgba(254, 242, 242, 1);
--border: rgba(0, 0, 0, 0.08);
--input: rgba(0, 0, 0, 0.08);
Expand All @@ -130,7 +133,7 @@ html[data-theme="cream"] {
--sidebar-primary: rgba(83, 90, 232, 1);
--sidebar-primary-foreground: rgba(255, 255, 255, 1);
--sidebar-accent: rgba(226, 212, 254, 1);
--sidebar-accent-foreground: rgba(1, 55, 66, 1);
--sidebar-accent-foreground: rgba(16, 18, 43, 1);
--sidebar-border: rgba(0, 0, 0, 0.08);
--sidebar-ring: rgba(83, 90, 232, 0.45);
--status-default: rgba(16, 18, 43, 0.55);
Expand All @@ -150,25 +153,27 @@ html[data-theme="cream"] {
html[data-theme="bloom"] {
color-scheme: light;
--background: rgba(239, 232, 255, 1);
/* Bottom stop for shell gradient (globals.css); lavender family, lighter than top. */
--shell-gradient-end: color-mix(in srgb, var(--background) 40%, rgb(255, 255, 255));
/* Shell gradient stops (globals.css): light lavender tint at top → white at bottom. */
--shell-gradient-start: color-mix(in srgb, var(--background) 55%, rgb(255, 255, 255));
--shell-gradient-end: rgb(255, 255, 255);
--foreground: rgba(16, 18, 43, 1);
--card: rgba(255, 255, 255, 1);
--card-foreground: rgba(16, 18, 43, 1);
--popover: rgba(255, 255, 255, 1);
--popover-foreground: rgba(16, 18, 43, 1);
--primary: rgba(83, 90, 232, 1);
--primary-foreground: rgba(255, 255, 255, 1);
--secondary: rgba(248, 243, 228, 1);
--secondary-foreground: rgba(16, 18, 43, 1);
/* Match light theme — neutral pale grey reads as a soft pill on the lavender shell. */
--secondary: rgba(245, 245, 245, 1);
--secondary-foreground: rgba(23, 23, 23, 1);
/* Row hovers / `bg-muted`: subtle neutral lift (same alpha family as `--border`). */
--muted: rgba(0, 0, 0, 0.08);
--muted-foreground: rgba(16, 18, 43, 0.72);
--accent: rgba(248, 243, 228, 1);
--accent-foreground: rgba(16, 18, 43, 1);
/* Softer reds than cream — less harsh against lavender gradient / cream fills */
--destructive: rgba(176, 45, 64, 1);
--destructive-foreground: rgba(255, 251, 252, 1);
/* Destructive matches default light theme so the brand red stays consistent across light/cream/bloom. */
--destructive: rgba(220, 38, 38, 1);
--destructive-foreground: rgba(254, 242, 242, 1);
--border: rgba(0, 0, 0, 0.08);
--input: rgba(0, 0, 0, 0.08);
--ring: rgba(83, 90, 232, 0.45);
Expand Down Expand Up @@ -199,61 +204,6 @@ html[data-theme="bloom"] {
--semantic-shadow-lg: 0 10px 15px -3px rgb(16 18 43 / 0.12), 0 4px 6px -4px rgb(83 90 232 / 0.1);
}

/**
* Tailor brand — dark palette (`deep-dark`): near-black shell; same primaries / secondaries as cream.
*/
html[data-theme="deep-dark"] {
color-scheme: dark;
--background: rgba(10, 10, 11, 1);
--foreground: rgba(237, 238, 242, 1);
--card: rgba(18, 18, 21, 1);
--card-foreground: rgba(237, 238, 242, 1);
--popover: rgba(22, 22, 26, 1);
--popover-foreground: rgba(237, 238, 242, 1);
/* Match cream — Primary #535AE8 */
--primary: rgba(83, 90, 232, 1);
--primary-foreground: rgba(255, 255, 255, 1);
/* Light violet + dark green text (same pairing as cream) */
--secondary: rgba(226, 212, 254, 1);
--secondary-foreground: rgba(1, 55, 66, 1);
--muted: rgba(28, 28, 34, 1);
--muted-foreground: rgba(163, 167, 180, 1);
--accent: rgba(226, 212, 254, 1);
--accent-foreground: rgba(1, 55, 66, 1);
--destructive: rgba(185, 28, 28, 1);
--destructive-foreground: rgba(254, 242, 242, 1);
--border: rgba(255, 255, 255, 0.1);
--input: rgba(255, 255, 255, 0.12);
--ring: rgba(83, 90, 232, 0.45);
--chart-1: rgba(83, 90, 232, 1);
--chart-2: rgba(0, 151, 156, 1);
--chart-3: rgba(1, 55, 66, 1);
--chart-4: rgba(110, 95, 195, 1);
--chart-5: rgba(217, 119, 6, 1);
--radius: 1rem;
--sidebar: rgba(10, 10, 11, 1);
--sidebar-foreground: rgba(237, 238, 242, 1);
--sidebar-primary: rgba(83, 90, 232, 1);
--sidebar-primary-foreground: rgba(255, 255, 255, 1);
/*
* Hover + current row: dark elevated chip on near-black sidebar — light label.
* (Lavender wash reads as a “light tab” here; a muted lift matches dark UI better.)
*/
--sidebar-accent: rgba(42, 43, 54, 1);
--sidebar-accent-foreground: rgba(238, 239, 246, 1);
--sidebar-border: rgba(255, 255, 255, 0.1);
--sidebar-ring: rgba(83, 90, 232, 0.45);
--status-default: rgba(144, 150, 168, 0.88);
--status-neutral: #00979c;
--status-completed: #00979c;
--status-attention: #d97706;
--status-danger: #dc2626;
--semantic-shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.42);
--semantic-shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.42), 0 1px 2px -1px rgb(0 0 0 / 0.36);
--semantic-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.46), 0 2px 4px -2px rgb(0 0 0 / 0.36);
--semantic-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.54), 0 4px 6px -4px rgb(83 90 232 / 0.12);
}

@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
Expand Down
20 changes: 15 additions & 5 deletions packages/core/src/components/appshell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
type ContextData,
} from "@/contexts/appshell-context";
import { RouterContainer } from "@/routing/router";
import { ThemeProvider, type Theme } from "@/contexts/theme-context";
import { ThemeProvider, type Theme, type Font } from "@/contexts/theme-context";
import { BreadcrumbOverrideProvider } from "@/contexts/breadcrumb-context";
import { CommandPaletteProvider, type SearchSource } from "@/contexts/command-palette-context";
import { BuiltInCommandPalette } from "@/components/command-palette";
Expand Down Expand Up @@ -173,12 +173,20 @@ type SharedAppShellProps = React.PropsWithChildren<{
* Initial theme before any value is loaded from localStorage (`appshell-ui-theme`).
* Does not replace a stored preference.
*
* Named palettes **`cream`**, **`bloom`**, **`deep-dark`**, plus default **`light`** / **`dark`**, in addition to
* default light/dark and `system` (OS preference maps to **default** light or dark only).
* Named palettes **`cream`**, **`bloom`**, plus default **`light`** / **`dark`**, in addition to
* `system` (OS preference maps to **default** light or dark only — not cream or bloom).
*
* @default "system"
* @default "bloom"
*/
defaultTheme?: Theme;

/**
* Initial font axis before any value is loaded from localStorage (`appshell-ui-font`).
* Independent of `defaultTheme`; any color theme works with either font.
*
* @default "geist"
*/
defaultFont?: Font;
}>;

/**
Expand Down Expand Up @@ -286,8 +294,10 @@ export const AppShell = (props: AppShellProps) => {
<BreadcrumbOverrideProvider>
<CommandPaletteProvider searchSources={props.searchSources}>
<ThemeProvider
defaultTheme={props.defaultTheme ?? "system"}
defaultTheme={props.defaultTheme ?? "bloom"}
defaultFont={props.defaultFont ?? "geist"}
storageKey="appshell-ui-theme"
fontStorageKey="appshell-ui-font"
>
<RouterContainer rootComponent={props.rootComponent} rootGuards={props.rootGuards}>
{props.children}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const badgeVariants = cva(
error:
"astw:border-transparent astw:bg-destructive astw:text-destructive-foreground astw:hover:bg-destructive/80",
neutral:
"astw:border-transparent astw:bg-secondary astw:text-secondary-foreground astw:hover:bg-secondary/80",
"astw:border-transparent astw:bg-neutral-200 astw:text-neutral-700 astw:hover:bg-neutral-300 astw:dark:bg-neutral-800 astw:dark:text-neutral-200 astw:dark:hover:bg-neutral-700",
// Outline variants with status dots - matches Figma design
"outline-success":
"astw:gap-0.5 astw:pl-1.5 astw:pr-2 astw:border-border astw:bg-card astw:text-foreground",
Expand Down
Loading
Loading