@kbach/react 1.0.0-beta.2 → 1.0.0-beta.20

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/README.md CHANGED
@@ -24,22 +24,18 @@ export default defineConfig({
24
24
  });
25
25
  ```
26
26
 
27
- ```css
28
- /* src/kbach.css */
29
- /* kbach:start */
30
- /* kbach:end */
31
- ```
27
+ That's it — the plugin finds or creates your `kbach.css` on its own,
28
+ wherever your source actually lives (`src/`, `app/`, ...; see "Where does
29
+ `kbach.css` go?" below if you want to know exactly where or control it).
30
+ Import it once from your entry point and render normally:
32
31
 
33
32
  ```tsx
34
33
  // main.tsx
35
- import { initKbach } from '@kbach/react';
36
34
  import { createRoot } from 'react-dom/client';
37
35
  import App from './App';
38
36
  import './kbach.css';
39
37
 
40
- initKbach().then(() => {
41
- createRoot(document.getElementById('root')!).render(<App />);
42
- });
38
+ createRoot(document.getElementById('root')!).render(<App />);
43
39
  ```
44
40
 
45
41
  ```tsx
@@ -48,20 +44,208 @@ initKbach().then(() => {
48
44
  </div>
49
45
  ```
50
46
 
47
+ No `initKbach()`/async step before your first render: `className` here is
48
+ a plain string prop, resolved entirely by the CSS `@kbach/react/vite`
49
+ already generated at build time, same as any other static stylesheet —
50
+ nothing here calls into the WASM engine at render time, so there's nothing
51
+ to await.
52
+
53
+ ### Where does `kbach.css` go?
54
+
55
+ You don't need to think about this for the common case — skip to
56
+ [React Router](#react-router)/[Next.js](#nextjs) if that's what brought you
57
+ here. For everyone else: the plugin looks for a file already containing
58
+ `/* kbach:start */`/`/* kbach:end */` markers under each of your `include`
59
+ directories (`src`, `app`, `pages`, `components` by default) and the
60
+ project root, in that order, and keeps using whichever one it finds. If
61
+ none exists yet, it creates `<first-existing-include-dir>/kbach.css` for
62
+ you, markers included — you never have to hand-author that file. Pass
63
+ `cssFile` to `kbach()` only if you want to pin the location explicitly
64
+ (a path outside every `include` dir, or more than one candidate present at
65
+ once).
66
+
67
+ `initKbach()` (still exported from `@kbach/react`) is only needed for two
68
+ opt-in features that genuinely do call into the Rust/WASM engine at
69
+ runtime: the dynamic [`kb()`](#dynamic-classes) function, and the optional
70
+ `@kbach/react/jsx-runtime` (a `jsxImportSource` override that intercepts
71
+ *every* element's `className` automatically instead of relying on the
72
+ static build). If you use either, `await initKbach()` once before your
73
+ first render, same as before.
74
+
75
+ ## React Router
76
+
77
+ React Router is a client-side routing library with no build-tool coupling
78
+ of its own, so it layers on top of the exact same Vite + `kbach.css` setup
79
+ either way — but which "either way" depends on which of React Router's two
80
+ genuinely different modes you're using.
81
+
82
+ **Library mode** (you call `createRoot` yourself — `createBrowserRouter` +
83
+ `<RouterProvider>`, or the classic `<BrowserRouter>` component): needs
84
+ nothing beyond the Quick Start above.
85
+
86
+ ```tsx
87
+ // main.tsx
88
+ import { createRoot } from 'react-dom/client';
89
+ import { RouterProvider, createBrowserRouter } from 'react-router';
90
+ import './kbach.css';
91
+ import Home from './routes/home';
92
+ import About from './routes/about';
93
+
94
+ const router = createBrowserRouter([
95
+ { path: '/', Component: Home },
96
+ { path: '/about', Component: About },
97
+ ]);
98
+
99
+ createRoot(document.getElementById('root')!).render(<RouterProvider router={router} />);
100
+ ```
101
+
102
+ **Framework mode** (`npx create-react-router@latest` — file-based routing
103
+ under `app/`, its own Vite plugin, SSR by default):
104
+
105
+ ```ts
106
+ // vite.config.ts
107
+ import { reactRouter } from '@react-router/dev/vite';
108
+ import { kbach } from '@kbach/react/vite';
109
+ import { defineConfig } from 'vite';
110
+
111
+ export default defineConfig({
112
+ plugins: [kbach(), reactRouter()],
113
+ });
114
+ ```
115
+
116
+ Same `kbach()` call as everywhere else — its `app/`-vs-`src/` convention
117
+ is exactly what auto-detection (above) exists for, so there's nothing
118
+ React-Router-specific to configure. The one real gotcha is unrelated to
119
+ Kbach at all: **don't call `createRoot` yourself in `app/root.tsx`** — the
120
+ framework's own generated client/server entry points already do this;
121
+ `root.tsx` only ever *exports* a `Layout`/default component. Adding your
122
+ own `createRoot(...).render(...)` at that file's top level runs during SSR
123
+ too, where `document` doesn't exist, and crashes the build immediately.
124
+ `app/root.tsx` needs only:
125
+
126
+ ```tsx
127
+ import './kbach.css';
128
+ export default function App() {
129
+ return <Outlet />;
130
+ }
131
+ ```
132
+
133
+ Either mode: every route's own components use `className` exactly like any
134
+ other component — routing has no effect on how Kbach resolves classes,
135
+ since resolution already happened at build time, before routing even
136
+ enters the picture.
137
+
138
+ ## Next.js
139
+
140
+ Next.js uses webpack/Turbopack, not Vite, so `@kbach/react/vite` doesn't
141
+ apply — use `@kbach/react/postcss` instead, which does the exact same
142
+ build-time class-string scanning and CSS generation (they share the same
143
+ underlying engine), just wired into PostCSS, which both of Next.js's
144
+ bundlers already run for you with zero extra config.
145
+
146
+ ```js
147
+ // postcss.config.js
148
+ module.exports = {
149
+ plugins: {
150
+ '@kbach/react/postcss': {},
151
+ },
152
+ };
153
+ ```
154
+
155
+ ```css
156
+ /* app/globals.css (App Router) or styles/globals.css (Pages Router) */
157
+ /* kbach:start */
158
+ /* kbach:end */
159
+ ```
160
+
161
+ ```tsx
162
+ // app/layout.tsx
163
+ import './globals.css';
164
+
165
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
166
+ return (
167
+ <html lang="en">
168
+ <body>{children}</body>
169
+ </html>
170
+ );
171
+ }
172
+ ```
173
+
174
+ Plain `className="..."` strings on Server Components work with zero extra
175
+ setup — the CSS already exists at build time, same as any other static
176
+ stylesheet. Hook-based exports (`useTheme`, `useColors`, `<ThemeProvider>`)
177
+ already carry their own `'use client'` directive, so importing them into a
178
+ Client Component just works; importing one into a Server Component is a
179
+ compile-time error the same way importing `useState` into one would be —
180
+ that's React's own rule, not a Kbach limitation.
181
+
182
+ `root`/`theme`/`config`/`include`/`safelist` options all match
183
+ `@kbach/react/vite`'s exactly (see `KbachPluginOptions`, above) — `root`
184
+ additionally defaults to `process.cwd()` rather than a bundler-resolved
185
+ project root, since PostCSS has no equivalent to Vite's own resolved
186
+ `root` to read; pass it explicitly if your `postcss.config.js` doesn't
187
+ live at your project root (e.g. a monorepo app).
188
+
51
189
  ## Dark mode
52
190
 
191
+ **Following the OS setting — zero setup.** `dark:` classes already work,
192
+ via plain CSS, with nothing to configure:
193
+
194
+ ```tsx
195
+ <div className="bg-white dark:bg-neutral-12">...</div>
196
+ ```
197
+
198
+ **A manual light/dark toggle — one file, no other setup.** `@media
199
+ (prefers-color-scheme)` (the default above) can't be flipped by a button —
200
+ it only ever reflects the OS. For a real toggle, add one file:
201
+
202
+ ```js
203
+ // kbach.config.js — at your project root
204
+ export default { darkMode: 'attribute' };
205
+ ```
206
+
207
+ That's it. Don't pass anything to `kbach()`/`'@kbach/react/postcss'` and
208
+ don't call anything at app startup — both sides pick this up on their own
209
+ (the plugin auto-discovers the file; the runtime reads the strategy back
210
+ out of the CSS the plugin already generated). Then just use `useTheme()`:
211
+
53
212
  ```tsx
54
213
  import { useTheme } from '@kbach/react';
55
214
 
56
215
  function ThemeToggle() {
57
- const { mode, isDark, setMode, toggle } = useTheme();
216
+ const { mode, toggle } = useTheme();
58
217
  return <button onClick={toggle}>{mode}</button>;
59
218
  }
60
219
  ```
61
220
 
62
- Works anywhere, no provider needed. `<ThemeProvider defaultMode="dark">` is
63
- optional, for seeding a startup default. Outside React (plain functions,
64
- event handlers), use `getGlobalDarkMode()`/`toggleGlobalDarkMode()` instead.
221
+ Works anywhere in the tree, no provider required. `<ThemeProvider
222
+ defaultMode="dark">` is optional, only for seeding a startup default.
223
+ Outside React, use `getGlobalDarkMode()`/`toggleGlobalDarkMode()` directly.
224
+
225
+ <details>
226
+ <summary>Not using the static plugin, or need to set the strategy explicitly?</summary>
227
+
228
+ There's nothing for the runtime to auto-detect if no build step ever
229
+ generates `dark:` CSS (a pure runtime `kb()`-only setup) — call
230
+ `applyKbachConfig()` yourself once, before your first render, instead:
231
+
232
+ ```tsx
233
+ import { applyKbachConfig } from '@kbach/react';
234
+
235
+ applyKbachConfig({ darkMode: 'attribute' });
236
+ ```
237
+
238
+ An explicit call like this always wins over whatever the CSS auto-detect
239
+ found, if the two ever disagree. If you do reach for it alongside the
240
+ plugin, import the same `kbach.config.js` rather than retyping the value —
241
+ `import kbachConfig from '../kbach.config.js'; applyKbachConfig(kbachConfig);`.
242
+
243
+ `useTheme()`/`toggleGlobalDarkMode()` still track `isDark` correctly under
244
+ the default `'media'` strategy too, with none of the above — they just
245
+ won't visibly affect `dark:` classes until the runtime knows about
246
+ `'class'`/`'attribute'` one way or another.
247
+
248
+ </details>
65
249
 
66
250
  ## Theming
67
251
 
@@ -77,16 +261,20 @@ setTheme({
77
261
  ## Dynamic classes
78
262
 
79
263
  Classes that appear literally in your source are resolved at build time.
80
- For classes assembled from truly dynamic data, use `kb()`:
264
+ For classes assembled from truly dynamic data, use `kb()` — this is the
265
+ one thing in this package that genuinely calls into the WASM engine at
266
+ runtime, so `await initKbach()` once at startup before the first `kb()`
267
+ call (or before rendering, if you can't guarantee it runs first otherwise):
81
268
 
82
269
  ```ts
83
- import { kb } from '@kbach/react';
270
+ import { initKbach, kb } from '@kbach/react';
84
271
 
272
+ await initKbach();
85
273
  const className = kb(`bg-${userColor}-6`);
86
274
  ```
87
275
 
88
276
  Prefer a `safelist` entry in the plugin config when the possible values are
89
- known ahead of time — keeps everything static.
277
+ known ahead of time — keeps everything static, no `initKbach()` needed.
90
278
 
91
279
  ## Reading colors as values
92
280
 
@@ -0,0 +1,234 @@
1
+ import {
2
+ defaultTheme,
3
+ detectDarkModeStrategyFromDom,
4
+ getTheme,
5
+ setTheme
6
+ } from "./chunk-OVKVZA7B.mjs";
7
+
8
+ // src/darkModeStore.ts
9
+ var STORAGE_KEY = "kbach-theme";
10
+ var listeners = /* @__PURE__ */ new Set();
11
+ function systemPrefersDark() {
12
+ if (typeof window === "undefined" || !window.matchMedia) return false;
13
+ return window.matchMedia("(prefers-color-scheme: dark)").matches;
14
+ }
15
+ function storage() {
16
+ return typeof window === "undefined" ? null : window.localStorage;
17
+ }
18
+ function readPersistedMode() {
19
+ const ls = storage();
20
+ if (!ls) return null;
21
+ try {
22
+ const raw = ls.getItem(STORAGE_KEY);
23
+ return raw === "light" || raw === "dark" || raw === "system" ? raw : null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+ function resolveIsDark(m) {
29
+ return m === "system" ? systemPrefersDark() : m === "dark";
30
+ }
31
+ function applyToDom(dark) {
32
+ if (typeof document === "undefined") return;
33
+ const root = document.documentElement;
34
+ const strategy = getTheme().darkMode;
35
+ root.classList.toggle("dark", strategy === "class" && dark);
36
+ root.classList.toggle("light", strategy === "class" && !dark);
37
+ if (strategy === "attribute") {
38
+ root.setAttribute("data-theme", dark ? "dark" : "light");
39
+ } else {
40
+ root.removeAttribute("data-theme");
41
+ }
42
+ }
43
+ detectDarkModeStrategyFromDom();
44
+ var persistedMode = readPersistedMode();
45
+ var hadPersistedMode = persistedMode !== null;
46
+ var mode = persistedMode ?? "system";
47
+ var isDark = resolveIsDark(mode);
48
+ applyToDom(isDark);
49
+ if (typeof window !== "undefined" && window.matchMedia) {
50
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
51
+ const onSystemChange = (event) => {
52
+ if (mode !== "system") return;
53
+ isDark = event.matches;
54
+ applyToDom(isDark);
55
+ notify();
56
+ };
57
+ if (mq.addEventListener) mq.addEventListener("change", onSystemChange);
58
+ else mq.addListener(onSystemChange);
59
+ }
60
+ function notify() {
61
+ for (const listener of listeners) listener();
62
+ }
63
+ function resyncDomWithActiveTheme() {
64
+ applyToDom(isDark);
65
+ }
66
+ function getGlobalDarkMode() {
67
+ return isDark;
68
+ }
69
+ function getGlobalThemeMode() {
70
+ return mode;
71
+ }
72
+ function setGlobalThemeMode(nextMode) {
73
+ mode = nextMode;
74
+ isDark = resolveIsDark(nextMode);
75
+ applyToDom(isDark);
76
+ const ls = storage();
77
+ if (ls) {
78
+ try {
79
+ ls.setItem(STORAGE_KEY, nextMode);
80
+ } catch {
81
+ }
82
+ }
83
+ notify();
84
+ }
85
+ function toggleGlobalDarkMode() {
86
+ setGlobalThemeMode(isDark ? "light" : "dark");
87
+ }
88
+ function subscribeGlobalDarkMode(listener) {
89
+ listeners.add(listener);
90
+ return () => listeners.delete(listener);
91
+ }
92
+ function seedDefaultMode(fallback) {
93
+ if (hadPersistedMode || mode === fallback) return;
94
+ mode = fallback;
95
+ isDark = resolveIsDark(fallback);
96
+ applyToDom(isDark);
97
+ notify();
98
+ }
99
+
100
+ // src/colorVariables.ts
101
+ function parseHexRgb(hex) {
102
+ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
103
+ if (!m) return null;
104
+ let h = m[1];
105
+ if (h.length === 3) h = h.split("").map((c) => c + c).join("");
106
+ const n = parseInt(h, 16);
107
+ return [n >> 16 & 255, n >> 8 & 255, n & 255];
108
+ }
109
+ function wrapDarkDecls(strategy, decls) {
110
+ switch (strategy) {
111
+ case "class":
112
+ return `.dark { ${decls} }`;
113
+ case "media":
114
+ return `@media (prefers-color-scheme: dark) { :root { ${decls} } }`;
115
+ case "attribute":
116
+ default:
117
+ return `[data-theme="dark"] { ${decls} }`;
118
+ }
119
+ }
120
+ var STYLE_ID = "kbach-colors";
121
+ var injectedForTheme = null;
122
+ function ensureColorVariablesInjected(theme) {
123
+ if (typeof document === "undefined") return;
124
+ if (injectedForTheme === theme) return;
125
+ injectedForTheme = theme;
126
+ const lightDecls = [];
127
+ const darkDecls = [];
128
+ for (const [name, value] of Object.entries(theme.colors)) {
129
+ if (typeof value === "string") continue;
130
+ const light = parseHexRgb(value.light);
131
+ const dark = parseHexRgb(value.dark);
132
+ if (!light || !dark) continue;
133
+ lightDecls.push(`--kb-color-${name}: ${light.join(",")};`);
134
+ darkDecls.push(`--kb-color-${name}: ${dark.join(",")};`);
135
+ }
136
+ let el = document.getElementById(STYLE_ID);
137
+ if (lightDecls.length === 0) {
138
+ el?.remove();
139
+ return;
140
+ }
141
+ if (!el) {
142
+ el = document.createElement("style");
143
+ el.id = STYLE_ID;
144
+ document.head.insertBefore(el, document.head.firstChild);
145
+ }
146
+ el.textContent = `:root { ${lightDecls.join(" ")} }
147
+ ${wrapDarkDecls(theme.darkMode, darkDecls.join(" "))}`;
148
+ }
149
+
150
+ // src/config.ts
151
+ function applyOpacityToHex(hex, opacityText) {
152
+ const rgb = parseHexRgb(hex);
153
+ if (!rgb) return hex;
154
+ const a = Number(opacityText) / 100;
155
+ const clamped = Number.isFinite(a) ? Math.min(1, Math.max(0, a)) : 1;
156
+ return `rgba(${rgb.join(",")},${clamped})`;
157
+ }
158
+ function resolveColorEntry(value, lookup) {
159
+ if (typeof value !== "string") return value;
160
+ const slash = value.indexOf("/");
161
+ const name = slash === -1 ? value : value.slice(0, slash);
162
+ const opacity = slash === -1 ? void 0 : value.slice(slash + 1);
163
+ const base = lookup(name);
164
+ if (base === void 0) return value;
165
+ if (opacity === void 0) return base;
166
+ return typeof base === "string" ? applyOpacityToHex(base, opacity) : { light: applyOpacityToHex(base.light, opacity), dark: applyOpacityToHex(base.dark, opacity) };
167
+ }
168
+ function resolveColors(extend, existing) {
169
+ const { dark: darkExtend, ...lightExtend } = extend;
170
+ const resolved = {};
171
+ const lookup = (name) => resolved[name] ?? existing[name];
172
+ for (const [name, value] of Object.entries(lightExtend)) {
173
+ if (typeof value === "object" && value !== null && !("light" in value) && !("dark" in value)) continue;
174
+ resolved[name] = resolveColorEntry(value, lookup);
175
+ }
176
+ if (darkExtend) {
177
+ const resolvedDark = {};
178
+ const lookupDark = (name) => {
179
+ const found = resolvedDark[name] ?? existing[name];
180
+ return typeof found === "string" ? found : void 0;
181
+ };
182
+ for (const [name, value] of Object.entries(darkExtend)) {
183
+ const entry = resolveColorEntry(value, lookupDark);
184
+ resolvedDark[name] = typeof entry === "string" ? entry : entry.dark;
185
+ }
186
+ for (const [name, darkValue] of Object.entries(resolvedDark)) {
187
+ const lightEntry = resolved[name];
188
+ if (lightEntry === void 0 || typeof lightEntry !== "string") continue;
189
+ resolved[name] = { light: lightEntry, dark: darkValue };
190
+ }
191
+ }
192
+ return resolved;
193
+ }
194
+ function resolveKbachConfig(config) {
195
+ let theme = { ...defaultTheme, ...config.theme };
196
+ if (config.extend?.colors) {
197
+ theme = { ...theme, colors: { ...theme.colors, ...resolveColors(config.extend.colors, theme.colors) } };
198
+ }
199
+ if (config.extend?.spacing) {
200
+ theme = { ...theme, spacing: { ...theme.spacing, ...config.extend.spacing } };
201
+ }
202
+ if (config.extend?.screens) {
203
+ theme = { ...theme, screens: { ...theme.screens, ...config.extend.screens } };
204
+ }
205
+ if (config.extend?.fontFamily) {
206
+ theme = { ...theme, fontFamily: { ...theme.fontFamily, ...config.extend.fontFamily } };
207
+ }
208
+ if (config.extend?.container) {
209
+ theme = { ...theme, container: { ...theme.container, ...config.extend.container } };
210
+ }
211
+ if (config.darkMode) {
212
+ theme = { ...theme, darkMode: config.darkMode };
213
+ }
214
+ return theme;
215
+ }
216
+ function applyKbachConfig(config) {
217
+ const theme = resolveKbachConfig(config);
218
+ setTheme(theme);
219
+ resyncDomWithActiveTheme();
220
+ return theme;
221
+ }
222
+
223
+ export {
224
+ parseHexRgb,
225
+ ensureColorVariablesInjected,
226
+ getGlobalDarkMode,
227
+ getGlobalThemeMode,
228
+ setGlobalThemeMode,
229
+ toggleGlobalDarkMode,
230
+ subscribeGlobalDarkMode,
231
+ seedDefaultMode,
232
+ resolveKbachConfig,
233
+ applyKbachConfig
234
+ };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  kb
3
- } from "./chunk-ZQJKL2MV.mjs";
3
+ } from "./chunk-BJEOK2VK.mjs";
4
4
 
5
5
  // src/jsx-runtime.tsx
6
6
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
@@ -2,7 +2,7 @@ import {
2
2
  BASE_RESET,
3
3
  RESET_STYLE_ID,
4
4
  getThemeJson
5
- } from "./chunk-N5HCQIBB.mjs";
5
+ } from "./chunk-OVKVZA7B.mjs";
6
6
 
7
7
  // src/wasmLoader.ts
8
8
  import init, { generate_css as wasmGenerateCss } from "@kbach/core-engine";
@@ -22,7 +22,7 @@ function isKbachReady() {
22
22
  function generateCss(classString, themeJson) {
23
23
  if (!ready) {
24
24
  throw new Error(
25
- "[kbach] generateCss() was called before initKbach() resolved. Call `await initKbach()` once at startup (e.g. before rendering your app) before using kb()."
25
+ "[Kbach] generateCss() was called before initKbach() resolved. Call `await initKbach()` once at startup (e.g. before rendering your app) before using kb()."
26
26
  );
27
27
  }
28
28
  return wasmGenerateCss(classString, themeJson);
@@ -79,9 +79,23 @@ function ensureResetInjected() {
79
79
  el.textContent = BASE_RESET;
80
80
  document.head.insertBefore(el, document.head.firstChild);
81
81
  }
82
+ var KB_CACHE_MAX = 500;
83
+ var kbCache = /* @__PURE__ */ new Map();
84
+ var cachedForThemeJson = null;
82
85
  function kb(classString) {
83
- const json = generateCss(classString, getThemeJson());
84
- const { className, rules } = JSON.parse(json);
86
+ const themeJson = getThemeJson();
87
+ if (themeJson !== cachedForThemeJson) {
88
+ kbCache.clear();
89
+ cachedForThemeJson = themeJson;
90
+ }
91
+ let result = kbCache.get(classString);
92
+ if (result === void 0) {
93
+ const json = generateCss(classString, themeJson);
94
+ result = JSON.parse(json);
95
+ if (kbCache.size >= KB_CACHE_MAX) kbCache.clear();
96
+ kbCache.set(classString, result);
97
+ }
98
+ const { className, rules } = result;
85
99
  if (!runtimeCSSDisabled) {
86
100
  ensureResetInjected();
87
101
  for (const { rule, order } of rules) {