Skip to content

Commit 9f7d92c

Browse files
committed
feat(theme): add ThemeProvider, useTheme, migration docs
1 parent c794064 commit 9f7d92c

6 files changed

Lines changed: 193 additions & 9 deletions

File tree

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,25 @@
22

33
This design system is created and maintained by the IT dev-ops team at the Teaching Center of the Department of Finance at the University of Zurich. It simplifies the shared use of commonly used components in our web-development projects with corporate colors already included.
44

5+
## Theming (v5)
6+
7+
As of v5 the components support two themes, selected via a `data-theme` attribute:
8+
9+
- **`neutral`** (default) — de-branded, shadcn-like. Active on `:root` with no setup.
10+
- **`uzh`** — the UZH corporate-design theme (UZH blue/red, Source Sans 3).
11+
12+
Switching is a pure CSS-variable cascade (no JS engine). UZH apps opt in with
13+
`<html data-theme="uzh">` or by wrapping a subtree in `ThemeProvider`:
14+
15+
```tsx
16+
import { ThemeProvider } from "@uzh-bf/design-system";
17+
<ThemeProvider theme="uzh">{children}</ThemeProvider>;
18+
```
19+
20+
**v4 → v5 is a breaking change for UZH apps** (the default is now `neutral`, not
21+
UZH-branded). See [`packages/design-system/MIGRATION.md`](packages/design-system/MIGRATION.md)
22+
for the full upgrade guide, including new exports, props, and variants.
23+
524
## Prerequisites
625

726
- Node.js version 22 (as specified in the project configuration)

packages/design-system/.ladle/components.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,22 @@
11
// organize-imports-ignore
22
import type { GlobalProvider } from '@ladle/react'
33
import React from 'react'
4+
import { ThemeProvider, type Theme } from 'src/ThemeProvider'
45
import 'src/tailwind.css'
56

