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,311 @@
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: story `args` resolution must handle three shapes that
8
+ * commonly appear in real consumer stories but were silently dropped or
9
+ * mis-parsed before:
10
+ *
11
+ * 1. **Spread of a shared base** (`...BASE_ARGS`) — the canonical pattern
12
+ * for stories that mostly share args and only override one field:
13
+ *
14
+ * const BASE_ARGS = { marketSymbol: "SOL", activeSide: "long", ... };
15
+ * export const WithLegend = { args: { ...BASE_ARGS, markers: [...] } };
16
+ *
17
+ * Previously the scanner only walked `Node.isPropertyAssignment`,
18
+ * silently skipping `Node.isSpreadAssignment`. Every BASE_ARGS field
19
+ * went missing from `argsContext`, so the component body saw
20
+ * `marketSymbol = undefined` and rendered blank.
21
+ *
22
+ * 2. **`null` / `undefined` keyword literals** as story arg values —
23
+ * e.g. `error: null` in BASE_ARGS. `parseLiteralValue` previously
24
+ * fell through to `val.getText()` and returned the STRING `"null"`,
25
+ * which is truthy. Conditionals like `{error && <Banner>{error}</Banner>}`
26
+ * then rendered the banner with literal "null" text.
27
+ *
28
+ * 3. **Array literal property values** (`markers: [{...}, {...}]`) —
29
+ * `resolveExpressionValue` doesn't handle ArrayLiteralExpression, so
30
+ * the story-args loop set the arg to `undefined`. Inside the component
31
+ * `{markers.length ? <Legend/> : null}` then evaluated `undefined.length`
32
+ * → undefined → both branches skipped → legend missing. Fix: fall back
33
+ * to `parseLiteralValue` (already used by `extractPropsFromNode` for
34
+ * the JSX-prop equivalent).
35
+ *
36
+ * These fixes are exercised against an on-disk fixture project so the
37
+ * test models the production scan path (`pnpm inkbridge:scan`), not a
38
+ * synthetic in-memory variant.
39
+ */
40
+
41
+ interface JsxNodeLike {
42
+ type: 'element' | 'text';
43
+ tagName?: string;
44
+ content?: string;
45
+ props?: Record<string, unknown>;
46
+ children?: JsxNodeLike[];
47
+ }
48
+
49
+ interface StoryShape {
50
+ name: string;
51
+ jsxTree?: JsxNodeLike;
52
+ }
53
+
54
+ interface ScannedShape {
55
+ name: string;
56
+ stories?: StoryShape[];
57
+ }
58
+
59
+ function findElement(node: JsxNodeLike | undefined | null, tag: string): JsxNodeLike | null {
60
+ if (!node || node.type !== 'element') return null;
61
+ if (node.tagName === tag) return node;
62
+ for (const child of node.children || []) {
63
+ const found = findElement(child, tag);
64
+ if (found) return found;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function findAllElements(node: JsxNodeLike | undefined | null, tag: string, out: JsxNodeLike[] = []): JsxNodeLike[] {
70
+ if (!node || node.type !== 'element') return out;
71
+ if (node.tagName === tag) out.push(node);
72
+ for (const child of node.children || []) findAllElements(child, tag, out);
73
+ return out;
74
+ }
75
+
76
+ function collectText(node: JsxNodeLike | undefined | null, out: string[] = []): string[] {
77
+ if (!node) return out;
78
+ if (node.type === 'text') {
79
+ if (typeof node.content === 'string') out.push(node.content);
80
+ return out;
81
+ }
82
+ for (const child of node.children || []) collectText(child, out);
83
+ return out;
84
+ }
85
+
86
+ const FIXTURE_DIR = path.resolve(
87
+ process.cwd(),
88
+ 'tools/figma-plugin/scanner/__fixtures__/story-args-resolution'
89
+ );
90
+
91
+ // Write fixture files to disk. The scanner discovers components via glob,
92
+ // so in-memory ts-morph files aren't enough — we need real files. Cleaned
93
+ // up at end of run so re-runs are idempotent.
94
+ function writeFixtures(): void {
95
+ fs.mkdirSync(FIXTURE_DIR, { recursive: true });
96
+ fs.writeFileSync(
97
+ path.join(FIXTURE_DIR, 'Card.tsx'),
98
+ `
99
+ export interface CardProps {
100
+ title: string;
101
+ subtitle: string;
102
+ error: string | null;
103
+ activeSide: "long" | "short";
104
+ items: Array<{ id: string; label: string }>;
105
+ }
106
+ export function Card({ title, subtitle, error, activeSide, items }: CardProps) {
107
+ return (
108
+ <div>
109
+ <h1>{title}</h1>
110
+ <p>{subtitle}</p>
111
+ {error && <div data-role="error-banner">{error}</div>}
112
+ <button data-active={activeSide === "long" ? "true" : "false"}>{activeSide}</button>
113
+ {items.length ? (
114
+ <ul>
115
+ {items.map((it) => <li data-id={it.id}>{it.label}</li>)}
116
+ </ul>
117
+ ) : null}
118
+ </div>
119
+ );
120
+ }
121
+ `,
122
+ 'utf-8'
123
+ );
124
+ fs.writeFileSync(
125
+ path.join(FIXTURE_DIR, 'Card.stories.tsx'),
126
+ `
127
+ import { Card } from "./Card";
128
+ const meta = { component: Card };
129
+ export default meta;
130
+
131
+ const BASE_ARGS = {
132
+ title: "Hello",
133
+ subtitle: "from base",
134
+ error: null,
135
+ activeSide: "long" as const,
136
+ items: [],
137
+ };
138
+
139
+ // Case 1: spread of base + override with array literal. Exercises
140
+ // the SpreadAssignment branch AND the ArrayLiteralExpression fallback
141
+ // in the PropertyAssignment branch.
142
+ export const WithItems = {
143
+ args: {
144
+ ...BASE_ARGS,
145
+ items: [{ id: "a", label: "Alpha" }, { id: "b", label: "Beta" }],
146
+ },
147
+ };
148
+
149
+ // Case 2: spread that includes a \`null\` keyword literal. The base's
150
+ // \`error: null\` MUST NOT become the string "null" (which is truthy
151
+ // and would render the error banner).
152
+ export const NoError = {
153
+ args: { ...BASE_ARGS },
154
+ };
155
+
156
+ // Case 3: explicit override that flips a value. Verifies args
157
+ // ordering — properties after the spread win.
158
+ export const ShortSide = {
159
+ args: { ...BASE_ARGS, activeSide: "short" as const },
160
+ };
161
+
162
+ // Case 4: direct \`null\` PropertyAssignment (no spread). Mirrors
163
+ // the real PerpsHeader story shape: \`viewingWalletLabel: null\`
164
+ // written inline. The args-extraction path uses
165
+ // \`extractStringValue\`, which stringifies the null keyword to
166
+ // "null" — \`buildArgsPropsContext\` must drop that so the
167
+ // conditional \`{viewingWalletLabel ? ... : null}\` resolves
168
+ // falsy and the wallet div doesn't render.
169
+ export const DirectNoError = {
170
+ args: {
171
+ title: "Direct",
172
+ subtitle: "no spread",
173
+ error: null,
174
+ activeSide: "long" as const,
175
+ items: [],
176
+ },
177
+ };
178
+ `,
179
+ 'utf-8'
180
+ );
181
+ }
182
+
183
+ function cleanupFixtures(): void {
184
+ try {
185
+ fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
186
+ } catch {
187
+ /* ignore */
188
+ }
189
+ }
190
+
191
+ async function run(): Promise<void> {
192
+ writeFixtures();
193
+
194
+ const scanner = new ComponentScanner({
195
+ componentPaths: [FIXTURE_DIR],
196
+ filePattern: '*.tsx',
197
+ exclude: [],
198
+ });
199
+
200
+ const results = (await scanner.scanAll()) as unknown as ScannedShape[];
201
+ const card = results.find((r) => r.name === 'Card');
202
+ assert.ok(card, `must find the Card component; got names: ${results.map((r) => r.name).join(', ')}`);
203
+ const stories = card.stories || [];
204
+ const byName = (n: string) => stories.find((s) => s.name === n || s.name.replace(/\s+/g, '') === n);
205
+
206
+ // -------------------------------------------------------------------------
207
+ // Case 1: WithItems — spread + array literal override
208
+ // -------------------------------------------------------------------------
209
+ {
210
+ const story = byName('WithItems');
211
+ assert.ok(story?.jsxTree, 'WithItems story must produce a jsxTree');
212
+ const h1 = findElement(story.jsxTree, 'h1');
213
+ const h1Text = collectText(h1).join('');
214
+ assert.ok(
215
+ h1Text.includes('Hello'),
216
+ `WithItems should inherit title="Hello" from BASE_ARGS via spread; got "${h1Text}"`,
217
+ );
218
+ const items = findAllElements(story.jsxTree, 'li');
219
+ assert.equal(
220
+ items.length,
221
+ 2,
222
+ `WithItems should render 2 <li> from the array-literal override; got ${items.length}`,
223
+ );
224
+ const ids = items.map((it) => it.props?.['data-id']).join(',');
225
+ assert.equal(
226
+ ids,
227
+ 'a,b',
228
+ `array-literal story-arg must preserve object keys; got "${ids}"`,
229
+ );
230
+ }
231
+
232
+ // -------------------------------------------------------------------------
233
+ // Case 2: NoError — spread including `error: null`
234
+ // -------------------------------------------------------------------------
235
+ {
236
+ const story = byName('NoError');
237
+ assert.ok(story?.jsxTree, 'NoError story must produce a jsxTree');
238
+ // The error banner is the only nested <div> with data-role="error-banner".
239
+ function findByRole(n: JsxNodeLike | undefined, role: string): JsxNodeLike | null {
240
+ if (!n || n.type !== 'element') return null;
241
+ if (n.props?.['data-role'] === role) return n;
242
+ for (const c of n.children || []) {
243
+ const f = findByRole(c, role);
244
+ if (f) return f;
245
+ }
246
+ return null;
247
+ }
248
+ const errBanner = findByRole(story.jsxTree, 'error-banner');
249
+ assert.equal(
250
+ errBanner,
251
+ null,
252
+ `null literal must not be coerced to truthy string "null" — error banner should be absent`,
253
+ );
254
+ // h1 still resolves the title from the base.
255
+ const h1 = findElement(story.jsxTree, 'h1');
256
+ const h1Text = collectText(h1).join('');
257
+ assert.ok(h1Text.includes('Hello'), `NoError should inherit title from BASE_ARGS; got "${h1Text}"`);
258
+ }
259
+
260
+ // -------------------------------------------------------------------------
261
+ // Case 3: ShortSide — explicit override after spread wins
262
+ // -------------------------------------------------------------------------
263
+ {
264
+ const story = byName('ShortSide');
265
+ assert.ok(story?.jsxTree, 'ShortSide story must produce a jsxTree');
266
+ const btn = findElement(story.jsxTree, 'button');
267
+ const btnText = collectText(btn).join('');
268
+ assert.ok(
269
+ btnText.includes('short'),
270
+ `explicit override should win over base; got button text "${btnText}"`,
271
+ );
272
+ assert.equal(
273
+ btn?.props?.['data-active'],
274
+ 'false',
275
+ `activeSide override should flip the ternary; expected data-active="false" got "${btn?.props?.['data-active']}"`,
276
+ );
277
+ }
278
+
279
+ // -------------------------------------------------------------------------
280
+ // Case 4: DirectNoError — direct PropertyAssignment of a `null` literal
281
+ // (no spread). Mirrors PerpsHeader's `viewingWalletLabel: null`.
282
+ // -------------------------------------------------------------------------
283
+ {
284
+ const story = byName('DirectNoError');
285
+ assert.ok(story?.jsxTree, 'DirectNoError story must produce a jsxTree');
286
+ function findByRole2(n: JsxNodeLike | undefined, role: string): JsxNodeLike | null {
287
+ if (!n || n.type !== 'element') return null;
288
+ if (n.props?.['data-role'] === role) return n;
289
+ for (const c of n.children || []) {
290
+ const f = findByRole2(c, role);
291
+ if (f) return f;
292
+ }
293
+ return null;
294
+ }
295
+ const errBanner = findByRole2(story.jsxTree, 'error-banner');
296
+ assert.equal(
297
+ errBanner,
298
+ null,
299
+ `direct PropertyAssignment of \`null\` must drop the prop — error banner should be absent (was the PerpsHeader "null" bug)`,
300
+ );
301
+ }
302
+
303
+ console.log('story-args-resolution-regression: PASS (4 cases)');
304
+ }
305
+
306
+ run()
307
+ .catch((err) => {
308
+ cleanupFixtures();
309
+ throw err;
310
+ })
311
+ .then(cleanupFixtures);
@@ -8,7 +8,7 @@ import {
8
8
  treeHasDescendantClass,
9
9
  treeHasPortalWithFullHeight,
10
10
  } from '../src/design-system/story-dimensioning';
11
- import type { JsxNode } from '../src/tailwind';
11
+ import { findAnyMaxWidthInTree, type JsxNode } from '../src/tailwind';
12
12
  import type { ComponentStory } from '../src/components';
13
13
 
14
14
  /**
@@ -295,4 +295,79 @@ assert.equal(
295
295
  'null tree falls back to layoutClasses scan',
296
296
  );
297
297
 
298
+ // ---------------------------------------------------------------------------
299
+ // findAnyMaxWidthInTree — looser fallback for portal-mounted dialogs
300
+ // ---------------------------------------------------------------------------
301
+ //
302
+ // The strict `extractLeadingContainerMaxWidthFromTree` walks only the
303
+ // single-child spine AND requires `w-full` on the same node. shadcn
304
+ // DialogContent has `sm:max-w-lg` but no `w-full`, and its parent Dialog
305
+ // usually has multi-child fan-out (overlay + content). So the strict helper
306
+ // returns null and the story falls back to the generic 900px width, making
307
+ // the dialog preview look much wider than it would in the browser.
308
+ // `findAnyMaxWidthInTree` is the looser fallback wired in
309
+ // `resolveStoryLayoutWidth` for that case.
310
+
311
+ // Single-child spine with max-w → picks it up (parity with the strict helper).
312
+ assert.equal(
313
+ findAnyMaxWidthInTree(el({ className: 'max-w-lg' })),
314
+ 512,
315
+ 'max-w-lg on root returns 512 (lg = 32rem)',
316
+ );
317
+
318
+ // Variant-prefixed (sm:max-w-lg) is honoured — extractMaxWidth already
319
+ // handles responsive variants after the Pass-2 extractor fix.
320
+ assert.equal(
321
+ findAnyMaxWidthInTree(el({ className: 'sm:max-w-lg grid' })),
322
+ 512,
323
+ 'variant-prefixed sm:max-w-lg resolves to 512 — covers shadcn DialogContent',
324
+ );
325
+
326
+ // Multi-child parent doesn't block the walk — the helper descends every branch.
327
+ {
328
+ const tree = el({}, [
329
+ el({ className: 'overlay fixed inset-0' }),
330
+ el({ className: 'sm:max-w-lg grid' }, [el({ className: 'p-6' })]),
331
+ ]);
332
+ assert.equal(
333
+ findAnyMaxWidthInTree(tree),
334
+ 512,
335
+ 'multi-child parent (Dialog overlay + content) does not block the walk',
336
+ );
337
+ }
338
+
339
+ // Without `w-full` the strict helper bails — this looser one does not.
340
+ assert.equal(
341
+ findAnyMaxWidthInTree(el({ className: 'max-w-sm' })),
342
+ 384,
343
+ 'lack of w-full does not prevent picking up max-w-* — sm = 24rem = 384',
344
+ );
345
+
346
+ // Picks the SMALLEST max-w-* when multiple appear in the tree (most
347
+ // restrictive constraint wins as the story-width hint).
348
+ {
349
+ const tree = el({ className: 'max-w-4xl' }, [
350
+ el({ className: 'max-w-md' }),
351
+ ]);
352
+ assert.equal(
353
+ findAnyMaxWidthInTree(tree),
354
+ 448,
355
+ 'when multiple max-w-* appear, the smallest wins (md = 28rem = 448)',
356
+ );
357
+ }
358
+
359
+ // No max-w anywhere → null (so callers can fall through to 900px default).
360
+ assert.equal(
361
+ findAnyMaxWidthInTree(el({ className: 'flex flex-col p-4' })),
362
+ null,
363
+ 'tree with no max-w-* returns null — caller decides the fallback',
364
+ );
365
+
366
+ // Null tree is tolerated.
367
+ assert.equal(
368
+ findAnyMaxWidthInTree(undefined),
369
+ null,
370
+ 'undefined tree returns null without throwing',
371
+ );
372
+
298
373
  console.log('story-dimensioning-regression: PASS');
@@ -35,6 +35,57 @@ async function loadStylesheet(id: string, base: string): Promise<{ path: string;
35
35
  return { path: filePath, base: path.dirname(filePath), content };
36
36
  }
37
37
 
38
+ /**
39
+ * Resolve a Tailwind v4 `@plugin "..."` directive to a JS module.
40
+ *
41
+ * Tailwind v4's `compile()` invokes this whenever it encounters
42
+ * `@plugin "foo"` in CSS. The directive points at a node_modules
43
+ * package whose default export is a Tailwind plugin (e.g.
44
+ * `@tailwindcss/typography`). Without this option, `compile()` throws
45
+ * "No `loadModule` function provided" the moment any `@plugin` line
46
+ * exists in the consumer's CSS.
47
+ *
48
+ * We don't actually USE the plugin's generated classes for the style
49
+ * map (typography utilities like `prose` are matched separately if at
50
+ * all), but the compile call has to succeed for the rest of the
51
+ * scan-time CSS lookup to work.
52
+ */
53
+ async function loadModule(
54
+ id: string,
55
+ base: string,
56
+ _resourceHint: 'plugin' | 'config'
57
+ ): Promise<{ path: string; base: string; module: unknown }> {
58
+ const root = process.cwd();
59
+ // Resolve the bare package id against the consumer's node_modules.
60
+ // Use Node's createRequire so we honour the consumer's resolution
61
+ // (pnpm hoisting, peer deps, etc.) without forcing a path shape.
62
+ const { createRequire } = await import('node:module');
63
+ const require = createRequire(path.join(root, 'package.json'));
64
+ let resolved: string;
65
+ try {
66
+ resolved = require.resolve(id, { paths: [root, base] });
67
+ } catch (_e) {
68
+ // Plugin not installed — return a no-op plugin so compile doesn't
69
+ // crash. The scan won't expand that plugin's utilities, but the
70
+ // rest of the CSS still resolves.
71
+ return { path: id, base, module: () => ({}) };
72
+ }
73
+ const imported = await import(resolved);
74
+ // Tailwind v4's loadModule contract wants the Plugin or Config OBJECT,
75
+ // not the ESM namespace. ESM modules (`export default plugin(...)`)
76
+ // surface the plugin under `.default`; CJS interop sometimes
77
+ // double-wraps (`{ default: { default: pluginFn } }`). Unwrap once or
78
+ // twice until we hit something that isn't a plain namespace object.
79
+ let mod: unknown = imported;
80
+ if (mod && typeof mod === 'object' && 'default' in (mod as object)) {
81
+ mod = (mod as { default: unknown }).default;
82
+ }
83
+ if (mod && typeof mod === 'object' && 'default' in (mod as object)) {
84
+ mod = (mod as { default: unknown }).default;
85
+ }
86
+ return { path: resolved, base: path.dirname(resolved), module: mod };
87
+ }
88
+
38
89
  function extractClassName(selector: string): string | null {
39
90
  const dot = selector.indexOf('.');
40
91
  if (dot === -1) return null;
@@ -134,9 +185,15 @@ export async function buildStyleMap(classes: string[]): Promise<StyleMap> {
134
185
  if (unique.length === 0) return {};
135
186
 
136
187
  const css = fs.readFileSync(path.resolve(process.cwd(), 'src/app/globals.css'), 'utf-8');
188
+ // Cast `loadModule` to the compile() type. Our internal return type uses
189
+ // `module: unknown` because we accept any user-provided plugin / config
190
+ // shape; Tailwind's exported `Plugin | UserConfig` is narrower than what
191
+ // we can statically guarantee, so a structural cast here is safer than
192
+ // forcing the loader to assert against the narrow union.
137
193
  const compiled = await compile(css, {
138
194
  base: process.cwd(),
139
195
  loadStylesheet,
196
+ loadModule: loadModule as never,
140
197
  });
141
198
 
142
199
  const cssOutput = compiled.build(unique);