@webjsdev/cli 0.10.11 → 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.
@@ -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.