Skip to content

Commit f4f4fbf

Browse files
serpentbladeclaude
andcommitted
docs(features): document the :root engine-DOM escape hatch (nested selectors) + ROZ128
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e4091e9 commit f4f4fbf

1 file changed

Lines changed: 62 additions & 1 deletion

File tree

docs/guide/features.md

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -996,7 +996,12 @@ A `<slot name="X">` whose `X` matches a declared `<props>` key is a compile erro
996996

997997
## `:root { }` — the global escape hatch in scoped styles
998998

999-
`<style>` is scoped by default. Anything inside a `:root { }` selector is emitted globally — useful for CSS variables, font definitions, or anything else that legitimately belongs on the document:
999+
`<style>` is scoped by default. The `:root { }` selector is the escape hatch, and it carries **two distinct capabilities** depending on what you put inside it:
1000+
1001+
1. **Flat custom-property declarations** (`:root { --var: … }`) → emitted globally as a top-level `:root` rule — for CSS variables, font definitions, or anything else that legitimately belongs on the document.
1002+
2. **Nested selector rules** (`:root { .selector { … } }`) → the inner rules are emitted **bare/unscoped** (without Rozie's `[data-rozie-s-*]` scope attribute) so they can reach **engine-rendered runtime DOM** — the **engine-DOM escape hatch** (Phase 34).
1003+
1004+
### Flat custom properties — the global document layer
10001005

10011006
```rozie
10021007
<style>
@@ -1016,6 +1021,62 @@ A `<slot name="X">` whose `X` matches a declared `<props>` key is a compile erro
10161021

10171022
Each target picks the right escape hatch: Vue gets a sibling unscoped `<style>` block, Svelte gets `:global(:root)`, Angular gets `::ng-deep :root`, React/Solid get a separate `.global.css` file imported next to the module CSS, and Lit — whose `static styles` are shadow-DOM-scoped by default — gets the `:root` rules injected into the document via an `injectGlobalStyles` runtime call.
10181023

1024+
### Nested selectors — the engine-DOM escape hatch
1025+
1026+
When you wrap a **selector rule** inside `:root { }` (rather than a flat custom property), Rozie emits that inner rule **bare and unscoped** — it does *not* get the component's `[data-rozie-s-<hash>]` scope attribute. This is the mechanism a wrapped vanilla-JS engine component needs to style the DOM the engine creates **at runtime**.
1027+
1028+
The problem it solves: when Rozie wraps an engine like CodeMirror, ProseMirror/TipTap, or flatpickr, that engine renders its own DOM nodes (`.cm-editor`/`.cm-scroller`, TipTap's `is-editor-empty` placeholder node, flatpickr's body-appended calendar). Those nodes are created by the engine *after* mount and **never carry Rozie's scope attribute** — so an ordinary scoped rule like `.cm-editor { … }` silently fails to match them on React/Solid/Lit (and is shadow-DOM-isolated on Lit). The nested-`:root` form lifts the rule out of scoping so it reaches engine DOM on **all six targets**, including through Lit's shadow boundary:
1029+
1030+
```rozie
1031+
<style>
1032+
/* Scoped to this component's own template elements. */
1033+
.editor-shell { border: 1px solid #d1d5db; border-radius: 8px; }
1034+
1035+
/* Engine-DOM escape hatch — these reach CodeMirror's runtime nodes,
1036+
which never carry Rozie's [data-rozie-s-*] scope attribute. */
1037+
:root {
1038+
.cm-editor { height: 100%; }
1039+
.cm-scroller { font-family: ui-monospace, monospace; }
1040+
}
1041+
</style>
1042+
```
1043+
1044+
A real example from the TipTap wrapper styles the Placeholder extension's ghost text — the `is-editor-empty` node ProseMirror injects into an empty document:
1045+
1046+
```rozie
1047+
<style>
1048+
:root {
1049+
.ProseMirror .is-editor-empty:first-child::before {
1050+
content: attr(data-placeholder);
1051+
color: #9ca3af;
1052+
pointer-events: none;
1053+
height: 0;
1054+
float: left;
1055+
}
1056+
}
1057+
</style>
1058+
```
1059+
1060+
Per-target emission of the nested rules mirrors the flat case but for selector rules rather than custom properties: React emits a `.global.css` sidecar, Vue an unscoped second `<style>` block, Svelte a `:global { … }` wrapper, Angular bare `::ng-deep`, Solid a `__rozieInjectStyle` head-inject, and Lit a **dual-sink** — the rules land in both `static styles` (for the shadow root) and `injectGlobalStyles` (for engine DOM that escapes the shadow boundary, e.g. a body-appended calendar).
1061+
1062+
This injection is intentionally **page-wide** — the rules go in as authored, with no anchoring or containment enforcement. If you want containment, scope the inner selectors under a wrapper class yourself (e.g. `:root { .my-editor .cm-editor { … } }`).
1063+
1064+
### `:global()` is forbidden (ROZ128)
1065+
1066+
You might reach for `:global(.cm-editor)` out of Vue/Svelte habit. **Don't** — it's a hard compile error (**ROZ128**). The `:global()` pseudo works natively *only* on Vue and Svelte (whose compilers understand it); on React, Solid, and Lit the browser sees an unknown pseudo and silently discards the entire rule. Rather than ship a selector that works on two of six targets and dies invisibly on three, Rozie blocks `:global()` in `<style>` selectors loudly and points you at the `:root { … }` engine-DOM escape hatch, which lowers to the same unscoped output on every target:
1067+
1068+
```rozie
1069+
<style>
1070+
/* ❌ ROZ128 — works on Vue/Svelte, silently dead on React/Solid/Lit. */
1071+
:global(.cm-editor) { height: 100%; }
1072+
1073+
/* ✅ Canonical — bare/unscoped on all six targets. */
1074+
:root {
1075+
.cm-editor { height: 100%; }
1076+
}
1077+
</style>
1078+
```
1079+
10191080
## `:deep()` — reaching into child components from scoped styles
10201081

10211082
`:root` is the global escape hatch; `:deep()` is the **cross-component** one. Because `<style>` is scoped per component, a parent's selector like `.board > .rozie-sortable-list` can never match the child SortableList's rendered DOM — every component has its own scope attribute and the parent's selector goes looking for the parent's marker on the child's elements. `:deep(...)` lifts the inner selector out of the scope so it reaches the child's DOM directly:

0 commit comments

Comments
 (0)