inkbridge 0.1.0-beta.21 → 0.1.0-beta.23

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 (69) hide show
  1. package/README.md +29 -0
  2. package/code.js +15 -15
  3. package/manifest.json +1 -2
  4. package/package.json +40 -22
  5. package/scanner/border-dash-pattern-regression.ts +163 -0
  6. package/scanner/child-sizing-matrix-regression.ts +9 -0
  7. package/scanner/cli.ts +21 -5
  8. package/scanner/component-scanner.ts +1333 -77
  9. package/scanner/conditional-map-branch-regression.ts +180 -0
  10. package/scanner/css-token-reader.ts +66 -5
  11. package/scanner/dialog-content-gate-regression.ts +195 -0
  12. package/scanner/expression-evaluator-regression.ts +432 -0
  13. package/scanner/framework-adapter-shadcn-regression.ts +157 -1
  14. package/scanner/hidden-check-drift-regression.ts +125 -0
  15. package/scanner/horizontal-text-shrink-regression.ts +230 -0
  16. package/scanner/imported-array-map-regression.ts +195 -0
  17. package/scanner/inline-flex-regression.ts +5 -0
  18. package/scanner/intrinsic-sizing-regression.ts +333 -0
  19. package/scanner/portal-class-strip-regression.ts +109 -0
  20. package/scanner/responsive-hidden-inline-regression.ts +226 -0
  21. package/scanner/responsive-opt-in-regression.ts +212 -0
  22. package/scanner/select-root-flatten-regression.ts +314 -0
  23. package/scanner/space-between-single-child-regression.ts +163 -0
  24. package/scanner/story-args-resolution-regression.ts +311 -0
  25. package/scanner/story-dimensioning-regression.ts +76 -1
  26. package/scanner/style-map.ts +57 -0
  27. package/scanner/table-column-alignment-regression.ts +355 -0
  28. package/scanner/ternary-fragment-branch-regression.ts +196 -0
  29. package/scanner/text-truncate-regression.ts +481 -0
  30. package/scanner/types.ts +13 -0
  31. package/src/components/component-gen.ts +21 -38
  32. package/src/design-system/cva-master.ts +11 -18
  33. package/src/design-system/design-system.ts +36 -7
  34. package/src/design-system/frame-stabilizers.ts +109 -12
  35. package/src/design-system/preview-builder.ts +38 -0
  36. package/src/design-system/selectable-state.ts +8 -1
  37. package/src/design-system/story-builder.ts +62 -32
  38. package/src/design-system/story-dimensioning.ts +14 -3
  39. package/src/design-system/tag-predicates.ts +8 -0
  40. package/src/design-system/typography.ts +26 -0
  41. package/src/design-system/ui-builder.ts +188 -60
  42. package/src/effects/icon-builder.ts +8 -0
  43. package/src/framework-adapters/shadcn.ts +113 -0
  44. package/src/github/github.ts +22 -4
  45. package/src/layout/index.ts +4 -0
  46. package/src/layout/intrinsic-applier.ts +105 -0
  47. package/src/layout/intrinsic-sizing.ts +183 -0
  48. package/src/layout/layout-parser.ts +36 -0
  49. package/src/layout/parser/layout-mode.ts +14 -4
  50. package/src/layout/table-layout.ts +271 -0
  51. package/src/layout/text-truncate-pass.ts +151 -0
  52. package/src/layout/width-solver.ts +63 -17
  53. package/src/main.ts +37 -124
  54. package/src/plugin/config.ts +21 -0
  55. package/src/plugin/packs/pack-provider.ts +20 -4
  56. package/src/plugin/packs/packs.ts +14 -0
  57. package/src/render-engine-version.ts +1 -1
  58. package/src/tailwind/jsx-utils.ts +39 -0
  59. package/src/tailwind/node-ir.ts +8 -1
  60. package/src/tailwind/responsive-analyzer.ts +57 -3
  61. package/src/tailwind/tailwind.ts +344 -51
  62. package/src/text/index.ts +1 -0
  63. package/src/text/inline-text.ts +112 -12
  64. package/src/text/text-builder.ts +2 -2
  65. package/src/text/text-truncate.ts +101 -0
  66. package/src/tokens/tokens.ts +107 -16
  67. package/src/tokens/variables.ts +203 -46
  68. package/templates/scan-components-route.ts +8 -0
  69. package/ui.html +144 -43
