rewritable 0.3.0 → 0.5.0

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/src/skin.mjs ADDED
@@ -0,0 +1,350 @@
1
+ // `rwa skin <file> NAME` — the deterministic, model-free theme-swap command.
2
+ // It applies a preset's <style data-rwa-skin> block to an existing rewritable
3
+ // through the canonical applyPlan write path, so it inherits atomic write,
4
+ // frozen-zone safety, and the file-error surface (not_found/read_error/
5
+ // not_a_rewritable, exit 2) identically to `rwa edit` / `rwa doc`.
6
+ //
7
+ // Envelope choice is forced by the structural-shape guard (apply_edits rejects a
8
+ // change to the <style>/<script> count):
9
+ // - block ABSENT → INSERT: adding a <style> changes the count, so route the
10
+ // first skin through replace_document (the shape-exempt path),
11
+ // block prepended as the leading child of INLINE_DOC.
12
+ // - block PRESENT → SWAP: rewrite the one block's bytes; <style> count is
13
+ // unchanged, so a surgical apply_edits (find=old, replace=new).
14
+ // - reset → remove the one block (count drops) via replace_document.
15
+ // Every write lands as ONE commit tagged actor `skin:NAME` / `skin:reset`.
16
+ // This is v1 (theme-only); the always-on content-aware L1 restyle is a later
17
+ // phase (see docs/plans/2026-06-03-skinning-design.md).
18
+
19
+ import { readFile } from 'node:fs/promises';
20
+ import { extractInlineDoc } from './seed.mjs';
21
+ import { applyPlan, CliError } from './edit.mjs';
22
+ import { skinByName, RWA_SKIN_RECIPES } from './skins.mjs';
23
+ import { applyEdits, RwaEditError, findFrozenZones } from './apply-edits.mjs';
24
+ import { compileDslPlan } from './dsl-compiler.mjs';
25
+
26
+ // The single skin block (any data-rwa-skin value). CSS cannot contain a literal
27
+ // </style>, so the non-greedy match is exact; the `data-rwa-skin=` requirement
28
+ // means an author's own <style> is never matched. These mirror the seed's
29
+ // RWA_SKIN_BLOCK_RE / RWA_SKIN_BLOCK_TRAIL_RE (seeds/rewritable.html ~:2729).
30
+ const SKIN_BLOCK_RE = /<style\b[^>]*\bdata-rwa-skin=["'][^"']*["'][^>]*>[\s\S]*?<\/style>/i;
31
+ // Same, plus an optional trailing newline, so reset removes the block cleanly.
32
+ const SKIN_BLOCK_TRAIL_RE = /<style\b[^>]*\bdata-rwa-skin=["'][^"']*["'][^>]*>[\s\S]*?<\/style>\n?/i;
33
+
34
+ // ── deskin + splice (MANUAL MIRROR of seeds/rewritable.html ~:2731–2851) ──────
35
+ // spliceSkinBlock / deskinDoc (+ its parser-free helpers) are copied
36
+ // BYTE-IDENTICAL from the seed (the canonical site for the browser ✦ gallery's
37
+ // L1). No cmp gate; the pin test cli/tests/skin-l1-seed-mirror.test.mjs extracts
38
+ // the function bodies from the seed and compares them against these so drift
39
+ // fails the suite loudly. When the seed changes, re-copy here. The seed names
40
+ // the two regexes RWA_SKIN_BLOCK_RE / RWA_SKIN_BLOCK_TRAIL_RE; the CLI declares
41
+ // them above without the RWA_ prefix — the mirror test normalizes that prefix.
42
+
43
+ // spliceSkinBlock — swap an existing <style data-rwa-skin> block for `theme`, or
44
+ // prepend it. Splice-based (not String.replace) so theme bytes land verbatim
45
+ // (a $-sequence in CSS would otherwise be mangled). Shared by the L1 compose
46
+ // transform in applySkinL1; mirrors applySkin's deterministic L0 swap/insert.
47
+ function spliceSkinBlock(doc, theme) {
48
+ const m = doc.match(SKIN_BLOCK_RE);
49
+ if (!m) return theme + '\n' + doc;
50
+ const i = doc.indexOf(m[0]);
51
+ return doc.slice(0, i) + theme + doc.slice(i + m[0].length);
52
+ }
53
+ // deskinDoc — deterministically strip a PRIOR skin so a re-skin/reset starts clean,
54
+ // regardless of model compliance (closes the v2 best-effort de-skin limitation):
55
+ // 1. remove the <style data-rwa-skin> block; 2. balanced-tag UNWRAP pure sk-*
56
+ // div/span wrappers (keep inner — handles nesting like sk-stat-row > sk-stat);
57
+ // 3. strip sk-* tokens from mixed class attrs. Parser-free, idempotent, byte-exact
58
+ // for non-sk content. applySkinL1 runs the agent on deskinDoc(cur); resetSkin
59
+ // commits deskinDoc(doc). (Stat-tile recipes split text, so their unwrap joins it —
60
+ // a known minor lossiness, far better than orphan-wrapper accumulation.)
61
+ const SK_TOKEN_RE = /^sk-[a-z][a-z0-9-]*$/;
62
+ const isSkToken = (tok) => SK_TOKEN_RE.test(tok);
63
+ function readClassAttr(tagInner) {
64
+ const m = /(^|\s)class\s*=\s*("([^"]*)"|'([^']*)')/i.exec(tagInner);
65
+ if (!m) return null;
66
+ return m[3] !== undefined ? m[3] : m[4];
67
+ }
68
+ function classIsPureSk(classValue) {
69
+ const toks = classValue.trim().split(/\s+/).filter(Boolean);
70
+ return toks.length > 0 && toks.every(isSkToken);
71
+ }
72
+ // quote-aware: a `>` inside a quoted attr value is skipped. Returns index past `>`.
73
+ function skEndOfTag(s, lt) {
74
+ let quote = null;
75
+ for (let i = lt + 1; i < s.length; i++) {
76
+ const c = s[i];
77
+ if (quote) { if (c === quote) quote = null; }
78
+ else if (c === '"' || c === "'") quote = c;
79
+ else if (c === '>') return i + 1;
80
+ }
81
+ return -1;
82
+ }
83
+ function skReadTagName(s, start) {
84
+ let i = start, name = '';
85
+ while (i < s.length) {
86
+ const c = s[i];
87
+ if (/[a-zA-Z0-9]/.test(c) || c === '-' || c === ':') { name += c; i++; } else break;
88
+ }
89
+ return name === '' ? null : name;
90
+ }
91
+ // balanced same-name close, accounting for nested same-name opens.
92
+ function skFindMatchingClose(s, openTagEnd, tag) {
93
+ let depth = 1, i = openTagEnd;
94
+ const tlc = tag.toLowerCase();
95
+ while (i < s.length) {
96
+ const lt = s.indexOf('<', i);
97
+ if (lt === -1) return null;
98
+ const after = s[lt + 1];
99
+ if (after === '/') {
100
+ const name = skReadTagName(s, lt + 2);
101
+ const tagEnd = skEndOfTag(s, lt);
102
+ if (tagEnd === -1) return null;
103
+ if (name !== null && name.toLowerCase() === tlc) { depth--; if (depth === 0) return { closeStart: lt, closeEnd: tagEnd }; }
104
+ i = tagEnd;
105
+ } else if (after === '!') {
106
+ if (s.startsWith('<!--', lt)) { const ce = s.indexOf('-->', lt + 4); i = ce === -1 ? s.length : ce + 3; }
107
+ else { const tagEnd = skEndOfTag(s, lt); i = tagEnd === -1 ? s.length : tagEnd; }
108
+ } else {
109
+ const name = skReadTagName(s, lt + 1);
110
+ const tagEnd = skEndOfTag(s, lt);
111
+ if (tagEnd === -1) return null;
112
+ if (name !== null && name.toLowerCase() === tlc) depth++;
113
+ i = tagEnd;
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+ function unwrapPureSkWrappers(doc) {
119
+ let s = doc, i = 0;
120
+ while (i < s.length) {
121
+ const lt = s.indexOf('<', i);
122
+ if (lt === -1) break;
123
+ const after = s[lt + 1];
124
+ if (after === '/' || after === '!' || after === '?') {
125
+ if (s.startsWith('<!--', lt)) { const ce = s.indexOf('-->', lt + 4); i = ce === -1 ? s.length : ce + 3; }
126
+ else { const te = skEndOfTag(s, lt); i = te === -1 ? s.length : te; }
127
+ continue;
128
+ }
129
+ const name = skReadTagName(s, lt + 1);
130
+ const tagEnd = skEndOfTag(s, lt);
131
+ if (name === null || tagEnd === -1) { i = lt + 1; continue; }
132
+ const lname = name.toLowerCase();
133
+ if (lname === 'div' || lname === 'span') {
134
+ const tagInner = s.slice(lt + 1 + name.length, tagEnd - 1);
135
+ const classValue = readClassAttr(tagInner);
136
+ if (classValue !== null && classIsPureSk(classValue)) {
137
+ const match = skFindMatchingClose(s, tagEnd, lname);
138
+ if (match) {
139
+ s = s.slice(0, lt) + s.slice(tagEnd, match.closeStart) + s.slice(match.closeEnd);
140
+ i = lt; // restart at exposed inner (may begin with a nested wrapper)
141
+ continue;
142
+ }
143
+ }
144
+ }
145
+ i = tagEnd;
146
+ }
147
+ return s;
148
+ }
149
+ function stripSkClassTokens(doc) {
150
+ return doc.replace(/(\s*)class\s*=\s*("([^"]*)"|'([^']*)')/gi, (full, lead, quoted, dq, sq) => {
151
+ const quoteChar = dq !== undefined ? '"' : "'";
152
+ const value = dq !== undefined ? dq : sq;
153
+ const kept = value.split(/\s+/).filter(Boolean).filter((t) => !isSkToken(t));
154
+ return kept.length === 0 ? '' : lead + 'class=' + quoteChar + kept.join(' ') + quoteChar;
155
+ });
156
+ }
157
+ export function deskinDoc(doc) {
158
+ if (typeof doc !== 'string') return doc;
159
+ let s = doc.replace(SKIN_BLOCK_TRAIL_RE, '');
160
+ s = unwrapPureSkWrappers(s);
161
+ s = stripSkClassTokens(s);
162
+ return s;
163
+ }
164
+
165
+ /**
166
+ * Apply a named preset (or `reset`) to a rewritable on disk, deterministically.
167
+ *
168
+ * @param {string} filePath — path to the target .html
169
+ * @param {string} action — a skin name (see cli/src/skins.mjs) or the literal `reset`
170
+ * @returns {Promise<{exitCode:0, mode:'insert'|'swap'|'reset'|'noop', skin:string|null}>}
171
+ * @throws {CliError} exit 2 on file / non-rewritable / unknown-skin errors
172
+ */
173
+ export async function skinCmd(filePath, action) {
174
+ // Read + validate the target identically to edit.mjs/doc.mjs (file errors first).
175
+ let fileText;
176
+ try {
177
+ fileText = await readFile(filePath, 'utf8');
178
+ } catch (e) {
179
+ if (e && e.code === 'ENOENT') throw new CliError(2, 'not_found', { path: filePath });
180
+ throw new CliError(2, 'read_error', { path: filePath, errno: e && e.code, message: e && e.message });
181
+ }
182
+ let currentDoc;
183
+ try {
184
+ currentDoc = extractInlineDoc(fileText);
185
+ } catch (_e) {
186
+ throw new CliError(2, 'not_a_rewritable', { path: filePath });
187
+ }
188
+
189
+ const existing = currentDoc.match(SKIN_BLOCK_RE);
190
+
191
+ if (action === 'reset') {
192
+ // reset now mirrors the seed's resetSkin: deskinDoc clears the theme block
193
+ // AND any sk-* wrappers/classes a prior L1 restyle left, regardless of the
194
+ // --l1 flag. A doc with no theme block and no sk-* hooks is byte-unchanged
195
+ // by deskinDoc → noop (no write), preserving the idempotent reset contract.
196
+ const newDoc = deskinDoc(currentDoc);
197
+ if (newDoc === currentDoc) return { exitCode: 0, mode: 'noop', skin: null };
198
+ await applyPlan(filePath, { version: 'rwa-edit/1', doc: newDoc, reason: 'skin:reset' });
199
+ return { exitCode: 0, mode: 'reset', skin: null };
200
+ }
201
+
202
+ const skin = skinByName(action); // throws exit-2 on unknown name
203
+
204
+ if (existing) {
205
+ await applyPlan(filePath, {
206
+ version: 'rwa-edit/1',
207
+ edits: [{ find: existing[0], replace: skin.theme }],
208
+ });
209
+ return { exitCode: 0, mode: 'swap', skin: skin.name };
210
+ }
211
+
212
+ const newDoc = skin.theme + '\n' + currentDoc;
213
+ await applyPlan(filePath, { version: 'rwa-edit/1', doc: newDoc, reason: `skin:${skin.name}` });
214
+ return { exitCode: 0, mode: 'insert', skin: skin.name };
215
+ }
216
+
217
+ /**
218
+ * Apply a named preset with the opt-in L1 (agent-driven, content-aware) restyle.
219
+ * Mirrors the seed's applySkinL1 (seeds/rewritable.html ~:2908) but adapted to
220
+ * the CLI's no-mid-stream-tool-call agent loop (runAgentLoop returns the
221
+ * envelope WITHOUT writing — that IS the seed's noCommit accumulate seam):
222
+ *
223
+ * 1. deskinDoc(currentDoc) → cleanBase (deterministic, so a re-skin starts
224
+ * clean regardless of model compliance).
225
+ * 2. runAgentLoop(recipe, cleanBase) → an apply_edits / DSL envelope (additive
226
+ * sk-* hooks). Applied IN MEMORY against cleanBase via applyEdits — NOT
227
+ * applyPlan, so nothing is written yet. replace_document is refused (the
228
+ * agent must not rewrite wholesale), mirroring the seed's
229
+ * compose_requires_apply_edits guard.
230
+ * 3. spliceSkinBlock(agentDoc, theme) → finalDoc.
231
+ * 4. ONE applyPlan(filePath, {doc: finalDoc, reason}) — a single replace_document
232
+ * commit (theme + wrappers land together; one undo frame in the browser).
233
+ *
234
+ * Graceful fallback: if the agent declines / produces nothing usable, the theme
235
+ * is still spliced onto cleanBase and committed once (theme-only), so a skin
236
+ * always lands. The ONLY loud failure is a missing/unreachable backend — that is
237
+ * surfaced to the caller (bin maps it to exit 4) rather than silently degrading,
238
+ * matching how `rwa edit`'s instruction path treats a missing backend.
239
+ *
240
+ * @param {string} filePath — path to the target .html
241
+ * @param {string} name — a skin name (see cli/src/skins.mjs); `reset` is not an L1 action
242
+ * @param {object} agentOpts — { systemPrompt, toolSchemas, backend, frozenZoneNames?, onRetry? }
243
+ * — the same shape bin/rwa.mjs builds for `rwa edit`'s instruction path.
244
+ * @returns {Promise<{exitCode:0, mode:'l1'|'theme-only', skin:string, degraded?:boolean}>}
245
+ * @throws {CliError} exit 2 (file / non-rewritable / unknown-skin); exit 4 (backend)
246
+ */
247
+ export async function skinCmdL1(filePath, name, agentOpts) {
248
+ // File + non-rewritable validation, same surface as skinCmd.
249
+ let fileText;
250
+ try {
251
+ fileText = await readFile(filePath, 'utf8');
252
+ } catch (e) {
253
+ if (e && e.code === 'ENOENT') throw new CliError(2, 'not_found', { path: filePath });
254
+ throw new CliError(2, 'read_error', { path: filePath, errno: e && e.code, message: e && e.message });
255
+ }
256
+ let currentDoc;
257
+ try {
258
+ currentDoc = extractInlineDoc(fileText);
259
+ } catch (_e) {
260
+ throw new CliError(2, 'not_a_rewritable', { path: filePath });
261
+ }
262
+
263
+ const skin = skinByName(name); // throws exit-2 on unknown name
264
+ const recipe = RWA_SKIN_RECIPES[name]; // every shipped preset has one
265
+
266
+ // Deterministically de-skin any PRIOR skin first, so re-skin starts clean
267
+ // (the seed runs deskinDoc on the base before driving the agent).
268
+ const cleanBase = deskinDoc(currentDoc);
269
+
270
+ // Drive the agent over cleanBase. runAgentLoop does NOT write — it returns the
271
+ // first valid envelope. Frozen-zone names come from the CURRENT doc so the
272
+ // model sees the same list the apply guard enforces.
273
+ const { runAgentLoop, AgentError } = await import('./agent-loop.mjs');
274
+ let agentDoc = null; // the agent's restyled doc (in memory), or null on decline/failure
275
+ let degraded = false; // true when we fell back to theme-only
276
+ try {
277
+ const { envelope, toolName } = await runAgentLoop({
278
+ systemPrompt: agentOpts.systemPrompt,
279
+ toolSchemas: agentOpts.toolSchemas,
280
+ currentDoc: cleanBase,
281
+ instruction: recipe,
282
+ frozenZoneNames: agentOpts.frozenZoneNames || findFrozenZones(cleanBase).map(z => z.name),
283
+ backend: agentOpts.backend,
284
+ onRetry: agentOpts.onRetry,
285
+ });
286
+ agentDoc = composeAgentDoc(envelope, toolName, cleanBase);
287
+ } catch (e) {
288
+ // Split the two failure classes the way the task spec (and CLI convention)
289
+ // requires:
290
+ // - backend_error (no key handled upstream / unreachable host / HTTP error)
291
+ // → LOUD. Propagate so bin maps it to exit 4. The seed can degrade an
292
+ // unreachable backend to theme-only because it has a bridge fallback; the
293
+ // CLI has none, so "L1 with no usable backend" is a real error, matching
294
+ // how `rwa edit`'s instruction path treats a missing/unreachable backend.
295
+ // - no_envelope_after_retries (backend WAS reached but the model never
296
+ // produced a usable envelope — declined / invalid JSON every turn) → the
297
+ // "agent declines / produces nothing usable" case: GRACEFUL theme-only,
298
+ // so the skin still lands. Mirrors the seed's model_declined degrade.
299
+ if (e && e.subcode === 'backend_error') {
300
+ throw new CliError(4, e.subcode, e.details);
301
+ }
302
+ if (e && e.subcode === 'no_envelope_after_retries') { agentDoc = null; degraded = true; }
303
+ else if (e instanceof CliError) throw e;
304
+ // A compose-stage RwaEditError (agent edits invalid against cleanBase) is a
305
+ // graceful theme-only fallback — the skin still lands. Mirrors the seed,
306
+ // where an invalid agent edit degrades to a theme-only commit.
307
+ else if (e instanceof RwaEditError) { agentDoc = null; degraded = true; }
308
+ else throw e;
309
+ }
310
+ if (agentDoc === null) degraded = true;
311
+
312
+ // compose-then-commit: splice the deterministic theme block onto the agent's
313
+ // output (or onto cleanBase when degraded) and commit ONCE. replace_document
314
+ // is shape-exempt so the runtime-added <style> is allowed (the agent's
315
+ // in-memory apply_edits could not add one — structuralShape blocked it).
316
+ const finalDoc = spliceSkinBlock(agentDoc !== null ? agentDoc : cleanBase, skin.theme);
317
+ await applyPlan(filePath, {
318
+ version: 'rwa-edit/1',
319
+ doc: finalDoc,
320
+ reason: `skin:${skin.name} (theme+L1)`,
321
+ });
322
+ return { exitCode: 0, mode: degraded ? 'theme-only' : 'l1', skin: skin.name, degraded };
323
+ }
324
+
325
+ // composeAgentDoc — apply the agent's envelope IN MEMORY against cleanBase
326
+ // (no write). Mirrors the seed's compose-mode dispatch in modify(): apply_edits
327
+ // and the DSL's apply_edits compile path are allowed; replace_document (raw or a
328
+ // DSL escape op) is refused with compose_requires_apply_edits so the agent can't
329
+ // rewrite wholesale and bypass the additive / structural-shape guard. Returns
330
+ // the restyled doc string. Throws RwaEditError on an invalid edit (caught by the
331
+ // caller → graceful theme-only fallback) or compose_requires_apply_edits.
332
+ function composeAgentDoc(envelope, toolName, cleanBase) {
333
+ if (toolName === 'apply_dsl_plan') {
334
+ let compiled;
335
+ try {
336
+ compiled = compileDslPlan(envelope, cleanBase);
337
+ } catch (e) {
338
+ throw new RwaEditError(e.code || 'dsl_compile_error', null, { message: e.message });
339
+ }
340
+ if (compiled.tool === 'replace_document') {
341
+ throw new RwaEditError('compose_requires_apply_edits');
342
+ }
343
+ return applyEdits(cleanBase, compiled.envelope.edits);
344
+ }
345
+ if (toolName === 'apply_edits') {
346
+ return applyEdits(cleanBase, envelope.edits);
347
+ }
348
+ // replace_document (or any other tool) — refused in compose mode.
349
+ throw new RwaEditError('compose_requires_apply_edits');
350
+ }
package/src/skins.mjs ADDED
@@ -0,0 +1,274 @@
1
+ // The canonical preset library — the SINGLE SOURCE the CLI reads skins from
2
+ // (and, later, the runtime gallery + service `/import` mirror, pinned by test).
3
+ // Zero-dep, pure data, so the in-package CLI reads it offline and a mirror can
4
+ // embed just the bytes. See docs/plans/2026-06-03-skinning-design.md.
5
+ //
6
+ // A skin's `theme` is ONE self-contained `<style data-rwa-skin="NAME">` block:
7
+ // CSS-variable + element rules scoped to `#rwa-doc-mount` (NOT `:root`, so the
8
+ // runtime chrome keeps the frozen light palette — a dark skin re-tints only the
9
+ // document). System fonts only; no web fonts, no external refs (self-contained).
10
+ //
11
+ // v1 is deterministic THEME-ONLY: the blocks below carry no `sk-*` L1 hook
12
+ // rules — those land with the always-on content-aware restyle (v2), when the
13
+ // markup pass actually attaches the hook classes. Keeping v1 hook-free avoids
14
+ // shipping dead CSS. Values mirror docs/plans/2026-06-03-skinning-design.md
15
+ // (Preset Library), re-scoped from the illustrative `.rwa-skin-NAME` form to the
16
+ // canonical `#rwa-doc-mount` model.
17
+
18
+ export const SKINS = Object.freeze({
19
+ 'notion-clean': {
20
+ name: 'notion-clean',
21
+ label: 'Notion Clean',
22
+ swatch: ['#ffffff', '#37352f', '#2383e2'],
23
+ theme: `<style data-rwa-skin="notion-clean">
24
+ #rwa-doc-mount{
25
+ --nc-ink:#37352f; --nc-soft:#6b6b6b; --nc-faint:#9b9a97; --nc-line:#ededec;
26
+ --nc-bg:#ffffff; --nc-tint:#f7f6f3; --nc-accent:#2383e2;
27
+ font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
28
+ color:var(--nc-ink); line-height:1.7;
29
+ }
30
+ #rwa-doc-mount article{max-width:740px;margin:72px auto;padding:0 40px;}
31
+ #rwa-doc-mount h1{font-size:2.5rem;font-weight:700;letter-spacing:-.02em;line-height:1.15;margin:1.4em 0 .3em;color:var(--nc-ink);}
32
+ #rwa-doc-mount h2{font-size:1.5rem;font-weight:600;letter-spacing:-.01em;margin:2em 0 .3em;color:var(--nc-ink);}
33
+ #rwa-doc-mount h3{font-size:1.2rem;font-weight:600;margin:1.6em 0 .25em;color:var(--nc-ink);}
34
+ #rwa-doc-mount h4,#rwa-doc-mount h5,#rwa-doc-mount h6{font-weight:600;color:var(--nc-soft);margin:1.3em 0 .2em;}
35
+ #rwa-doc-mount p{font-size:1rem;color:var(--nc-ink);margin:0 0 .9em;}
36
+ #rwa-doc-mount a{color:var(--nc-ink);text-decoration:underline;text-decoration-color:var(--nc-faint);text-underline-offset:3px;}
37
+ #rwa-doc-mount a:hover{text-decoration-color:var(--nc-accent);color:var(--nc-accent);}
38
+ #rwa-doc-mount ul,#rwa-doc-mount ol{margin:0 0 .9em;padding-left:1.6em;color:var(--nc-ink);}
39
+ #rwa-doc-mount li{margin:.25em 0;}
40
+ #rwa-doc-mount blockquote{margin:1.2em 0;padding:.2em 0 .2em 1em;border-left:3px solid var(--nc-ink);color:var(--nc-soft);font-style:normal;}
41
+ #rwa-doc-mount hr{border:0;border-top:1px solid var(--nc-line);margin:2.4em 0;}
42
+ #rwa-doc-mount code{font-family:'SF Mono',Menlo,Monaco,ui-monospace,monospace;font-size:.85em;background:var(--nc-tint);color:#eb5757;padding:.15em .4em;border-radius:4px;}
43
+ #rwa-doc-mount pre{background:var(--nc-tint);border:1px solid var(--nc-line);border-radius:8px;padding:16px 18px;}
44
+ #rwa-doc-mount table{font-size:.95em;}
45
+ #rwa-doc-mount th,#rwa-doc-mount td{border-bottom:1px solid var(--nc-line);padding:.55em .8em;}
46
+ #rwa-doc-mount th{background:var(--nc-tint);color:var(--nc-soft);font-weight:600;}
47
+ #rwa-doc-mount .sk-eyebrow{font-size:.8rem;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--nc-accent);margin:0 0 .4em;}
48
+ #rwa-doc-mount .sk-callout{margin:1.4em 0;padding:14px 18px;background:var(--nc-tint);border:1px solid var(--nc-line);border-radius:8px;color:var(--nc-soft);}
49
+ </style>`,
50
+ },
51
+
52
+ 'linear-dark': {
53
+ name: 'linear-dark',
54
+ label: 'Linear Dark',
55
+ swatch: ['#08090d', '#e9eaee', '#7c6cff'],
56
+ theme: `<style data-rwa-skin="linear-dark">
57
+ #rwa-doc-mount{
58
+ --ld-bg:#08090d; --ld-surf:#101117; --ld-line:#23252f;
59
+ --ld-ink:#e9eaee; --ld-soft:#a0a3ad; --ld-faint:#6a6d78;
60
+ --ld-accent:#7c6cff; --ld-accent-2:#5e6ad2;
61
+ background:var(--ld-bg); color:var(--ld-ink);
62
+ font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
63
+ line-height:1.65;
64
+ }
65
+ #rwa-doc-mount article{max-width:720px;margin:56px auto;padding:0 36px;}
66
+ #rwa-doc-mount h1{font-size:2.2rem;font-weight:600;letter-spacing:-.025em;line-height:1.15;color:#fff;margin:1.3em 0 .35em;}
67
+ #rwa-doc-mount h2{font-size:1.4rem;font-weight:600;letter-spacing:-.01em;color:var(--ld-ink);margin:2em 0 .35em;}
68
+ #rwa-doc-mount h3{font-size:1.12rem;font-weight:600;color:var(--ld-soft);margin:1.6em 0 .3em;}
69
+ #rwa-doc-mount h4,#rwa-doc-mount h5,#rwa-doc-mount h6{font-family:'SF Mono',Menlo,monospace;font-size:.78rem;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--ld-faint);margin:1.4em 0 .3em;}
70
+ #rwa-doc-mount p{color:var(--ld-soft);margin:0 0 1em;}
71
+ #rwa-doc-mount a{color:var(--ld-accent);text-decoration:none;}
72
+ #rwa-doc-mount a:hover{color:#9b8eff;text-decoration:underline;text-underline-offset:3px;}
73
+ #rwa-doc-mount ul,#rwa-doc-mount ol{color:var(--ld-soft);padding-left:1.5em;margin:0 0 1em;}
74
+ #rwa-doc-mount li{margin:.3em 0;}
75
+ #rwa-doc-mount li::marker{color:var(--ld-faint);}
76
+ #rwa-doc-mount blockquote{margin:1.2em 0;padding:.4em 0 .4em 1em;border-left:2px solid var(--ld-accent);color:var(--ld-soft);font-style:normal;}
77
+ #rwa-doc-mount hr{border:0;border-top:1px solid var(--ld-line);margin:2.2em 0;}
78
+ #rwa-doc-mount code{font-family:'SF Mono',Menlo,monospace;font-size:.85em;background:var(--ld-surf);color:#c9c4ff;border:1px solid var(--ld-line);padding:.12em .4em;border-radius:5px;}
79
+ #rwa-doc-mount pre{background:var(--ld-surf);border:1px solid var(--ld-line);border-radius:10px;padding:16px 18px;color:var(--ld-ink);}
80
+ #rwa-doc-mount pre code{background:transparent;border:0;color:inherit;}
81
+ #rwa-doc-mount table{font-size:.92em;}
82
+ #rwa-doc-mount th{background:var(--ld-surf);color:var(--ld-soft);font-weight:600;border-bottom:1px solid var(--ld-line);padding:.55em .8em;}
83
+ #rwa-doc-mount td{border-bottom:1px solid var(--ld-line);padding:.55em .8em;color:var(--ld-soft);}
84
+ #rwa-doc-mount .sk-eyebrow{font-family:'SF Mono',Menlo,monospace;font-size:.72rem;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--ld-accent);margin:0 0 .6em;}
85
+ #rwa-doc-mount .sk-stat-row{display:flex;flex-wrap:wrap;gap:14px;margin:1.6em 0;}
86
+ #rwa-doc-mount .sk-stat{display:flex;flex-direction:column;gap:2px;padding:12px 16px;background:var(--ld-surf);border:1px solid var(--ld-line);border-radius:10px;}
87
+ #rwa-doc-mount .sk-stat b{font-size:1.45rem;font-weight:600;color:#fff;}
88
+ #rwa-doc-mount .sk-stat span{font-family:'SF Mono',Menlo,monospace;font-size:.68rem;letter-spacing:.08em;text-transform:uppercase;color:var(--ld-faint);}
89
+ </style>`,
90
+ },
91
+
92
+ 'editorial-serif': {
93
+ name: 'editorial-serif',
94
+ label: 'Editorial Serif',
95
+ swatch: ['#fbfaf7', '#1c1a17', '#9a1b1b'],
96
+ theme: `<style data-rwa-skin="editorial-serif">
97
+ #rwa-doc-mount{
98
+ --es-paper:#fbfaf7; --es-ink:#1c1a17; --es-soft:#4a463f; --es-faint:#857f74;
99
+ --es-line:#e0dbd0; --es-accent:#9a1b1b; --es-rule:#c8c0b2;
100
+ background:var(--es-paper);
101
+ font-family:Georgia,Cambria,'Times New Roman',Times,serif;
102
+ color:var(--es-ink); line-height:1.7;
103
+ }
104
+ #rwa-doc-mount article{max-width:680px;margin:80px auto;padding:0 36px;background:var(--es-paper);}
105
+ #rwa-doc-mount h1{font-size:3rem;font-weight:700;line-height:1.08;letter-spacing:-.01em;color:var(--es-ink);margin:.3em 0 .35em;}
106
+ #rwa-doc-mount h2{font-size:1.7rem;font-weight:700;color:var(--es-ink);margin:2em 0 .4em;line-height:1.2;}
107
+ #rwa-doc-mount h3{font-size:1.3rem;font-weight:700;font-style:italic;color:var(--es-soft);margin:1.6em 0 .3em;}
108
+ #rwa-doc-mount p{font-size:1.12rem;color:var(--es-ink);margin:0 0 1.1em;hyphens:auto;}
109
+ #rwa-doc-mount a{color:var(--es-accent);text-decoration:underline;text-underline-offset:2px;text-decoration-thickness:1px;}
110
+ #rwa-doc-mount a:hover{text-decoration-thickness:2px;}
111
+ #rwa-doc-mount ul,#rwa-doc-mount ol{color:var(--es-ink);padding-left:1.4em;margin:0 0 1.1em;}
112
+ #rwa-doc-mount li{margin:.35em 0;}
113
+ #rwa-doc-mount blockquote{margin:1.5em 0;padding:.8em 1.2em;border-left:0;border-top:1px solid var(--es-rule);border-bottom:1px solid var(--es-rule);font-size:1.45rem;line-height:1.4;font-style:italic;color:var(--es-soft);text-align:center;}
114
+ #rwa-doc-mount hr{border:0;border-top:1px solid var(--es-rule);margin:2.4em auto;width:120px;}
115
+ #rwa-doc-mount code{font-family:'SF Mono',Menlo,monospace;font-size:.85em;background:#f1ede4;color:var(--es-accent);padding:.1em .35em;border-radius:3px;}
116
+ #rwa-doc-mount pre{font-family:'SF Mono',Menlo,monospace;background:#f1ede4;border:1px solid var(--es-line);border-radius:4px;padding:14px 16px;font-size:.85rem;}
117
+ #rwa-doc-mount table{font-size:.98em;}
118
+ #rwa-doc-mount th{font-family:Georgia,serif;font-variant:small-caps;letter-spacing:.04em;color:var(--es-soft);border-bottom:2px solid var(--es-ink);background:transparent;padding:.5em .7em;}
119
+ #rwa-doc-mount td{border-bottom:1px solid var(--es-line);padding:.5em .7em;}
120
+ #rwa-doc-mount .sk-kicker{font-size:.8rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--es-accent);margin:0 0 .5em;}
121
+ #rwa-doc-mount .sk-byline{font-style:italic;color:var(--es-faint);font-size:.95rem;margin:0 0 1.4em;}
122
+ #rwa-doc-mount .sk-lede p::first-letter{float:left;font-size:3.4em;line-height:.82;font-weight:700;padding:.04em .1em 0 0;color:var(--es-ink);}
123
+ #rwa-doc-mount .sk-pull{margin:1.5em 0;font-size:1.45rem;line-height:1.4;font-style:italic;text-align:center;color:var(--es-soft);}
124
+ </style>`,
125
+ },
126
+
127
+ 'stripe-docs': {
128
+ name: 'stripe-docs',
129
+ label: 'Stripe Docs',
130
+ swatch: ['#ffffff', '#1a1f36', '#635bff'],
131
+ theme: `<style data-rwa-skin="stripe-docs">
132
+ #rwa-doc-mount{
133
+ --sd-ink:#1a1f36; --sd-soft:#3c4257; --sd-faint:#697386; --sd-line:#e3e8ee;
134
+ --sd-bg:#ffffff; --sd-tint:#f6f9fc; --sd-accent:#635bff; --sd-accent-2:#0073e6;
135
+ font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
136
+ color:var(--sd-ink); line-height:1.65;
137
+ }
138
+ #rwa-doc-mount article{max-width:760px;margin:64px auto;padding:0 36px;}
139
+ #rwa-doc-mount h1{font-size:2.3rem;font-weight:700;letter-spacing:-.02em;line-height:1.15;color:var(--sd-ink);margin:1.4em 0 .4em;}
140
+ #rwa-doc-mount h2{font-size:1.45rem;font-weight:600;color:var(--sd-ink);margin:2em 0 .4em;padding-bottom:.3em;border-bottom:1px solid var(--sd-line);}
141
+ #rwa-doc-mount h3{font-size:1.15rem;font-weight:600;color:var(--sd-soft);margin:1.6em 0 .3em;}
142
+ #rwa-doc-mount p{color:var(--sd-soft);margin:0 0 1em;}
143
+ #rwa-doc-mount a{color:var(--sd-accent);text-decoration:none;font-weight:500;}
144
+ #rwa-doc-mount a:hover{color:var(--sd-accent-2);text-decoration:underline;text-underline-offset:2px;}
145
+ #rwa-doc-mount ul,#rwa-doc-mount ol{color:var(--sd-soft);padding-left:1.5em;margin:0 0 1em;}
146
+ #rwa-doc-mount li{margin:.3em 0;}
147
+ #rwa-doc-mount blockquote{margin:1.2em 0;padding:12px 16px;background:var(--sd-tint);border-left:3px solid var(--sd-accent);border-radius:0 6px 6px 0;color:var(--sd-soft);font-style:normal;}
148
+ #rwa-doc-mount hr{border:0;border-top:1px solid var(--sd-line);margin:2.2em 0;}
149
+ #rwa-doc-mount code{font-family:'SF Mono',Menlo,Monaco,ui-monospace,monospace;font-size:.85em;background:var(--sd-tint);color:var(--sd-accent);border:1px solid var(--sd-line);padding:.1em .4em;border-radius:5px;}
150
+ #rwa-doc-mount pre{background:#0a2540;color:#f6f9fc;border:0;border-radius:10px;padding:18px 20px;box-shadow:0 2px 6px rgba(10,37,64,.18);}
151
+ #rwa-doc-mount pre code{background:transparent;border:0;color:inherit;}
152
+ #rwa-doc-mount table{font-size:.92em;border:1px solid var(--sd-line);border-radius:8px;overflow:hidden;}
153
+ #rwa-doc-mount th{background:var(--sd-tint);color:var(--sd-faint);font-weight:600;text-transform:uppercase;letter-spacing:.04em;font-size:.78rem;border-bottom:1px solid var(--sd-line);padding:.55em .85em;}
154
+ #rwa-doc-mount td{border-bottom:1px solid var(--sd-line);padding:.55em .85em;color:var(--sd-soft);}
155
+ #rwa-doc-mount .sk-hero{margin:0 0 2em;padding:0 0 1.4em;border-bottom:1px solid var(--sd-line);}
156
+ #rwa-doc-mount .sk-hero h1{margin-top:0;}
157
+ #rwa-doc-mount .sk-pill{display:inline-block;font-size:.72rem;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--sd-accent);background:var(--sd-tint);border:1px solid var(--sd-line);border-radius:999px;padding:.25em .7em;margin:0 0 .8em;}
158
+ </style>`,
159
+ },
160
+
161
+ 'terminal-mono': {
162
+ name: 'terminal-mono',
163
+ label: 'Terminal',
164
+ swatch: ['#080a08', '#9ee69e', '#39ff14'],
165
+ theme: `<style data-rwa-skin="terminal-mono">
166
+ #rwa-doc-mount{
167
+ --gray-50:#0c0f0c;--gray-100:#101410;--gray-200:#1b241b;--gray-300:#2c3b2c;
168
+ --gray-400:#4f7a4f;--gray-500:#6fae6f;--gray-600:#86cd86;--gray-700:#9ee69e;
169
+ --gray-800:#b6f5b6;--gray-900:#d6ffd6;--white:#080a08;
170
+ --green:#39ff14;--yellow:#e8e84a;--red:#ff5f56;--blue:#5fd7ff;
171
+ --radius:0px;--radius-sm:0px;
172
+ background:#080a08;color:#9ee69e;
173
+ font-family:var(--font-mono);
174
+ font-size:15px;line-height:1.55;
175
+ }
176
+ #rwa-doc-mount article{max-width:74ch;margin:40px auto;padding:0 28px;}
177
+ #rwa-doc-mount h1,#rwa-doc-mount h2,#rwa-doc-mount h3,#rwa-doc-mount h4,#rwa-doc-mount h5,#rwa-doc-mount h6{font-family:var(--font-mono);font-weight:700;letter-spacing:0;color:#d6ffd6;text-transform:none;}
178
+ #rwa-doc-mount h1{font-size:1.7rem;}
179
+ #rwa-doc-mount h1::before{content:"# ";color:#4f7a4f;}
180
+ #rwa-doc-mount h2{font-size:1.35rem;}
181
+ #rwa-doc-mount h2::before{content:"## ";color:#4f7a4f;}
182
+ #rwa-doc-mount h3::before{content:"### ";color:#4f7a4f;}
183
+ #rwa-doc-mount p{color:#9ee69e;}
184
+ #rwa-doc-mount a{color:#5fd7ff;text-decoration:underline;text-underline-offset:3px;}
185
+ #rwa-doc-mount a:hover{background:#5fd7ff;color:#080a08;text-decoration:none;}
186
+ #rwa-doc-mount ul,#rwa-doc-mount ol{padding-left:2ch;}
187
+ #rwa-doc-mount ul li{list-style:none;}
188
+ #rwa-doc-mount ul li::before{content:"\\203A ";color:#39ff14;margin-left:-2ch;}
189
+ #rwa-doc-mount blockquote{border-left:0;padding:.4em 1ch;color:#86cd86;font-style:normal;background:#101410;border:1px solid #2c3b2c;}
190
+ #rwa-doc-mount hr{border-top:1px dashed #2c3b2c;}
191
+ #rwa-doc-mount code{background:#101410;color:#39ff14;border-radius:0;padding:.05em .4ch;}
192
+ #rwa-doc-mount pre{background:#0c0f0c;border:1px solid #2c3b2c;border-radius:0;color:#9ee69e;box-shadow:inset 0 0 0 1px #101410;}
193
+ #rwa-doc-mount table{font-size:.95em;}
194
+ #rwa-doc-mount th,#rwa-doc-mount td{border-bottom:1px solid #2c3b2c;}
195
+ #rwa-doc-mount th{background:#101410;color:#39ff14;text-transform:uppercase;letter-spacing:.08em;font-weight:700;}
196
+ #rwa-doc-mount .sk-hero{margin:0 0 1.6em;padding:.6em 1ch;border:1px solid #2c3b2c;background:#0c0f0c;}
197
+ #rwa-doc-mount .sk-byline{color:#6fae6f;font-size:.85em;}
198
+ #rwa-doc-mount .sk-stat-row{display:flex;flex-wrap:wrap;gap:1ch;margin:1.4em 0;}
199
+ #rwa-doc-mount .sk-stat{display:flex;flex-direction:column;border:1px solid #2c3b2c;padding:.5em 1ch;background:#101410;}
200
+ #rwa-doc-mount .sk-stat-num{font-size:1.4rem;font-weight:700;color:#39ff14;}
201
+ #rwa-doc-mount .sk-stat-label{font-size:.7rem;text-transform:uppercase;letter-spacing:.08em;color:#6fae6f;}
202
+ #rwa-doc-mount .sk-blink{color:#39ff14;animation:sk-blink 1s step-end infinite;}
203
+ @keyframes sk-blink{50%{opacity:0;}}
204
+ @media (prefers-reduced-motion:reduce){#rwa-doc-mount .sk-blink{animation:none;}}
205
+ </style>`,
206
+ },
207
+ });
208
+
209
+ export const SKIN_NAMES = Object.keys(SKINS);
210
+
211
+ // RWA_SKIN_L1_PREAMBLE + RECIPES — per-preset L1 (content-aware) restyle
212
+ // instructions, surfaced by `rwa skin <file> NAME --l1`. The opt-in L1 path
213
+ // de-skins the doc, drives the agent with the recipe over a multi-turn backend
214
+ // to add additive sk-* class hooks + wrapper elements, then splices the preset
215
+ // theme block and commits ONCE (mirrors the seed's applySkinL1).
216
+ //
217
+ // MANUAL MIRROR (no cmp gate): these are copied BYTE-IDENTICAL from
218
+ // seeds/rewritable.html (RWA_SKIN_L1_PREAMBLE + RWA_SKIN_RECIPES, ~line 2862).
219
+ // The seed is the canonical site (the browser ✦ gallery's always-on L1 reads
220
+ // it). When the seed changes, re-copy here; the pin test
221
+ // cli/tests/skin-l1-seed-mirror.test.mjs extracts both blocks from the seed and
222
+ // deep-equals them against these, so drift fails the suite loudly.
223
+ //
224
+ // Every recipe is additive-only + 1:1-invertible (wrap + add class, never
225
+ // re-tag/move/merge) so de-skin is a clean sk-* strip and data-rwa-id is never
226
+ // renumbered.
227
+ export const RWA_SKIN_L1_PREAMBLE =
228
+ `Apply a visual restyle to this document by adding sk-* class hooks and additive wrapper elements. STRICT RULES: only ADD wrapper <div>/<span> elements and ADD class attributes; never delete, move, merge, reorder, re-tag, or rewrite existing content; never change any data-rwa-id; never add <style> or <script>; never touch frozen zones. If the document already contains sk-* wrappers or sk-* classes from a previous skin, first remove those wrappers (keeping their inner content) and strip the sk-* classes, then apply the restyle below. Use apply_edits with surgical (find,replace) pairs.
229
+
230
+ Restyle:
231
+ `;
232
+
233
+ export const RWA_SKIN_RECIPES = {
234
+ 'notion-clean': RWA_SKIN_L1_PREAMBLE +
235
+ `1. If a short subtitle or dek paragraph sits directly under the H1, add class="sk-eyebrow" to it.
236
+ 2. Convert any paragraph that begins with "Note:", "Tip:", or "Important:" into a <div class="sk-callout"> wrapping that paragraph.
237
+ 3. Leave lists and tables unchanged.`,
238
+ 'linear-dark': RWA_SKIN_L1_PREAMBLE +
239
+ `1. If a short kicker or category line opens the document (above or right after the H1), wrap it as <div class="sk-eyebrow">…</div>.
240
+ 2. If you find a contiguous run of 2 to 4 short metric lines (for example "MRR $48k", "Churn 1.2%"), wrap each as <div class="sk-stat"><b>$48k</b><span>MRR</span></div> and group them in one <div class="sk-stat-row">…</div>.
241
+ 3. Leave all other structure unchanged.`,
242
+ 'editorial-serif': RWA_SKIN_L1_PREAMBLE +
243
+ `1. A category or section word before the title becomes <div class="sk-kicker">…</div> above the H1.
244
+ 2. A byline or dateline under the H1 (for example "By …", a date, "5 min read") gets class="sk-byline".
245
+ 3. Wrap the first body paragraph (the lede) as <div class="sk-lede">…</div>.
246
+ 4. A standalone single-sentence emphatic paragraph becomes <div class="sk-pull">…</div> (do NOT re-tag it to a blockquote).`,
247
+ 'stripe-docs': RWA_SKIN_L1_PREAMBLE +
248
+ `1. Wrap the leading H1 and its dek paragraph together in <div class="sk-hero">…</div>.
249
+ 2. A one-word kicker or category before the H1 becomes <span class="sk-pill">…</span> at the top of the hero.
250
+ 3. Leave code blocks, lists, and tables unchanged.`,
251
+ 'terminal-mono': RWA_SKIN_L1_PREAMBLE +
252
+ `1. Wrap the leading H1 (and a following byline or subtitle paragraph) in <div class="sk-hero">…</div>; add class="sk-byline" to that paragraph.
253
+ 2. A contiguous run of metric lines (number plus short label, for example "42 commits") becomes a <div class="sk-stat-row"> with each metric as <div class="sk-stat"><span class="sk-stat-num">42</span><span class="sk-stat-label">commits</span></div>.
254
+ 3. Append <span class="sk-blink">▋</span> after the final paragraph.`,
255
+ };
256
+
257
+ /**
258
+ * Look up a preset by name. Throws an exit-2 error (listing known skins) on an
259
+ * unknown name, mirroring the CLI's `not_found`-class file errors so the bin
260
+ * dispatcher and tests get a consistent, actionable failure.
261
+ *
262
+ * @param {string} name
263
+ * @returns {{name:string,label:string,swatch:string[],theme:string}}
264
+ */
265
+ export function skinByName(name) {
266
+ const s = SKINS[name];
267
+ if (!s) {
268
+ const e = new Error(`unknown skin "${name}". Known skins: ${SKIN_NAMES.join(', ')}`);
269
+ e.exitCode = 2;
270
+ e.subcode = 'unknown_skin';
271
+ throw e;
272
+ }
273
+ return s;
274
+ }