Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ so publishing never reloads the admin app mid-test. The Vite dev proxy follows
the configured CMS `PORT`, keeping the Playwright admin UI pointed at the
disposable CMS instead of any regular dev server on port 3001.

When Vite itself runs on Bun, its Node-compatible native proxy can stop
draining multi-megabyte request bodies after socket backpressure fills both
sides. `largeBodyDevProxyPlugin` intercepts only known-length CMS API bodies of
at least 1 MiB, buffers them with the same 128 MiB ceiling as `Bun.serve`, and
forwards them with an explicit `Content-Length`. Small requests, non-CMS
traffic, and streaming AI responses remain on Vite's native proxy.

For debugging against a server you started yourself, set
`E2E_REUSE_SERVER=1` and override `E2E_ADMIN_BASE_URL` /
`E2E_PUBLIC_BASE_URL` as needed. Do not use reuse mode for CI or for
Expand Down
7 changes: 4 additions & 3 deletions docs/features/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The agent runs on a provider-agnostic AI runtime (`server/ai/`) that can drive a
## TL;DR

- **Structure via HTML.** `site_insert_html` and `site_replace_node_html` accept semantic HTML strings; the browser executor calls `importHtml` (the same pipeline as the paste-HTML UI) to convert them into first-class, editable `PageNode`s.
- **Styling via CSS.** The agent emits CSS the same way a human pastes it: a `<style>` block and/or `class=` attributes inside the `site_insert_html`/`site_replace_node_html` payload, or the standalone `site_apply_css` tool. The importer (`cssToStyleRules`) classifies every selector — a bare `.foo {}` rule becomes a reusable Selectors-panel class bound to `class="foo"`; any other selector (`.hero a`, `a:hover`, `nav > li`) becomes an ambient rule; `style=` attributes land on the node's inline styles. There is no structured `classes` parameter — the agent never hand-builds classes node-by-node at insert time. `site_apply_css` is the single tool for CSS on its own, with explicit merge, replace, rule-delete, and property-removal operations; exact selector identity and `!important` priority survive the round trip.
- **Styling via CSS.** The agent emits CSS the same way a human pastes it: a `<style>` block and/or `class=` attributes inside the `site_insert_html`/`site_replace_node_html` payload, or the standalone `site_apply_css` tool. The importer (`cssToStyleRules`) classifies every selector — class-bearing selectors become reusable Selectors-panel classes while preserving the full selector and decoded class token; class-free selectors (`a:hover`, `nav > li`) become ambient rules; `style=` attributes land on the node's inline styles. There is no structured `classes` parameter — the agent never hand-builds classes node-by-node at insert time. `site_apply_css` is the single tool for CSS on its own, with explicit merge, replace, rule-delete, and property-removal operations; exact selector identity and `!important` priority survive the round trip.
- **Site scope: 35 tools total.** 6 server-side catalog read tools (resolved server-side from the posted snapshot / DB) + 29 browser-bridged tools.
- **Content scope: 15 tools total.** 7 server-side content/catalog/media/user read tools + 8 browser-bridged document mutation/navigation tools.
- **Two-endpoint bridge.** `POST /admin/api/ai/chat/:scope` opens an NDJSON stream. When the model calls a browser-bridged tool, the server emits `toolRequest`; the browser executor reads or mutates the live workspace and POSTs the `AiToolOutput` result to `POST /admin/api/ai/tool-result`.
Expand Down Expand Up @@ -397,7 +397,8 @@ All 29 tools carry `execution: 'browser'` in their `AiTool` definition. The serv
Styling rides on the `html` payload — there is no separate `classes` parameter. The executor runs `importHtml(html)`, which harvests any `<style>` block's CSS, then hands it to `cssToStyleRules`. That classifier routes each selector:

- a bare `.foo {}` rule → a reusable Selectors-panel **class**, bound to every `class="foo"` node in the fragment;
- any other selector (`.hero a`, `a:hover`, `nav > li`, `@media …`) → an **ambient** rule (media queries fold into the matching breakpoint's `contextStyles`);
- class-bearing selectors (`.hero a`, `.group:hover .group-hover\:block`) → a **class** rule whose decoded binding name is the rightmost class token and whose full selector is preserved;
- class-free selectors (`a:hover`, `nav > li`) → an **ambient** rule (media queries fold into the matching breakpoint's `contextStyles`);
- supported stylesheet-level rules such as `@keyframes` → ambient raw CSS rules emitted by the publisher;
- inline `style="…"` attributes → the node's inline styles.

Expand Down Expand Up @@ -578,7 +579,7 @@ Drivers that support explicit prompt-cache controls (Anthropic) apply `cache_con
**Static prefix key rules** (full text lives in `server/ai/tools/site/systemPrompt.ts`):
- **Design system first.** Establish or reuse tokens before/while building (`site_set_color_tokens`, `site_set_type_scale`, `site_set_spacing_scale`, `site_set_font_tokens`), then reference them in CSS (`var(--<slug>)`, `var(--text-l)`, `var(--space-m)`, `var(--<font-var>)`) instead of raw hex/px/font-family. The dynamic suffix's `Tokens —` line shows what already exists; `(none …)` means no design system yet.
- Structure as HTML (`site_insert_html` / `site_replace_node_html`); style with CSS in the same payload — a `<style>` block and/or `class=` attributes referencing the design tokens. The importer classifies selectors, so the agent never hand-builds classes at insert time.
- `<style>` blocks inside imported HTML are parsed: a bare `.foo {}` rule becomes a Selectors-panel class bound to `class="foo"`; any other selector (`.hero a`, `a:hover`, `@media …`) becomes an ambient rule, and supported `@keyframes` publish as raw keyframes CSS. `style=` attributes land on the node's inline styles. These are applied — not stripped.
- `<style>` blocks inside imported HTML are parsed: class-bearing selectors become Selectors-panel classes while preserving complex selector text, class-free selectors (`a:hover`) become ambient rules, and supported `@keyframes` publish as raw keyframes CSS. `style=` attributes land on the node's inline styles. These are applied — not stripped.
- CSS-only edits use an explicit `site_apply_css` operation: merge for additive patches, remove-properties for stale declarations, replace only with the selector's complete desired CSS, and delete for whole exact rules. Read the document first before destructive operations; grouped and ungrouped selectors are different identities.
- One `site_insert_html` call per logical section (nav, hero, pricing, footer = 4–6 calls); smaller chunks recover better if one fails.
- Per-breakpoint variation: `@media` queries — in the `<style>` block of an insert or inside `site_apply_css` — with min/max-width queries that line up with the breakpoint widths in the dynamic suffix. Never invent ids like `"mobile"` or `"desktop"`.
Expand Down
2 changes: 1 addition & 1 deletion docs/features/html-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ The result: imported markup renders its `class` attribute, the classes show up i
The importer preserves CSS across two layers, both gated by `isEmittableProperty`:

- **Inline `style="…"`** → the node's `inlineStyles` bag (per-node `style=""` layer). Full declarations, not just backgrounds.
- **`<style>` blocks** → parsed by the consumer (`cssToStyleRules`) into registry `StyleRule`s shown in the Selectors panel. A `.foo {}` rule binds to nodes carrying `class="foo"`; ambient selectors (`body`, `a:hover`, `.a .b`) register globally. First-wins on name/selector collisions with existing rules.
- **`<style>` blocks** → parsed by the consumer (`cssToStyleRules`) into registry `StyleRule`s shown in the Selectors panel. A `.foo {}` rule binds to nodes carrying `class="foo"`; complex class-bearing selectors preserve their selector and expose a decoded picker class (for example `.group:hover .group-hover\:block` exposes `group-hover:block`); class-free selectors (`body`, `a:hover`) register as ambient. First-wins on name/selector collisions with existing rules.
- **Class names without a matching `<style>` rule** still survive — `insertImportedNodes` auto-creates a bare (style-less) class for the name (see [Class linking](#class-linking-name--id)), styleable afterwards in the editor or by the agent.

`importHtml` itself stays CSS-agnostic: it returns raw `styleCss` and the consumer (which has the site's breakpoints and may import `@core/siteImport`) does the parsing. This avoids an `htmlImport → siteImport` import cycle and lets `@media` fold into the site's real breakpoints.
Expand Down
12 changes: 12 additions & 0 deletions docs/features/publisher.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,18 @@ style-<hash>.css = collectClassCSS(site) ← user-defi
userStyles-<hash>.css = collectUserStylesheetCss(site, page) ← author stylesheets, scoped to this page
```

`styleRuleTreeShake.ts` computes the site-wide used class-id set once across
page and Visual Component trees. A class rule emits only when its id is used
and every known class dependency in its preserved selector is used. Ambient
selector fragments emit when at least one selector-list alternative has all of
its known class dependencies in use; class-free selectors and supported raw
blocks stay conservative. The editor canvas calls the same selector and
memoizes the filtered registry by immutable registry identity + used-id
signature, so large imported utility catalogs do not become large iframe
stylesheets. A full precompiled Tailwind catalog can therefore remain
picker-addressable while the `style` bundle contains only selected utilities
plus global preflight.

Media-library background images are optimized in the same publish pass as
`<img srcset>`. `mediaPrefetch.ts` collects `/uploads/...` URLs from
image/media module props, node `inlineStyles.backgroundImage`, and StyleRule
Expand Down
24 changes: 21 additions & 3 deletions docs/features/site-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,9 @@ interface ImportScript {

| Source rule | Stored as |
|---|---|
| `.foo { … }` (single class) | `StyleRule{ kind:'class', name:'foo', selector:'.foo' }` |
| `h1`, `body`, `a:hover`, `.hero .title` | `StyleRule{ kind:'ambient', selector: verbatim }` |
| `.foo { … }` | `StyleRule{ kind:'class', name:'foo', selector:'.foo' }` |
| `.hero .title`, `.group:hover .group-hover\:block` | One bindable class rule per selector-list alternative. The rightmost decoded class is `name`; the full selector is preserved. Selector dependency classes (`hero`, `group`) receive bare picker entries when they have no rule of their own. |
| `h1`, `body`, `a:hover` | `StyleRule{ kind:'ambient', selector: verbatim }` |
| `@media ... { … }` | Merged into a matching viewport context's `contextStyles` when it matches a configured media query (or an older/default max-width threshold); otherwise preserved as a reusable media condition |
| Unconditional local `@import "file.css"` | Followed recursively from the linked stylesheet; the imported file keeps its own source path so relative `url(...)` assets resolve correctly |
| Trusted Google CSS2 `@import` | Parsed into `ImportGoogleFont` install requests and committed as self-hosted installed font entries |
Expand Down Expand Up @@ -249,7 +250,24 @@ Resolutions apply in `applyCrossSheetClassResolutions` (via `applyConflictResolu

After all renames, `normalizeBindableClassRules` enforces the registry's unique-class-name invariant: per final name, the FIRST class-kind rule (in cascade source order) stays bindable; every later same-name class fragment becomes an ambient rule with the same selector — its declarations keep their cascade position, so within-cascade overrides (`base.css .btn` + `page.css .btn`) still compose like real CSS.

Separately, two groups of single-class rules are converted to `kind:'ambient'` at plan time (`preserveGloballyMatchedClassRules`): classes that no imported node actually uses, and the shared Bootstrap-like utility names above. Static templates often create or toggle unused classes from JavaScript (`.mt-cursor`, `.is-open`, `.show`, etc.); leaving those as editable class rules would let publisher class tree-shaking drop them because no imported node owns their `classIds`. Shared utilities are ambient for a different reason: nodes keep the plain class token, while every source rule for that token remains publishable in cascade order.
All imported class-bearing selectors remain picker-addressable, whether or not
the imported HTML currently uses them. This is what makes a large utility
catalogue (including a full precompiled Tailwind build) useful after import:
escaped names such as `hover:bg-blue-700` are decoded for the class picker while
their original selectors remain intact.

Publishing and the canvas use the same dependency-aware tree-shaker. A
class-kind rule emits only when its registry id is assigned and every known
class dependency in its selector is in use; repeated fragments demoted to
ambient rules emit only when their known selector dependencies are in use.
Class-free ambient selectors and supported raw blocks such as `@keyframes`
remain conservative and global. This retains framework cascades such as `.row`
plus `.row > *` without shipping thousands of unused utilities.

Runtime code that constructs class names dynamically cannot be inferred from a
static page tree. Those classes must be assigned in the editor (including to a
hidden structural node) or the stylesheet should use `mode:'file'`, which is
the explicit non-tree-shaken escape hatch for runtime-owned CSS.

The escape hatch for "this sheet's resets/styles must not leak into other pages at all" is no longer a generated scope class — it is keeping that sheet as a file (`mode: 'file'`), page-scoped via runtime config.

Expand Down
8 changes: 4 additions & 4 deletions docs/reference/css-class-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ The site's style rule registry — `Record<string, StyleRule>` stored on the sit

Three forms of rules:

1. **Author-facing class rules** (`kind: 'class'`) — the user picks a name (`hero-button`, `card-meta`) and the editor applies them via `node.classIds`. Selector is `.<name>`.
2. **Ambient rules** (`kind: 'ambient'`) — attach by CSS selector matching, not by node assignment (e.g. `h1`, `.hero .title`, `a:hover`). The publisher emits the rule but never writes to `class=` attributes. Supported stylesheet-level imports such as `@keyframes` are stored as ambient rules with `rawCss`.
1. **Author-facing class rules** (`kind: 'class'`) — the user picks a name (`hero-button`, `card-meta`) and the editor applies it via `node.classIds`. User-created selectors are `.<name>`; imported utility rules can retain complex selectors such as `.group:hover .group-hover\:block` while exposing the decoded binding name `group-hover:block`.
2. **Ambient rules** (`kind: 'ambient'`) — attach by CSS selector matching, not by node assignment (e.g. `h1`, `a:hover`). The publisher emits the rule but never writes it to `class=` attributes. Supported stylesheet-level imports such as `@keyframes` are stored as ambient rules with `rawCss`.
3. **Scoped classes** — generated class-kind rules owned by a single node (for "set this property only on this element"). The scope object pins the rule to its node.

---
Expand All @@ -16,7 +16,7 @@ Three forms of rules:
- Source-of-truth schema: `StyleRuleSchema` in `src/core/page-tree/styleRule.ts`.
- Compiled to CSS by `classCss.ts` in the publisher; collected via `collectClassCSS(site)`.
- Each node references class-kind rules by id (`node.classIds: string[]`). Later ids in the array win in cascade order.
- Selector UI surfaces display the rule's CSS selector (`styleRuleSelector(rule)`): class-kind rules appear as `.<name>`, ambient rules appear as their verbatim `selector` text.
- Selector UI surfaces display the rule's CSS selector (`styleRuleSelector(rule)`): canonical classes appear as `.<name>`, imported class rules retain their verbatim selector, and ambient rules also use their verbatim `selector` text.
- Rule **name** is the class token for class-kind rules. For ambient rules, `selector` is the source of truth; user edits keep `name` aligned to the selector so old class-name-only UI paths cannot add an extra dot.
- Rule **id** is the stable internal identifier (`<nanoid>`).
- Scoped rules (`scope: { type: 'node', nodeId, role: 'module-style' }`) are pinned to one node.
Expand Down Expand Up @@ -75,7 +75,7 @@ Class-kind rule **name** is the public class token. It is stored without the lea
- No leading, trailing, or embedded ASCII whitespace
- No control characters

The selector itself is produced by `classKindSelector(name)`, which escapes the token for CSS when needed. Ambient rule **name** mirrors the ambient selector after user edits so older class-name-only surfaces cannot accidentally render an extra leading dot.
New class selectors are produced by `classKindSelector(name)`, which escapes the token for CSS when needed. Imported class-bearing selectors preserve their original escaped selector; renaming rewrites the decoded class token in place. Ambient rule **name** mirrors the ambient selector after user edits so older class-name-only surfaces cannot accidentally render an extra leading dot.

Rule **id** is internal (a nanoid). Refs from nodes (`classIds`) and from internal data structures use the id, not the name. This means renaming a rule doesn't break anything that references it.

Expand Down
Loading
Loading