poveste 0.5.1 → 0.5.3

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.
Files changed (52) hide show
  1. package/dist/node/bin.js +1 -0
  2. package/dist/node/builtin-plugins/pinceau-tokens.d.ts.map +1 -1
  3. package/dist/node/builtin-plugins/pinceau-tokens.js +2 -1
  4. package/dist/node/builtin-plugins/tailwind-tokens.d.ts.map +1 -1
  5. package/dist/node/builtin-plugins/tailwind-tokens.js +2 -1
  6. package/dist/node/collect/worker.d.ts.map +1 -1
  7. package/dist/node/collect/worker.js +10 -3
  8. package/dist/node/commands/dev.d.ts +1 -1
  9. package/dist/node/commands/dev.d.ts.map +1 -1
  10. package/dist/node/commands/dev.js +3 -1
  11. package/dist/node/commands/port.d.ts +10 -0
  12. package/dist/node/commands/port.d.ts.map +1 -0
  13. package/dist/node/commands/port.js +24 -0
  14. package/dist/node/commands/preview.d.ts +1 -1
  15. package/dist/node/commands/preview.d.ts.map +1 -1
  16. package/dist/node/commands/preview.js +3 -1
  17. package/dist/node/config.d.ts.map +1 -1
  18. package/dist/node/config.js +0 -1
  19. package/dist/node/dom/env.d.ts +3 -0
  20. package/dist/node/dom/env.d.ts.map +1 -1
  21. package/dist/node/dom/env.js +31 -0
  22. package/dist/node/preview.d.ts.map +1 -1
  23. package/dist/node/preview.js +6 -1
  24. package/dist/node/server.d.ts.map +1 -1
  25. package/dist/node/server.js +6 -0
  26. package/dist/node/style-isolation/entry-css-merger.js +2 -2
  27. package/dist/node/style-isolation/selectors.d.ts +1 -0
  28. package/dist/node/style-isolation/selectors.d.ts.map +1 -1
  29. package/dist/node/style-isolation/selectors.js +3 -0
  30. package/dist/node/style-isolation/transforms.d.ts +2 -0
  31. package/dist/node/style-isolation/transforms.d.ts.map +1 -1
  32. package/dist/node/style-isolation/transforms.js +20 -9
  33. package/dist/node/style-isolation/vite-plugin.js +2 -2
  34. package/package.json +5 -5
  35. package/src/node/__tests__/dom-env.spec.ts +42 -1
  36. package/src/node/__tests__/port.spec.ts +33 -0
  37. package/src/node/bin.ts +1 -0
  38. package/src/node/builtin-plugins/pinceau-tokens.ts +3 -1
  39. package/src/node/builtin-plugins/tailwind-tokens.ts +3 -1
  40. package/src/node/collect/worker.ts +10 -4
  41. package/src/node/commands/dev.ts +5 -2
  42. package/src/node/commands/port.ts +29 -0
  43. package/src/node/commands/preview.ts +5 -2
  44. package/src/node/config.ts +0 -1
  45. package/src/node/dom/env.ts +34 -0
  46. package/src/node/preview.ts +6 -1
  47. package/src/node/server.ts +7 -0
  48. package/src/node/style-isolation/__tests__/transforms.spec.ts +37 -0
  49. package/src/node/style-isolation/entry-css-merger.ts +2 -2
  50. package/src/node/style-isolation/selectors.ts +3 -0
  51. package/src/node/style-isolation/transforms.ts +23 -9
  52. package/src/node/style-isolation/vite-plugin.ts +2 -2
package/dist/node/bin.js CHANGED
@@ -6,6 +6,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
6
6
  const { version } = JSON.parse(fs.readFileSync(resolve(__dirname, '../../package.json'), 'utf8'));
7
7
  process.env.NODE_ENV = 'development';
8
8
  process.env.HISTOIRE = 'true';
9
+ process.env.POVESTE = 'true';
9
10
  const program = sade('poveste');
10
11
  program.version(version);
11
12
  program.command('dev')
