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
2 changes: 1 addition & 1 deletion docs/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ Each iframe `<head>` receives five `<style>` elements (three from `ClassStyleInj
| Element | Injector | Cascade layer | Contents |
|---|---|---|---|
| `<style id="instatic-editor-chrome">` | `EditorChromeInjector` | **unlayered** | Editor-only chrome: placeholder, slot-instance, list placeholder, unknown-module fallback |
| `<style id="mc-classes">` | `ClassStyleInjector` | `@layer user-authored` | Publisher reset + framework CSS + class registry CSS |
| `<style id="mc-classes">` | `ClassStyleInjector` | `@layer user-authored` | Publisher reset + framework CSS + module `canvasCss` + class registry CSS |
| `<style id="mc-classes-preview">` | `ClassStyleInjector` | `@layer user-authored` | Higher-specificity preview rule while a property control is hovered; empty for state-pseudo rules |
| `<style id="mc-classes-force-state">` | `ClassStyleInjector` | `@layer user-authored` | Forced state preview: paints the active state-pseudo rule onto the selected node via a doubled `[data-node-id]` selector |
| `<style id="mc-user-styles">` | `UserStylesheetInjector` | `@layer user-authored` | User-uploaded stylesheets (verbatim, unscoped) |
Expand Down
4 changes: 2 additions & 2 deletions docs/features/canvas-iframe-per-frame.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ IframeFrameSurface
<iframe srcDoc="<!doctype html><html>…">
(inside iframe document, via createPortal)
├── EditorChromeInjector (head: unlayered editor chrome CSS)
├── ClassStyleInjector (head: @layer user-authored — publisher reset + class registry)
├── ClassStyleInjector (head: @layer user-authored — publisher reset + module canvasCss + class registry)
├── UserStylesheetInjector (head: @layer user-authored — user-uploaded stylesheets)
├── {children} (body: React node tree via NodeRenderer)
└── RuntimeScriptInjector (body: opt-in runtime scripts when "Run scripts" is on)
Expand Down Expand Up @@ -124,7 +124,7 @@ Five `<style>` elements are injected per iframe (three from `ClassStyleInjector`
| Injector | `id` attribute | Cascade layer | Purpose |
|---|---|---|---|
| `EditorChromeInjector` | `instatic-editor-chrome` | **unlayered** | Editor chrome: placeholder, slot-instance, unknown-module styles. Copies safe required design tokens from parent `:root` onto iframe `:root`. The editor UI font, admin text-size tokens, and admin spacing tokens are forwarded as **chrome-namespaced** `--chrome-font-sans`, `--chrome-text-*`, and `--chrome-space-*` aliases, not as `--font-sans`, `--text-*`, or `--space-*`, because the injector is unlayered and would otherwise clobber the site's own Framework tokens. |
| `ClassStyleInjector` | `mc-classes` | `@layer user-authored` | Publisher reset + framework CSS + class registry CSS |
| `ClassStyleInjector` | `mc-classes` | `@layer user-authored` | Publisher reset + framework CSS + module `canvasCss` + class registry CSS, in the order `publishPage()` emits them |
| `ClassStyleInjector` | `mc-classes-preview` | `@layer user-authored` | Higher-specificity preview rule (doubled selector) while a property control is hovered. Empty for state-pseudo rules — those use `mc-classes-force-state` instead. |
| `ClassStyleInjector` | `mc-classes-force-state` | `@layer user-authored` | Force-paints the active state-pseudo rule (`.btn:hover`, `.card:focus`, etc.) onto the selected node via a doubled `[data-node-id]` selector so the state is visible/editable without physically triggering it. Mirrors the full `contextStyles` emission per breakpoint and condition. |
| `UserStylesheetInjector` | `mc-user-styles` | `@layer user-authored` | User-uploaded stylesheets (verbatim, unscoped) |
Expand Down
75 changes: 75 additions & 0 deletions docs/features/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ src/modules/base/
├── outlet/ — base.outlet (Content Outlet — template content slot)
├── button/ — base.button (anchor.ts — element-decision leaf)
├── link/ — base.link (content.ts — children/text fallback leaf)
├── nav/ — base.nav (nav bar that collapses to a dropdown or overlay, icons.ts/navCss.ts/navRuntimeJs.ts leaves)
├── nav-item/ — base.nav-item (nestable menu entries, linked or submenu-toggle labels, content.ts/navItemCss.ts/navItemRuntimeJs.ts leaves)
├── image/ — base.image
├── svg/ — base.svg (inline sanitized SVG)
├── video/ — base.video (youtube.ts — ID parse + embed URL leaf)
Expand Down Expand Up @@ -136,6 +138,9 @@ interface ModuleDefinition<TProps extends Record<string, unknown>> {
/** React component for the editor canvas live preview. */
component: React.ComponentType<ModuleComponentProps<TProps>>

/** Optional stylesheet for `component`. Trusted modules only. See "Canvas stylesheet". */
canvasCss?: string

/**
* Optional dependency-backed editor runtime. When present, the canvas renders
* this module in a sandboxed iframe with an import map built from the module's
Expand All @@ -157,6 +162,10 @@ interface ModuleDefinition<TProps extends Record<string, unknown>> {

Constraint #179: **`render()` is pure** — no DOM, no React, no side effects. Inputs in, strings out.

`render()` may also return `instanceCss`. This is CSS that depends on that instance's props, which `css` cannot carry because only the first instance's `css` is kept per moduleId. The publisher deduplicates `instanceCss` by content, so instances producing the same string share one block. Keep it to the prop-dependent rules. Example: `base.nav` emits the media query for its author-chosen collapse width this way, for itself and the Menu Items nested inside it.

Plugin modules cannot return `instanceCss`. The plugin module adapter (`src/core/plugins/moduleAdapter.ts`) forwards only `html`, `css`, and `js` from a plugin's `render()`, so any other field is dropped. A plugin module that needs per-instance styling writes the value into its `html` (a `style` attribute or a custom property) and keeps the rules that read it in `css`.

`render()` may also return `js` next to `html`/`css` — an optional vanilla-JS runtime for the module TYPE, deduplicated per moduleId (like CSS) and served as an external file at `/_instatic/module-js/<moduleId>.js` on published pages. Authoring contract: a self-contained IIFE; bind via document-level event delegation (hole fragments insert into the DOM after load); idempotent; no load-order assumptions; no framework runtimes. Size discipline in the spirit of the ~1 KB hole runtime — the ~8 KB form runtime is the ceiling, not the norm. Module JS never executes in the admin canvas: the canvas renders React editor components, never published render() output.

---
Expand Down Expand Up @@ -198,6 +207,12 @@ schema: PropertySchema = {
| `svg` | Inline SVG editor | SVG markup string |
| `group` | Collapsible section header (visual grouping only) | — (no data shape change) |

A `number` control may carry `presets` (an array of numbers). They render as one-click buttons under the field, which still accepts any number. Example: `base.nav`'s "Collapse below" width.

A `select` option may carry `iconSvg` (inline SVG markup). When any option has one, the control renders as an icon button row (`<SegmentedControl>`) with each option's `label` as its tooltip, instead of a dropdown. The markup is passed through `sanitizeSvg` before rendering. Example: `base.nav`'s "Menu icon" picker.

A `url` control has two modes: **URL** (free text) and **Page** (pick a CMS page, stored as a `cms:page:<id>` reference that survives slug renames). In URL mode the field accepts `https:`, `http:`, `mailto:`, `tel:`, and `sms:` URLs, and site-relative links: a root path (`/about`), a fragment (`#pricing`), a query (`?page=2`), or a dot path (`./a`, `../a`). It rejects every other scheme, a bare word such as `about` or `example.com`, and protocol-relative `//host`. The rule is `isValidUrl` in `src/core/utils/urlValidation.ts`. While the text is not yet valid the field keeps what is being typed and shows "Invalid URL"; the prop is written once the text is valid, and leaving the field drops an unfinished entry.

`PropertyControl` is a discriminated union — `propertySchema.ts` has the full schema.

### Conditional controls
Expand Down Expand Up @@ -324,9 +339,28 @@ interface ModuleComponentProps<TProps> {
* undefined outside the editor (publisher, plugin sandbox preview).
*/
nodeWrapperProps?: NodeWrapperProps
/** Set only while this node is being inline-edited in this frame. See canvas-iframe-per-frame.md → "Inline text editing". */
inlineEdit?: InlineEditBinding
/**
* Pixel width of the breakpoint frame this component renders in (375 in a
* mobile frame). undefined outside the editor, for sandboxed plugin modules,
* and in agent snapshot rendering.
*/
canvasViewportWidth?: number
}
```

`canvasViewportWidth` exists because a media query in the canvas matches the iframe, and module CSS and JS from `render()` never run there. A component that changes layout at a width compares the prop against that width itself. Treat `undefined` as "render the default layout". Example: `NavEditor` marks the nav collapsed when `canvasViewportWidth <= collapseBreakpoint`.

### Canvas stylesheet (`canvasCss`)

The canvas never loads `render().css`. Most editor components do not need it: they render the same elements and are styled by the author's classes. A component whose preview needs real rules (an ancestor's state, `:has()`, hover) declares them in `canvasCss` on the module definition.

- **Where it lands.** `ClassStyleInjector` collects `canvasCss` from every trusted module (`collectModuleCanvasCss`) and puts it in each frame's `mc-classes` sheet, inside `@layer user-authored`: after the publisher reset and framework CSS, before class registry CSS and user stylesheets. That is the position `publishPage()` gives module CSS, so an author's class or a global `a { … }` rule wins or loses against the module exactly as it does when published.
- **Write the published selectors.** Use the same class names and specificity as `render().css`. Do not style the preview with inline `style` props: an inline style beats every author class in the canvas and none when published. A dynamic custom property that `render()` also emits is fine.
- **Share the rules.** Build both sheets from one function when the canvas differs only in what triggers a rule. `base.nav` and `base.nav-item` generate their collapse rules from a `scope` selector and an `open` selector (`nav/navCss.ts`, `nav-item/navItemCss.ts`). Published, those are the `[data-instatic-nav-collapse="<width>"]` attribute inside a media query and the state attribute module JS sets. In the canvas they are `[data-instatic-nav-canvas="collapsed"]`, which `NavEditor` stamps from `canvasViewportWidth`, and "this node or a node inside it is selected" (`shared/navCollapse.ts`).
- **Trusted modules only.** A plugin module's component renders in a sandbox frame this stylesheet never reaches, and the string is injected unsanitised, so `canvasCss` on an untrusted module is ignored.

**Spreading `nodeWrapperProps` is mandatory.** Every module editor component must spread `nodeWrapperProps` onto its root JSX element and apply `mcClassName` as the class. Without this the node is invisible to the editor's interaction layer (selection, hover, keyboard). Example:

```tsx
Expand Down Expand Up @@ -458,6 +492,47 @@ Modules don't need to know — they just receive resolved props.

At render time, `resolveDynamicProps(...)` substitutes the bound value. Used inside loops (the entry stack supplies `currentEntry`) and inside entry templates (the published row supplies `currentEntry`).

### Build a navigation menu

A menu is one `base.nav` (Header Navigation) with `base.nav-item` (Menu Item) nodes inside it. Nest Menu Items inside a Menu Item to make a submenu, to any depth. The page tree is the menu structure.

`base.nav` props:

| Prop | Panel label | Effect |
|----------------------|-------------------|--------|
| `ariaLabel` | Accessible name | `aria-label` on the `<nav>`. Omitted when empty. |
| `toggleLabel` | Menu button label | Screen-reader-only text for the collapsed menu button. |
| `collapseBreakpoint` | Collapse below | Viewport width in px at or below which the menu button replaces the row. `0` never collapses. |
| `collapsedStyle` | Collapsed menu | `dropdown` (panel under the nav) or `overlay` (covers the screen). |
| `menuBackground` | Menu background | Background of the collapsed menu and of submenus. Empty uses the `Canvas` system colour: the browser's page background for the active colour scheme, not the site's body colour. Set it on a site with a dark background. |
| `toggleIcon` | Menu icon | Glyph on the menu button. |
| `toggleIconColor` | Menu icon color | Empty inherits the surrounding text colour. |
| `menuAlign` | Menu alignment | Where the items sit in the bar. Collapsed, where the menu button sits. |

`base.nav-item` props:

| Prop | Panel label | Effect |
|----------|-------------|--------|
| `label` | Label | The item text. |
| `isLink` | Enable link | On by default. Off makes the item a static label and hides Link and Target. The stored `href` is kept for turning it back on. |
| `href` | Link | A `url` control: a URL or a CMS page. |
| `target` | Target | Same tab or new tab. |

What `isLink` renders:

| `isLink` | Has a submenu | Label element | Submenu toggle |
|----------|---------------|---------------|----------------|
| on | no | `<a>` | none |
| on | yes | `<a>` | a separate caret `<button>` beside the label |
| off | yes | `<button>` | the label itself: clicking it opens the submenu |
| off | no | `<span>` | none |

Turn `isLink` off for a parent item that only groups a submenu, so that a tap on the label opens the submenu and does not navigate.

A submenu opens on hover or keyboard focus at desktop widths, and from its toggle at every width. Escape or a click outside an open item closes it.

Every class the two modules emit starts with `instatic-nav`, so author CSS can target them without colliding with site classes.

---

## Forbidden patterns
Expand Down
2 changes: 2 additions & 0 deletions docs/features/plugin-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,8 @@ See [docs/features/publisher.md](publisher.md) for the full three-layer pipeline

### Module JS on published pages — requires `frontend.assets`

A plugin module's `render()` returns `html`, `css`, and optionally `js`. Nothing else crosses: `moduleAdapter.ts` drops `instanceCss`, and a plugin module has no `canvasCss`, because both are first-party module features that bypass sanitisation or the sandbox frame.

A plugin module's `render()` may return `js` (see `PluginRenderOutput`). It crosses the QuickJS boundary string-typed (non-strings are dropped by the VM normalizer) and is then gated host-side in `moduleAdapter.ts`: unless the plugin's **granted** permissions include `frontend.assets` — the same authority that already controls script tags via `frontend.assets[]` — the `js` is dropped with one `console.warn` per module. Enforcement always checks `grantedPermissions`, never the declared `permissions` array. With the grant, the JS is deduped per moduleId and served at `/_instatic/module-js/<moduleId>.js` on pages that use the module. Manifest format is unchanged.

### Frontend assets — requires `frontend.assets`
Expand Down
10 changes: 9 additions & 1 deletion docs/reference/module-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ That's the whole feature loop: module picker, Properties Panel, publisher, CSS d

```ts
render: (props: TProps, renderedChildren: string[]) => RenderOutput
// RenderOutput = { html: string; css?: string }
// RenderOutput = { html: string; css?: string; instanceCss?: string; js?: string }
```

### `props` is trusted (after escaping)
Expand Down Expand Up @@ -150,6 +150,12 @@ The `schema` field maps prop keys to `PropertyControl` descriptors. Full union i
| `svg` | Inline SVG editor | SVG markup string |
| `group` | Collapsible section (visual grouping, no data) | — (children record) |

A `number` control may carry `presets` (an array of numbers). They render as one-click buttons under the field, which still accepts any number. Example: `base.nav`'s "Collapse below" width.

A `select` option may carry `iconSvg` (inline SVG markup). When any option has one, the control renders as an icon button row (`<SegmentedControl>`) with each option's `label` as its tooltip, instead of a dropdown. The markup is passed through `sanitizeSvg` before rendering. Example: `base.nav`'s "Menu icon" picker.

A `url` control accepts `https:`, `http:`, `mailto:`, `tel:`, and `sms:` URLs and site-relative links (`/about`, `#pricing`, `?page=2`, `./a`, `../a`). Bare words and protocol-relative `//host` are rejected. This input rule (`isValidUrl`) is narrower than the publisher's `isSafeUrl`, which also lets through any other schemeless value. Detail: [docs/features/modules.md](../features/modules.md) → "Control types".

### Conditional controls

```ts
Expand Down Expand Up @@ -241,6 +247,8 @@ export const HeadingEditor: React.FC<ModuleComponentProps<HeadingProps>> = ({
- Apply `mcClassName` as the root element's class. Without it the author's CSS class rules don't apply in the canvas.
- Produce the same DOM structure as `render()` — canvas selection geometry, drop-target detection, and dimension measurement assume parity.
- `nodeWrapperProps` is `undefined` outside the editor (publisher, plugin preview). Components that check for its presence can render a plain published element when it's absent.
- `canvasViewportWidth` is the pixel width of the breakpoint frame the component renders in. Use it when the layout changes at a width, because a media query in the canvas matches the iframe and module CSS never loads there. It is `undefined` for sandboxed plugin modules and agent snapshot rendering.
- Do not style the preview with inline `style` props. They beat every author class in the canvas and none when published. Put the rules in the module's `canvasCss`, which the canvas injects where the publisher puts module CSS. Trusted modules only. See [docs/features/modules.md](../features/modules.md) → "Canvas stylesheet".
- **`*Editor.tsx` must only export React components.** Exporting utilities, types, or constants from the editor file breaks React Fast Refresh HMR — Vite can only hot-patch a component file when every export is a component.

### Sharing logic between `index.ts` and `*Editor.tsx`
Expand Down
Loading