@nativele/ts-dom 0.5.1 → 0.5.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.
@@ -0,0 +1,319 @@
1
+ ---
2
+ name: nativele-state-management
3
+ description: "Decide where state lives in an app built with Nativele (component state, a shared createStore, or an existing Zustand, Redux Toolkit or nanostores store) and connect external stores to this.bind with a small adapter: selectors that limit re-rendering, actions and dispatch from event handlers, unsubscribing when a component is removed. Use when a project depends on @nativele/ts-dom and on zustand, @reduxjs/toolkit, redux or nanostores, or when components need to share data."
4
+ metadata:
5
+ tier: official
6
+ nativele: ">=0.5.2 <0.6.0"
7
+ verified: "2026-11-05"
8
+ tools: "zustand@5.0.16 @reduxjs/toolkit@2.14.0 redux@5.0.1 nanostores@1.5.5 vite@8.3.6 typescript@7.0.2 create-vite@9.3.4"
9
+ author: "Nativele maintainers"
10
+ detects: "zustand @reduxjs/toolkit redux nanostores"
11
+ ---
12
+
13
+ # Nativele state management
14
+
15
+ ## When to use
16
+
17
+ - An app that already uses Nativele (`@nativele/ts-dom`) has to decide where a piece of data lives: in one component, in a store shared by several components, or in a state library.
18
+ - The app already keeps state in Zustand, Redux Toolkit, Redux or nanostores, and Nativele components have to show it and change it.
19
+ - Signals: `zustand`, `@reduxjs/toolkit`, `redux` or `nanostores` next to `@nativele/ts-dom` in `package.json`; a value passed down through several layers of components only so a deep one can show it.
20
+ - The starting point is a Vite app set up with Nativele by `nativele-vite` (`npx ts-dom skills add nativele-vite`), for example one started with `npm create vite@latest -- --template vanilla-ts`. This Skill does not teach the state libraries themselves, and it does not write the components (`nativele-core`).
21
+ - Not for React, Vue or another framework's own bindings to these libraries (`react-redux`, Zustand's hooks): here the Nativele components subscribe themselves.
22
+
23
+ ## Versions
24
+
25
+ | Tool | Verified version |
26
+ |---|---|
27
+ | @nativele/ts-dom | 0.5.2 |
28
+ | zustand (`zustand/vanilla`) | 5.0.16 |
29
+ | @reduxjs/toolkit | 2.14.0 |
30
+ | redux | 5.0.1 (installed by `@reduxjs/toolkit`; a `legacy_createStore` store with the adapter was checked by hand with `tsc` and Node, the example project does not run it) |
31
+ | nanostores | 1.5.5 (`atom` only) |
32
+ | vite | 8.3.6 |
33
+ | typescript | 7.0.2 |
34
+ | create-vite (`vanilla-ts` template, the starting point) | 9.3.4 |
35
+
36
+ Verified on 2026-11-05 in Google Chrome 148 with the example project in the Nativele repository (`skills/nativele-state-management/example`), which CI runs again when the example or the library changes and once a month. With a different major version of a state library, treat the adapter and every limitation about that library as unverified.
37
+
38
+ ## Set-up
39
+
40
+ 1. Decide where each piece of data lives:
41
+
42
+ | The data is | Put it in | Why |
43
+ |---|---|---|
44
+ | used by one component, or by it and the children it passes props to (a field's text, open or closed, the selected row) | the component's `state` | it is changed with `this.set` and rendered by the component that owns it |
45
+ | read or changed by components that are not parent and child, or would be passed through layers that do not use it (the signed-in user, a theme) | a `createStore` from `@nativele/ts-dom` in its own module | every component that needs it imports it and binds it; the layers between them do not know about it |
46
+ | already kept in Zustand, Redux or nanostores, or shared with code that is not Nativele, or in need of what the library offers (its middleware, devtools, persistence) | that library's store, connected with the adapter below | the store stays the one source; Nativele components only read it and send it actions |
47
+
48
+ Do not move an existing Zustand or Redux store into `createStore` to use Nativele, and do not add a state library for data that `state` or `createStore` already holds.
49
+
50
+ 2. Keep each store in its own module outside the components folder, for example `src/stores/`, and import it from the components that use it. `ts-dom check src/nv` runs `tsc` over the whole project, so it type-checks these modules too.
51
+ 3. For Zustand and Redux, add the adapter below as `src/stores/from-store.ts`. `this.bind` takes an object with `get()` and `subscribe(fn)`, calls `fn` at once with `get()`, calls it again on every change, and ends the subscription when the element is removed (see the components guide). A Zustand or Redux store has `getState()` instead of `get()`, and a Redux listener gets no value, so neither can be passed as it is (limitations 1 and 2). The adapter reads the state itself, applies a selector, and calls back only when the selected value changes under `Object.is`:
52
+
53
+ ```ts
54
+ // src/stores/from-store.ts
55
+ import type { ReadableStore } from "@nativele/ts-dom";
56
+
57
+ export interface ExternalStore<S> {
58
+ getState(): S;
59
+ subscribe(listener: () => void): () => void;
60
+ }
61
+
62
+ export function fromStore<S, T>(store: ExternalStore<S>, select: (state: S) => T): ReadableStore<T> {
63
+ return {
64
+ get: () => select(store.getState()),
65
+ subscribe(fn) {
66
+ let last = select(store.getState());
67
+ return store.subscribe(() => {
68
+ const next = select(store.getState());
69
+ if (Object.is(next, last)) return;
70
+ last = next;
71
+ fn(next);
72
+ });
73
+ },
74
+ };
75
+ }
76
+ ```
77
+
78
+ 4. In each component, make one source per value it shows (`fromStore(store, (s) => s.items.length)`, at module level), bind it in `connected()`, and copy the value into `state` with `this.set`. Bind in `connected()`, never in the constructor (limitation 6).
79
+ 5. Change the store from the component's `data-on-*` handlers: call a Zustand action (`cart.getState().add("apple")`), dispatch a Redux action (`store.dispatch(increment())`), or set a nanostores atom (`$theme.set("dark")`). The store notifies synchronously, so the view has changed when the handler returns.
80
+ 6. For a nanostores atom, bind `{ get: $theme.get, subscribe: $theme.listen }`, not the atom itself (limitation 3).
81
+
82
+ ## Example
83
+
84
+ The stores:
85
+
86
+ ```ts
87
+ // src/stores/cart.ts (Zustand)
88
+ import { createStore } from "zustand/vanilla";
89
+
90
+ export interface CartState {
91
+ items: string[];
92
+ note: string;
93
+ add(item: string): void;
94
+ setNote(note: string): void;
95
+ }
96
+
97
+ export const cart = createStore<CartState>()((set) => ({
98
+ items: [],
99
+ note: "",
100
+ add: (item) => set((s) => ({ items: [...s.items, item] })),
101
+ setNote: (note) => set({ note }),
102
+ }));
103
+ ```
104
+
105
+ ```ts
106
+ // src/stores/counter.ts (Redux Toolkit)
107
+ import { configureStore, createSlice, type PayloadAction } from "@reduxjs/toolkit";
108
+
109
+ const counter = createSlice({
110
+ name: "counter",
111
+ initialState: { value: 0 },
112
+ reducers: {
113
+ increment: (state) => {
114
+ state.value += 1;
115
+ },
116
+ },
117
+ });
118
+
119
+ const filter = createSlice({
120
+ name: "filter",
121
+ initialState: "all" as "all" | "done",
122
+ reducers: { setFilter: (_state, action: PayloadAction<"all" | "done">) => action.payload },
123
+ });
124
+
125
+ export const store = configureStore({ reducer: { counter: counter.reducer, filter: filter.reducer } });
126
+ export const { increment } = counter.actions;
127
+ export const { setFilter } = filter.actions;
128
+ ```
129
+
130
+ ```ts
131
+ // src/stores/theme.ts (nanostores)
132
+ import { atom } from "nanostores";
133
+
134
+ export const $theme = atom<"light" | "dark">("light");
135
+ ```
136
+
137
+ ```ts
138
+ // src/stores/session.ts (Nativele)
139
+ import { createStore } from "@nativele/ts-dom";
140
+
141
+ export const session = createStore({ user: "guest" });
142
+ ```
143
+
144
+ A component that shows one slice of the Zustand store, and one that changes it:
145
+
146
+ ```html
147
+ <!-- src/nv/cart-badge.html -->
148
+ <span>{{ count }} in cart</span>
149
+ ```
150
+
151
+ ```ts
152
+ // src/nv/cart-badge.ts
153
+ import { Component, define } from "@nativele/ts-dom";
154
+ import { cart } from "../stores/cart.ts";
155
+ import { fromStore } from "../stores/from-store.ts";
156
+ import tpl from "./cart-badge.html.js";
157
+
158
+ const itemCount = fromStore(cart, (s) => s.items.length);
159
+
160
+ export class CartBadge extends Component(tpl, { state: { count: 0 } }) {
161
+ override connected(): void {
162
+ this.bind(itemCount, (count) => this.set({ count }));
163
+ }
164
+ }
165
+ define("cart-badge", CartBadge);
166
+ ```
167
+
168
+ ```html
169
+ <!-- src/nv/cart-form.html -->
170
+ <button type="button" data-on-click="addApple">add apple</button>
171
+ <input data-ref="note" data-on-input="onNote" />
172
+ ```
173
+
174
+ ```ts
175
+ // src/nv/cart-form.ts
176
+ import { Component, define } from "@nativele/ts-dom";
177
+ import { cart } from "../stores/cart.ts";
178
+ import tpl from "./cart-form.html.js";
179
+
180
+ export class CartForm extends Component(tpl) {
181
+ addApple(): void {
182
+ cart.getState().add("apple");
183
+ }
184
+ onNote(): void {
185
+ cart.getState().setNote(this.refs.note.value);
186
+ }
187
+ }
188
+ define("cart-form", CartForm);
189
+ ```
190
+
191
+ Typing a note changes `note` only, so `cart-badge` gets no callback and does not render.
192
+
193
+ A component that shows a Redux value and dispatches from a click:
194
+
195
+ ```html
196
+ <!-- src/nv/counter-view.html -->
197
+ <b>{{ value }}</b>
198
+ <button type="button" data-on-click="increment">+1</button>
199
+ ```
200
+
201
+ ```ts
202
+ // src/nv/counter-view.ts
203
+ import { Component, define } from "@nativele/ts-dom";
204
+ import { fromStore } from "../stores/from-store.ts";
205
+ import { increment, store } from "../stores/counter.ts";
206
+ import tpl from "./counter-view.html.js";
207
+
208
+ const counterValue = fromStore(store, (s) => s.counter.value);
209
+
210
+ export class CounterView extends Component(tpl, { state: { value: 0 } }) {
211
+ override connected(): void {
212
+ this.bind(counterValue, (value) => this.set({ value }));
213
+ }
214
+ increment(): void {
215
+ store.dispatch(increment());
216
+ }
217
+ }
218
+ define("counter-view", CounterView);
219
+ ```
220
+
221
+ A nanostores atom and a Nativele store, each bound directly:
222
+
223
+ ```ts
224
+ // src/nv/theme-switch.ts (template: <button type="button" data-on-click="toggle">theme: {{ theme }}</button>)
225
+ export class ThemeSwitch extends Component(tpl, { state: { theme: "light" as "light" | "dark" } }) {
226
+ override connected(): void {
227
+ this.bind({ get: $theme.get, subscribe: $theme.listen }, (theme) => this.set({ theme }));
228
+ }
229
+ toggle(): void {
230
+ $theme.set($theme.get() === "light" ? "dark" : "light");
231
+ }
232
+ }
233
+ define("theme-switch", ThemeSwitch);
234
+
235
+ // src/nv/user-badge.ts (template: <span>{{ user }}</span>); another component calls session.set((s) => ({ ...s, user: "Ada" }))
236
+ export class UserBadge extends Component(tpl, { state: { user: "" } }) {
237
+ override connected(): void {
238
+ this.bind(session, (s) => this.set({ user: s.user }));
239
+ }
240
+ }
241
+ define("user-badge", UserBadge);
242
+ ```
243
+
244
+ `src/main.ts` imports every component module; `index.html` places the tags anywhere on the page (`<cart-badge></cart-badge>` in a section, `<user-badge></user-badge>` in the header), with nothing passed between them.
245
+
246
+ ## Known limitations
247
+
248
+ 1. **A Zustand or Redux store cannot be passed to `bind`.** `this.bind(cart, ...)` fails with `TS2741: Property 'get' is missing in type 'StoreApi<CartState>'`, and `this.bind(store, ...)` with a Redux store with `TS2345: ... Property 'get' is missing`. Use `fromStore`. A Zustand store could be bound whole as `{ get: cart.getState, subscribe: cart.subscribe }` (Zustand 5 passes the state to its listeners), but then see limitation 4.
249
+ 2. **A Redux listener gets no value.** `{ get: store.getState, subscribe: store.subscribe }` fails with `TS2322: Type '(listener: ListenerCallback) => Unsubscribe' is not assignable ... Target signature provides too few arguments`: Redux 5 calls its listeners with no arguments. Use `fromStore`, which calls `getState()` itself.
250
+ 3. **A nanostores atom passes the type check but calls back twice.** `this.bind($theme, fn)` compiles, and `fn` runs twice when the component connects, because nanostores' `subscribe` also calls the listener at once. Bind `{ get: $theme.get, subscribe: $theme.listen }`: `listen` does not call at once, so `fn` runs once on connect and once per change. Not verified: `computed`, `map` and other nanostores stores.
251
+ 4. **Bind a selected value, not the whole store.** A whole store calls back on every change of any field. When the callback copies into `state`, `this.set` skips the render if nothing it copied changed (it compares each field with `Object.is`), but the callback still runs; a callback that writes `this.text`, `refs` or a list itself does that work every time. With `fromStore` and a selector, a change elsewhere in the store runs no callback and no render.
252
+ 5. **A selector must return one value that stays the same when unchanged.** A selector that builds a new object or array (`(s) => ({ count: s.items.length })`) differs under `Object.is` every time, so it calls back on every change of the store. Select each value with its own `fromStore`, or select an object or array the store itself keeps (Zustand and Redux keep an unchanged slice as the same object).
253
+ 6. **Bind in `connected()`.** When the element is removed, `bind` ends every subscription, and no store sends it updates. When the same element is inserted again, `connected()` runs, binds again, and the callback runs at once with the current value, so changes made while it was out show; it then gets each change once, not twice. A `bind` made anywhere else (the constructor, a field, code outside the component) ends on the first removal and is not made again.
254
+ 7. **`createStore` is exported by both `zustand/vanilla` and `@nativele/ts-dom`.** Importing both into one module fails with `TS2300: Duplicate identifier 'createStore'`. Keep each store in its own module (step 2), or rename one import (`import { createStore as createZustandStore } from "zustand/vanilla"`).
255
+ 8. **Store updates are synchronous.** A Zustand action, a Redux `dispatch` of a plain action and a nanostores `set` call `bind` callbacks before they return, so the view has changed when the handler ends.
256
+ 9. Not verified: Zustand middleware (`subscribeWithSelector`, `persist`, `devtools`), RTK Query and auto-batched actions, Redux thunks, an equality function other than `Object.is`, and the state libraries' framework bindings.
257
+
258
+ ## Verify
259
+
260
+ Run in the project:
261
+
262
+ ```sh
263
+ npm run build # expect: ts-dom check: no issues, then ✓ built
264
+ npx ts-dom doctor src/nv # expect: ts-dom doctor: the set-up looks right (info lines are suggestions)
265
+ ```
266
+
267
+ `ts-dom check` type-checks the store modules and the adapter as well. To keep the checks of limitations 1 and 2 in the project, add a file the app never imports, with one wrong use per store under `// @ts-expect-error`:
268
+
269
+ ```ts
270
+ // src/types-check.ts
271
+ import type { CartBadge } from "./nv/cart-badge.ts";
272
+ import { cart } from "./stores/cart.ts";
273
+ import { store } from "./stores/counter.ts";
274
+
275
+ export function typesCheck(el: CartBadge): void {
276
+ // @ts-expect-error a Zustand store has getState, not get
277
+ el.bind(cart, () => {});
278
+ // @ts-expect-error a Redux store has getState, not get
279
+ el.bind(store, () => {});
280
+ // @ts-expect-error a Redux listener gets no value
281
+ el.bind({ get: store.getState, subscribe: store.subscribe }, () => {});
282
+ }
283
+ ```
284
+
285
+ If one of them stops being an error, `ts-dom check` reports `TS2578: Unused '@ts-expect-error' directive`.
286
+
287
+ In the browser, against `npm run dev`: click and type in each component and check that the bound components show the new value; remove a bound element (`el.remove()`), change its store, insert it again, and check that it shows the current value. To prove that a selector limits re-rendering, count calls by wrapping the component's prototype methods before acting, without changing the component:
288
+
289
+ ```js
290
+ const proto = customElements.get("cart-badge").prototype;
291
+ for (const name of ["set", "render"]) {
292
+ const original = proto[name];
293
+ proto[name] = function (...args) { console.log(name); return original.apply(this, args); };
294
+ }
295
+ ```
296
+
297
+ Then typing a note must log nothing for `cart-badge`, and adding an item must log `set` and `render` once each. The example project's [`verify.mjs`](https://github.com/Pixmi/nativele/blob/main/skills/nativele-state-management/example/verify.mjs) does these checks with Playwright for Zustand, Redux, nanostores and `createStore`, plus the `bind` contract itself (an immediate call, no call for an `Object.is`-equal set, nothing after removal), the nanostores double call, selectors that return a new object or a kept slice, and no console errors.
298
+
299
+ ## Troubleshooting
300
+
301
+ | Symptom | Cause | Fix |
302
+ |---|---|---|
303
+ | `TS2741: Property 'get' is missing in type 'StoreApi<...>'` | a Zustand store passed to `bind` | `this.bind(fromStore(cart, (s) => s.x), ...)` |
304
+ | `TS2345: ... Property 'get' is missing in type '... Store<...>'` | a Redux store passed to `bind` | `fromStore(store, (s) => s.x)` |
305
+ | `TS2322: ... '(listener: ListenerCallback) => Unsubscribe' ... Target signature provides too few arguments` | Redux's `subscribe` passed to `bind` as it is | `fromStore`, which calls `getState()` |
306
+ | The callback runs twice when the component connects | a nanostores atom bound directly | bind `{ get: $a.get, subscribe: $a.listen }` |
307
+ | A component renders, or its callback runs, when an unrelated field changes | the whole store is bound, or the selector returns a new object | one `fromStore` per value, each returning a value the store keeps |
308
+ | A component stops updating after it was moved or removed and inserted again | `bind` was called outside `connected()` | call `this.bind` in `connected()` |
309
+ | `TS2300: Duplicate identifier 'createStore'` | `zustand/vanilla` and `@nativele/ts-dom` imported into one module | separate modules, or rename one import |
310
+ | `[missing-binding] "x" is used by {{ x }} ... but the component declares no state, prop or derived value of that name` | a template shows a store field the component has no `state` for | add the field to `state` and copy it in the `bind` callback |
311
+
312
+ ## See also
313
+
314
+ - `bind`, `createStore` and the store contract: https://github.com/Pixmi/nativele/blob/main/docs/components.md#state-store-when-not-using-state
315
+ - Component `state`, `set` and `batch`: https://github.com/Pixmi/nativele/blob/main/docs/components.md#data-and-view-state-optional
316
+ - Shared stores instead of passing through layers: https://github.com/Pixmi/nativele/blob/main/docs/components.md#avoiding-layer-by-layer-passing-lint-ts-domdata-flow
317
+ - Copying a store value into state: https://github.com/Pixmi/nativele/blob/main/docs/recipes.md#showing-data-from-a-store
318
+ - Moved elements, connecting and subscriptions: https://github.com/Pixmi/nativele/blob/main/docs/lists.md#moving-items-connecting-and-subscriptions
319
+ - Setting Nativele up in Vite: `npx ts-dom skills nativele-vite`
@@ -0,0 +1,220 @@
1
+ ---
2
+ name: nativele-tailwind
3
+ description: "Style Nativele components with Tailwind CSS v4 in a Vite app that already uses Tailwind: classes in .html templates and in render, which files Tailwind scans, dynamic class names, <style scoped> next to utilities, and bringing the Tailwind stylesheet into shadow: true components. Use when a project depends on tailwindcss and @nativele/ts-dom."
4
+ metadata:
5
+ tier: experimental
6
+ nativele: ">=0.5.2 <0.6.0"
7
+ verified: "2026-11-05"
8
+ tools: "tailwindcss@4.3.4 @tailwindcss/vite@4.3.4 vite@8.3.6 typescript@7.0.2 create-vite@9.3.4"
9
+ author: "Nativele maintainers"
10
+ detects: "tailwindcss"
11
+ ---
12
+
13
+ # Nativele + Tailwind CSS
14
+
15
+ ## When to use
16
+
17
+ - A Vite app that already uses Tailwind CSS v4 through `@tailwindcss/vite` and adds components written with Nativele (`@nativele/ts-dom`), or a Nativele app that adds Tailwind that way.
18
+ - Signals: `tailwindcss`, `@tailwindcss/vite` and `@nativele/ts-dom` in `package.json`, a CSS file with `@import "tailwindcss";`.
19
+ - Not for setting Tailwind up: follow Tailwind's own guide for Vite first, and check that a utility class works on a plain page before adding Nativele. Not for Tailwind v3, the PostCSS plugin or the Tailwind CLI (not verified).
20
+ - Not for adding Nativele to Vite (`npx ts-dom skills add nativele-vite`) or writing the components (`npx ts-dom skills add nativele-core`).
21
+
22
+ ## Versions
23
+
24
+ | Tool | Verified version |
25
+ |---|---|
26
+ | @nativele/ts-dom | 0.5.2 |
27
+ | tailwindcss | 4.3.4 |
28
+ | @tailwindcss/vite | 4.3.4 |
29
+ | vite | 8.3.6 |
30
+ | typescript | 7.0.2 |
31
+ | create-vite (`vanilla-ts` template, the starting point) | 9.3.4 |
32
+
33
+ Verified on 2026-11-05 in Google Chrome 148, by reading computed styles with Playwright both against `vite` (dev) and against `vite build` served by `vite preview`, in an app made with `npm create vite@latest -- --template vanilla-ts`, given Tailwind by Tailwind's Vite guide, then Nativele by `nativele-vite`, in a git repository. With a different major version of Tailwind, treat every step as unverified.
34
+
35
+ ## Set-up
36
+
37
+ The starting point: `src/style.css` is `@import "tailwindcss";`, `src/main.ts` imports `./style.css`, `vite.config.ts` has `tailwindcss()` in `plugins`, and Nativele is set up by `nativele-vite` (components in `src/nv/`, `*.html.ts` and `ts-dom-tags.ts` in `.gitignore`, `"build": "ts-dom check src/nv && vite build"`).
38
+
39
+ 1. Put both plugins in `vite.config.ts`. Their order did not change the result:
40
+
41
+ ```ts
42
+ import { defineConfig } from "vite";
43
+ import tailwindcss from "@tailwindcss/vite";
44
+ import { tsDom } from "@nativele/ts-dom/vite";
45
+
46
+ export default defineConfig({ plugins: [tsDom({ dirs: ["src/nv"] }), tailwindcss()] });
47
+ ```
48
+
49
+ 2. Keep `import "./style.css"` in the entry module. For Light DOM components (the default) nothing else is needed: Tailwind's automatic source detection reads the `.html` templates and the component `.ts` files, so a class written only in a template, only on a tag in `index.html`, or only as a full string in TypeScript (`classList.toggle("tracking-[0.3em]", on)` in `render`) is generated, in `vite dev` and in `vite build`. No `@source` is needed while the components are inside the project folder.
50
+ 3. The generated `*.html.ts` files do not need to be scanned. Tailwind skips every file matched by `.gitignore` (also outside a git repository), and with the generated files not ignored the built CSS was byte for byte the same. The Skills that `ts-dom skills add` copies into `.agents/` (or `.claude/`) are scanned, though, and their example classes end up in the build; exclude that folder under the import, relative to the CSS file: `@source not "../.agents";`.
51
+ 4. Only when templates live outside the project folder (for example a sibling package of a monorepo), add a `@source` line, with the path relative to the CSS file:
52
+
53
+ ```css
54
+ /* src/style.css */
55
+ @import "tailwindcss";
56
+ @source "../../shared-nv";
57
+ ```
58
+
59
+ Nativele needs that folder too: list it in `tsDom({ dirs })`, in the arguments of `ts-dom check` and `ts-dom doctor`, and in the tsconfig `include`; keep each component's `.ts` next to its template there (a template whose importer is in another folder is reported as `no-importer`); and the folder must resolve `@nativele/ts-dom` itself, or `tsc` reports `TS2307` for its generated `.html.ts`.
60
+
61
+ 5. Only for `shadow: true` components: add one module that turns the same CSS into a constructable stylesheet, and adopt it in each Shadow component's constructor (limitation 3 explains why and what it costs):
62
+
63
+ ```ts
64
+ // src/tailwind-sheet.ts
65
+ import css from "./style.css?inline";
66
+
67
+ export const tailwindSheet = new CSSStyleSheet();
68
+ tailwindSheet.replaceSync(css);
69
+ ```
70
+
71
+ ```ts
72
+ constructor() {
73
+ super();
74
+ this.shadowRoot!.adoptedStyleSheets = [tailwindSheet];
75
+ }
76
+ ```
77
+
78
+ `?inline` is typed by `vite/client`, which the template's `tsconfig.json` already lists in `types`.
79
+
80
+ ## Example
81
+
82
+ A Light DOM component with Tailwind classes in the template, a class toggled in `render`, a class picked from full strings, and a scoped rule that utilities may override:
83
+
84
+ ```html
85
+ <!-- src/nv/status-card.html -->
86
+ <style scoped>
87
+ :scope { display: block }
88
+ @layer components {
89
+ .note { color: var(--color-slate-600) }
90
+ }
91
+ </style>
92
+ <div class="rounded-lg bg-lime-300 p-4">
93
+ <p data-ref="heading" class="font-bold">{{ heading }}</p>
94
+ <p data-ref="level" class="note">{{ level }}</p>
95
+ </div>
96
+ ```
97
+
98
+ ```ts
99
+ // src/nv/status-card.ts
100
+ import { Component, define } from "@nativele/ts-dom";
101
+ import tpl from "./status-card.html.js";
102
+
103
+ const LEVEL = { ok: "text-emerald-900", warn: "text-rose-900" } as const;
104
+
105
+ export class StatusCard extends Component(tpl, {
106
+ props: { heading: "", level: "ok" as keyof typeof LEVEL, wide: false },
107
+ }) {
108
+ override render(view: { heading: string; level: keyof typeof LEVEL; wide: boolean }): void {
109
+ this.refs.heading.classList.toggle("tracking-[0.3em]", view.wide);
110
+ this.refs.level.className = `note ${LEVEL[view.level]}`;
111
+ }
112
+ }
113
+ define("status-card", StatusCard);
114
+ ```
115
+
116
+ A Shadow DOM component that adopts the Tailwind sheet from step 5:
117
+
118
+ ```html
119
+ <!-- src/nv/badge-box.html -->
120
+ <div class="rounded-xl bg-violet-300 p-4 shadow-lg"><slot></slot></div>
121
+ ```
122
+
123
+ ```ts
124
+ // src/nv/badge-box.ts
125
+ import { Component, define } from "@nativele/ts-dom";
126
+ import { tailwindSheet } from "../tailwind-sheet.ts";
127
+ import tpl from "./badge-box.html.js";
128
+
129
+ export class BadgeBox extends Component(tpl, { shadow: true }) {
130
+ constructor() {
131
+ super();
132
+ this.shadowRoot!.adoptedStyleSheets = [tailwindSheet];
133
+ }
134
+ }
135
+ define("badge-box", BadgeBox);
136
+ ```
137
+
138
+ ```ts
139
+ // src/main.ts
140
+ import "./style.css";
141
+ import "./nv/status-card.ts";
142
+ import "./nv/badge-box.ts";
143
+ ```
144
+
145
+ ```html
146
+ <!-- index.html (body) -->
147
+ <status-card class="mt-2" heading="Build" level="warn"></status-card>
148
+ <badge-box>inside</badge-box>
149
+ <script type="module" src="/src/main.ts"></script>
150
+ ```
151
+
152
+ ## Known limitations
153
+
154
+ 1. **Class names must be complete strings in a scanned file.** A class built from pieces (`` `text-${tone}-800` ``) is set on the element but has no CSS, in dev and in the build. Pick from full strings instead (the `LEVEL` map in the example), or, when the set really is open, list the classes in the CSS with `@source inline("text-{amber,rose}-800");`.
155
+ 2. **Template `<style>` is not processed by Tailwind.** Nativele injects it as written, so `@apply` in it does nothing (verified in a Light DOM `<style scoped>`). Use the theme variables instead (`color: var(--color-teal-400)`): Tailwind emits a theme variable only when it finds its name in a scanned file, and it does find it in the `.html` template.
156
+ 3. **Shadow DOM needs the sheet adopted, and the document still needs the stylesheet.** Document styles do not reach a shadow root (see the Light and Shadow table in the components guide), so a `shadow: true` component without step 5 shows its Tailwind classes unstyled. Ways that were tried:
157
+
158
+ | Way | Result |
159
+ |---|---|
160
+ | `adoptedStyleSheets` with `./style.css?inline` (step 5) | styled on the first frame, in dev and build; a class added to a Shadow template during `vite dev` applies after the reload. Costs: the whole Tailwind CSS is in the JavaScript a second time (in the verified app the CSS file was 8.0 kB, and the JavaScript grew by 8.4 kB minified, 2.4 kB gzip), and it is parsed once more; one shared `CSSStyleSheet` serves every instance |
161
+ | A `<link rel="stylesheet">` with `href` from `./style.css?url`, prepended to the shadow root in the constructor | works, and in the build `?url` points at the same CSS file the page already links (no second copy); but the component is unstyled until the stylesheet loads (a flash of unstyled content) |
162
+ | A `<style>` in the template | cannot hold Tailwind output: it is not processed (limitation 2) |
163
+
164
+ Use `adoptedStyleSheets`. Keep `import "./style.css"` in the entry anyway: Tailwind declares its internal variables with `@property`, which the browser registers only from the document. With the sheet adopted only in a shadow root and none in the document, plain utilities (`bg-violet-300`) worked but `shadow-lg` and `translate-x-[5px]` computed to `none`. When components do not need Shadow DOM's isolation, prefer Light DOM: it needs none of this.
165
+ 4. **Template styles beat utilities, whatever the specificity.** Tailwind puts utilities in `@layer utilities`, and the `<style>` Nativele injects is not in a layer, so a rule in `<style scoped>` overrides a utility on the same element (`.scoped-red` beat `text-sky-600`). To let utilities override a template's defaults, wrap those rules in `@layer components { }` inside the `<style scoped>` (as in the example); for a single element, the important modifier (`text-sky-600!`) also wins.
166
+ 5. **A `dark` class on `<html>` does not reach a shadow root.** With the class-based variant (`@custom-variant dark (&:where(.dark, .dark *));`), `dark:` utilities inside a `shadow: true` component match only an ancestor inside the same shadow root. Copy the class onto the component's inner wrapper (a `data-ref="wrapper"` element around everything that uses `dark:`), once at connect and on every change; the `sync()` at connect also covers a component created after dark mode was turned on:
167
+
168
+ ```ts
169
+ override connected(): void {
170
+ const root = document.documentElement;
171
+ const sync = (): void => {
172
+ this.refs.wrapper.classList.toggle("dark", root.classList.contains("dark"));
173
+ };
174
+ const observer = new MutationObserver(sync);
175
+ observer.observe(root, { attributes: true, attributeFilter: ["class"] });
176
+ this.onCleanup(() => observer.disconnect());
177
+ sync();
178
+ }
179
+ ```
180
+ 6. Not verified: Tailwind v3, `@tailwindcss/postcss` and the Tailwind CLI; Vite's `root` in a sub-folder; `dark:` from the `prefers-color-scheme` default inside a shadow root; Nativele components shipped as a package in `node_modules`; a template `<style>` inside a `shadow: true` component together with Tailwind; the CSS nesting fallback of `<style scoped>` in browsers without `@scope`; Tailwind's `prefix()` option and the `dark:` variant inside a shadow root.
181
+
182
+ ## Verify
183
+
184
+ Run in the project:
185
+
186
+ ```sh
187
+ npm run build # expect: ts-dom check: no issues, then ✓ built
188
+ npx ts-dom doctor src/nv # expect: ts-dom doctor: the set-up looks right
189
+ grep -cF '<a class used only in one of your templates>' dist/assets/*.css # expect: 1
190
+ grep -cF '<a class used only in an installed Skill file>' dist/assets/*.css # expect: 0 (the @source not line of step 3)
191
+ ```
192
+
193
+ For an arbitrary value, search for the escaped form (`grep -cF 'p-\[13px\]'`). Then, against both `npm run dev` and `npx vite preview`, read computed styles in the browser, for example:
194
+
195
+ ```js
196
+ getComputedStyle(document.querySelector("status-card").firstElementChild).backgroundColor // not "rgba(0, 0, 0, 0)"
197
+ getComputedStyle(document.querySelector("badge-box").shadowRoot.firstElementChild).boxShadow // not "none"
198
+ ```
199
+
200
+ ## Troubleshooting
201
+
202
+ | Symptom | Cause | Fix |
203
+ |---|---|---|
204
+ | A class set in `render` does nothing; the element has it in the DOM | the class name is built from pieces | use full strings, or `@source inline(...)` (limitation 1) |
205
+ | Utilities inside a `shadow: true` component do nothing | document styles do not reach a shadow root | step 5 |
206
+ | In a Shadow component colors and padding work, but `shadow-*`, `ring-*` or `translate-*` compute to `none` | the stylesheet is adopted in the shadow root but missing from the document, so Tailwind's `@property` variables are not registered | keep `import "./style.css"` in the entry |
207
+ | A Shadow component flashes unstyled on load | the stylesheet comes from a `<link>` | `adoptedStyleSheets` (step 5) |
208
+ | A utility on an element does not override the template's `<style scoped>` rule | unlayered template styles beat `@layer utilities` | wrap the template rule in `@layer components { }`, or use the `!` modifier |
209
+ | `@apply` in a template `<style>` has no effect | template styles are not processed by Tailwind | use theme variables (`var(--color-...)`) or utility classes in the markup |
210
+ | Templates in another folder of a monorepo are not styled | they are outside the project folder Tailwind scans | `@source "<path relative to the CSS file>"` |
211
+ | The built CSS has classes no component uses, from the Skill files | Tailwind scans `.agents/` (or `.claude/`), where `ts-dom skills add` puts the Skills, unless `.gitignore` matches it | add `@source not "../.agents";` (relative to the CSS file) under `@import "tailwindcss";` |
212
+ | A check that a class is absent from the build fails, although no component uses it | Tailwind scans every file in the project that `.gitignore` does not match, including test scripts that name the class | keep such scripts outside the project, or build the class name from pieces in them |
213
+
214
+ ## See also
215
+
216
+ - Light DOM and Shadow DOM, and what reaches a shadow root: https://github.com/Pixmi/nativele/blob/main/docs/components.md#light-default-and-shadow
217
+ - `<style>` and `<style scoped>` in templates: https://github.com/Pixmi/nativele/blob/main/docs/components.md#styles
218
+ - Common DOM operations in `render` (classes): https://github.com/Pixmi/nativele/blob/main/docs/components.md#common-dom-operations-in-render
219
+ - The Vite plugin: https://github.com/Pixmi/nativele/blob/main/docs/tooling.md#with-vite-optional
220
+ - Adding Nativele to Vite: `npx ts-dom skills nativele-vite`