@@ -1 +1 @@
1
- {"version":3,"file":"pinceau-tokens.d.ts","sourceRoot":"","sources":["../../../src/node/builtin-plugins/pinceau-tokens.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAiB,MAAM,iBAAiB,CAAA;AAI5D,MAAM,WAAW,oBAAoB;IACnC,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IACjC,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,wBAAgB,aAAa,CAAC,QAAQ,GAAE,oBAAyB,GAAG,MAAM,CA2DzE"}
1
+ {"version":3,"file":"pinceau-tokens.d.ts","sourceRoot":"","sources":["../../../src/node/builtin-plugins/pinceau-tokens.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAiB,MAAM,iBAAiB,CAAA;AAI5D,MAAM,WAAW,oBAAoB;IACnC,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IACjC,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,wBAAgB,aAAa,CAAC,QAAQ,GAAE,oBAAyB,GAAG,MAAM,CA6DzE"}
@@ -41,7 +41,8 @@ export function pinceauTokens(_options = {}) {
41
41
  });
42
42
  }
43
43
  },
44
- onDev(api, onCleanup) {
44
+ async onDev(api, onCleanup) {
45
+ await generate(api);
45
46
  const watcher = api.watcher.watch(themePath)
46
47
  .on('change', () => generate(api))
47
48
  .on('add', () => generate(api));
@@ -1 +1 @@
1
- {"version":3,"file":"tailwind-tokens.d.ts","sourceRoot":"","sources":["../../../src/node/builtin-plugins/tailwind-tokens.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAiB,MAAM,iBAAiB,CAAA;AAI5D,MAAM,WAAW,qBAAqB;IACpC;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAqBD,wBAAgB,cAAc,CAAC,OAAO,GAAE,qBAA0B,GAAG,MAAM,CAgE1E"}
1
+ {"version":3,"file":"tailwind-tokens.d.ts","sourceRoot":"","sources":["../../../src/node/builtin-plugins/tailwind-tokens.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAiB,MAAM,iBAAiB,CAAA;AAI5D,MAAM,WAAW,qBAAqB;IACpC;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAqBD,wBAAgB,cAAc,CAAC,OAAO,GAAE,qBAA0B,GAAG,MAAM,CAkE1E"}
@@ -61,8 +61,9 @@ export function tailwindTokens(options = {}) {
61
61
  }
62
62
  }
63
63
  },
64
- onDev(api, onCleanup) {
64
+ async onDev(api, onCleanup) {
65
65
  if (tailwindCssFile) {
66
+ await generate(api);
66
67
  const watcher = api.watcher.watch(tailwindCssFile)
67
68
  .on('change', () => generate(api))
68
69
  .on('add', () => generate(api));
@@ -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,5 @@
1
1
  export interface DevOptions {
2
- port: number;
2
+ port?: number | string;
3
3
  open?: boolean;
4
4
  host?: string | boolean;
5
5
  config?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"dev.d.ts","sourceRoot":"","sources":["../../../src/node/commands/dev.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;IACvB,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED,wBAAsB,UAAU,CAAC,OAAO,EAAE,UAAU,iBAoDnD"}
1
+ {"version":3,"file":"dev.d.ts","sourceRoot":"","sources":["../../../src/node/commands/dev.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;IACvB,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED,wBAAsB,UAAU,CAAC,OAAO,EAAE,UAAU,iBAsDnD"}
@@ -3,7 +3,9 @@ import pc from 'picocolors';
3
3
  import { resolveConfigFile } from '../config.js';
4
4
  import { createContext } from '../context.js';
5
5
  import { createServer } from '../server.js';
6
+ import { resolvePort } from './port.js';
6
7
  export async function devCommand(options) {
8
+ const port = resolvePort(options.port, 'dev');
7
9
  let stop;
8
10
  async function start() {
9
11
  const ctx = await createContext({
@@ -11,7 +13,7 @@ export async function devCommand(options) {
11
13
  mode: 'dev',
12
14
  });
13
15
  const { server, viteConfigFile, close } = await createServer(ctx, {
14
- port: options.port,
16
+ port,
15
17
  open: options.open,
16
18
  host: options.host,
17
19
  });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Reads a `--port` value off the parsed CLI options.
3
+ *
4
+ * sade declares the flag as taking a value but does not enforce one, so
5
+ * `poveste preview --port` arrives as the boolean `true` and reaches node's
6
+ * listen validation, which rejects it with `ERR_INVALID_ARG_VALUE` and names
7
+ * neither the flag nor the mistake. A non-numeric value fails the same way.
8
+ */
9
+ export declare function resolvePort(value: unknown, command: string): number | undefined;
10
+ //# sourceMappingURL=port.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"port.d.ts","sourceRoot":"","sources":["../../../src/node/commands/port.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAoB/E"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Reads a `--port` value off the parsed CLI options.
3
+ *
4
+ * sade declares the flag as taking a value but does not enforce one, so
5
+ * `poveste preview --port` arrives as the boolean `true` and reaches node's
6
+ * listen validation, which rejects it with `ERR_INVALID_ARG_VALUE` and names
7
+ * neither the flag nor the mistake. A non-numeric value fails the same way.
8
+ */
9
+ export function resolvePort(value, command) {
10
+ if (value === undefined || value === null) {
11
+ return undefined;
12
+ }
13
+ // Checked by type, not by coercion: a valueless flag arrives as `true`, and
14
+ // `Number(true)` is 1, a valid port that would bind to a privileged one.
15
+ const port = typeof value === 'number'
16
+ ? value
17
+ : typeof value === 'string' && value.trim() !== ''
18
+ ? Number(value)
19
+ : Number.NaN;
20
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
21
+ throw new TypeError(`--port needs a number between 0 and 65535, got ${JSON.stringify(value)}. Try \`poveste ${command} --port 6006\`.`);
22
+ }
23
+ return port;
24
+ }
@@ -1,5 +1,5 @@
1
1
  export interface PreviewOptions {
2
- port?: number;
2
+ port?: number | string;
3
3
  host?: string | boolean;
4
4
  open?: boolean;
5
5
  }
@@ -1 +1 @@
1
- {"version":3,"file":"preview.d.ts","sourceRoot":"","sources":["../../../src/node/commands/preview.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;IACvB,IAAI,CAAC,EAAE,OAAO,CAAA;CACf;AAED,wBAAsB,cAAc,CAAC,OAAO,EAAE,cAAc,iBAiB3D"}
1
+ {"version":3,"file":"preview.d.ts","sourceRoot":"","sources":["../../../src/node/commands/preview.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;IACvB,IAAI,CAAC,EAAE,OAAO,CAAA;CACf;AAED,wBAAsB,cAAc,CAAC,OAAO,EAAE,cAAc,iBAmB3D"}
@@ -1,6 +1,8 @@
1
1
  import { createContext } from '../context.js';
2
2
  import { startPreview } from '../preview.js';
3
+ import { resolvePort } from './port.js';
3
4
  export async function previewCommand(options) {
5
+ const port = resolvePort(options.port, 'preview');
4
6
  const ctx = await createContext({
5
7
  mode: 'build',
6
8
  });
@@ -10,7 +12,7 @@ export async function previewCommand(options) {
10
12
  }
11
13
  }
12
14
  const { printUrls } = await startPreview({
13
- port: options.port,
15
+ port,
14
16
  host: options.host,
15
17
  open: options.open,
16
18
  }, ctx);
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/node/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,UAAU,EAEV,aAAa,EAEd,MAAM,iBAAiB,CAAA;AACxB,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAkB3C,wBAAgB,gBAAgB,IAAI,aAAa,CA4HhD;AAED,eAAO,MAAM,eAAe,UAU3B,CAAA;AAED,wBAAgB,iBAAiB,CAAC,GAAG,GAAE,MAAsB,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,CAQ1F;AAED,wBAAsB,cAAc,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,CAexF;AAED,eAAO,MAAM,gBAAgB,uBAK3B,CAAA;AAEF,eAAO,MAAM,WAAW,uBA8DtB,CAAA;AAEF,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAsB,EAAE,IAAI,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAgB7H;AA2BD,wBAAsB,aAAa,CAAC,GAAG,EAAE,OAAO,iBAoE/C;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,OAAO,CAAC,aAAa,CAAC,0BAE1D;AAED,OAAO,QAAQ,MAAM,CAAC;IACpB,UAAU,UAAU;QAClB;;WAEG;QACH,OAAO,CAAC,EAAE,OAAO,CAAC,aAAa,CAAC,CAAA;QAChC;;WAEG;QACH,QAAQ,CAAC,EAAE,OAAO,CAAC,aAAa,CAAC,CAAA;KAClC;CACF"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/node/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,UAAU,EAEV,aAAa,EAEd,MAAM,iBAAiB,CAAA;AACxB,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAkB3C,wBAAgB,gBAAgB,IAAI,aAAa,CA2HhD;AAED,eAAO,MAAM,eAAe,UAU3B,CAAA;AAED,wBAAgB,iBAAiB,CAAC,GAAG,GAAE,MAAsB,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,CAQ1F;AAED,wBAAsB,cAAc,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,CAexF;AAED,eAAO,MAAM,gBAAgB,uBAK3B,CAAA;AAEF,eAAO,MAAM,WAAW,uBA8DtB,CAAA;AAEF,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAsB,EAAE,IAAI,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAgB7H;AA2BD,wBAAsB,aAAa,CAAC,GAAG,EAAE,OAAO,iBAoE/C;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,OAAO,CAAC,aAAa,CAAC,0BAE1D;AAED,OAAO,QAAQ,MAAM,CAAC;IACpB,UAAU,UAAU;QAClB;;WAEG;QACH,OAAO,CAAC,EAAE,OAAO,CAAC,aAAa,CAAC,CAAA;QAChC;;WAEG;QACH,QAAQ,CAAC,EAAE,OAAO,CAAC,aAAa,CAAC,CAAA;KAClC;CACF"}
@@ -110,7 +110,6 @@ export function getDefaultConfig() {
110
110
  contrastColor: '#eee',
111
111
  },
112
112
  ],
113
- sandboxDarkClass: 'dark',
114
113
  routerMode: 'history',
115
114
  build: {
116
115
  excludeFromVendorsChunk: [],
@@ -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 +1 @@
1
- {"version":3,"file":"preview.d.ts","sourceRoot":"","sources":["../../src/node/preview.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAG3C,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;IACvB,IAAI,CAAC,EAAE,OAAO,CAAA;CACf;AAED,UAAU,aAAa;IACrB,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC3B;AAED,wBAAsB,YAAY,CAAC,OAAO,EAAE,mBAAmB,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,CAwBrG"}
1
+ {"version":3,"file":"preview.d.ts","sourceRoot":"","sources":["../../src/node/preview.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAG3C,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;IACvB,IAAI,CAAC,EAAE,OAAO,CAAA;CACf;AAED,UAAU,aAAa;IACrB,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC3B;AAED,wBAAsB,YAAY,CAAC,OAAO,EAAE,mBAAmB,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,CA6BrG"}
@@ -12,7 +12,12 @@ export async function startPreview(options, ctx) {
12
12
  port,
13
13
  host: options.host,
14
14
  open: options.open,
15
- strictPort: false,
15
+ // A port asked for by name has to be that port or nothing. Moving to the
16
+ // next free one serves the book where nobody is looking, and lands it on
17
+ // whatever example owns that number, whose suite then adopts it (#175).
18
+ // Without an explicit port there is no such expectation, and `build`'s
19
+ // internal preview reads the port back off the server.
20
+ strictPort: options.port != null,
16
21
  },
17
22
  });
18
23
  const baseUrl = server.resolvedUrls?.local[0] ?? `http://localhost:${port}${ctx.resolvedViteConfig.base}`;
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/node/server.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAa3C,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;CACxB;AAED,wBAAsB,YAAY,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO,GAAE,mBAAwB;;;;GAsNjF"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/node/server.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAa3C,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;CACxB;AAED,wBAAsB,YAAY,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO,GAAE,mBAAwB;;;;GA6NjF"}
@@ -19,6 +19,12 @@ export async function createServer(ctx, options = {}) {
19
19
  if (options.host) {
20
20
  viteConfig.server.host = options.host;
21
21
  }
22
+ // Same rule as `preview`: a port asked for by name is that port or
23
+ // nothing. Floating to the next free one hands back a server nobody asked
24
+ // for, and whoever opens the port they typed reads a different book.
25
+ if (options.port != null) {
26
+ viteConfig.server.strictPort = true;
27
+ }
22
28
  }
23
29
  const server = await createViteServer(mergeViteConfig(viteConfig, {
24
30
  optimizeDeps: { include: viteConfig.optimizeDeps?.include ?? [], noDiscovery: collecting },
@@ -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.3",
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.3",
72
+ "@poveste/shared": "^0.5.3",
73
+ "@poveste/controls": "^0.5.3",
74
+ "@poveste/vendors": "^0.5.3"
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
+ })
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { resolvePort } from '../commands/port.js'
3
+
4
+ describe('resolvePort', () => {
5
+ it('returns undefined when the flag was not passed', () => {
6
+ expect(resolvePort(undefined, 'preview')).toBeUndefined()
7
+ })
8
+
9
+ it('parses the number sade hands over as a string', () => {
10
+ expect(resolvePort('4569', 'preview')).toBe(4569)
11
+ })
12
+
13
+ it('passes a number through', () => {
14
+ expect(resolvePort(6006, 'dev')).toBe(6006)
15
+ })
16
+
17
+ // sade declares `--port <port>` but does not enforce the value, so a bare
18
+ // `--port` arrives as `true`. `Number(true)` is 1 — a valid port that binds
19
+ // to a privileged one — so this has to be rejected by type.
20
+ it.each([
21
+ ['a valueless flag', true],
22
+ ['a non-numeric value', 'abc'],
23
+ ['an empty value', ''],
24
+ ['a port above the range', '99999'],
25
+ ['a fractional port', '80.5'],
26
+ ])('rejects %s', (_name, value) => {
27
+ expect(() => resolvePort(value, 'preview')).toThrow(/--port needs a number/)
28
+ })
29
+
30
+ it('names the command in the message, so the hint is runnable', () => {
31
+ expect(() => resolvePort(true, 'dev')).toThrow(/poveste dev --port 6006/)
32
+ })
33
+ })
package/src/node/bin.ts CHANGED
@@ -9,6 +9,7 @@ const { version } = JSON.parse(fs.readFileSync(resolve(__dirname, '../../package
9
9
 
10
10
  process.env.NODE_ENV = 'development'
11
11
  process.env.HISTOIRE = 'true'
12
+ process.env.POVESTE = 'true'
12
13
 
13
14
  const program = sade('poveste')
14
15
  program.version(version)
@@ -52,7 +52,9 @@ export function pinceauTokens(_options: PinceauTokensOptions = {}): Plugin {
52
52
  }
53
53
  },
54
54
 
55
- onDev(api, onCleanup) {
55
+ async onDev(api, onCleanup) {
56
+ await generate(api)
57
+
56
58
  const watcher = api.watcher.watch(themePath)
57
59
  .on('change', () => generate(api))
58
60
  .on('add', () => generate(api))
@@ -77,8 +77,10 @@ export function tailwindTokens(options: TailwindTokensOptions = {}): Plugin {
77
77
  }
78
78
  },
79
79
 
80
- onDev(api, onCleanup) {
80
+ async onDev(api, onCleanup) {
81
81
  if (tailwindCssFile) {
82
+ await generate(api)
83
+
82
84
  const watcher = api.watcher.watch(tailwindCssFile)
83
85
  .on('change', () => generate(api))
84
86
  .on('add', () => generate(api))
@@ -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
 
@@ -4,15 +4,18 @@ import pc from 'picocolors'
4
4
  import { resolveConfigFile } from '../config.js'
5
5
  import { createContext } from '../context.js'
6
6
  import { createServer } from '../server.js'
7
+ import { resolvePort } from './port.js'
7
8
 
8
9
  export interface DevOptions {
9
- port: number
10
+ port?: number | string
10
11
  open?: boolean
11
12
  host?: string | boolean
12
13
  config?: string
13
14
  }
14
15
 
15
16
  export async function devCommand(options: DevOptions) {
17
+ const port = resolvePort(options.port, 'dev')
18
+
16
19
  let stop: () => Promise<void>
17
20
 
18
21
  async function start() {
@@ -21,7 +24,7 @@ export async function devCommand(options: DevOptions) {
21
24
  mode: 'dev',
22
25
  })
23
26
  const { server, viteConfigFile, close } = await createServer(ctx, {
24
- port: options.port,
27
+ port,
25
28
  open: options.open,
26
29
  host: options.host,
27
30
  })
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Reads a `--port` value off the parsed CLI options.
3
+ *
4
+ * sade declares the flag as taking a value but does not enforce one, so
5
+ * `poveste preview --port` arrives as the boolean `true` and reaches node's
6
+ * listen validation, which rejects it with `ERR_INVALID_ARG_VALUE` and names
7
+ * neither the flag nor the mistake. A non-numeric value fails the same way.
8
+ */
9
+ export function resolvePort(value: unknown, command: string): number | undefined {
10
+ if (value === undefined || value === null) {
11
+ return undefined
12
+ }
13
+
14
+ // Checked by type, not by coercion: a valueless flag arrives as `true`, and
15
+ // `Number(true)` is 1, a valid port that would bind to a privileged one.
16
+ const port = typeof value === 'number'
17
+ ? value
18
+ : typeof value === 'string' && value.trim() !== ''
19
+ ? Number(value)
20
+ : Number.NaN
21
+
22
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
23
+ throw new TypeError(
24
+ `--port needs a number between 0 and 65535, got ${JSON.stringify(value)}. Try \`poveste ${command} --port 6006\`.`,
25
+ )
26
+ }
27
+
28
+ return port
29
+ }
@@ -1,13 +1,16 @@
1
1
  import { createContext } from '../context.js'
2
2
  import { startPreview } from '../preview.js'
3
+ import { resolvePort } from './port.js'
3
4
 
4
5
  export interface PreviewOptions {
5
- port?: number
6
+ port?: number | string
6
7
  host?: string | boolean
7
8
  open?: boolean
8
9
  }
9
10
 
10
11
  export async function previewCommand(options: PreviewOptions) {
12
+ const port = resolvePort(options.port, 'preview')
13
+
11
14
  const ctx = await createContext({
12
15
  mode: 'build',
13
16
  })
@@ -19,7 +22,7 @@ export async function previewCommand(options: PreviewOptions) {
19
22
  }
20
23
 
21
24
  const { printUrls } = await startPreview({
22
- port: options.port,
25
+ port,
23
26
  host: options.host,
24
27
  open: options.open,
25
28
  }, ctx)
@@ -122,7 +122,6 @@ export function getDefaultConfig(): PovesteConfig {
122
122
  contrastColor: '#eee',
123
123
  },
124
124
  ],
125
- sandboxDarkClass: 'dark',
126
125
  routerMode: 'history',
127
126
  build: {
128
127
  excludeFromVendorsChunk: [],
@@ -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
  }
@@ -26,7 +26,12 @@ export async function startPreview(options: StartPreviewOptions, ctx: Context):
26
26
  port,
27
27
  host: options.host,
28
28
  open: options.open,
29
- strictPort: false,
29
+ // A port asked for by name has to be that port or nothing. Moving to the
30
+ // next free one serves the book where nobody is looking, and lands it on
31
+ // whatever example owns that number, whose suite then adopts it (#175).
32
+ // Without an explicit port there is no such expectation, and `build`'s
33
+ // internal preview reads the port back off the server.
34
+ strictPort: options.port != null,
30
35
  },
31
36
  })
32
37
 
@@ -30,6 +30,13 @@ export async function createServer(ctx: Context, options: CreateServerOptions =
30
30
  if (options.host) {
31
31
  viteConfig.server.host = options.host
32
32
  }
33
+
34
+ // Same rule as `preview`: a port asked for by name is that port or
35
+ // nothing. Floating to the next free one hands back a server nobody asked
36
+ // for, and whoever opens the port they typed reads a different book.
37
+ if (options.port != null) {
38
+ viteConfig.server.strictPort = true
39
+ }
33
40
  }
34
41
 
35
42
  const server = await createViteServer(
@@ -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`,