poveste 0.5.1 → 0.5.2

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.
@@ -1 +1 @@
1
- {"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../../../src/node/collect/worker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAoB,WAAW,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AACrF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AAatD,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,WAAW,CAAA;IACjB,SAAS,EAAE,eAAe,CAAA;CAC3B;AAED,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,WAAW,EAAE,CAAA;CACzB;kCAgB8B,OAAO,KAAG,OAAO,CAAC,UAAU,CAAC;AAA5D,wBAyDC"}
1
+ {"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../../../src/node/collect/worker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAoB,WAAW,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AACrF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AAatD,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,WAAW,CAAA;IACjB,SAAS,EAAE,eAAe,CAAA;CAC3B;AAED,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,WAAW,EAAE,CAAA;CACzB;kCAmB8B,OAAO,KAAG,OAAO,CAAC,UAAU,CAAC;AAA5D,wBA4DC"}
@@ -5,10 +5,13 @@ import { createBirpc } from 'birpc';
5
5
  import { dirname, resolve } from 'pathe';
6
6
  import pc from 'picocolors';
7
7
  import { ModuleCacheMap, ViteNodeRunner } from 'vite-node/client';
8
- import { createDomEnv } from '../dom/env.js';
8
+ import { createDomEnv, resetDomEnv } from '../dom/env.js';
9
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
10
  const _moduleCache = new ModuleCacheMap();
11
11
  let _runner;
12
+ // Worker-lifetime: externalised runtimes cache the DOM they first saw, so one
13
+ // per story broke re-collection.
14
+ let _domEnv;
12
15
  let _rpc;
13
16
  // Cleanup module cache
14
17
  parentPort.on('message', (message) => {
@@ -34,7 +37,12 @@ export default async (payload) => {
34
37
  return _rpc.resolveId(id, importer);
35
38
  },
36
39
  }));
37
- const { destroy: destroyDomEnv } = createDomEnv();
40
+ if (_domEnv) {
41
+ resetDomEnv(_domEnv);
42
+ }
43
+ else {
44
+ _domEnv = createDomEnv();
45
+ }
38
46
  const el = window.document.createElement('div');
39
47
  const beforeExecuteTime = performance.now();
40
48
  // Mount app to collect stories/variants
@@ -55,7 +63,6 @@ export default async (payload) => {
55
63
  s.docsText = text;
56
64
  });
57
65
  }
58
- destroyDomEnv();
59
66
  const endTime = performance.now();
60
67
  console.log(pc.dim(`${payload.storyFile.relativePath} ${Math.round(endTime - startTime)}ms (setup:${Math.round(beforeExecuteTime - startTime)}ms, execute:${Math.round(afterExecuteTime - beforeExecuteTime)}ms, run:${Math.round(afterRunTime - afterExecuteTime)}ms)`));
