Skip to content

Commit f4cc5ec

Browse files
authored
Merge pull request #129 from developit/claude/theme-tokens-radius-slice
feat(tokens): theming-token refactor — radius scale + elevation + surface tokens
2 parents 20c6369 + 37ad9e1 commit f4cc5ec

74 files changed

Lines changed: 1237 additions & 218 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/theme-system.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"kinu": minor
3+
---
4+
5+
Theming: kinu's default tokens now live in a `@layer tokens` cascade layer, and
6+
control sizing moves behind density tokens.
7+
8+
**Token layering.** Every `--k-*` default in `variables.css` is declared inside
9+
`@layer tokens`. Unlayered rules beat layered ones regardless of specificity, so
10+
overriding kinu no longer needs specificity tricks or `!important` — a plain
11+
`:root { --k-primary: … }` in your own stylesheet wins on its own, as does a
12+
`[data-theme="…"] { --k-* }` block. Existing overrides keep working; the
13+
escalation some of them use is now unnecessary.
14+
15+
**Density.** Row and control sizing is token-driven (`--k-row-*`,
16+
`--k-control-*`) rather than hard-coded, so buttons, inputs, selects, textareas,
17+
toggles, tabs and menu rows resize as a coordinated set. Pick a ramp with
18+
`<html data-kinu-density="sm|md|lg">`.
19+
20+
The default is adaptive, which changes sizing on touch and narrow viewports:
21+
under `(pointer: coarse), (max-width: 640px)` kinu applies the `lg` ramp
22+
automatically, so controls are taller than in previous releases (36px → 40px
23+
baseline, 14px → 15px text, 16px → 18px icons). Setting `data-kinu-density` to
24+
any value — including an empty one — opts out and restores fixed sizing.
25+
26+
**Focus appearance.** Two tokens now drive focus across every input-like
27+
component: `--k-focus-ring` (the `:focus-visible` box-shadow) and
28+
`--k-focus-border` (its border colour). Input, textarea, select, switch, radio,
29+
otp, toggle, color-picker and the input-group wrapper all honour them, so one
30+
pair of values restyles the set. Components with deliberately bespoke focus
31+
treatment — button, slider thumb, tab, chip, tree, listbox — keep their own
32+
rules. `--k-focus-border` defaults to the resting border colour, so the default
33+
focus appearance is unchanged.
34+
35+
**Bundled themes.** Themes now ship as their own stylesheets under a
36+
`kinu/themes/*.css` subpath, so you can pull one in next to the base CSS:
37+
38+
```js
39+
import 'kinu/style.css';
40+
import 'kinu/themes/claw.css';
41+
```
42+
43+
Then set `data-theme` on the root element — `<html data-theme="claw">` — to
44+
activate it. Importing a theme without setting the attribute costs you nothing
45+
but the bytes; the stylesheet only declares `[data-theme="…"]` rules.
46+
47+
The first bundled theme is `claw`, a monochromatic Claude Code-style design
48+
system, which doubles as the worked example for what these hooks can do.

