@webjsdev/cli 0.10.10 → 0.10.12
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/bin/webjs.js +6 -4
- package/lib/create.js +16 -2
- package/lib/mcp-docs.js +400 -0
- package/lib/mcp-source.js +244 -0
- package/lib/mcp.js +167 -18
- package/package.json +7 -2
- package/resources/AGENTS.md +404 -0
- package/resources/agent-docs/advanced.md +1090 -0
- package/resources/agent-docs/built-ins.md +367 -0
- package/resources/agent-docs/components.md +486 -0
- package/resources/agent-docs/configuration.md +207 -0
- package/resources/agent-docs/framework-dev.md +65 -0
- package/resources/agent-docs/lit-muscle-memory-gotchas.md +456 -0
- package/resources/agent-docs/metadata.md +334 -0
- package/resources/agent-docs/recipes.md +440 -0
- package/resources/agent-docs/service-worker.md +100 -0
- package/resources/agent-docs/ssr-partial-nav-design.md +214 -0
- package/resources/agent-docs/styling.md +235 -0
- package/resources/agent-docs/testing.md +372 -0
- package/resources/agent-docs/typescript.md +334 -0
- package/templates/.dockerignore +6 -4
- package/templates/AGENTS.md +25 -10
- package/templates/CONVENTIONS.md +18 -1
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# SSR partial navigation: design note
|
|
2
|
+
|
|
3
|
+
**Status:** SHIPPED (feature/nested-layout-partial-swap, 2026-05-16).
|
|
4
|
+
The mechanism described below is implemented and tested. This document
|
|
5
|
+
is preserved as the design record. Runtime reference for callers lives
|
|
6
|
+
in `agent-docs/advanced.md` (Client router section) and the framework
|
|
7
|
+
API table in `AGENTS.md`.
|
|
8
|
+
|
|
9
|
+
**Motivating bug (resolved):** ui-website docs sidenav lost scroll on
|
|
10
|
+
every link click because the docs layout sat 2 levels deep under the
|
|
11
|
+
root layout, beyond `findLayoutShell`'s body-direct-child probe.
|
|
12
|
+
**Previous workaround (now deleted):** `app/docs/layout.ts` saved /
|
|
13
|
+
restored `.docs-sidenav` `scrollTop` via `sessionStorage` on every
|
|
14
|
+
`webjs:navigate` event. Removed in the same PR as the framework fix.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
**What actually shipped vs. what's below:**
|
|
19
|
+
- The recommendation in this doc was `<webjs-frame>` as the primary
|
|
20
|
+
primitive. During design discussion the decision evolved to make
|
|
21
|
+
layout-marker discovery **auto-derived from folder structure**, so
|
|
22
|
+
layout authors write nothing. `<webjs-frame>` ships as the escape
|
|
23
|
+
hatch for non-layout partial-swap regions (rare).
|
|
24
|
+
- The marker format is `<!--wj:children:<segment-path>-->` comment
|
|
25
|
+
pairs (Remix v3 lineage), not the `<webjs-frame>`-element approach
|
|
26
|
+
sketched below.
|
|
27
|
+
- Wire-byte optimization, snapshot cache, keyed DOM diff with live-
|
|
28
|
+
attribute preservation, and per-segment `<template id="wj-loading:...">`
|
|
29
|
+
cloning all shipped in the same PR (originally deferred as v2+).
|
|
30
|
+
|
|
31
|
+
The original `<webjs-frame>`-centric sketch below is preserved as
|
|
32
|
+
historical context.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Goal
|
|
37
|
+
|
|
38
|
+
Preserve the DOM of any layout, at any depth, across same-origin navigations. Re-render only the deepest segment that actually changed.
|
|
39
|
+
|
|
40
|
+
## Non-goals
|
|
41
|
+
|
|
42
|
+
- Per-segment data fetching (Remix v3 `<Frame src>` style). Wire model stays one SSR response per nav.
|
|
43
|
+
- React-style reconciler with full keyed-DOM diff inside the swap region. Out of scope for v1, can come later.
|
|
44
|
+
- Parallel routes / intercepting routes (Next.js feature). Separate design.
|
|
45
|
+
|
|
46
|
+
## Background: how the four references handle this
|
|
47
|
+
|
|
48
|
+
| Framework | Mechanism | Wire format | Scope decided by |
|
|
49
|
+
|---|---|---|---|
|
|
50
|
+
| **Turbo** | `<turbo-frame id="X">` (flat DOM element) | full HTML response, server may optimize via `Turbo-Frame: X` header | Innermost enclosing `<turbo-frame>` of the click (`closest()`) |
|
|
51
|
+
| **Remix v3** | `<!--rmx:f:id-->...<!--/rmx:f-->` comment markers + per-frame `src` | per-frame HTML or `<template id>` streams | Author-declared `<Frame name="...">` + `rmx-target` on link |
|
|
52
|
+
| **Next.js App Router** | Recursive `FlightRouterState` tuple + per-segment `CacheNode` tree | RSC Flight (`react-server-dom-webpack`) | Server walks the tree, returns from divergence point |
|
|
53
|
+
| **Lit Labs** | `Routes` controller with `outlet()` + child controllers via `RoutesConnectedEvent` | full template re-render (no partial scoping) | N/A (full subtree re-render every nav) |
|
|
54
|
+
|
|
55
|
+
**Closest fit to webjs's current router:** Turbo. webjs already mirrors Turbo Drive (link interception, body swap, `pushState`, `data-no-router` ≡ `data-turbo="false"`).
|
|
56
|
+
|
|
57
|
+
## Recommendation
|
|
58
|
+
|
|
59
|
+
Adopt a Turbo-style frame primitive: `<webjs-frame id="...">`. Layouts that want partial-swap behavior wrap their replaceable region:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// app/docs/layout.ts
|
|
63
|
+
import { html } from '@webjsdev/core';
|
|
64
|
+
import { sidenav } from './sidenav.ts';
|
|
65
|
+
|
|
66
|
+
export default function DocsLayout({ children }) {
|
|
67
|
+
return html`
|
|
68
|
+
<div class="docs-grid">
|
|
69
|
+
${sidenav()}
|
|
70
|
+
<webjs-frame id="docs-content">${children}</webjs-frame>
|
|
71
|
+
</div>
|
|
72
|
+
`;
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Algorithm: `packages/core/src/router-client.js` delta
|
|
77
|
+
|
|
78
|
+
Existing `findLayoutShell(body)` stays as a fallback. Add `findActiveFrame(linkEl)`:
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
function findActiveFrame(linkEl) {
|
|
82
|
+
// Walk up through shadow boundaries and into light DOM via composedPath at call site.
|
|
83
|
+
const frame = linkEl.closest('webjs-frame');
|
|
84
|
+
return frame ? frame.id : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function navigate(url, event) {
|
|
88
|
+
const frameId = event ? findActiveFrame(event.target) : null;
|
|
89
|
+
|
|
90
|
+
const res = await fetch(url, {
|
|
91
|
+
headers: frameId ? { 'X-Webjs-Frame': frameId } : {},
|
|
92
|
+
});
|
|
93
|
+
if (!res.headers.get('content-type')?.startsWith('text/html')) {
|
|
94
|
+
// existing fallback: full nav
|
|
95
|
+
window.location.href = url;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const html = await res.text();
|
|
100
|
+
const incoming = Document.parseHTMLUnsafe(html);
|
|
101
|
+
|
|
102
|
+
// 1. Frame path: preferred if active frame exists in both.
|
|
103
|
+
if (frameId) {
|
|
104
|
+
const target = document.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
|
|
105
|
+
const source = incoming.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
|
|
106
|
+
if (target && source) {
|
|
107
|
+
target.replaceChildren(...source.childNodes);
|
|
108
|
+
mergeHead(incoming.head);
|
|
109
|
+
runFrameScripts(target);
|
|
110
|
+
customElements.upgrade(target);
|
|
111
|
+
history.pushState({}, '', url);
|
|
112
|
+
document.dispatchEvent(new CustomEvent('webjs:navigate', { detail: { url, frameId } }));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 2. Existing layout-shell path (one level deep).
|
|
118
|
+
const shell = findLayoutShell(document.body);
|
|
119
|
+
const incomingShell = shell ? findLayoutShell(incoming.body) : null;
|
|
120
|
+
if (shell && incomingShell && shellsMatch(shell, incomingShell)) {
|
|
121
|
+
swapShellContent(shell, incomingShell);
|
|
122
|
+
/* existing path... */
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 3. Full body swap (existing fallback).
|
|
127
|
+
document.body.replaceChildren(...incoming.body.childNodes);
|
|
128
|
+
/* existing path... */
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
That's the entire detection delta: a `querySelector` keyed by the active frame's id, with the existing logic preserved as fallback.
|
|
133
|
+
|
|
134
|
+
### `<webjs-frame>` element: ~30 lines
|
|
135
|
+
|
|
136
|
+
```js
|
|
137
|
+
// packages/core/src/webjs-frame.js
|
|
138
|
+
import { WebComponent, html } from './index.js';
|
|
139
|
+
|
|
140
|
+
export class WebjsFrame extends WebComponent {
|
|
141
|
+
static properties = { id: { type: String, reflect: true } };
|
|
142
|
+
render() { return html`<slot></slot>`; }
|
|
143
|
+
}
|
|
144
|
+
WebjsFrame.register('webjs-frame');
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Light DOM (default): no shadow boundary, no slot mechanics. The element exists purely as a swap anchor with an addressable `id`. Children are normal light-DOM children that the router replaces via `replaceChildren`.
|
|
148
|
+
|
|
149
|
+
### Server side: `X-Webjs-Frame` request header (optional optimization)
|
|
150
|
+
|
|
151
|
+
When set, the SSR pipeline can return only the matching frame's HTML wrapped in a minimal stub document, skipping the rest of the layout chain. Wire is still plain HTML (no new format). v1 ships **without** this optimization. Full SSR response, client extracts what it needs. The header is forward-compat for the perf pass.
|
|
152
|
+
|
|
153
|
+
### Head merging
|
|
154
|
+
|
|
155
|
+
Same as today's `mergeHead`: replace `<title>`, merge `<meta>` tags by `name`/`property`, append new `<link>`/`<style>` elements, dedupe.
|
|
156
|
+
|
|
157
|
+
### Script handling inside the swap region
|
|
158
|
+
|
|
159
|
+
Re-execute `<script>` elements that match the existing one-level-shell path's `runScripts` logic. Idempotent registration via `Class.register()` makes this safe. The framework already handles `customElements.define` collisions.
|
|
160
|
+
|
|
161
|
+
## Edge cases
|
|
162
|
+
|
|
163
|
+
| Case | Behavior |
|
|
164
|
+
|---|---|
|
|
165
|
+
| Click on a link inside `<webjs-frame>` but `data-no-router` | Full browser navigation (existing semantics) |
|
|
166
|
+
| Click on a link *outside* any frame, both pages share a `findLayoutShell` match | Falls through to existing layout-shell path |
|
|
167
|
+
| Frame in old page but not in new (route change leaves the layout tree) | Frame lookup fails → fall to layout-shell or full body swap. Correct. |
|
|
168
|
+
| Nested `<webjs-frame>`s | Innermost wins: `closest('webjs-frame')` returns the nearest enclosing frame. Mirrors Turbo behavior. |
|
|
169
|
+
| Form submission inside a frame | Same. POST response gets the same frame-extract treatment. (Implement in form-submit path alongside link-click.) |
|
|
170
|
+
| Hash-fragment-only navigation | Existing behavior. No fetch, browser handles. |
|
|
171
|
+
| `data-frame="_top"` on a link | Escapes the enclosing frame, full nav. (Turbo precedent.) |
|
|
172
|
+
|
|
173
|
+
## What this fixes
|
|
174
|
+
|
|
175
|
+
- **ui-website docs sidenav scroll**: docs layout wraps content in `<webjs-frame id="docs-content">`. Sidenav lives *outside* the frame. Navigation between `/docs/components/a` → `/docs/components/b` only swaps frame children. The sidenav DOM is untouched, and `<aside>` scroll position is preserved natively. The `sessionStorage` workaround in `app/docs/layout.ts` can be deleted.
|
|
176
|
+
- **Any nested-layout app**: the same primitive works whether the partial-swap region is 2, 3, or 5 levels deep.
|
|
177
|
+
- **Mixed layouts**: pages that don't opt-in fall through to the existing one-level shell detection or full body swap. No regression risk.
|
|
178
|
+
|
|
179
|
+
## What's deliberately deferred (future passes)
|
|
180
|
+
|
|
181
|
+
1. **Keyed `data-key` DOM diff inside the frame.** Adopt Remix v3's `diff-dom.ts` algorithm to preserve input values, `<details>` open state, popover state, scroll positions on inner scroll containers across nav. Today's `replaceChildren` is coarse, but fine for v1 since the frame *itself* is preserved (outer scroll, sidenav, etc.).
|
|
182
|
+
2. **`X-Webjs-Frame` server optimization** to avoid re-rendering layouts the client already has.
|
|
183
|
+
3. **Server-pushed partial updates** (turbo-stream equivalent) via `<webjs-stream action="replace" target="...">`. Separate feature, useful for SSE/WebSocket-driven UI.
|
|
184
|
+
4. **Frame-scoped error boundaries.** If a frame fetch 5xxs, render only the frame's `error.ts`, not the whole page.
|
|
185
|
+
|
|
186
|
+
## Implementation plan
|
|
187
|
+
|
|
188
|
+
1. **`packages/core/src/webjs-frame.js`**: new file, the custom element.
|
|
189
|
+
2. **`packages/core/index.js`**: export `WebjsFrame`. Re-export `<webjs-frame>` via auto-registration import (so any app that imports `@webjsdev/core` gets it).
|
|
190
|
+
3. **`packages/core/src/router-client.js`**: add `findActiveFrame()`, frame-swap branch in `navigate()`. Preserve existing `findLayoutShell` and full-body fallback.
|
|
191
|
+
4. **`packages/core/src/router-client.js` (form path)**: apply the same frame-extract to form submit responses.
|
|
192
|
+
5. **`packages/server/src/dev.js`**: accept `X-Webjs-Frame` header in dev mode (no-op for v1 but adds the request signal for telemetry).
|
|
193
|
+
6. **Tests:**
|
|
194
|
+
- `packages/core/test/routing/router-client.test.js`: frame detection, querySelector with various ids, fallback when source frame missing.
|
|
195
|
+
- `test/e2e/nested-layout-partial-swap.test.mjs`: load `/docs/components/a`, scroll sidenav, click `/docs/components/b`, assert scroll preserved AND only frame children swapped.
|
|
196
|
+
7. **Docs:**
|
|
197
|
+
- `AGENTS.md`: add `<webjs-frame>` to the public API table.
|
|
198
|
+
- `agent-docs/advanced.md`: new "Frames" section under the client-router doc.
|
|
199
|
+
- `docs/` app: a new docs page showing the pattern.
|
|
200
|
+
8. **`packages/cli/templates/`**: none for v1 (frames are opt-in, no scaffold change needed).
|
|
201
|
+
9. **ui-website cleanup**: remove the `sessionStorage` workaround in `app/docs/layout.ts` after the frame lands and tests prove scroll preservation.
|
|
202
|
+
|
|
203
|
+
## Open questions
|
|
204
|
+
|
|
205
|
+
1. **Should `<webjs-frame>` ship a `src=""` attribute** for lazy loading like turbo-frame does? Probably yes eventually, but not for v1. The motivating use case is layout-scope, not lazy data.
|
|
206
|
+
2. **Should there be a sub-element registry** (something like `data-layout="docs"` as a shorthand)? Keep it explicit for v1: one mechanism. Consider sugar later.
|
|
207
|
+
3. **`X-Webjs-Frame` header naming.** `Webjs-Frame` (like `Turbo-Frame`) is shorter. Either works, and v1 implements neither for response routing so the bikeshed is deferred.
|
|
208
|
+
|
|
209
|
+
## References
|
|
210
|
+
|
|
211
|
+
- Turbo source: `frame_controller.js:132-148` (response parse), `frame_renderer.js:5-16` (swap), `link_interceptor.js:48` (innermost-frame rule)
|
|
212
|
+
- Remix v3 source: `packages/component/src/lib/frame.ts:1134-1146` (comment markers), `diff-dom.ts:124-162` (live-attr preservation list)
|
|
213
|
+
- Next.js source: `packages/next/src/client/components/router-reducer/ppr-navigations.ts:230,251,292,354,486` (cache reuse vs create), `walk-tree-with-flight-router-state.tsx:106-112` (server-side tree walk)
|
|
214
|
+
- webjs current: `packages/core/src/router-client.js` (`findLayoutShell`, single body-children scan)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# Styling: Tailwind-first, plus vanilla-CSS opt-out
|
|
2
|
+
|
|
3
|
+
Tailwind is the strong default. The conventions below cover the
|
|
4
|
+
Tailwind-first rule and the lit reflex it exists to counter, then how to
|
|
5
|
+
opt out and use plain CSS everywhere (fully supported).
|
|
6
|
+
|
|
7
|
+
## Tailwind-first is the strong default
|
|
8
|
+
|
|
9
|
+
**Use Tailwind utilities for pages AND light-DOM components (the default
|
|
10
|
+
DOM mode).** Layout, spacing, color (via the `@theme` tokens),
|
|
11
|
+
typography, borders, radius, shadows, and interaction states
|
|
12
|
+
(hover/focus/active/disabled, dark mode) are all utility-expressible.
|
|
13
|
+
Light DOM does not scope styles, so a utility class on a light-DOM
|
|
14
|
+
element resolves against the global stylesheet exactly as it does on a
|
|
15
|
+
page. That is why utilities are the right tool there, not an exception.
|
|
16
|
+
|
|
17
|
+
### The lit muscle-memory trap (read this first)
|
|
18
|
+
|
|
19
|
+
AI agents with strong lit / web-components training carry one habit that
|
|
20
|
+
fights this default: in lit, a component owns a shadow root and scopes
|
|
21
|
+
its CSS with `static styles = css\`\``, so the reflex is to author scoped
|
|
22
|
+
CSS or an inline `<style>` with semantic class names (`.hero`,
|
|
23
|
+
`.feature`, `.card`, `.btn`) for every component.
|
|
24
|
+
|
|
25
|
+
**In webjs the default is light DOM, which does NOT scope.** A scoped
|
|
26
|
+
`css` block does nothing without `static shadow = true` (the framework
|
|
27
|
+
warns at runtime), and an inline `<style>` with bare semantic class names
|
|
28
|
+
leaks those names into the global namespace. So reaching for either in a
|
|
29
|
+
light-DOM component is the reflex to resist. Prefer Tailwind utilities.
|
|
30
|
+
When the same utility bundle repeats, extract it into a `lib/utils/ui.ts`
|
|
31
|
+
helper that returns an `` html`...` `` fragment (the existing pattern,
|
|
32
|
+
below), NOT a CSS class. The helper keeps the utilities visible at the
|
|
33
|
+
definition site and runs at SSR time, so the output is identical to
|
|
34
|
+
writing the classes inline.
|
|
35
|
+
|
|
36
|
+
### The custom-CSS allowlist (the only things raw CSS is for)
|
|
37
|
+
|
|
38
|
+
Reserve raw CSS for what utilities genuinely cannot express. This is the
|
|
39
|
+
exhaustive list; anything outside it should be a utility (or a
|
|
40
|
+
`lib/utils/ui.ts` helper):
|
|
41
|
+
|
|
42
|
+
- **design-token `:root` + `@theme` definitions** (the palette, fonts,
|
|
43
|
+
fluid type scale, motion durations declared once in the root layout),
|
|
44
|
+
- **`@property` animated custom properties** paired with `@keyframes`,
|
|
45
|
+
- **`::-webkit-scrollbar` and `scrollbar-color`** (no utility surface),
|
|
46
|
+
- **`prefers-reduced-motion` blocks**,
|
|
47
|
+
- **complex `color-mix()` or gradient effects** a utility cannot spell.
|
|
48
|
+
|
|
49
|
+
When custom CSS IS unavoidable inside a light-DOM component, the
|
|
50
|
+
tag-prefix invariant still holds (see the Vanilla CSS section below):
|
|
51
|
+
every class selector is prefixed with the component tag. **Shadow-DOM components
|
|
52
|
+
(`static shadow = true`) legitimately author `static styles = css\`\``,
|
|
53
|
+
which is the right home for scoped CSS and is unchanged by this rule.**
|
|
54
|
+
The Tailwind-first steer is about the LIGHT-DOM default, not about
|
|
55
|
+
shadow DOM.
|
|
56
|
+
|
|
57
|
+
## Tailwind + JS helpers (default convention)
|
|
58
|
+
|
|
59
|
+
Default stack: Tailwind CSS browser runtime + `@theme` design tokens
|
|
60
|
+
declared once in the root layout (palette, fonts, fluid type, motion
|
|
61
|
+
durations). Consume via utility classes (`text-fg`, `bg-bg-elev`,
|
|
62
|
+
`font-serif`, `duration-fast`, `text-display`).
|
|
63
|
+
|
|
64
|
+
**DRY via JS helpers, not `@apply`.** When the same bundle of Tailwind
|
|
65
|
+
classes repeats across 2+ places, extract it into a helper in
|
|
66
|
+
`lib/utils/ui.ts`:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { html } from '@webjsdev/core';
|
|
70
|
+
|
|
71
|
+
/** `● label` kicker: small caps, accent colour, above headings. */
|
|
72
|
+
export function rubric(label: string, mb: 'sm' | 'md' = 'md') {
|
|
73
|
+
const mbCls = mb === 'sm' ? 'mb-3' : 'mb-4';
|
|
74
|
+
return html`
|
|
75
|
+
<span class="block font-mono text-[11px] leading-none font-semibold tracking-[0.2em] uppercase text-accent ${mbCls}">● ${label}</span>
|
|
76
|
+
`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** "← label" back link: small caps, muted. */
|
|
80
|
+
export function backLink(href: string, label: string) {
|
|
81
|
+
return html`
|
|
82
|
+
<a href=${href} class="inline-block mb-12 text-fg-subtle no-underline font-mono text-[11px] leading-none font-medium tracking-[0.15em] uppercase transition-colors duration-fast hover:text-fg">← ${label}</a>
|
|
83
|
+
`;
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
// app/blog/[slug]/page.ts
|
|
89
|
+
import { rubric, backLink } from '../../../lib/utils/ui.ts';
|
|
90
|
+
|
|
91
|
+
export default function Post({ params }) {
|
|
92
|
+
return html`
|
|
93
|
+
${backLink('/', 'Posts')}
|
|
94
|
+
${rubric('post')}
|
|
95
|
+
<h1 class="font-serif text-display ...">${title}</h1>
|
|
96
|
+
`;
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### When to extract, when to keep inline
|
|
101
|
+
|
|
102
|
+
| Repeats | Action |
|
|
103
|
+
|---|---|
|
|
104
|
+
| Once | Inline the classes. |
|
|
105
|
+
| 2–3 times, identical | Extract to `lib/utils/ui.ts`. |
|
|
106
|
+
| Varies by 1–2 props | Extract with a small parameter (`mb: 'sm' \| 'md'`). |
|
|
107
|
+
| Radically different per call site | Keep inline. Don't force-fit. |
|
|
108
|
+
|
|
109
|
+
### Why not `@apply`?
|
|
110
|
+
|
|
111
|
+
`@apply` hides which utilities a class uses from the reader and creates
|
|
112
|
+
a second source of truth. JS helpers keep the class bundle visible at
|
|
113
|
+
the definition site and compose naturally with other props (conditional
|
|
114
|
+
classes, active states, etc.). They run at SSR time. Output HTML is
|
|
115
|
+
identical to inline classes, no client-side runtime.
|
|
116
|
+
|
|
117
|
+
## Dark mode: two signals, keep them in sync
|
|
118
|
+
|
|
119
|
+
The default scaffold runs **two** theming systems, and a theme switch must
|
|
120
|
+
drive **both** or one half goes stale (this is the single most common
|
|
121
|
+
dark-mode bug in a scaffolded app):
|
|
122
|
+
|
|
123
|
+
1. **Editorial chrome tokens** (`--fg`, `--bg`, `--accent`, ...) declared in
|
|
124
|
+
the root layout. They react to a **`data-theme` attribute** on `<html>`
|
|
125
|
+
(`data-theme="light"` vs absent) and default to dark.
|
|
126
|
+
2. **Webjs UI (shadcn) component tokens** (`--background`, `--foreground`,
|
|
127
|
+
`--primary`, ...) used by everything under `components/ui/`. They react
|
|
128
|
+
to a **`.dark` class** on an ancestor (`@custom-variant dark (&:is(.dark *))`)
|
|
129
|
+
and default to light.
|
|
130
|
+
|
|
131
|
+
The scaffold's head init script and `theme-toggle` set **both** signals on
|
|
132
|
+
`<html>`: they write `data-theme` AND `classList.toggle('dark', isDark)`. If
|
|
133
|
+
you wire your own theme switch or replace the toggle, you MUST set both.
|
|
134
|
+
Setting only `data-theme` leaves the ui-* components rendering light tokens
|
|
135
|
+
on a dark page (white buttons, white cards, invisible text) while the chrome
|
|
136
|
+
looks correct.
|
|
137
|
+
|
|
138
|
+
**Verify dark mode in a real browser, not just light.** Light mode passing
|
|
139
|
+
proves nothing about dark mode: with neither signal set, both systems sit at
|
|
140
|
+
a coincidentally matching default, so the divergence only appears once
|
|
141
|
+
`.dark` / `data-theme` are applied. Emulate dark (`colorScheme: 'dark'` or
|
|
142
|
+
flip the toggle) and check a shadcn component's computed `background-color`,
|
|
143
|
+
not just the page chrome.
|
|
144
|
+
|
|
145
|
+
## Vanilla CSS for the whole app (opt-out of Tailwind)
|
|
146
|
+
|
|
147
|
+
Tailwind isn't required. To hand-write CSS everywhere, you need a
|
|
148
|
+
scoping convention so generic class names (`.btn`, `.input`, `.header`)
|
|
149
|
+
don't collide across pages, layouts, and components in the global
|
|
150
|
+
light-DOM namespace.
|
|
151
|
+
|
|
152
|
+
### Convention: three scopes, one rule each
|
|
153
|
+
|
|
154
|
+
| Scope | Wrapper selector | Where it lives |
|
|
155
|
+
|---|---|---|
|
|
156
|
+
| **Component** | Custom-element tag | Nested CSS under `my-counter { … }` |
|
|
157
|
+
| **Page** | `.page-<route>` | Wrap the page's markup in `<div class="page-<route>">` |
|
|
158
|
+
| **Layout** | `.layout-<name>` | Wrap the layout's markup in `<div class="layout-<name>">` |
|
|
159
|
+
|
|
160
|
+
Naming convention: derive the scope class from the file path. Slashes
|
|
161
|
+
→ hyphens. Dynamic segments become their param name. Route groups
|
|
162
|
+
`(marketing)` drop.
|
|
163
|
+
|
|
164
|
+
- `app/page.ts` → `.page-home`
|
|
165
|
+
- `app/about/page.ts` → `.page-about`
|
|
166
|
+
- `app/dashboard/posts/new/page.ts` → `.page-dashboard-posts-new`
|
|
167
|
+
- `app/blog/[slug]/page.ts` → `.page-blog-slug`
|
|
168
|
+
- `app/(marketing)/about/page.ts` → `.page-about`
|
|
169
|
+
- `app/layout.ts` → `.layout-root`
|
|
170
|
+
- `app/admin/layout.ts` → `.layout-admin`
|
|
171
|
+
|
|
172
|
+
Styles colocate with the markup as `const STYLES = css\`…\`` and
|
|
173
|
+
interpolate via `<style>${STYLES.text}</style>`. `ts-lit-plugin` /
|
|
174
|
+
`@webjsdev/ts-plugin` highlights the CSS and resolves class go-to-definition.
|
|
175
|
+
|
|
176
|
+
### Example: a page
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
import { html, css } from '@webjsdev/core';
|
|
180
|
+
|
|
181
|
+
const STYLES = css`
|
|
182
|
+
.page-dashboard {
|
|
183
|
+
.actions { display: flex; gap: 12px; }
|
|
184
|
+
.btn { padding: 12px 24px; border-radius: 999px; }
|
|
185
|
+
.btn-primary { background: var(--accent); color: var(--accent-fg); }
|
|
186
|
+
}
|
|
187
|
+
`;
|
|
188
|
+
|
|
189
|
+
export default function Dashboard() {
|
|
190
|
+
return html`
|
|
191
|
+
<style>${STYLES.text}</style>
|
|
192
|
+
<div class="page-dashboard">
|
|
193
|
+
<div class="actions">
|
|
194
|
+
<a class="btn btn-primary" href="/new">+ New</a>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
`;
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Example: a layout
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
const STYLES = css`
|
|
205
|
+
.layout-root {
|
|
206
|
+
.header { position: sticky; top: 0; }
|
|
207
|
+
.nav { display: flex; gap: 16px; }
|
|
208
|
+
}
|
|
209
|
+
`;
|
|
210
|
+
|
|
211
|
+
export default function RootLayout({ children }) {
|
|
212
|
+
return html`
|
|
213
|
+
<style>${STYLES.text}</style>
|
|
214
|
+
<div class="layout-root">
|
|
215
|
+
<header class="header">
|
|
216
|
+
<nav class="nav">…</nav>
|
|
217
|
+
</header>
|
|
218
|
+
<main>${children}</main>
|
|
219
|
+
</div>
|
|
220
|
+
`;
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Inside each scope, `.btn` / `.input` / `.header` / `.form` / `.item`
|
|
225
|
+
are free names because CSS descendant combinators stop them at the scope
|
|
226
|
+
boundary. A small curated set of **primitives** (`rubric`, `banner`,
|
|
227
|
+
`accent-link`, `display-h1`, …) can live global in the root layout as
|
|
228
|
+
your design system. Everything else is scoped.
|
|
229
|
+
|
|
230
|
+
### Tradeoffs vs Tailwind
|
|
231
|
+
|
|
232
|
+
More files you write, more discipline required, slight rename cost (2
|
|
233
|
+
textual edits when a route folder moves). In exchange: no browser-
|
|
234
|
+
runtime script, no `@theme` block, idiomatic CSS, plain cascade you can
|
|
235
|
+
debug with any tool.
|