6-
type Theme = 'neutral' | 'uzh'
7-
87
/**
98
* Ladle global provider with a design-system theme switcher.
109
*
11-
* Wraps every story in a `data-theme` (neutral | uzh) container and an optional
12-
* `.dark` class, so the v5 dual-theme system can be previewed live in the demo.
10+
* Wraps every story in the design-system `ThemeProvider` (neutral | uzh) and an
11+
* optional `.dark` class, so the v5 dual-theme system can be previewed live in
12+
* the demo.
1313
*/
1414
export const Provider: GlobalProvider = ({ children }) => {
1515
const [theme, setTheme] = React.useState<Theme>('neutral')
1616
const [dark, setDark] = React.useState(false)
1717

1818
return (
19-
<div data-theme={theme} className={dark ? 'dark' : undefined}>
19+
<ThemeProvider theme={theme} className={dark ? 'dark' : undefined}>
2020
<div
2121
style={{
2222
position: 'fixed',
@@ -56,6 +56,6 @@ export const Provider: GlobalProvider = ({ children }) => {
5656
</label>
5757
</div>
5858
{children}
59-
</div>
59+
</ThemeProvider>
6060
)
6161
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Migration: v4 → v5 (dual theme)
2+
3+
v5 introduces a **dual-theme** system. The same components now render under two
4+
themes, selected by a `data-theme` attribute:
5+
6+
- **`neutral`** (default) — de-branded, shadcn-like. Applied on `:root`, so it is
7+
what you get with no extra setup.
8+
- **`uzh`** — the UZH corporate-design theme (UZH blue/red palette, Source Sans 3).
9+
10+
Both themes share one set of components; only the CSS custom properties differ.
11+
There is no JS theme engine — switching is a pure CSS-variable cascade.
12+
13+
## Breaking change: UZH apps must opt in
14+
15+
In v4 the design system was UZH-branded by default. In v5 the default is
16+
`neutral`. **The three UZH apps must explicitly select the `uzh` theme**, or they
17+
will render de-branded.
18+
19+
Pick one of:
20+
21+
```html
22+
<!-- Simplest: set it once on the document root -->
23+
<html data-theme="uzh"></html>
24+
```
25+
26+
```tsx
27+
// Or wrap the app subtree with the design-system provider
28+
import { ThemeProvider } from '@uzh-bf/design-system'
29+
30+
export function App({ children }) {
31+
return <ThemeProvider theme="uzh">{children}</ThemeProvider>
32+
}
33+
```
34+
35+
Anything inside a `data-theme="uzh"` container (set directly or via
36+
`ThemeProvider`) resolves its tokens against the UZH theme. Nesting is allowed:
37+
a `uzh` subtree inside a `neutral` page works, and vice versa.
38+
39+
## New exports
40+
41+
| Export | Purpose |
42+
| --------------- | ------------------------------------------------------------- |
43+
| `ThemeProvider` | Renders a `data-theme` container; controlled or uncontrolled. |
44+
| `useTheme` | Reads `{ theme, setTheme }` from the nearest `ThemeProvider`. |
45+
| `Theme` | `'neutral' \| 'uzh'` type. |
46+
47+
`ThemeProvider` is optional — a plain `data-theme="uzh"` attribute is enough.
48+
Use the provider when you want an in-app theme toggle via `useTheme`.
49+
50+
## Fonts
51+
52+
The design system now `@import`s its webfonts (Source Sans 3 for `uzh`, JetBrains
53+
Mono for monospace) from Google Fonts inside `tailwind.css`. No per-app font setup
54+
is required. `neutral` uses the system font stack.
55+
56+
## New component props & variants
57+
58+
These are **additive** — existing usage keeps working.
59+
60+
- **`Alert`** — new `variant`s: `neutral`, `info`, `success`, `warning`, `error`
61+
(tinted background + coloured left border + status icon). `default` and
62+
`destructive` are unchanged.
63+
- **`Badge`** — new `variant`s: `success`, `warning`, `info`, `error` (solid).
64+
- **`Input` / `Textarea`** — new `invalid?: boolean` prop → native `aria-invalid`
65+
(destructive border + ring). The Formik field wrappers wire this automatically
66+
from `error && touched`.
67+
- **`Avatar`** — new `size?: 'sm' | 'md' | 'lg'` (28 / 40 / 56 px). Omit for the
68+
legacy 32 px default.
69+
- **shadcn `TableRow`** (from the `Table`/`TableHeader`/`TableBody`/… family in
70+
`ui/table`, not the legacy `Table` component) — new `hoverable?: boolean`
71+
(default `true`) to opt out of row hover.
72+
- **`Tabs`** — restyled to an underline pattern (active tab gets a primary
73+
bottom-border instead of a filled pill). API unchanged.
74+
75+
## Visual changes to verify
76+
77+
- **`AvatarFallback`** background changed from `bg-muted` to `bg-primary-20
78+
text-primary-100` (theme-tinted initials). Override via `className` if you
79+
relied on the grey fallback.
80+
- **`Tabs`** look different (underline, not pill). Layout/markup is unchanged.
81+
82+
Everything else is theme-token routing: components that hardcoded UZH colours now
83+
read semantic tokens, so they follow whichever `data-theme` is active.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
'use client'
2+
3+
import * as React from 'react'
4+
import { twMerge } from 'tailwind-merge'
5+
6+
export type Theme = 'neutral' | 'uzh'
7+
8+
interface ThemeContextValue {
9+
theme: Theme
10+
setTheme: (theme: Theme) => void
11+
}
12+
13+
const ThemeContext = React.createContext<ThemeContextValue | null>(null)
14+
15+
const noop = () => {}
16+
17+
/**
18+
* Wraps an application (or subtree) in a design-system theme.
19+
*
20+
* Renders a `data-theme` container so all design-system components below it
21+
* resolve their tokens against the chosen theme. The default theme is
22+
* `neutral` (de-branded shadcn); UZH apps pass `theme="uzh"`.
23+
*
24+
* Works controlled (`theme` prop) or uncontrolled (`defaultTheme` + `useTheme`).
25+
*
26+
* @param theme - Controlled theme. When set, the provider is fully controlled.
27+
* @param defaultTheme - Initial theme for uncontrolled usage (default `neutral`).
28+
* @param className - Optional classes for the wrapping container. The container
29+
* uses `display: contents`, so only inherited classes (e.g. `dark`) take
30+
* effect; box-model classes (padding, sizing, background) are a no-op.
31+
* @param children - The subtree that should consume the theme.
32+
*/
33+
export function ThemeProvider({
34+
theme: controlledTheme,
35+
defaultTheme = 'neutral',
36+
className,
37+
children,
38+
}: {
39+
theme?: Theme
40+
defaultTheme?: Theme
41+
className?: string
42+
children: React.ReactNode
43+
}) {
44+
const [uncontrolledTheme, setUncontrolledTheme] =
45+
React.useState<Theme>(defaultTheme)
46+
const isControlled = controlledTheme !== undefined
47+
const theme = controlledTheme ?? uncontrolledTheme
48+
49+
// In controlled mode the parent owns `theme`, so `setTheme` is a no-op rather
50+
// than writing to internal state that is never rendered (which would desync if
51+
// the consumer later dropped the `theme` prop).
52+
const value: ThemeContextValue = {
53+
theme,
54+
setTheme: isControlled ? noop : setUncontrolledTheme,
55+
}
56+
57+
return (
58+
<ThemeContext.Provider value={value}>
59+
<div data-theme={theme} className={twMerge('contents', className)}>
60+
{children}
61+
</div>
62+
</ThemeContext.Provider>
63+
)
64+
}
65+
66+
/**
67+
* Reads the current design-system theme from the nearest `ThemeProvider`.
68+
*
69+
* @returns The active `theme` and a `setTheme` setter (no-op target when the
70+
* provider is controlled via its `theme` prop).
71+
* @throws If used outside a `ThemeProvider`.
72+
*/
73+
export function useTheme(): ThemeContextValue {
74+
const context = React.useContext(ThemeContext)
75+
if (!context) {
76+
throw new Error('useTheme must be used within a ThemeProvider')
77+
}
78+
return context
79+
}

packages/design-system/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export * from './Switch'
7373
export * from './Table'
7474
export * from './Tabs'
7575
export * from './Tag'
76+
export * from './ThemeProvider'
7677
export * from './Toast'
7778
export * from './Toggle'
7879
export * from './ToggleGroup'

project/2026-06-13-v5-dual-theme-uzh-neutral-plan.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,9 +142,11 @@ Reference this plan path. Work one slice at a time. Update `Progress` each slice
142142
- [x] S3 Input + Textarea invalid. Added `invalid?: boolean` to base Input + Textarea → native `aria-invalid` (`invalid ?? ariaInvalid`, no `{...props}` clobber). Existing shadcn classes already give destructive border/ring on aria-invalid + UZH-blue `focus-visible:ring-ring` — no class churn. Wired `invalid={!!error && isTouched}` from forms/TextField (2 branches) + forms/TextareaField (2 branches). Review fix: added missing `isTouched` guard to TextareaField error tooltip (consistency w/ TextField + aria gating). Existing Error stories demo it. Verified tsc/build/format.
143143
- [x] S4 Refinement batch (scoped to additive/low-regression). Avatar: `size` prop sm/md/lg (28/40/56px, no default → legacy 32px kept); fallback `bg-primary-20 text-primary-100` (theme-tinted, ~7:1 contrast both themes). Table (shadcn): `hoverable?: boolean` opt-out. Avatar story Sizes example. Verified tsc/build/format; CSS has size-7/10/14. DELIBERATELY NOT forced (regression-averse, components already theme-switch + close to spec): uppercase shadcn table headers, 13px/600 labels, shimmer skeleton (kept animate-pulse), Progress (already h-2 + themed primary). MIGRATION note: AvatarFallback bg changed muted→primary-20 (override via className if undesired).
144144
- [x] S5 Tabs underline + Card + headings. ui/tabs.tsx: TabsList → bottom-border baseline (`border-b border-border gap-4`); TabsTrigger → underline active (`-mb-px border-b-2 border-b-transparent data-[state=active]:border-b-primary-100 data-[state=active]:text-foreground`, hover text, `ring-[3px]` focus). src/Tabs.tsx wrapper: dropped `data-[state=active]:font-bold` (stale pill style), kept full-width grid layout. Card: NO change (already `rounded-xl border shadow-sm`). Headings H1/H2/H3 already correct; added `--text-md` (17px) + `--text-md--line-height: 1.35` token for H4. Review fixes: removed dead `focus-visible:outline-ring` (color w/o width = invisible; `ring-[3px]` is real indicator); added text-md line-height. Verified: tsc clean; ladle build ok; prettier clean.
145-
- [ ] S6 ThemeProvider + docs + dev-lint + security
146-
- Current slice: S6 next.
147-
- Next action: ThemeProvider + useTheme exports, README/MIGRATION docs (`data-theme="uzh"` for UZH apps), dev-lint, final security review.
145+
- [x] S6 ThemeProvider + docs. Added `src/ThemeProvider.tsx` (`ThemeProvider`, `useTheme`, `Theme` type) — renders `<div data-theme className='contents'>` (layout-transparent; custom-prop inheritance + `.dark` descendant matching both survive `display:contents`), controlled OR uncontrolled (`setTheme` honest no-op when controlled, no stale-state desync). Exported from `index.ts`. Dogfooded: Ladle global provider now wraps stories in `<ThemeProvider>` (dropped local `Theme` type). Docs: root README `## Theming (v5)` section + new `packages/design-system/MIGRATION.md` (v4→v5: neutral default, UZH opt-in `data-theme="uzh"`, new exports/props/variants table, AvatarFallback + Tabs visual notes). Review+simplify (2 subagents): made controlled-mode `setTheme` a real no-op, dropped pointless `useMemo`, documented `contents` className caveat, clarified Table→ShadcnTableRow in migration. Verified: tsc clean; ladle build ok; prettier clean.
146+
- **DEV-LINT DEFERRED (A2, evidence-backed)**: reference `_adherence.oxlintrc.json` was generated for the reference's inline-style/synthesized-API code, not our Tailwind+cva library. Against our `src/`: raw-hex rule = 8 hits ALL legit (ColorPicker swatch palette, Cycle default color props, recharts `#ccc`/`#fff` internal selectors); raw-px rule = 75 hits ALL legit Tailwind arbitrary values (`w-[280px]`, `border-[3px]`); JSX prop rules describe a different component API (e.g. Button variants primary|outline|destructive|ghost ≠ ours). Wiring it in = ~83 false positives + new dep, 0 real catches. Mismatched tool; A2 was optional. Deferred to a follow-up (author a Tailwind-aware token-regression guard if wanted). Per-slice grep already verifies zero `uzh-*` leaks.
147+
- [ ] Finish: final security review (whole branch) + MR + demo deploy.
148+
- Current slice: S6 done; Finish next.
149+
- Next action: final security review subagent (whole branch), then `$df-mr-description-writer` MR vs `main` + both-theme screenshots; confirm gh-pages demo deploy carries theme toggle.
148150

149151
### Verification approach note
150152
Per-slice: `tsc` + `ladle build` + targeted compiled-CSS/grep assertions. Live visual both-theme screenshots consolidated at finish for MR evidence (browser set up once across all components) rather than per slice.

0 commit comments

Comments
 (0)