.changeset/theming-tokens.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"kinu": minor
3+
---
4+
5+
More theming hooks: a radius scale, elevation tokens, and tokens for the
6+
handful of values that previously meant re-authoring an awkward selector.
7+
8+
**Radius scale.** `--k-radius-1` through `--k-radius-4` express a four-step ramp
9+
as offsets from the `--k-radius` anchor, tightest to loosest, with step 3 being
10+
`--k-radius` itself. Components that hard-coded `calc(var(--k-radius) - 2px)`
11+
now reference a step, so moving `--k-radius` shifts the whole set coherently
12+
while any individual step stays re-pinnable (`--k-radius-1: 0`) without
13+
disturbing the others. Computed values are unchanged.
14+
15+
**Elevation.** `--k-shadow-1` (resting), `--k-shadow-2` (floating) and
16+
`--k-shadow-3` (modal) replace per-component shadow literals, so three values
17+
retune every elevated surface at once.
18+
19+
This normalizes the scale, which changes default appearance. Dropdowns, menus
20+
and comboboxes previously carried a heavier shadow than modal dialogs did.
21+
Surfaces now stack in the order you would expect: cards below popovers, menus
22+
and hover-cards, which sit below dialogs, sheets and drawers. In practice
23+
dialogs and sheets read noticeably deeper than before and dropdowns lighter.
24+
Setting the three tokens restores any previous look.
25+
26+
**Surface tokens.** `--k-font-mono` (shared by `kbd` and prose `code`),
27+
`--k-tab-indicator-bg`, `--k-avatar-font-size`, `--k-scrollbar-size` /
28+
`--k-scrollbar-thumb` / `--k-scrollbar-thumb-hover`, and `--k-selection-bg`.
29+
These cover the cases where restyling previously meant reproducing a
30+
pseudo-element, some anchor-positioning machinery, or an internal sizing trick —
31+
set a value instead of a selector. The segmented tab indicator can now be
32+
recoloured without re-authoring its `::before` and the `@supports` fallback that
33+
backs it, and avatar initials take a plain `font-size`: the em-multiplier the
34+
initials are built on stays internal, so a px value can no longer be fed through
35+
it and blow the glyph up.
36+
37+
Text selection inside kinu components is now tinted from `--k-selection-bg`.
38+
Like the scrollbar styling, the rule is scoped to kinu elements and their
39+
contents, so selection elsewhere on your page keeps the browser's colour. Set
40+
`--k-selection-bg` to `Highlight` to opt kinu's own components back out.

demo/index.html

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,24 @@
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content" />
66
<title>UI Toolkit Demo</title>
7+
<!--
8+
Theme bootstrap — must run synchronously before any CSS is applied so
9+
we don't paint kinu defaults first and then flash to the chosen theme.
10+
Applies the persisted picker selection (theme + density) written by the
11+
demo's code. The customizer's overlay is applied from its settings JSON
12+
on module load (it can't be regenerated by an inline script without the
13+
Radix colour data), so customizations may flash on first paint.
14+
-->
15+
<script>
16+
(function () {
17+
try {
18+
let t = localStorage.getItem('kinu-theme');
19+
if (t) document.documentElement.setAttribute('data-theme', t);
20+
let d = localStorage.getItem('kinu-density');
21+
if (d) document.documentElement.setAttribute('data-kinu-density', d);
22+
} catch (e) {}
23+
})();
24+
</script>
725
<style>
826
* {
927
box-sizing: border-box;

demo/src/nav.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
DropdownMenuContent,
1010
DropdownMenuItem,
1111
} from 'kinu';
12-
import {ThemeCustomizer} from './theme-customizer.tsx';
12+
import {ThemePicker} from './theme-picker.tsx';
1313
import {KinuLogo} from './logo';
1414