@@ -0,0 +1,180 @@
1
+ import assert from 'node:assert/strict';
2
+ import * as path from 'node:path';
3
+ import * as fs from 'node:fs';
4
+ import { ComponentScanner } from './component-scanner';
5
+
6
+ /**
7
+ * Regression: when a `.map()` iteration is the truthy (or falsy) branch
8
+ * of a JSX ternary, `pushChosenJsxBranch` must dispatch to
9
+ * `expandMapCall` instead of falling back to `resolveExpressionValue`
10
+ * (which doesn't know how to iterate `.map`).
11
+ *
12
+ * Bug shape that prompted this fix (greenhouse-app `RecentTradesCard`):
13
+ *
14
+ * <TableBody>
15
+ * {rows.length ? (
16
+ * rows.map((row) => <TableRow key={row.id}>...</TableRow>)
17
+ * ) : (
18
+ * <EmptyRow />
19
+ * )}
20
+ * </TableBody>
21
+ *
22
+ * Without this fix the scanner picked the truthy branch correctly
23
+ * (rows.length resolves to a positive number), then silently dropped the
24
+ * CallExpression and rendered an empty `<tbody>` in Figma. The fix adds
25
+ * a `Node.isCallExpression(chosen)` case in `pushChosenJsxBranch` that
26
+ * routes `.map(...)` through `expandMapCall`.
27
+ *
28
+ * Bare `arr.map(...)` calls directly inside JSX (no ternary wrapper)
29
+ * already worked via the `Node.isCallExpression(expr)` branch in
30
+ * `buildJsxTree`. This fixture exists specifically to lock in the
31
+ * conditional-branch case.
32
+ */
33
+
34
+ interface JsxNodeLike {
35
+ type: 'element' | 'text';
36
+ tagName?: string;
37
+ content?: string;
38
+ props?: Record<string, unknown>;
39
+ children?: JsxNodeLike[];
40
+ }
41
+
42
+ interface ScannedShape {
43
+ name: string;
44
+ stories?: Array<{ name: string; jsxTree?: JsxNodeLike }>;
45
+ }
46
+
47
+ const FIXTURE_DIR = path.resolve(
48
+ process.cwd(),
49
+ 'tools/figma-plugin/scanner/__fixtures__/conditional-map-branch'
50
+ );
51
+
52
+ function findAll(node: JsxNodeLike | undefined, tag: string, out: JsxNodeLike[] = []): JsxNodeLike[] {
53
+ if (!node || node.type !== 'element') return out;
54
+ if (node.tagName === tag) out.push(node);
55
+ for (const c of node.children || []) findAll(c, tag, out);
56
+ return out;
57
+ }
58
+ function findOne(node: JsxNodeLike | undefined, tag: string): JsxNodeLike | null {
59
+ if (!node || node.type !== 'element') return null;
60
+ if (node.tagName === tag) return node;
61
+ for (const c of node.children || []) {
62
+ const f = findOne(c, tag);
63
+ if (f) return f;
64
+ }
65
+ return null;
66
+ }
67
+
68
+ function writeFixtures(): void {
69
+ fs.mkdirSync(FIXTURE_DIR, { recursive: true });
70
+ fs.writeFileSync(
71
+ path.join(FIXTURE_DIR, 'TradesList.tsx'),
72
+ `
73
+ export interface TradeRow {
74
+ id: string;
75
+ label: string;
76
+ }
77
+ export function TradesList({ rows }: { rows: TradeRow[] }) {
78
+ return (
79
+ <div>
80
+ <h1>Trades</h1>
81
+ {rows.length ? (
82
+ rows.map((row) => (
83
+ <div data-row-id={row.id}>{row.label}</div>
84
+ ))
85
+ ) : (
86
+ <div data-role="empty">No trades</div>
87
+ )}
88
+ </div>
89
+ );
90
+ }
91
+ `,
92
+ 'utf-8'
93
+ );
94
+ fs.writeFileSync(
95
+ path.join(FIXTURE_DIR, 'TradesList.stories.tsx'),
96
+ `
97
+ import { TradesList } from "./TradesList";
98
+ const meta = { component: TradesList };
99
+ export default meta;
100
+
101
+ // Inline array literal in args + ternary-with-map: exercises the
102
+ // pushChosenJsxBranch CallExpression case.
103
+ export const WithRows = {
104
+ args: {
105
+ rows: [
106
+ { id: "a", label: "Alpha" },
107
+ { id: "b", label: "Beta" },
108
+ { id: "c", label: "Gamma" },
109
+ ],
110
+ },
111
+ };
112
+
113
+ // Empty rows → ternary picks the falsy branch (the empty-state div).
114
+ // Exercises the JsxElement case in pushChosenJsxBranch, which always
115
+ // worked but is the natural counterpart to assert here.
116
+ export const Empty = {
117
+ args: { rows: [] },
118
+ };
119
+ `,
120
+ 'utf-8'
121
+ );
122
+ }
123
+
124
+ function cleanup(): void {
125
+ try { fs.rmSync(FIXTURE_DIR, { recursive: true, force: true }); } catch { /* ignore */ }
126
+ }
127
+
128
+ async function run(): Promise<void> {
129
+ writeFixtures();
130
+ const scanner = new ComponentScanner({
131
+ componentPaths: [FIXTURE_DIR],
132
+ filePattern: '*.tsx',
133
+ exclude: [],
134
+ });
135
+ const results = (await scanner.scanAll()) as unknown as ScannedShape[];
136
+ const def = results.find((r) => r.name === 'TradesList');
137
+ assert.ok(def, 'TradesList component must be scanned');
138
+ const byName = (n: string) => (def.stories || []).find((s) => s.name === n);
139
+
140
+ // -------------------------------------------------------------------------
141
+ // WithRows: the truthy branch of `rows.length ? rows.map(...) : <Empty/>`
142
+ // must expand the .map and emit one <div data-row-id> per row.
143
+ // -------------------------------------------------------------------------
144
+ {
145
+ const story = byName('WithRows');
146
+ assert.ok(story?.jsxTree, 'WithRows story must have a jsxTree');
147
+ const rows = findAll(story.jsxTree, 'div').filter((n) => n.props?.['data-row-id'] != null);
148
+ assert.equal(rows.length, 3, `expected 3 row divs from rows.map, got ${rows.length}`);
149
+ const ids = rows.map((r) => r.props?.['data-row-id']).join(',');
150
+ assert.equal(ids, 'a,b,c', `row order/ids must round-trip, got "${ids}"`);
151
+ const empty = findOne(story.jsxTree, 'div');
152
+ // The empty-state div has data-role="empty" — must NOT be emitted when
153
+ // rows are present.
154
+ const emptyDiv = findAll(story.jsxTree, 'div').find((n) => n.props?.['data-role'] === 'empty');
155
+ assert.equal(emptyDiv, undefined, 'empty-state div must not appear when rows are populated');
156
+ assert.ok(empty, 'sanity: root div renders');
157
+ }
158
+
159
+ // -------------------------------------------------------------------------
160
+ // Empty: the falsy branch picks the empty-state div (JsxElement path,
161
+ // which already worked — this asserts symmetry).
162
+ // -------------------------------------------------------------------------
163
+ {
164
+ const story = byName('Empty');
165
+ assert.ok(story?.jsxTree, 'Empty story must have a jsxTree');
166
+ const emptyDiv = findAll(story.jsxTree, 'div').find((n) => n.props?.['data-role'] === 'empty');
167
+ assert.ok(emptyDiv, 'empty-state div must appear when rows is empty');
168
+ const rowDivs = findAll(story.jsxTree, 'div').filter((n) => n.props?.['data-row-id'] != null);
169
+ assert.equal(rowDivs.length, 0, 'no row divs when rows is empty');
170
+ }
171
+
172
+ cleanup();
173
+ console.log('conditional-map-branch-regression: PASS (2 cases)');
174
+ }
175
+
176
+ run().catch((err) => {
177
+ cleanup();
178
+ console.error(err);
179
+ process.exit(1);
180
+ });
@@ -240,7 +240,19 @@ export function resolveCssTokenPathFromImports(filePath: string, visited: Set<st
240
240
  return absolute;
241
241
  }
