@kouji-ui/themes 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kouji-ui/themes",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Theme tokens and theme stylesheets for kouji-ui — pure CSS, swappable at runtime via data-theme.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,7 +32,8 @@
32
32
  "exports": {
33
33
  ".": "./src/index.css",
34
34
  "./base.css": "./src/base.css",
35
- "./themes/dark.css": "./src/themes/dark.css"
35
+ "./density.css": "./src/density.css",
36
+ "./themes/*.css": "./src/themes/*.css"
36
37
  },
37
38
  "scripts": {
38
39
  "build": "echo 'no build for css-only package'",
@@ -0,0 +1,135 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ @kouji-ui/themes — density layer
3
+ App-wide density. Before this, density existed only on the table
4
+ (--kj-table-row-height); everything else was fixed.
5
+
6
+ Mechanism: two inherited scalars, not per-component selectors.
7
+ Components use ViewEncapsulation.None, so a [data-density] selector
8
+ WOULD reach them — but it would need one block per component per level,
9
+ and the ratio would live in dozens of places. Two inherited numbers put
10
+ the ratio in one place and, because custom properties inherit, a dense
11
+ region nests correctly inside a comfortable app with no extra rules.
12
+
13
+ Every derived value is round()ed to a whole pixel. Fractional heights
14
+ blur hairlines and desync rows from any JS/SVG surface that mirrors them.
15
+
16
+ Layer note: these rules join the EXISTING kj.base layer. Do not add a
17
+ kj.density sublayer — that would mean editing the @layer statement in
18
+ base.css, which desyncs consumers who re-declare the layer order.
19
+ Sitting in kj.base keeps themes (kj.shared) and consumer CSS above it.
20
+ ──────────────────────────────────────────────────────────── */
21
+
22
+ /* Registered so JS can read a resolved px value. An UNregistered custom
23
+ property has no computed value: getComputedStyle() returns the raw token
24
+ stream ("round(calc(...))"), and parseFloat gives NaN.
25
+
26
+ Only the scalars and the JS-read ladders are registered. The existing
27
+ --kj-space-* / --kj-text-* deliberately are NOT: registration makes
28
+ invalid-at-computed-value-time *apply*, which would silently discard a
29
+ legitimate consumer override and is a breaking change. */
30
+ @property --kj-density { syntax: "<number>"; inherits: true; initial-value: 1; }
31
+ @property --kj-type-scale { syntax: "<number>"; inherits: true; initial-value: 1; }
32
+
33
+ @property --kj-ctl-h-xs { syntax: "<length>"; inherits: true; initial-value: 28px; }
34
+ @property --kj-ctl-h-sm { syntax: "<length>"; inherits: true; initial-value: 32px; }
35
+ @property --kj-ctl-h-md { syntax: "<length>"; inherits: true; initial-value: 36px; }
36
+ @property --kj-ctl-h-lg { syntax: "<length>"; inherits: true; initial-value: 44px; }
37
+ @property --kj-ctl-h-xl { syntax: "<length>"; inherits: true; initial-value: 52px; }
38
+ @property --kj-row-h { syntax: "<length>"; inherits: true; initial-value: 36px; }
39
+
40
+ @layer kj.base {
41
+ :root {
42
+ --kj-density: 1;
43
+ --kj-type-scale: 1;
44
+
45
+ /* ── numeric spacing ladder ──
46
+ The t-shirt names below re-point at these. Values are kept in rem so
47
+ spacing still tracks the root font size, exactly as before. At
48
+ density 1 every t-shirt token resolves to its previous value to the
49
+ byte — this is a re-pointing, not a re-scaling. */
50
+ --kj-space-1: round(calc(0.125rem * var(--kj-density)), 1px); /* 2px */
51
+ --kj-space-2: round(calc(0.25rem * var(--kj-density)), 1px); /* 4px */
52
+ --kj-space-3: round(calc(0.375rem * var(--kj-density)), 1px); /* 6px */
53
+ --kj-space-4: round(calc(0.5rem * var(--kj-density)), 1px); /* 8px */
54
+ --kj-space-5: round(calc(0.625rem * var(--kj-density)), 1px); /* 10px */
55
+ --kj-space-6: round(calc(0.75rem * var(--kj-density)), 1px); /* 12px */
56
+ --kj-space-7: round(calc(1rem * var(--kj-density)), 1px); /* 16px */
57
+ --kj-space-8: round(calc(1.25rem * var(--kj-density)), 1px); /* 20px */
58
+ --kj-space-9: round(calc(1.5rem * var(--kj-density)), 1px); /* 24px */
59
+ --kj-space-10: round(calc(1.75rem * var(--kj-density)), 1px); /* 28px */
60
+ --kj-space-11: round(calc(2rem * var(--kj-density)), 1px); /* 32px */
61
+
62
+ /* t-shirt names stay the documented API; numeric is the dense-UI
63
+ escape hatch. These land on the same values they always had. */
64
+ --kj-space-xs: var(--kj-space-2); /* 4px */
65
+ --kj-space-sm: var(--kj-space-4); /* 8px */
66
+ --kj-space-md: var(--kj-space-6); /* 12px */
67
+ --kj-space-lg: var(--kj-space-7); /* 16px */
68
+ --kj-space-xl: var(--kj-space-9); /* 24px */
69
+ --kj-space-2xl: var(--kj-space-11); /* 32px */
70
+ --kj-space-3xl: round(calc(3rem * var(--kj-density)), 1px); /* 48px */
71
+ --kj-space-4xl: round(calc(4rem * var(--kj-density)), 1px); /* 64px */
72
+ --kj-space-5xl: round(calc(6rem * var(--kj-density)), 1px); /* 96px */
73
+ --kj-space-6xl: round(calc(8rem * var(--kj-density)), 1px); /* 128px */
74
+
75
+ /* ── type ──
76
+ xs..2xl keep their existing values at scale 1. 3xs/2xs and 3xl/4xl/
77
+ display are ADDITIVE: dense UIs need badge- and meta-sized steps that
78
+ a 6-step scale cannot express, and new names mean no reflow for
79
+ existing consumers. Type is snapped to a half pixel, not a whole one —
80
+ fonts render fine on halves and it keeps the ramp from collapsing. */
81
+ --kj-text-3xs: round(calc(0.625rem * var(--kj-type-scale)), 0.5px); /* 10px */
82
+ --kj-text-2xs: round(calc(0.6875rem * var(--kj-type-scale)), 0.5px); /* 11px */
83
+ --kj-text-xs: round(calc(0.75rem * var(--kj-type-scale)), 0.5px); /* 12px */
84
+ --kj-text-sm: round(calc(0.875rem * var(--kj-type-scale)), 0.5px); /* 14px */
85
+ --kj-text-base: round(calc(1rem * var(--kj-type-scale)), 0.5px); /* 16px */
86
+ --kj-text-lg: round(calc(1.125rem * var(--kj-type-scale)), 0.5px); /* 18px */
87
+ --kj-text-xl: round(calc(1.25rem * var(--kj-type-scale)), 0.5px); /* 20px */
88
+ --kj-text-2xl: round(calc(1.5rem * var(--kj-type-scale)), 0.5px); /* 24px */
89
+ --kj-text-3xl: round(calc(2rem * var(--kj-type-scale)), 0.5px); /* 32px */
90
+ --kj-text-4xl: round(calc(2.5rem * var(--kj-type-scale)), 0.5px); /* 40px */
91
+ --kj-text-display: round(calc(3rem * var(--kj-type-scale)), 0.5px); /* 48px */
92
+
93
+ /* Code surfaces (Monaco, terminals) take metrics as JS numbers — they
94
+ cannot inherit font-size — so they read these two directly. */
95
+ --kj-text-code: round(calc(0.8125rem * var(--kj-type-scale)), 0.5px); /* 13px */
96
+ --kj-leading-code: round(calc(1.125rem * var(--kj-type-scale)), 1px); /* 18px */
97
+
98
+ /* ── control heights ──
99
+ sm/md/lg stay each component's semantic API; this shared ladder is
100
+ what those variants resolve to, so density scales every variant
101
+ uniformly instead of each component hardcoding a rem value. Values
102
+ match the current button heights exactly at density 1. */
103
+ --kj-ctl-h-xs: round(calc(1.75rem * var(--kj-density)), 1px); /* 28px */
104
+ --kj-ctl-h-sm: round(calc(2rem * var(--kj-density)), 1px); /* 32px */
105
+ --kj-ctl-h-md: round(calc(2.25rem * var(--kj-density)), 1px); /* 36px */
106
+ --kj-ctl-h-lg: round(calc(2.75rem * var(--kj-density)), 1px); /* 44px */
107
+ --kj-ctl-h-xl: round(calc(3.25rem * var(--kj-density)), 1px); /* 52px */
108
+
109
+ /* Row height for list/table/tree surfaces. */
110
+ --kj-row-h: var(--kj-ctl-h-md);
111
+ }
112
+
113
+ /* ── presets ──
114
+ Ratios validated in a dense IDE: type breathes more gently than boxes,
115
+ because an 0.85 squeeze that flatters padding is punishing on 12px text.
116
+
117
+ "standard" is declared as an explicit no-op so KjTableDensity's existing
118
+ three values all remain valid — renaming or dropping one would be an API
119
+ break. "comfy" is accepted as an alias; "comfortable" stays canonical. */
120
+ [data-density="compact"] {
121
+ --kj-density: 0.85;
122
+ --kj-type-scale: 0.875;
123
+ }
124
+
125
+ [data-density="standard"] {
126
+ --kj-density: 1;
127
+ --kj-type-scale: 1;
128
+ }
129
+
130
+ [data-density="comfortable"],
131
+ [data-density="comfy"] {
132
+ --kj-density: 1.18;
133
+ --kj-type-scale: 1.125;
134
+ }
135
+ }
@@ -0,0 +1,144 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import postcss from 'postcss';
4
+ import { describe, expect, test } from 'vitest';
5
+
6
+ const css = readFileSync(resolve(import.meta.dirname, 'density.css'), 'utf-8');
7
+ const baseCss = readFileSync(resolve(import.meta.dirname, 'base.css'), 'utf-8');
8
+ const indexCss = readFileSync(resolve(import.meta.dirname, 'index.css'), 'utf-8');
9
+
10
+ /** Custom properties registered via `@property` in density.css. */
11
+ function registeredProps(text: string): Set<string> {
12
+ const out = new Set<string>();
13
+ postcss.parse(text).walkAtRules('property', rule => out.add(rule.params.trim()));
14
+ return out;
15
+ }
16
+
17
+ /** Declarations inside a rule whose selector matches `selector`. */
18
+ function declsFor(text: string, selector: string): Map<string, string> {
19
+ const out = new Map<string, string>();
20
+ postcss.parse(text).walkRules(rule => {
21
+ if (!rule.selector.includes(selector)) return;
22
+ rule.walkDecls(decl => out.set(decl.prop, decl.value));
23
+ });
24
+ return out;
25
+ }
26
+
27
+ describe('density layer', () => {
28
+ test('is exported from the package entry', () => {
29
+ expect(indexCss).toContain("@import './density.css';");
30
+ });
31
+
32
+ test('joins the existing kj.base layer without redeclaring layer order', () => {
33
+ // Editing the `@layer kj.reset, kj.base, kj.shared, kj.component` statement
34
+ // desyncs consumers who re-declare it, so density.css must not carry one.
35
+ expect(css).toMatch(/@layer\s+kj\.base\s*\{/);
36
+ expect(css).not.toMatch(/@layer\s+[\w.]+\s*,/);
37
+ expect(baseCss).toMatch(/@layer kj\.reset, kj\.base, kj\.shared, kj\.component;/);
38
+ });
39
+
40
+ describe('@property registration', () => {
41
+ const registered = registeredProps(css);
42
+
43
+ test('registers the two scalars', () => {
44
+ expect(registered).toContain('--kj-density');
45
+ expect(registered).toContain('--kj-type-scale');
46
+ });
47
+
48
+ test('registers the height ladder that JS reads', () => {
49
+ for (const t of ['xs', 'sm', 'md', 'lg', 'xl']) {
50
+ expect(registered).toContain(`--kj-ctl-h-${t}`);
51
+ }
52
+ expect(registered).toContain('--kj-row-h');
53
+ });
54
+
55
+ test('does NOT register the pre-existing space/text tokens', () => {
56
+ // Registering them would make invalid-at-computed-value-time APPLY,
57
+ // silently discarding a legitimate consumer override such as
58
+ // `--kj-space-md: 0`. That is a breaking change, so it must not happen.
59
+ for (const prop of registered) {
60
+ expect(prop, `${prop} must not be registered`).not.toMatch(
61
+ /^--kj-(space|text)-/,
62
+ );
63
+ }
64
+ });
65
+ });
66
+
67
+ describe('scale', () => {
68
+ const root = declsFor(css, ':root');
69
+
70
+ test('every t-shirt spacing name re-points at the numeric ladder', () => {
71
+ const expected: Record<string, string> = {
72
+ '--kj-space-xs': 'var(--kj-space-2)',
73
+ '--kj-space-sm': 'var(--kj-space-4)',
74
+ '--kj-space-md': 'var(--kj-space-6)',
75
+ '--kj-space-lg': 'var(--kj-space-7)',
76
+ '--kj-space-xl': 'var(--kj-space-9)',
77
+ '--kj-space-2xl': 'var(--kj-space-11)',
78
+ };
79
+ for (const [name, target] of Object.entries(expected)) {
80
+ expect(root.get(name), `${name} must re-point at ${target}`).toBe(target);
81
+ }
82
+ });
83
+
84
+ test('the numeric ladder and heights derive from --kj-density', () => {
85
+ for (const n of [1, 4, 7, 11]) {
86
+ expect(root.get(`--kj-space-${n}`)).toMatch(
87
+ /round\(calc\(.*var\(--kj-density\).*\),\s*1px\)/,
88
+ );
89
+ }
90
+ for (const t of ['xs', 'sm', 'md', 'lg', 'xl']) {
91
+ expect(root.get(`--kj-ctl-h-${t}`)).toMatch(
92
+ /round\(calc\(.*var\(--kj-density\).*\),\s*1px\)/,
93
+ );
94
+ }
95
+ });
96
+
97
+ test('the type ramp derives from --kj-type-scale, snapped to a half px', () => {
98
+ for (const t of ['3xs', '2xs', 'xs', 'sm', 'base', 'lg', 'xl', '2xl']) {
99
+ expect(root.get(`--kj-text-${t}`)).toMatch(
100
+ /round\(calc\(.*var\(--kj-type-scale\).*\),\s*0\.5px\)/,
101
+ );
102
+ }
103
+ });
104
+
105
+ test('exposes code metrics for editor and terminal surfaces', () => {
106
+ expect(root.get('--kj-text-code')).toBeDefined();
107
+ expect(root.get('--kj-leading-code')).toBeDefined();
108
+ });
109
+ });
110
+
111
+ describe('presets', () => {
112
+ test('all three KjTableDensity values remain valid selectors', () => {
113
+ // Renaming or dropping one would break the existing table API.
114
+ for (const level of ['compact', 'standard', 'comfortable']) {
115
+ expect(css).toContain(`[data-density="${level}"]`);
116
+ }
117
+ });
118
+
119
+ test('standard is an explicit no-op', () => {
120
+ const standard = declsFor(css, '[data-density="standard"]');
121
+ expect(standard.get('--kj-density')).toBe('1');
122
+ expect(standard.get('--kj-type-scale')).toBe('1');
123
+ });
124
+
125
+ test('comfy is accepted as an alias of comfortable', () => {
126
+ expect(css).toMatch(/\[data-density="comfortable"\],\s*\[data-density="comfy"\]/);
127
+ });
128
+
129
+ test('type breathes more gently than boxes at both ends', () => {
130
+ const compact = declsFor(css, '[data-density="compact"]');
131
+ const comfortable = declsFor(css, '[data-density="comfortable"]');
132
+
133
+ const cDensity = Number(compact.get('--kj-density'));
134
+ const cType = Number(compact.get('--kj-type-scale'));
135
+ const fDensity = Number(comfortable.get('--kj-density'));
136
+ const fType = Number(comfortable.get('--kj-type-scale'));
137
+
138
+ // An 0.85 squeeze that flatters padding is punishing on 12px text, so the
139
+ // type scalar must always sit closer to 1 than the box scalar.
140
+ expect(1 - cType).toBeLessThan(1 - cDensity);
141
+ expect(fType - 1).toBeLessThan(fDensity - 1);
142
+ });
143
+ });
144
+ });
package/src/index.css CHANGED
@@ -1,4 +1,5 @@
1
1
  @import './base.css';
2
+ @import './density.css';
2
3
  @import './themes/dark.css';
3
4
  @import './themes/light.css';
4
5
  @import './themes/kouji.css';