1515
export function Nav({
@@ -64,7 +64,7 @@ export function Nav({
6464
</NavigationMenuItem>
6565
</NavigationMenuList>
6666
<div class="nav-actions">
67-
<ThemeCustomizer />
67+
<ThemePicker />
6868
</div>
6969
</NavigationMenu>
7070
);

demo/src/style.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
@import url("https://fonts.googleapis.com/css2?family=Public+Sans:wght@300;400;500;600;700;800&family=Newsreader:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600;1,700&family=Manrope:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");
22
@import "../../src/variables.css";
33
@import "../../src/base.css";
4+
@import "../../src/themes/claw.css";
45

56
/* ─────────────────────────────────────────────────────────────────────────
67
* Kinu brand identity — sage on warm cream paper.

demo/src/theme-customizer.tsx

Lines changed: 78 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,40 @@
11
import {useEffect, useState} from 'preact/hooks';
2-
import {Dialog, Button, Label, toast, Collapsible, Tooltip} from 'kinu';
2+
import {Dialog, Button, Label, toast, Collapsible} from 'kinu';
33

44
const CUSTOM_THEME_STORAGE_KEY = 'kinu-custom-theme';
55
const CUSTOM_THEME_STYLE_ID = 'kinu-custom-style';
6+
export const THEME_CUSTOMIZER_DIALOG_ID = 'theme-customizer';
7+
8+
/** Read the active theme from <html data-theme="..."> ('' = none/default). */
9+
function activeTheme(): string {
10+
if (typeof document === 'undefined') return '';
11+
return document.documentElement.getAttribute('data-theme') ?? '';
12+
}
13+
14+
/** Build the selectors the customizer's generated CSS should target.
15+
* Two independent axes compose here:
16+
* - active theme (`data-theme="x"`): scope the overlay on top of that
17+
* theme. With kinu's tokens layer, both `:root` (no theme) and
18+
* `[data-theme="x"]` are unlayered and beat kinu's defaults — and the
19+
* late-injected overlay wins source-order against the theme itself.
20+
* - light/dark via `data-color-scheme` (matching variables.css), so the
21+
* customization also reaches explicitly-scoped light/dark subtrees. */
22+
function lightSelectorForTheme(theme: string): string {
23+
// :root + explicit-light subtrees, scoped under the active theme.
24+
return theme
25+
? `[data-theme="${theme}"],\n[data-theme="${theme}"] [data-color-scheme="light"]`
26+
: ':root,\n[data-color-scheme="light"]';
27+
}
28+
function darkMediaSelectorForTheme(theme: string): string {
29+
// OS dark mode (inside @media prefers-color-scheme: dark).
30+
return theme ? `[data-theme="${theme}"]` : ':root';
31+
}
32+
function darkSelectorForTheme(theme: string): string {
33+
// Explicit dark via data-color-scheme, page-level or subtree.
34+
return theme
35+
? `[data-theme="${theme}"][data-color-scheme="dark"],\n[data-theme="${theme}"] [data-color-scheme="dark"]`
36+
: '[data-color-scheme="dark"]';
37+
}
638

739
/* ── hex→HSL helper ───────────────────────────────────────── */
840
function hexToHsl(hex: string): [number, number, number] {
@@ -98,7 +130,7 @@ interface ThemeSettings {
98130
scaling: string;
99131
}
100132

101-
function generateCSS(settings: ThemeSettings): string {
133+
function generateCSS(settings: ThemeSettings, theme: string = ''): string {
102134
const accent = ACCENT_COLORS[settings.accentColor];
103135
const gray = GRAY_COLORS[settings.grayColor];
104136
if (!accent || !gray) return '';
@@ -108,12 +140,16 @@ function generateCSS(settings: ThemeSettings): string {
108140
const radius = RADIUS_MAP[settings.radius] ?? '0.5rem';
109141
const scale = parseInt(settings.scaling) / 100;
110142

143+
const lightSel = lightSelectorForTheme(theme);
144+
const darkMediaSel = darkMediaSelectorForTheme(theme);
145+
const darkSel = darkSelectorForTheme(theme);
146+
111147
const lines = [
112-
'/* Light mode — :root + explicit-light subtrees (e.g. per-component',
113-
' `data-color-scheme="light"`). Both need the same tokens so that',
114-
' toggling a subtree back to light keeps the customized palette. */',
115-
':root,',
116-
'[data-color-scheme="light"] {',
148+
'/* Light mode — root + explicit-light subtrees (e.g. per-component',
149+
' `data-color-scheme="light"`), scoped to the active theme. Both need',
150+
' the same tokens so toggling a subtree back to light keeps the',
151+
' customized palette. */',
152+
`${lightSel} {`,
117153
` --k-primary: ${hsl(accent.light.step9)};`,
118154
` --k-primary-hover: ${hsl(accent.light.step10)};`,
119155
` --k-primary-foreground: ${fgLight};`,
@@ -145,7 +181,7 @@ function generateCSS(settings: ThemeSettings): string {
145181
// Dark mode (automatic)
146182
lines.push('/* Dark mode (automatic) */');
147183
lines.push('@media (prefers-color-scheme: dark) {');
148-
lines.push(' :root {');
184+
lines.push(` ${darkMediaSel} {`);
149185
lines.push(' color-scheme: dark;');
150186
lines.push(` --k-primary: ${hsl(accent.dark.step9)};`);
151187
lines.push(` --k-primary-hover: ${hsl(accent.dark.step10)};`);
@@ -171,7 +207,7 @@ function generateCSS(settings: ThemeSettings): string {
171207

172208
// Dark mode (explicit attribute — applies to any subtree)
173209
lines.push('/* Dark mode (explicit, scopable to any subtree) */');
174-
lines.push('[data-color-scheme="dark"] {');
210+
lines.push(`${darkSel} {`);
175211
lines.push(' color-scheme: dark;');
176212
lines.push(` --k-primary: ${hsl(accent.dark.step9)};`);
177213
lines.push(` --k-primary-hover: ${hsl(accent.dark.step10)};`);
@@ -232,10 +268,32 @@ function applyCustomTheme(css: string) {
232268
document.head.appendChild(style);
233269
}
234270

235-
try {
236-
const saved = localStorage.getItem(CUSTOM_THEME_STORAGE_KEY);
237-
if (saved) applyCustomTheme(saved);
238-
} catch {}
271+
/** Read saved settings, regenerate CSS scoped to the currently-active
272+
* theme (falling back to `:root` when none), inject, and write back
273+
* the result so the synchronous bootstrap script in index.html can
274+
* inject it on the next load without re-running the generator. */
275+
function applyFromStorage() {
276+
try {
277+
const raw = localStorage.getItem(CUSTOM_THEME_STORAGE_KEY + '-settings');
278+
if (!raw) return;
279+
const settings = JSON.parse(raw) as ThemeSettings;
280+
const css = generateCSS(settings, activeTheme());
281+
localStorage.setItem(CUSTOM_THEME_STORAGE_KEY, css);
282+
applyCustomTheme(css);
283+
} catch {}
284+
}
285+
286+
if (typeof document !== 'undefined') {
287+
applyFromStorage();
288+
289+
// Re-scope the customizer's overlay whenever the active theme changes
290+
// so the `[data-theme="..."]` selectors track the picker's selection.
291+
const obs = new MutationObserver(applyFromStorage);
292+
obs.observe(document.documentElement, {
293+
attributes: true,
294+
attributeFilter: ['data-theme'],
295+
});
296+
}
239297

240298
/* ── Color swatch ─────────────────────────────────────────── */
241299
function Swatch({color, selected, onClick, label}: {
@@ -287,13 +345,16 @@ export function ThemeCustomizer() {
287345
// };
288346

289347
const handleApply = () => {
290-
const css = generateCSS(settings);
348+
// Always generate against the *current* active theme, so the
349+
// customizer's overlay is correctly scoped to whatever the picker
350+
// has selected at apply-time.
351+
const css = generateCSS(settings, activeTheme());
291352
try {
292353
localStorage.setItem(CUSTOM_THEME_STORAGE_KEY, css);
293354
localStorage.setItem(CUSTOM_THEME_STORAGE_KEY + '-settings', JSON.stringify(settings));
294355
} catch {}
295356
applyCustomTheme(css);
296-
toast.show('Theme applied');
357+
toast.show('Customizations applied');
297358
};
298359

299360
const handleClear = () => {
@@ -303,19 +364,11 @@ export function ThemeCustomizer() {
303364
} catch {}
304365
applyCustomTheme('');
305366
setSettings({accentColor: 'blue', grayColor: 'slate', radius: 'medium', scaling: '100%'});
306-
toast.show('Theme reset to defaults');
367+
toast.show('Customizations cleared');
307368
};
308369

309370
return (
310-
<Dialog>
311-
<Dialog.Trigger>
312-
<Tooltip title="Theme" side="bottom">
313-
<Button variant="secondary" size="icon">
314-
<iconify-icon icon="lucide:palette" />
315-
</Button>
316-
</Tooltip>
317-
</Dialog.Trigger>
318-
371+
<Dialog id={THEME_CUSTOMIZER_DIALOG_ID}>
319372
<Dialog.Content>
320373
<div style="display:flex; flex-direction:column; gap:1.5rem">
321374
<div style="display:flex; align-items:center; justify-content:space-between; margin:-.5rem 0;">

0 commit comments

Comments
 (0)