242
242
 
243
- function readCssWithImports(filePath: string, visited: Set<string> = new Set()): string {
243
+ interface ReadCssOptions {
244
+ /**
245
+ * When true, `@import` directives that resolve via `node_modules`
246
+ * (bare specifiers like `"tailwindcss"` / `"tw-animate-css"`) are
247
+ * NOT expanded — only consumer-owned files (relative paths) get
248
+ * inlined. Used by the consumer-only scan path so the Design Tokens
249
+ * panel can show ONLY what the consumer wrote, without Tailwind's
250
+ * defaults leaking in.
251
+ */
252
+ skipBareImports?: boolean;
253
+ }
254
+
255
+ function readCssWithImports(filePath: string, visited: Set<string> = new Set(), opts: ReadCssOptions = {}): string {
244
256
  const absolute = path.resolve(filePath);
245
257
  if (visited.has(absolute)) return '';
246
258
  visited.add(absolute);
@@ -262,19 +274,46 @@ function readCssWithImports(filePath: string, visited: Set<string> = new Set()):
262
274
  const parts: string[] = [];
263
275
  for (const node of root.nodes || []) {
264
276
  if (node.type === 'atrule' && ((node as AtRule).name || '').toLowerCase() === 'import') {
265
- const imported = resolveImportedCssPath(absolute, (node as AtRule).params || '');
277
+ const params = (node as AtRule).params || '';
278
+ const specifier = extractImportSpecifier(params) || '';
279
+ const isBare = specifier && !specifier.startsWith('./') && !specifier.startsWith('../') && !path.isAbsolute(specifier);
280
+ if (opts.skipBareImports && isBare) {
281
+ // Drop the import entirely — don't expand and don't preserve the
282
+ // unresolved @import line either (the walker would otherwise see
283
+ // a parameterless atrule with no body).
284
+ continue;
285
+ }
286
+ const imported = resolveImportedCssPath(absolute, params);
266
287
  if (imported) {
267
- const importedCss = readCssWithImports(imported, visited);
288
+ const importedCss = readCssWithImports(imported, visited, opts);
268
289
  if (importedCss.trim()) parts.push(importedCss);
269
290
  continue;
270
291
  }
271
292
  }
272
- parts.push(node.toString());
293
+ parts.push(serializeNodeWithTerminator(node));
273
294
  }
274
295
 
275
296
  return parts.join('\n');
276
297
  }
277
298
 
299
+ /**
300
+ * Serialize a postcss node for concatenation. Bare at-rules (no block) need
301
+ * an explicit terminating `;` — postcss's `node.toString()` omits trailing
302
+ * semicolons since they're whitespace-style raws, but concatenating two
303
+ * unterminated bare at-rules (e.g. `@plugin "x"\n@custom-variant y\n@theme
304
+ * inline {...}`) collides them into a single rule whose params swallow
305
+ * everything after. Greenhouse-app's `@theme inline { --font-* }` block
306
+ * was being eaten as params of the preceding `@plugin` at-rule before
307
+ * this fix, so its font / colour overrides never reached the walker.
308
+ */
309
+ function serializeNodeWithTerminator(node: postcss.Node): string {
310
+ const text = node.toString();
311
+ if (node.type !== 'atrule') return text;
312
+ const at = node as AtRule;
313
+ if (at.nodes) return text; // has a {} block — already terminated
314
+ return text.trimEnd().endsWith(';') ? text : text + ';';
315
+ }
316
+
278
317
  export function discoverCssTokenPath(projectRoot: string, explicitPath?: string): string | null {
279
318
  if (explicitPath && explicitPath.trim()) {
280
319
  return discoverFilePath(projectRoot, explicitPath.trim());
@@ -567,6 +606,28 @@ function parseDtcgTokenMap(
567
606
  }
568
607
 
569
608
  export function readTokenSourceMap(options: ReadTokenSourceOptions): ScannedTokenMap {
609
+ return readTokenSourceMapInternal(options, {});
610
+ }
611
+
612
+ /**
613
+ * Like `readTokenSourceMap`, but only walks tokens the consumer actually
614
+ * wrote in their own files. `@import` directives that resolve via
615
+ * `node_modules` (Tailwind, tw-animate-css, …) are dropped from the
616
+ * concat instead of expanded, so the resulting map omits Tailwind's
617
+ * defaults (`--font-serif`, `--text-sm`, the default spacing scale,
618
+ * etc.). Used by the design-tokens panel to show only the consumer's
619
+ * overrides — runtime token resolution still uses the full map from
620
+ * `readTokenSourceMap` so utilities like `text-sm` keep working.
621
+ *
622
+ * Returns an empty map when the consumer mode is DTCG-only — DTCG
623
+ * files are by definition consumer-owned, so the full scan IS already
624
+ * the consumer-only scan.
625
+ */
626
+ export function readConsumerOwnedTokenMap(options: ReadTokenSourceOptions): ScannedTokenMap {
627
+ return readTokenSourceMapInternal(options, { skipBareImports: true });
628
+ }
629
+
630
+ function readTokenSourceMapInternal(options: ReadTokenSourceOptions, readOpts: ReadCssOptions): ScannedTokenMap {
570
631
  const projectRoot = path.resolve(options.projectRoot);
571
632
  const requestedMode: TokenSourceMode = options.tokenSourceMode === 'dtcg' ? 'dtcg' : 'css';
572
633
  const cssPath = discoverCssTokenPath(projectRoot, options.cssTokenPath);
@@ -581,7 +642,7 @@ export function readTokenSourceMap(options: ReadTokenSourceOptions): ScannedToke
581
642
 
582
643
  // css mode: CSS preferred; fall back to DTCG if no CSS file found, then embedded.
583
644
  if (cssPath) {
584
- const cssText = readCssWithImports(cssPath);
645
+ const cssText = readCssWithImports(cssPath, new Set(), readOpts);
585
646
  // Use the file that actually contains the declarations as source so the
586
647
  // write-back path (PR/patch) targets the right file even when globals.css
587
648
  // delegates token definitions to an imported file like tokens.css.
@@ -0,0 +1,195 @@
1
+ import assert from 'node:assert/strict';
2
+ import { ComponentScanner } from './component-scanner';
3
+
4
+ /**
5
+ * Regression: every `DialogContent` / `DialogPrimitive.Content` in a
6
+ * scanned JSX tree gets an `__dialogRootIsOpen` data prop derived
7
+ * from the nearest enclosing `<Dialog>` / `<DialogPrimitive.Root>`'s
8
+ * `open` / `defaultOpen` props. The render-time gate in
9
+ * `ui-builder.ts` uses this annotation to suppress nested closed
10
+ * dialogs so they don't bleed inline next to their parent.
11
+ *
12
+ * Real-world trigger: greenhouse-app `IncreasePositionModal` has a
13
+ * help-info `<Dialog>` nested inside its `<DialogHeader>`. The outer
14
+ * Dialog receives `open={true}` from the story, the inner has no
15
+ * explicit `open`. Before the gate, both DialogContents rendered —
16
+ * the inner "Open position / Overview" content appeared as a sliver
17
+ * floating top-right of the main modal in the sm preview, and as
18
+ * vertical-letter-per-line description text in the base preview.
19
+ *
20
+ * Why the annotation is required (instead of inheriting context at
21
+ * render time): the outer DialogContent is portal-extracted at scan
22
+ * time, so by the time `buildFigmaNode` reaches it the wrapping
23
+ * Dialog Root is no longer on its ancestor chain. Baking the open
24
+ * state into a data prop at scan time keeps the gate universal
25
+ * regardless of which portal path the render takes.
26
+ */
27
+
28
+ interface TestScannerView {
29
+ transformJsxTree: (tree: unknown) => unknown;
30
+ }
31
+
32
+ const scanner = new ComponentScanner({
33
+ componentPaths: [],
34
+ filePattern: '*.tsx',
35
+ exclude: [],
36
+ }) as unknown as TestScannerView;
37
+
38
+ interface NodeLike {
39
+ type: 'element' | 'text';
40
+ tagName?: string;
41
+ isComponent?: boolean;
42
+ props?: Record<string, string>;
43
+ children?: NodeLike[];
44
+ content?: string;
45
+ }
46
+
47
+ function element(
48
+ tagName: string,
49
+ props: Record<string, string> = {},
50
+ children: NodeLike[] = [],
51
+ ): NodeLike {
52
+ return {
53
+ type: 'element',
54
+ tagName,
55
+ isComponent: /^[A-Z]/.test(tagName),
56
+ props,
57
+ children,
58
+ };
59
+ }
60
+
61
+ function findElements(node: NodeLike, tagName: string, out: NodeLike[] = []): NodeLike[] {
62
+ if (!node || node.type !== 'element') return out;
63
+ if (node.tagName === tagName) out.push(node);
64
+ for (const child of node.children || []) findElements(child, tagName, out);
65
+ return out;
66
+ }
67
+
68
+ const transform = (tree: NodeLike): NodeLike =>
69
+ scanner.transformJsxTree(tree as unknown) as NodeLike;
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Case 1: outer Dialog open=true, nested Dialog has no open prop.
73
+ // Outer content gets isOpen=true, nested content gets isOpen=false.
74
+ // ---------------------------------------------------------------------------
75
+
76
+ {
77
+ const tree = element('div', {}, [
78
+ element('Dialog', { open: 'true' }, [
79
+ element('DialogContent', {}, [
80
+ element('DialogHeader', {}, [
81
+ element('DialogTitle', {}, []),
82
+ element('Dialog', {}, [ // nested, no open
83
+ element('DialogTrigger', {}, []),
84
+ element('DialogContent', {}, [
85
+ element('p', {}, []),
86
+ ]),
87
+ ]),
88
+ ]),
89
+ ]),
90
+ ]),
91
+ ]);
92
+
93
+ const result = transform(tree);
94
+ const contents = findElements(result, 'DialogContent');
95
+ assert.equal(contents.length, 2, 'both DialogContents preserved in the tree');
96
+ assert.equal(
97
+ contents[0].props?.__dialogRootIsOpen,
98
+ 'true',
99
+ 'outer DialogContent annotated with isOpen=true from the open prop',
100
+ );
101
+ assert.equal(
102
+ contents[1].props?.__dialogRootIsOpen,
103
+ 'false',
104
+ 'nested DialogContent annotated with isOpen=false — gate will suppress it at render time',
105
+ );
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Case 2: defaultOpen also counts as open
110
+ // ---------------------------------------------------------------------------
111
+
112
+ {
113
+ const tree = element('div', {}, [
114
+ element('Dialog', { defaultOpen: 'true' }, [
115
+ element('DialogContent', {}, []),
116
+ ]),
117
+ ]);
118
+ const result = transform(tree);
119
+ const contents = findElements(result, 'DialogContent');
120
+ assert.equal(
121
+ contents[0].props?.__dialogRootIsOpen,
122
+ 'true',
123
+ 'defaultOpen=true propagates as isOpen — covers stories that pre-open the modal',
124
+ );
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Case 3: DialogPrimitive.Root / DialogPrimitive.Content alias works the same
129
+ // ---------------------------------------------------------------------------
130
+
131
+ {
132
+ const tree = element('div', {}, [
133
+ element('DialogPrimitive.Root', { open: 'true' }, [
134
+ element('DialogPrimitive.Content', {}, []),
135
+ ]),
136
+ ]);
137
+ const result = transform(tree);
138
+ const contents = findElements(result, 'DialogPrimitive.Content');
139
+ assert.equal(
140
+ contents[0].props?.__dialogRootIsOpen,
141
+ 'true',
142
+ 'Primitive.Root + Primitive.Content combo is annotated the same way',
143
+ );
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Case 4: deeply nested Dialog — each Content gets the nearest Root's state
148
+ // ---------------------------------------------------------------------------
149
+
150
+ {
151
+ const tree = element('div', {}, [
152
+ element('Dialog', { open: 'true' }, [
153
+ element('DialogContent', { 'data-marker': 'outer' }, [
154
+ element('Dialog', { defaultOpen: 'true' }, [
155
+ element('DialogContent', { 'data-marker': 'middle' }, [
156
+ element('Dialog', {}, [
157
+ element('DialogContent', { 'data-marker': 'inner' }, []),
158
+ ]),
159
+ ]),
160
+ ]),
161
+ ]),
162
+ ]),
163
+ ]);
164
+ const result = transform(tree);
165
+ const contents = findElements(result, 'DialogContent');
166
+ const byMarker = new Map(contents.map((c) => [c.props?.['data-marker'], c]));
167
+ assert.equal(byMarker.get('outer')?.props?.__dialogRootIsOpen, 'true', 'outermost: open=true');
168
+ assert.equal(byMarker.get('middle')?.props?.__dialogRootIsOpen, 'true', 'middle: defaultOpen=true');
169
+ assert.equal(
170
+ byMarker.get('inner')?.props?.__dialogRootIsOpen,
171
+ 'false',
172
+ 'innermost: no open prop → falsy annotation — gate suppresses it independent of ancestor state',
173
+ );
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Case 5: DialogContent NOT inside any Dialog Root — annotation absent.
178
+ // The render-time gate falls through (no suppression) since this is the
179
+ // portal-extracted shape that other stories rely on.
180
+ // ---------------------------------------------------------------------------
181
+
182
+ {
183
+ const tree = element('div', {}, [
184
+ element('DialogContent', {}, []),
185
+ ]);
186
+ const result = transform(tree);
187
+ const contents = findElements(result, 'DialogContent');
188
+ assert.equal(
189
+ contents[0].props?.__dialogRootIsOpen,
190
+ undefined,
191
+ 'DialogContent with no Dialog ancestor stays unannotated → renderer falls back to render',
192
+ );
193
+ }
194
+
195
+ console.log('dialog-content-gate-regression: PASS');