@webjsdev/mcp 0.1.7 → 0.1.8
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 +1 -1
- package/package.json +1 -1
- package/resources/AGENTS.md +83 -72
- package/resources/SKILL.md +226 -0
- package/resources/references/auth-and-sessions.md +220 -0
- package/resources/references/built-ins.md +200 -0
- package/resources/references/client-router-and-streaming.md +204 -0
- package/resources/references/components.md +167 -0
- package/resources/references/data-and-actions.md +187 -0
- package/resources/references/muscle-memory-gotchas.md +170 -0
- package/resources/references/optimistic-ui.md +128 -0
- package/resources/references/routing-and-pages.md +158 -0
- package/resources/references/runtime.md +80 -0
- package/resources/references/service-worker.md +78 -0
- package/resources/references/styling.md +123 -0
- package/resources/references/testing.md +125 -0
- package/resources/references/typescript.md +148 -0
- package/src/mcp-docs.js +27 -14
- package/src/mcp-source.js +1 -1
- package/src/mcp.js +4 -3
- package/resources/agent-docs/advanced.md +0 -1334
- package/resources/agent-docs/built-ins.md +0 -389
- package/resources/agent-docs/components-shadowing.md +0 -87
- package/resources/agent-docs/components.md +0 -734
- package/resources/agent-docs/configuration.md +0 -284
- package/resources/agent-docs/framework-dev.md +0 -73
- package/resources/agent-docs/lit-muscle-memory-gotchas.md +0 -470
- package/resources/agent-docs/metadata.md +0 -334
- package/resources/agent-docs/orm.md +0 -198
- package/resources/agent-docs/recipes.md +0 -664
- package/resources/agent-docs/runtime.md +0 -65
- package/resources/agent-docs/service-worker.md +0 -104
- package/resources/agent-docs/styling.md +0 -279
- package/resources/agent-docs/testing.md +0 -479
- package/resources/agent-docs/types-and-mutations.md +0 -171
- package/resources/agent-docs/typescript.md +0 -402
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
# Runtime: Node and Bun (and future Deno)
|
|
2
|
-
|
|
3
|
-
webjs runs on **Node 24+** or **Bun**. The app source is identical on either; only
|
|
4
|
-
the listener shell, the type stripper, and a few built-ins differ. The selection
|
|
5
|
-
is a runtime-neutral seam in `startServer` (it picks the `node:http` shell on
|
|
6
|
-
Node and the `Bun.serve` shell on Bun), designed to also host a future
|
|
7
|
-
`Deno.serve` / embedded adapter. Deno is planned, not yet supported. The
|
|
8
|
-
user-facing reference is the docs-site page `/docs/runtime`; keep the two in sync.
|
|
9
|
-
|
|
10
|
-
## Node vs Bun
|
|
11
|
-
|
|
12
|
-
| Area | Node 24+ | Bun |
|
|
13
|
-
|---|---|---|
|
|
14
|
-
| Install | `npm install` (required) | `bun install` (required, like Node) |
|
|
15
|
-
| Run | `npm run dev` / `npm run start` | `bun run dev` / `bun run start` |
|
|
16
|
-
| Listener | `node:http` shell | native `Bun.serve` (about 1.9x req/s on the LISTENING PATH only, a trivial-handler microbenchmark, NOT end-to-end: for a real SSR page the render dominates and the delta is small, see `scripts/bench-listener.mjs`) |
|
|
17
|
-
| TS strip | built-in `module.stripTypeScriptTypes` | `amaro` (byte-identical, position-preserving) |
|
|
18
|
-
| SQLite | built-in `node:sqlite` + `drizzle-orm/node-sqlite` | built-in `bun:sqlite` + `drizzle-orm/bun-sqlite` |
|
|
19
|
-
| Hot reload | `node --watch` | `bun --hot` |
|
|
20
|
-
| WebSocket | the `ws` library | native `Bun.serve` + the `BunWsAdapter` bridge |
|
|
21
|
-
| 103 Early Hints | yes | no (`Bun.serve` has no informational-response API) |
|
|
22
|
-
|
|
23
|
-
The bytes the browser fetches are identical across runtimes. The 103 Early Hints
|
|
24
|
-
gap costs only a small first-load latency edge where an edge forwards 103, never
|
|
25
|
-
correctness (the modulepreload hints still ship in the document head).
|
|
26
|
-
|
|
27
|
-
## Scaffolding the runtime
|
|
28
|
-
|
|
29
|
-
`webjs create <name>` defaults to Node. `webjs create <name> --runtime bun` (or
|
|
30
|
-
`bun create webjs <name>`, auto-detected from the invoking package manager,
|
|
31
|
-
#541) emits a Bun-flavored app: `bun.lock`, a pure `oven/bun:1` Dockerfile +
|
|
32
|
-
bun-install CI, and bun-command agent docs. `--runtime` is orthogonal to
|
|
33
|
-
`--template`.
|
|
34
|
-
|
|
35
|
-
## Bun
|
|
36
|
-
|
|
37
|
-
A Bun app installs with `bun install` (like Node), then its `dev` / `start` /
|
|
38
|
-
`db` scripts force `bun --bun` so the server runs on Bun:
|
|
39
|
-
|
|
40
|
-
```sh
|
|
41
|
-
bun install
|
|
42
|
-
bun run dev # or: bun run start
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
`bun --bun` overrides the `webjs` bin's Node shebang so the server runs on Bun
|
|
46
|
-
(selecting the native `Bun.serve` listener and `amaro` type stripping); the app's
|
|
47
|
-
deps resolve from `node_modules`, the same as Node. The `start.before` migrate
|
|
48
|
-
(`webjs db migrate`) runs under Bun too. Commit a `bun.lock` (the Bun analog of
|
|
49
|
-
`package-lock.json`) for reproducible, offline installs; the scaffold's Bun
|
|
50
|
-
Dockerfile runs `bun install` and serves via `CMD ["bun", "--bun", "run",
|
|
51
|
-
"start"]`.
|
|
52
|
-
|
|
53
|
-
## SQLite busy_timeout (#674)
|
|
54
|
-
|
|
55
|
-
Both `node:sqlite` and `bun:sqlite` default `busy_timeout` to 0, so a contended
|
|
56
|
-
write throws `database is locked` immediately (better-sqlite3, used before the
|
|
57
|
-
built-in drivers, defaulted to 5000ms). The generated connection sets `PRAGMA
|
|
58
|
-
busy_timeout = 5000` + `PRAGMA journal_mode = WAL` on the raw client before
|
|
59
|
-
drizzle wraps it, on both runtime branches.
|
|
60
|
-
|
|
61
|
-
## Future runtimes
|
|
62
|
-
|
|
63
|
-
The listener seam is runtime-neutral, so a `Deno.serve` shell (or an embedded
|
|
64
|
-
adapter) slots in at the same point when added. Edge runtimes with no filesystem
|
|
65
|
-
are a separate, later target. Until then, treat Deno as planned, not supported.
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
# Service worker / offline primitive (opt-in, #271)
|
|
2
|
-
|
|
3
|
-
webjs ships a hand-authored service worker and an offline fallback into the UI
|
|
4
|
-
scaffolds (`public/sw.js`, `public/offline.html`; the full-stack and saas
|
|
5
|
-
templates, since the api template has no UI). It adds an offline experience
|
|
6
|
-
and an asset cache **without changing the JavaScript-disabled baseline**: the
|
|
7
|
-
worker only ever registers from JavaScript, so with JS off no worker exists and
|
|
8
|
-
pages, links, and forms behave exactly as before. It is **opt-in**: the files
|
|
9
|
-
ship dormant and do nothing until the app registers the worker.
|
|
10
|
-
|
|
11
|
-
This is a thin, hand-readable worker built directly on the native Service
|
|
12
|
-
Worker and Cache Storage APIs. There is no Workbox, no precache framework, and
|
|
13
|
-
no bundler step, matching webjs's no-build, close-to-web-standards posture.
|
|
14
|
-
|
|
15
|
-
## Enabling it (the opt-in registration snippet)
|
|
16
|
-
|
|
17
|
-
Add this inline script to the root layout's `<head>` (`app/layout.{js,ts}`). It
|
|
18
|
-
registers the worker after load, and only when JS is present, so it is
|
|
19
|
-
progressive-enhancement-safe:
|
|
20
|
-
|
|
21
|
-
```html
|
|
22
|
-
<script>
|
|
23
|
-
if ('serviceWorker' in navigator) {
|
|
24
|
-
addEventListener('load', () => {
|
|
25
|
-
// Tie the worker version to the deploy: read the importmap build id and
|
|
26
|
-
// register /sw.js?v=<build>, so a new deploy registers a "new" worker.
|
|
27
|
-
const tag = document.querySelector('script[type="importmap"]');
|
|
28
|
-
const build = (tag && tag.dataset.webjsBuild) || '';
|
|
29
|
-
navigator.serviceWorker.register('/sw.js' + (build ? '?v=' + build : ''));
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
</script>
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
With webjs's CSP enabled (`webjs.csp` in package.json), stamp the nonce on the
|
|
36
|
-
script. Read it in the layout with `import { cspNonce } from '@webjsdev/core'`
|
|
37
|
-
and emit `<script nonce="${cspNonce()}">…`.
|
|
38
|
-
|
|
39
|
-
## What the worker does (scope + strategy)
|
|
40
|
-
|
|
41
|
-
Registered from `/sw.js`, the worker's scope is the site root (`/`), so it sees
|
|
42
|
-
every navigation and same-origin asset request. Your worker file lives at
|
|
43
|
-
`public/sw.js`, and although most `public/*` assets serve at `/public/<name>`,
|
|
44
|
-
the framework serves this one (and `public/offline.html`) at the SITE ROOT with a
|
|
45
|
-
`Service-Worker-Allowed: /` header, so `register('/sw.js')` resolves to a 200 and
|
|
46
|
-
the worker controls the whole origin (#830).
|
|
47
|
-
|
|
48
|
-
- **Navigations are network-first.** The worker always tries the network first,
|
|
49
|
-
so the user sees fresh server-rendered HTML, and caches each successful page
|
|
50
|
-
(the SSR shell). When the network fails, it serves the cached page if you have
|
|
51
|
-
visited it, otherwise `/offline.html`. Network-first means the cache never
|
|
52
|
-
makes a page go stale; it is purely an offline safety net.
|
|
53
|
-
- **Static assets are stale-while-revalidate.** Same-origin modules (the per-file
|
|
54
|
-
ESM the no-build runtime serves), the framework runtime under `/__webjs/core/`,
|
|
55
|
-
vendor bundles under `/__webjs/vendor/`, and `public/` assets are served from
|
|
56
|
-
cache when present and refreshed in the background. In production these URLs
|
|
57
|
-
carry a `?v=<hash>` content fingerprint (#243), so a changed file gets a new
|
|
58
|
-
URL and the cache can never serve stale bytes.
|
|
59
|
-
|
|
60
|
-
**Never cached:** non-GET requests (writes), cross-origin requests, the action
|
|
61
|
-
RPC endpoint (`/__webjs/action/`), and the dev-only `/__webjs/events` (SSE) and
|
|
62
|
-
`/__webjs/reload.js`.
|
|
63
|
-
|
|
64
|
-
## Versioning ties to the deploy (`data-webjs-build`)
|
|
65
|
-
|
|
66
|
-
The cache name is `webjs-<build>`, where `<build>` is the `?v=` query the
|
|
67
|
-
registration passes (the importmap build id read from
|
|
68
|
-
`data-webjs-build`). When a deploy changes the build id:
|
|
69
|
-
|
|
70
|
-
1. the page registers `/sw.js?v=<new-build>`, a different worker URL, so the
|
|
71
|
-
browser fetches and installs the new worker;
|
|
72
|
-
2. the new worker's `activate` deletes every cache whose name is not the current
|
|
73
|
-
`webjs-<new-build>`, evicting the prior deploy's cache.
|
|
74
|
-
|
|
75
|
-
So a deploy refreshes the offline cache automatically, with no manual cache
|
|
76
|
-
busting. Without a `?v=` (e.g. a dev registration), the cache name is
|
|
77
|
-
`webjs-dev`.
|
|
78
|
-
|
|
79
|
-
## Updating the worker itself
|
|
80
|
-
|
|
81
|
-
The browser re-checks `/sw.js` on navigation and replaces the worker when its
|
|
82
|
-
bytes change. Because the registration URL carries the build id, a deploy always
|
|
83
|
-
changes that URL and triggers the update. The worker calls `skipWaiting()` +
|
|
84
|
-
`clients.claim()`, so a new version takes control promptly. To change the
|
|
85
|
-
caching strategy, edit `public/sw.js`; it is your file, not a framework
|
|
86
|
-
internal.
|
|
87
|
-
|
|
88
|
-
## Removing it
|
|
89
|
-
|
|
90
|
-
Delete the registration snippet (and optionally `public/sw.js` /
|
|
91
|
-
`public/offline.html`). To also un-register an already-installed worker on
|
|
92
|
-
clients, ship a one-line `navigator.serviceWorker.getRegistrations().then(rs =>
|
|
93
|
-
rs.forEach(r => r.unregister()))` for a release, or rely on the worker's own
|
|
94
|
-
update lifecycle.
|
|
95
|
-
|
|
96
|
-
## Tests
|
|
97
|
-
|
|
98
|
-
`test/service-worker/sw.test.mjs` runs the REAL `public/sw.js` source in a
|
|
99
|
-
`node:vm` sandbox with mocked service-worker globals and drives its handlers:
|
|
100
|
-
the install precache, the activate cache eviction, the network-first navigation
|
|
101
|
-
(fresh + cached), the offline-cached + offline-fallback paths, and the
|
|
102
|
-
never-cache rules (non-GET, cross-origin, the RPC endpoint), plus
|
|
103
|
-
stale-while-revalidate. `test/scaffolds/scaffold-integration.test.js` asserts
|
|
104
|
-
both files ship into a scaffolded app.
|
|
@@ -1,279 +0,0 @@
|
|
|
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>`. The standalone
|
|
174
|
-
`@webjsdev/intellisense` (and the `webjs` editor extension) resolves class
|
|
175
|
-
go-to-definition inside those blocks.
|
|
176
|
-
|
|
177
|
-
### Example: a page
|
|
178
|
-
|
|
179
|
-
```ts
|
|
180
|
-
import { html, css } from '@webjsdev/core';
|
|
181
|
-
|
|
182
|
-
const STYLES = css`
|
|
183
|
-
.page-dashboard {
|
|
184
|
-
.actions { display: flex; gap: 12px; }
|
|
185
|
-
.btn { padding: 12px 24px; border-radius: 999px; }
|
|
186
|
-
.btn-primary { background: var(--accent); color: var(--accent-fg); }
|
|
187
|
-
}
|
|
188
|
-
`;
|
|
189
|
-
|
|
190
|
-
export default function Dashboard() {
|
|
191
|
-
return html`
|
|
192
|
-
<style>${STYLES.text}</style>
|
|
193
|
-
<div class="page-dashboard">
|
|
194
|
-
<div class="actions">
|
|
195
|
-
<a class="btn btn-primary" href="/new">+ New</a>
|
|
196
|
-
</div>
|
|
197
|
-
</div>
|
|
198
|
-
`;
|
|
199
|
-
}
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
### Example: a layout
|
|
203
|
-
|
|
204
|
-
```ts
|
|
205
|
-
const STYLES = css`
|
|
206
|
-
.layout-root {
|
|
207
|
-
.header { position: sticky; top: 0; }
|
|
208
|
-
.nav { display: flex; gap: 16px; }
|
|
209
|
-
}
|
|
210
|
-
`;
|
|
211
|
-
|
|
212
|
-
export default function RootLayout({ children }) {
|
|
213
|
-
return html`
|
|
214
|
-
<style>${STYLES.text}</style>
|
|
215
|
-
<div class="layout-root">
|
|
216
|
-
<header class="header">
|
|
217
|
-
<nav class="nav">…</nav>
|
|
218
|
-
</header>
|
|
219
|
-
<main>${children}</main>
|
|
220
|
-
</div>
|
|
221
|
-
`;
|
|
222
|
-
}
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
Inside each scope, `.btn` / `.input` / `.header` / `.form` / `.item`
|
|
226
|
-
are free names because CSS descendant combinators stop them at the scope
|
|
227
|
-
boundary. A small curated set of **primitives** (`rubric`, `banner`,
|
|
228
|
-
`accent-link`, `display-h1`, …) can live global in the root layout as
|
|
229
|
-
your design system. Everything else is scoped.
|
|
230
|
-
|
|
231
|
-
### Tradeoffs vs Tailwind
|
|
232
|
-
|
|
233
|
-
More files you write, more discipline required, slight rename cost (2
|
|
234
|
-
textual edits when a route folder moves). In exchange: no browser-
|
|
235
|
-
runtime script, no `@theme` block, idiomatic CSS, plain cascade you can
|
|
236
|
-
debug with any tool.
|
|
237
|
-
|
|
238
|
-
## Gotcha: a sticky header flickers on iOS, use `position: fixed`
|
|
239
|
-
|
|
240
|
-
A `position: sticky` header (the common `sticky top-0` pattern) flickers its
|
|
241
|
-
background for one frame on iOS WebKit (every iOS browser, since they all use
|
|
242
|
-
WebKit) during a client-router FORWARD navigation. The scroll-to-top the router
|
|
243
|
-
runs after the content swap drives a sticky stuck-to-static recompute that
|
|
244
|
-
WebKit mis-repaints. It is iOS-only (fine on desktop and Android, and invisible
|
|
245
|
-
in DevTools device emulation, so it shows only on a real device). #610 confirmed
|
|
246
|
-
on-device that neither compositor promotion (`translateZ(0)` / `will-change`)
|
|
247
|
-
nor changing the swap paint timing fixes it.
|
|
248
|
-
|
|
249
|
-
The fix is `position: fixed` instead of sticky. A fixed header is always pinned
|
|
250
|
-
and never does the scroll-relative recompute, so the repaint bug never fires.
|
|
251
|
-
There is no reliable CSS-only fix that KEEPS `position: sticky` (the GPU-promotion
|
|
252
|
-
hacks `translateZ(0)` / `translate3d` / `will-change` / a wrapper element are all
|
|
253
|
-
variants that do NOT reliably work, confirmed on-device for #610). Preserving the
|
|
254
|
-
header across nav is correct and standard (Next.js, Remix, SvelteKit all preserve
|
|
255
|
-
nested layouts); only the `sticky` positioning is the problem.
|
|
256
|
-
|
|
257
|
-
Because fixed leaves normal flow, reserve the header height on the content below.
|
|
258
|
-
Use a single `--header-height` custom property as the source of truth rather than
|
|
259
|
-
a hardcoded number, and keep it exact with a `ResizeObserver` so it tracks
|
|
260
|
-
responsive / dynamic headers (degrades fine with no JS via the default):
|
|
261
|
-
|
|
262
|
-
```css
|
|
263
|
-
:root { --header-h: 56px; } /* sane SSR first-paint default */
|
|
264
|
-
header { position: fixed; inset-inline: 0; top: 0; }
|
|
265
|
-
body { padding-top: var(--header-h); }
|
|
266
|
-
```
|
|
267
|
-
```js
|
|
268
|
-
// refine the default to the header's real height (and on resize)
|
|
269
|
-
const hdr = document.querySelector('header');
|
|
270
|
-
const apply = () => document.documentElement.style.setProperty('--header-h', hdr.offsetHeight + 'px');
|
|
271
|
-
apply();
|
|
272
|
-
new ResizeObserver(apply).observe(hdr);
|
|
273
|
-
```
|
|
274
|
-
|
|
275
|
-
The example blog's root layout does exactly this (`examples/blog/app/layout.ts`).
|
|
276
|
-
For an app/dashboard layout, an alternative is an app-shell scroll container (a
|
|
277
|
-
non-scrolling `100dvh` flex column with `<main>` as the internal scroller), which
|
|
278
|
-
needs no offset at all but changes the scroll model (the window no longer
|
|
279
|
-
scrolls, so the iOS URL bar stays visible).
|