61
68
  return {
@@ -1,5 +1,8 @@
1
1
  export declare function createDomEnv(): {
2
2
  window: Window & typeof globalThis;
3
3
  destroy: () => void;
4
+ pristineKeys: Set<string>;
4
5
  };
6
+ /** Returns a reused environment to the state a fresh one would have been in. */
7
+ export declare function resetDomEnv(env: Pick<ReturnType<typeof createDomEnv>, 'window' | 'pristineKeys'>): void;
5
8
  //# sourceMappingURL=env.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"env.d.ts","sourceRoot":"","sources":["../../../src/node/dom/env.ts"],"names":[],"mappings":"AAmCA,wBAAgB,YAAY;;;EAqD3B"}
1
+ {"version":3,"file":"env.d.ts","sourceRoot":"","sources":["../../../src/node/dom/env.ts"],"names":[],"mappings":"AAmCA,wBAAgB,YAAY;;;;EAwD3B;AAED,gFAAgF;AAChF,wBAAgB,WAAW,CAAC,GAAG,EAAE,IAAI,CAAC,UAAU,CAAC,OAAO,YAAY,CAAC,EAAE,QAAQ,GAAG,cAAc,CAAC,QA4BhG"}
@@ -72,5 +72,36 @@ export function createDomEnv() {
72
72
  return {
73
73
  window,
74
74
  destroy,
75
+ // `populateGlobal` does `global.window = global`, so `window` here is
76
+ // `globalThis` — one surface, not two.
77
+ pristineKeys: new Set(Object.keys(globalThis)),
75
78
  };
76
79
  }
80
+ /** Returns a reused environment to the state a fresh one would have been in. */
81
+ export function resetDomEnv(env) {
82
+ const doc = env.window.document;
83
+ for (const el of [doc.documentElement, doc.head, doc.body]) {
84
+ if (!el)
85
+ continue;
86
+ for (const name of [...el.getAttributeNames()]) {
87
+ el.removeAttribute(name);
88
+ }
89
+ }
90
+ doc.head?.replaceChildren();
91
+ doc.body?.replaceChildren();
92
+ try {
93
+ // Only present when the key was proxied onto the global, which varies by
94
+ // node version.
95
+ env.window.localStorage?.clear();
96
+ env.window.sessionStorage?.clear();
97
+ }
98
+ catch {
99
+ // Unavailable for some jsdom urls.
100
+ }
101
+ // Additions only: the modules that installed the rest are cached, not re-run.
102
+ for (const key of Object.keys(globalThis)) {
103
+ if (!env.pristineKeys.has(key)) {
104
+ delete globalThis[key];
105
+ }
106
+ }
107
+ }
@@ -1,4 +1,4 @@
1
- import { STORY_SCOPE_ROOT } from './selectors.js';
1
+ import { CONTROLS_SLOT, STORY_SCOPE_ROOT } from './selectors.js';
2
2
  import { wrapUserCss } from './transforms.js';
3
3
  import { USER_CSS_MARK_END, USER_CSS_MARK_START } from './vite-plugin.js';
4
4
  export function entryCssMergerPlugin(opts = { isolateStyles: false }) {
@@ -121,7 +121,7 @@ export function wrapMarkedUserCss(css, scopeRoot) {
121
121
  }
122
122
  const inner = css.slice(start + USER_CSS_MARK_START.length, end).trim();
123
123
  if (inner.length > 0) {
124
- out += `${wrapUserCss(inner, { scopeRoot })}\n`;
124
+ out += `${wrapUserCss(inner, { scopeRoot, excludeRootClass: CONTROLS_SLOT.slice(1) })}\n`;
125
125
  }
126
126
  cursor = end + USER_CSS_MARK_END.length;
127
127
  }
@@ -1,6 +1,7 @@
1
1
  export declare const APP_SCOPE_ROOT = ".poveste-app-root";
2
2
  export declare const STORY_SCOPE_ROOT = ".__poveste-render-story";
3
3
  export declare const STORY_SCOPE_BOUNDARY = ".__poveste-render-story:not(.__poveste-render-custom-controls)";
4
+ export declare const CONTROLS_SLOT = ".__poveste-controls-slot";
4
5
  export declare const DEFAULTS_LAYER = "poveste-defaults";
5
6
  export declare const USER_GLOBALS_LAYER = "poveste-user-globals";
6
7
  export declare const GLOBAL_LAYER_QUERY = "poveste-global-layer";
@@ -1 +1 @@
1
- {"version":3,"file":"selectors.d.ts","sourceRoot":"","sources":["../../../src/node/style-isolation/selectors.ts"],"names":[],"mappings":"AAKA,eAAO,MAAM,cAAc,sBAAsB,CAAA;AACjD,eAAO,MAAM,gBAAgB,4BAA4B,CAAA;AAIzD,eAAO,MAAM,oBAAoB,mEAAmE,CAAA;AACpG,eAAO,MAAM,cAAc,qBAAqB,CAAA;AAChD,eAAO,MAAM,kBAAkB,yBAAyB,CAAA;AAGxD,eAAO,MAAM,kBAAkB,yBAAyB,CAAA"}
1
+ {"version":3,"file":"selectors.d.ts","sourceRoot":"","sources":["../../../src/node/style-isolation/selectors.ts"],"names":[],"mappings":"AAKA,eAAO,MAAM,cAAc,sBAAsB,CAAA;AACjD,eAAO,MAAM,gBAAgB,4BAA4B,CAAA;AAIzD,eAAO,MAAM,oBAAoB,mEAAmE,CAAA;AAGpG,eAAO,MAAM,aAAa,6BAA6B,CAAA;AACvD,eAAO,MAAM,cAAc,qBAAqB,CAAA;AAChD,eAAO,MAAM,kBAAkB,yBAAyB,CAAA;AAGxD,eAAO,MAAM,kBAAkB,yBAAyB,CAAA"}
@@ -8,6 +8,9 @@ export const STORY_SCOPE_ROOT = '.__poveste-render-story';
8
8
  // .__poveste-render-custom-controls) but render inside the chrome panel,
9
9
  // so the @scope boundary must keep them in chrome scope.
10
10
  export const STORY_SCOPE_BOUNDARY = '.__poveste-render-story:not(.__poveste-render-custom-controls)';
11
+ // The panel only — the sandbox body also carries the class above, so excluding
12
+ // that one would stop a `body` rule applying inside the story itself.
13
+ export const CONTROLS_SLOT = '.__poveste-controls-slot';
11
14
  export const DEFAULTS_LAYER = 'poveste-defaults';
12
15
  export const USER_GLOBALS_LAYER = 'poveste-user-globals';
13
16
  // Import query `globalStyles` files carry so the user-CSS plugin leaves them
@@ -1,5 +1,7 @@
1
1
  export interface WrapOptions {
2
2
  scopeRoot: string;
3
+ /** Bare class name rewritten roots must not match — the panel shares the scope-root class (#173). */
4
+ excludeRootClass?: string;
3
5
  }
4
6
  export interface WrapWithLowerOptions {
5
7
  scopeRoot: string;
@@ -1 +1 @@
1
- {"version":3,"file":"transforms.d.ts","sourceRoot":"","sources":["../../../src/node/style-isolation/transforms.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,GAAG,MAAM,CAWlE;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,oBAAoB,GAAG,MAAM,CAc7E;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAKlD"}
1
+ {"version":3,"file":"transforms.d.ts","sourceRoot":"","sources":["../../../src/node/style-isolation/transforms.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAA;IACjB,qGAAqG;IACrG,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAC1B;AAED,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,GAAG,MAAM,CAWlE;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,oBAAoB,GAAG,MAAM,CAc7E;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAKlD"}
@@ -7,7 +7,7 @@ export function wrapUserCss(css, opts) {
7
7
  }
8
8
  const { hoisted, remainder } = extractHoistableAtRules(css);
9
9
  const body = hasRootSelector(remainder)
10
- ? rewriteRootToScope(remainder)
10
+ ? rewriteRootToScope(remainder, opts.excludeRootClass)
11
11
  : remainder;
12
12
  const hoistedBlock = hoisted.length > 0 ? `${hoisted.join('\n')}\n` : '';
13
13
  return `${hoistedBlock}@scope (${opts.scopeRoot}) {\n${body}\n}\n`;
@@ -65,10 +65,21 @@ function isRootTypeSelector(name) {
65
65
  // under `of` rather than `selectors`, so it is untouched for free.
66
66
  const DESCENDABLE_PSEUDO_CLASSES = new Set(['is', 'where']);
67
67
  const SCOPE_PART = { type: 'pseudo-class', kind: 'scope' };
68
- function rewriteSelector(selector) {
69
- return selector.map((part, index) => {
68
+ // Rewritten roots only, so an author's own classes still apply in the panel.
69
+ function scopeParts(excludeClass) {
70
+ if (!excludeClass) {
71
+ return [SCOPE_PART];
72
+ }
73
+ return [SCOPE_PART, {
74
+ type: 'pseudo-class',
75
+ kind: 'not',
76
+ selectors: [[{ type: 'class', name: excludeClass }]],
77
+ }];
78
+ }
79
+ function rewriteSelector(selector, excludeClass) {
80
+ return selector.flatMap((part, index) => {
70
81
  if (part.type === 'pseudo-class' && part.kind === 'root') {
71
- return SCOPE_PART;
82
+ return scopeParts(excludeClass);
72
83
  }
73
84
  // A namespace is its own part, immediately before the type selector it
74
85
  // qualifies. Rewriting past it emits `*|:scope`, which is invalid — a
@@ -76,21 +87,21 @@ function rewriteSelector(selector) {
76
87
  // `svg|body` also means a body in the SVG namespace, which is not the
77
88
  // document root, so leaving these alone is right on both counts.
78
89
  if (part.type === 'type' && isRootTypeSelector(part.name ?? '')) {
79
- return selector[index - 1]?.type === 'namespace' ? part : SCOPE_PART;
90
+ return selector[index - 1]?.type === 'namespace' ? [part] : scopeParts(excludeClass);
80
91
  }
81
92
  if (part.type === 'pseudo-class' && DESCENDABLE_PSEUDO_CLASSES.has(part.kind ?? '') && part.selectors) {
82
- return { ...part, selectors: part.selectors.map(rewriteSelector) };
93
+ return [{ ...part, selectors: part.selectors.map(s => rewriteSelector(s, excludeClass)) }];
83
94
  }
84
- return part;
95
+ return [part];
85
96
  });
86
97
  }
87
- function rewriteRootToScope(css) {
98
+ function rewriteRootToScope(css, excludeClass) {
88
99
  const processed = lightningcssTransform({
89
100
  filename: 'user.css',
90
101
  code: Buffer.from(css, 'utf8'),
91
102
  minify: false,
92
103
  visitor: {
93
- Selector: selector => rewriteSelector(selector),
104
+ Selector: selector => rewriteSelector(selector, excludeClass),
94
105
  },
95
106
  });
96
107
  return Buffer.from(processed.code).toString('utf8');
@@ -1,4 +1,4 @@
1
- import { GLOBAL_LAYER_QUERY, STORY_SCOPE_ROOT } from './selectors.js';
1
+ import { CONTROLS_SLOT, GLOBAL_LAYER_QUERY, STORY_SCOPE_ROOT } from './selectors.js';
2
2
  import { wrapUserCss } from './transforms.js';
3
3
  export const USER_CSS_MARK_START = '/*__PVT_USER_CSS_START__*/';
4
4
  export const USER_CSS_MARK_END = '/*__PVT_USER_CSS_END__*/';
@@ -20,7 +20,7 @@ export function userCssScopePlugin(opts) {
20
20
  if (hasGlobalQuery(query))
21
21
  return null;
22
22
  if (mode === 'wrap') {
23
- return { code: wrapUserCss(code, { scopeRoot: STORY_SCOPE_ROOT }), map: null };
23
+ return { code: wrapUserCss(code, { scopeRoot: STORY_SCOPE_ROOT, excludeRootClass: CONTROLS_SLOT.slice(1) }), map: null };
24
24
  }
25
25
  return {
26
26
  code: `${USER_CSS_MARK_START}\n${code}\n${USER_CSS_MARK_END}\n`,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "poveste",
3
3
  "type": "module",
4
- "version": "0.5.1",
4
+ "version": "0.5.2",
5
5
  "description": "A fast story book powered by Vite",
6
6
  "author": {
7
7
  "name": "Sorin Gitlan"
@@ -68,10 +68,10 @@
68
68
  "sirv": "^3.0.0",
69
69
  "unhead": "^3.1.0",
70
70
  "vite-node": "^6.0.0",
71
- "@poveste/app": "^0.5.1",
72
- "@poveste/shared": "^0.5.1",
73
- "@poveste/controls": "^0.5.1",
74
- "@poveste/vendors": "^0.5.1"
71
+ "@poveste/app": "^0.5.2",
72
+ "@poveste/controls": "^0.5.2",
73
+ "@poveste/shared": "^0.5.2",
74
+ "@poveste/vendors": "^0.5.2"
75
75
  },
76
76
  "devDependencies": {
77
77
  "@types/fs-extra": "^11.0.4",
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, it, vi } from 'vitest'
2
- import { createDomEnv } from '../dom/env.js'
2
+ import { createDomEnv, resetDomEnv } from '../dom/env.js'
3
3
 
4
4
  describe('createDomEnv', () => {
5
5
  let env: ReturnType<typeof createDomEnv>
@@ -54,3 +54,44 @@ describe('createDomEnv', () => {
54
54
  })
55
55
  })
56
56
  })
57
+
58
+ describe('resetDomEnv', () => {
59
+ let env: ReturnType<typeof createDomEnv>
60
+
61
+ afterEach(() => {
62
+ env?.destroy()
63
+ })
64
+
65
+ it('returns a dirtied environment to its pristine state', () => {
66
+ env = createDomEnv()
67
+ const doc = env.window.document
68
+
69
+ doc.body.classList.add('theme-dark')
70
+ doc.documentElement.setAttribute('lang', 'fr')
71
+ doc.body.append(doc.createElement('div'))
72
+ doc.head.append(doc.createElement('style'))
73
+ // Proxied onto the global only on some node versions.
74
+ env.window.localStorage?.setItem('seen', '1')
75
+ ;(env.window as any).__setupInstalled = true
76
+
77
+ resetDomEnv(env)
78
+
79
+ expect(doc.body.getAttributeNames()).toEqual([])
80
+ expect(doc.documentElement.getAttributeNames()).toEqual([])
81
+ expect(doc.body.children).toHaveLength(0)
82
+ expect(doc.head.children).toHaveLength(0)
83
+ expect(env.window.localStorage?.getItem('seen') ?? null).toBeNull()
84
+ // A setup marking itself installed would skip later stories in the worker.
85
+ expect((env.window as any).__setupInstalled).toBeUndefined()
86
+ })
87
+
88
+ it('leaves the globals the environment itself installed', () => {
89
+ env = createDomEnv()
90
+
91
+ resetDomEnv(env)
92
+
93
+ expect(typeof env.window.ResizeObserver).toBe('function')
94
+ expect(typeof env.window.matchMedia).toBe('function')
95
+ expect(env.window.document.body).toBeTruthy()
96
+ })
97
+ })
@@ -8,7 +8,7 @@ import { createBirpc } from 'birpc'
8
8
  import { dirname, resolve } from 'pathe'
9
9
  import pc from 'picocolors'
10
10
  import { ModuleCacheMap, ViteNodeRunner } from 'vite-node/client'
11
- import { createDomEnv } from '../dom/env.js'
11
+ import { createDomEnv, resetDomEnv } from '../dom/env.js'
12
12
 
13
13
  const __dirname = dirname(fileURLToPath(import.meta.url))
14
14
 
@@ -25,6 +25,9 @@ export interface ReturnData {
25
25
 
26
26
  const _moduleCache = new ModuleCacheMap()
27
27
  let _runner: ViteNodeRunner
28
+ // Worker-lifetime: externalised runtimes cache the DOM they first saw, so one
29
+ // per story broke re-collection.
30
+ let _domEnv: ReturnType<typeof createDomEnv> | undefined
28
31
  let _rpc: ReturnType<typeof createBirpc<{
29
32
  fetchModule: FetchFunction
30
33
  resolveId: ResolveIdFunction
@@ -61,7 +64,12 @@ export default async (payload: Payload): Promise<ReturnData> => {
61
64
  },
62
65
  }))
63
66
 
64
- const { destroy: destroyDomEnv } = createDomEnv()
67
+ if (_domEnv) {
68
+ resetDomEnv(_domEnv)
69
+ }
70
+ else {
71
+ _domEnv = createDomEnv()
72
+ }
65
73
 
66
74
  const el = window.document.createElement('div')
67
75
 
@@ -86,8 +94,6 @@ export default async (payload: Payload): Promise<ReturnData> => {
86
94
  })
87
95
  }
88
96
 
89
- destroyDomEnv()
90
-
91
97
  const endTime = performance.now()
92
98
  console.log(pc.dim(`${payload.storyFile.relativePath} ${Math.round(endTime - startTime)}ms (setup:${Math.round(beforeExecuteTime - startTime)}ms, execute:${Math.round(afterExecuteTime - beforeExecuteTime)}ms, run:${Math.round(afterRunTime - afterExecuteTime)}ms)`))
93
99
 
@@ -85,5 +85,39 @@ export function createDomEnv() {
85
85
  return {
86
86
  window,
87
87
  destroy,
88
+ // `populateGlobal` does `global.window = global`, so `window` here is
89
+ // `globalThis` — one surface, not two.
90
+ pristineKeys: new Set(Object.keys(globalThis)),
91
+ }
92
+ }
93
+
94
+ /** Returns a reused environment to the state a fresh one would have been in. */
95
+ export function resetDomEnv(env: Pick<ReturnType<typeof createDomEnv>, 'window' | 'pristineKeys'>) {
96
+ const doc = env.window.document
97
+
98
+ for (const el of [doc.documentElement, doc.head, doc.body]) {
99
+ if (!el) continue
100
+ for (const name of [...el.getAttributeNames()]) {
101
+ el.removeAttribute(name)
102
+ }
103
+ }
104
+ doc.head?.replaceChildren()
105
+ doc.body?.replaceChildren()
106
+
107
+ try {
108
+ // Only present when the key was proxied onto the global, which varies by
109
+ // node version.
110
+ env.window.localStorage?.clear()
111
+ env.window.sessionStorage?.clear()
112
+ }
113
+ catch {
114
+ // Unavailable for some jsdom urls.
115
+ }
116
+
117
+ // Additions only: the modules that installed the rest are cached, not re-run.
118
+ for (const key of Object.keys(globalThis)) {
119
+ if (!env.pristineKeys.has(key)) {
120
+ delete (globalThis as Record<string, unknown>)[key]
121
+ }
88
122
  }
89
123
  }
@@ -98,6 +98,43 @@ describe('wrapUserCss', () => {
98
98
  const occurrences = (out.match(/@scope/g) ?? []).length
99
99
  expect(occurrences).toBe(1)
100
100
  })
101
+
102
+ // #173. Page-level rules stop at the controls slot, which carries the same
103
+ // scope-root class as the story body; author-written rules still reach it.
104
+ describe('with excludeRootClass', () => {
105
+ const opts = {
106
+ scopeRoot: '.__poveste-render-story',
107
+ excludeRootClass: '__poveste-controls-slot',
108
+ }
109
+
110
+ it.each([
111
+ [':root', ':root { background: deeppink }', ':scope:not(.__poveste-controls-slot)'],
112
+ ['html', 'html { background: deeppink }', ':scope:not(.__poveste-controls-slot)'],
113
+ ['body', 'body { background: deeppink }', ':scope:not(.__poveste-controls-slot)'],
114
+ ['body .card', 'body .card { color: red }', ':scope:not(.__poveste-controls-slot) .card'],
115
+ ])('keeps a page-level %s rule out of the controls slot', (_name, css, expected) => {
116
+ expect(selectorsIn(wrapUserCss(css, opts))).toEqual([expected])
117
+ })
118
+
119
+ it.each([
120
+ ['.user-card { background: deeppink }', '.user-card'],
121
+ ['.body-copy { color: red }', '.body-copy'],
122
+ ])('leaves the author-written %s alone', (css, expected) => {
123
+ expect(selectorsIn(wrapUserCss(css, opts))).toEqual([expected])
124
+ })
125
+
126
+ it('carries the exclusion into roots nested in :is()', () => {
127
+ const out = wrapUserCss(':is(html, body) { color: red }', opts)
128
+
129
+ expect(selectorsIn(out)).toEqual([
130
+ ':is(:scope:not(.__poveste-controls-slot), :scope:not(.__poveste-controls-slot))',
131
+ ])
132
+ })
133
+
134
+ it('is opt-in, so the chrome pass is unaffected', () => {
135
+ expect(selectorsIn(wrapUserCss('body { color: red }', { scopeRoot: '.x' }))).toEqual([':scope'])
136
+ })
137
+ })
101
138
  })
102
139
 
103
140
  describe('wrapChromeCss', () => {
@@ -1,5 +1,5 @@
1
1
  import type { Plugin as VitePlugin } from 'vite'
2
- import { STORY_SCOPE_ROOT } from './selectors.js'
2
+ import { CONTROLS_SLOT, STORY_SCOPE_ROOT } from './selectors.js'
3
3
  import { wrapUserCss } from './transforms.js'
4
4
  import { USER_CSS_MARK_END, USER_CSS_MARK_START } from './vite-plugin.js'
5
5
 
@@ -126,7 +126,7 @@ export function wrapMarkedUserCss(css: string, scopeRoot: string): string {
126
126
  }
127
127
  const inner = css.slice(start + USER_CSS_MARK_START.length, end).trim()
128
128
  if (inner.length > 0) {
129
- out += `${wrapUserCss(inner, { scopeRoot })}\n`
129
+ out += `${wrapUserCss(inner, { scopeRoot, excludeRootClass: CONTROLS_SLOT.slice(1) })}\n`
130
130
  }
131
131
  cursor = end + USER_CSS_MARK_END.length
132
132
  }
@@ -9,6 +9,9 @@ export const STORY_SCOPE_ROOT = '.__poveste-render-story'
9
9
  // .__poveste-render-custom-controls) but render inside the chrome panel,
10
10
  // so the @scope boundary must keep them in chrome scope.
11
11
  export const STORY_SCOPE_BOUNDARY = '.__poveste-render-story:not(.__poveste-render-custom-controls)'
12
+ // The panel only — the sandbox body also carries the class above, so excluding
13
+ // that one would stop a `body` rule applying inside the story itself.
14
+ export const CONTROLS_SLOT = '.__poveste-controls-slot'
12
15
  export const DEFAULTS_LAYER = 'poveste-defaults'
13
16
  export const USER_GLOBALS_LAYER = 'poveste-user-globals'
14
17
  // Import query `globalStyles` files carry so the user-CSS plugin leaves them
@@ -3,6 +3,8 @@ import { transform as lightningcssTransform } from 'lightningcss'
3
3
 
4
4
  export interface WrapOptions {
5
5
  scopeRoot: string
6
+ /** Bare class name rewritten roots must not match — the panel shares the scope-root class (#173). */
7
+ excludeRootClass?: string
6
8
  }
7
9
 
8
10
  export interface WrapWithLowerOptions {
@@ -17,7 +19,7 @@ export function wrapUserCss(css: string, opts: WrapOptions): string {
17
19
  }
18
20
  const { hoisted, remainder } = extractHoistableAtRules(css)
19
21
  const body = hasRootSelector(remainder)
20
- ? rewriteRootToScope(remainder)
22
+ ? rewriteRootToScope(remainder, opts.excludeRootClass)
21
23
  : remainder
22
24
  const hoistedBlock = hoisted.length > 0 ? `${hoisted.join('\n')}\n` : ''
23
25
  return `${hoistedBlock}@scope (${opts.scopeRoot}) {\n${body}\n}\n`
@@ -92,10 +94,22 @@ interface SelectorPart {
92
94
  selectors?: SelectorPart[][]
93
95
  }
94
96
 
95
- function rewriteSelector(selector: SelectorPart[]): SelectorPart[] {
96
- return selector.map((part, index) => {
97
+ // Rewritten roots only, so an author's own classes still apply in the panel.
98
+ function scopeParts(excludeClass?: string): SelectorPart[] {
99
+ if (!excludeClass) {
100
+ return [SCOPE_PART]
101
+ }
102
+ return [SCOPE_PART, {
103
+ type: 'pseudo-class',
104
+ kind: 'not',
105
+ selectors: [[{ type: 'class', name: excludeClass }]],
106
+ }]
107
+ }
108
+
109
+ function rewriteSelector(selector: SelectorPart[], excludeClass?: string): SelectorPart[] {
110
+ return selector.flatMap((part, index) => {
97
111
  if (part.type === 'pseudo-class' && part.kind === 'root') {
98
- return SCOPE_PART
112
+ return scopeParts(excludeClass)
99
113
  }
100
114
  // A namespace is its own part, immediately before the type selector it
101
115
  // qualifies. Rewriting past it emits `*|:scope`, which is invalid — a
@@ -103,22 +117,22 @@ function rewriteSelector(selector: SelectorPart[]): SelectorPart[] {
103
117
  // `svg|body` also means a body in the SVG namespace, which is not the
104
118
  // document root, so leaving these alone is right on both counts.
105
119
  if (part.type === 'type' && isRootTypeSelector(part.name ?? '')) {
106
- return selector[index - 1]?.type === 'namespace' ? part : SCOPE_PART
120
+ return selector[index - 1]?.type === 'namespace' ? [part] : scopeParts(excludeClass)
107
121
  }
108
122
  if (part.type === 'pseudo-class' && DESCENDABLE_PSEUDO_CLASSES.has(part.kind ?? '') && part.selectors) {
109
- return { ...part, selectors: part.selectors.map(rewriteSelector) }
123
+ return [{ ...part, selectors: part.selectors.map(s => rewriteSelector(s, excludeClass)) }]
110
124
  }
111
- return part
125
+ return [part]
112
126
  })
113
127
  }
114
128
 
115
- function rewriteRootToScope(css: string): string {
129
+ function rewriteRootToScope(css: string, excludeClass?: string): string {
116
130
  const processed = lightningcssTransform({
117
131
  filename: 'user.css',
118
132
  code: Buffer.from(css, 'utf8'),
119
133
  minify: false,
120
134
  visitor: {
121
- Selector: selector => rewriteSelector(selector as SelectorPart[]) as typeof selector,
135
+ Selector: selector => rewriteSelector(selector as SelectorPart[], excludeClass) as typeof selector,
122
136
  },
123
137
  })
124
138
  return Buffer.from(processed.code).toString('utf8')
@@ -1,5 +1,5 @@
1
1
  import type { Plugin as VitePlugin } from 'vite'
2
- import { GLOBAL_LAYER_QUERY, STORY_SCOPE_ROOT } from './selectors.js'
2
+ import { CONTROLS_SLOT, GLOBAL_LAYER_QUERY, STORY_SCOPE_ROOT } from './selectors.js'
3
3
  import { wrapUserCss } from './transforms.js'
4
4
 
5
5
  export const USER_CSS_MARK_START = '/*__PVT_USER_CSS_START__*/'
@@ -29,7 +29,7 @@ export function userCssScopePlugin(opts: UserCssScopePluginOptions): VitePlugin
29
29
  if (!isCssPath(path, query)) return null
30
30
  if (hasGlobalQuery(query)) return null
31
31
  if (mode === 'wrap') {
32
- return { code: wrapUserCss(code, { scopeRoot: STORY_SCOPE_ROOT }), map: null }
32
+ return { code: wrapUserCss(code, { scopeRoot: STORY_SCOPE_ROOT, excludeRootClass: CONTROLS_SLOT.slice(1) }), map: null }
33
33
  }
34
34
  return {
35
35
  code: `${USER_CSS_MARK_START}\n${code}\n${USER_CSS_MARK_END}\n`,