zyzz 0.0.23 → 0.0.25

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 (119) hide show
  1. package/README.md +23 -365
  2. package/dist/Style.d.ts +2 -0
  3. package/dist/Style.d.ts.map +1 -1
  4. package/dist/compiler/Graph.d.ts +3 -0
  5. package/dist/compiler/Graph.d.ts.map +1 -1
  6. package/dist/compiler/Graph.js +38 -2
  7. package/dist/compiler/Graph.js.map +1 -1
  8. package/dist/compiler/Source.d.ts.map +1 -1
  9. package/dist/compiler/Source.js +11 -3
  10. package/dist/compiler/Source.js.map +1 -1
  11. package/dist/compiler/Transform.d.ts +4 -0
  12. package/dist/compiler/Transform.d.ts.map +1 -1
  13. package/dist/compiler/Transform.js +79 -15
  14. package/dist/compiler/Transform.js.map +1 -1
  15. package/dist/compiler/internal/Applications.d.ts +20 -0
  16. package/dist/compiler/internal/Applications.d.ts.map +1 -1
  17. package/dist/compiler/internal/Applications.js +151 -47
  18. package/dist/compiler/internal/Applications.js.map +1 -1
  19. package/dist/compiler/internal/Compositions.d.ts.map +1 -1
  20. package/dist/compiler/internal/Compositions.js +6 -0
  21. package/dist/compiler/internal/Compositions.js.map +1 -1
  22. package/dist/compiler/internal/Configurations.d.ts.map +1 -1
  23. package/dist/compiler/internal/Configurations.js +4 -0
  24. package/dist/compiler/internal/Configurations.js.map +1 -1
  25. package/dist/compiler/internal/Contract.d.ts.map +1 -1
  26. package/dist/compiler/internal/Contract.js +34 -7
  27. package/dist/compiler/internal/Contract.js.map +1 -1
  28. package/dist/compiler/internal/PackedStyles.d.ts.map +1 -1
  29. package/dist/compiler/internal/PackedStyles.js +7 -0
  30. package/dist/compiler/internal/PackedStyles.js.map +1 -1
  31. package/dist/compiler/internal/Themes.d.ts.map +1 -1
  32. package/dist/compiler/internal/Themes.js +20 -8
  33. package/dist/compiler/internal/Themes.js.map +1 -1
  34. package/dist/internal/Configuration.d.ts +10 -0
  35. package/dist/internal/Configuration.d.ts.map +1 -1
  36. package/dist/internal/Configuration.js +10 -1
  37. package/dist/internal/Configuration.js.map +1 -1
  38. package/dist/internal/Token.d.ts +2 -0
  39. package/dist/internal/Token.d.ts.map +1 -1
  40. package/dist/internal/Token.js.map +1 -1
  41. package/dist/next/internal/Project.d.ts.map +1 -1
  42. package/dist/next/internal/Project.js +30 -25
  43. package/dist/next/internal/Project.js.map +1 -1
  44. package/dist/next/loader.js +2 -1
  45. package/dist/next/loader.js.map +1 -1
  46. package/dist/node/Host.d.ts.map +1 -1
  47. package/dist/node/Host.js +64 -27
  48. package/dist/node/Host.js.map +1 -1
  49. package/dist/node/internal/Contracts.d.ts +22 -0
  50. package/dist/node/internal/Contracts.d.ts.map +1 -0
  51. package/dist/node/internal/Contracts.js +287 -0
  52. package/dist/node/internal/Contracts.js.map +1 -0
  53. package/dist/react-native/internal/Device.js +90 -37
  54. package/dist/react-native/internal/Device.js.map +3 -3
  55. package/dist/react-native/internal/NativeZyzz.js.map +2 -2
  56. package/dist/react-native/internal/Store.d.ts +4 -0
  57. package/dist/react-native/internal/Store.d.ts.map +1 -1
  58. package/dist/react-native/internal/Store.js.map +1 -1
  59. package/dist/react-native/internal/Viewport.d.ts +6 -0
  60. package/dist/react-native/internal/Viewport.d.ts.map +1 -1
  61. package/dist/react-native/internal/Viewport.js +2 -0
  62. package/dist/react-native/internal/Viewport.js.map +1 -1
  63. package/dist/react-native/react.d.ts +5 -2
  64. package/dist/react-native/react.d.ts.map +1 -1
  65. package/dist/react-native/react.js +60 -8
  66. package/dist/react-native/react.js.map +1 -1
  67. package/dist/runtime/NativeContext.d.ts +6 -0
  68. package/dist/runtime/NativeContext.d.ts.map +1 -1
  69. package/dist/runtime/NativeContext.js +62 -9
  70. package/dist/runtime/NativeContext.js.map +1 -1
  71. package/dist/unplugin/index.d.ts.map +1 -1
  72. package/dist/unplugin/index.js +35 -14
  73. package/dist/unplugin/index.js.map +1 -1
  74. package/dist/variants.d.ts +3 -2
  75. package/dist/variants.d.ts.map +1 -1
  76. package/dist/variants.js.map +1 -1
  77. package/dist/vite/index.d.ts.map +1 -1
  78. package/dist/vite/index.js +30 -37
  79. package/dist/vite/index.js.map +1 -1
  80. package/dist/web/Css.d.ts +10 -5
  81. package/dist/web/Css.d.ts.map +1 -1
  82. package/dist/web/Css.js +170 -31
  83. package/dist/web/Css.js.map +1 -1
  84. package/dist/web/internal/ClassName.d.ts +49 -0
  85. package/dist/web/internal/ClassName.d.ts.map +1 -1
  86. package/dist/web/internal/ClassName.js +57 -0
  87. package/dist/web/internal/ClassName.js.map +1 -1
  88. package/native/NativeZyzz.cpp +52 -65
  89. package/native/NativeZyzz.h +0 -4
  90. package/native/ZyzzSpecJSI.h +0 -10
  91. package/native/android/ZyzzSpec-generated.cpp +0 -6
  92. package/package.json +1 -1
  93. package/src/Style.ts +2 -0
  94. package/src/compiler/Graph.ts +42 -2
  95. package/src/compiler/Source.ts +11 -3
  96. package/src/compiler/Transform.ts +130 -29
  97. package/src/compiler/internal/Applications.ts +239 -63
  98. package/src/compiler/internal/Compositions.ts +9 -0
  99. package/src/compiler/internal/Configurations.ts +4 -0
  100. package/src/compiler/internal/Contract.ts +59 -7
  101. package/src/compiler/internal/PackedStyles.ts +9 -0
  102. package/src/compiler/internal/Themes.ts +25 -7
  103. package/src/internal/Configuration.ts +23 -1
  104. package/src/internal/Token.ts +2 -0
  105. package/src/next/internal/Project.ts +28 -23
  106. package/src/next/loader.ts +2 -1
  107. package/src/node/Host.ts +90 -36
  108. package/src/node/internal/Contracts.ts +384 -0
  109. package/src/react-native/internal/Device.ts +129 -48
  110. package/src/react-native/internal/NativeZyzz.ts +4 -6
  111. package/src/react-native/internal/Store.ts +4 -0
  112. package/src/react-native/internal/Viewport.ts +7 -0
  113. package/src/react-native/react.ts +81 -11
  114. package/src/runtime/NativeContext.ts +83 -9
  115. package/src/unplugin/index.ts +39 -14
  116. package/src/variants.ts +3 -2
  117. package/src/vite/index.ts +35 -36
  118. package/src/web/Css.ts +228 -42
  119. package/src/web/internal/ClassName.ts +114 -0
@@ -26,7 +26,7 @@ export function read(
26
26
  if (
27
27
  ![
28
28
  1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21,
29
- 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33,
29
+ 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34,
30
30
  ].includes(data.version as number)
31
31
  )
32
32
  throw new Error('Unsupported Zyzz contract version.')
@@ -149,6 +149,22 @@ export function read(
149
149
  | 'grouped'
150
150
  | undefined
151
151
 
152
+ if (entry.composition !== undefined) {
153
+ if ((data.version as number) < 34)
154
+ throw new Error(
155
+ 'Composition modes require contract version 34 or later.',
156
+ )
157
+ if (
158
+ entry.composition !== 'independent' &&
159
+ entry.composition !== 'ordered'
160
+ )
161
+ throw new Error('Invalid packed composition mode.')
162
+ }
163
+ const composition = entry.composition as
164
+ | 'independent'
165
+ | 'ordered'
166
+ | undefined
167
+
152
168
  if (entry.defaultLayer !== undefined) {
153
169
  if ((data.version as number) < 27)
154
170
  throw new Error('Default layers require contract version 27 or later.')
@@ -180,6 +196,14 @@ export function read(
180
196
  'Conflicting packed shorthand mappings for one theme identity.',
181
197
  )
182
198
 
199
+ if (
200
+ contract &&
201
+ (contract.composition ?? 'ordered') !== (composition ?? 'ordered')
202
+ )
203
+ throw new Error(
204
+ 'Conflicting packed composition modes for one theme identity.',
205
+ )
206
+
183
207
  if (
184
208
  contract &&
185
209
  (contract.cssOutput ?? 'atomic') !== (cssOutput ?? 'atomic')
@@ -204,6 +228,7 @@ export function read(
204
228
  ...(entry.shorthands !== undefined
205
229
  ? { shorthands: Shorthands.read(entry.shorthands) }
206
230
  : {}),
231
+ ...(composition ? { composition } : {}),
207
232
  ...(cssOutput ? { cssOutput } : {}),
208
233
  ...(defaultLayer !== undefined ? { defaultLayer } : {}),
209
234
  [Token.complete]: true,
@@ -333,7 +358,7 @@ export function read(
333
358
  if (
334
359
  ![
335
360
  9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
336
- 27, 28, 29, 30, 31, 32, 33,
361
+ 27, 28, 29, 30, 31, 32, 33, 34,
337
362
  ].includes(data.version as number) ||
338
363
  ![
339
364
  'cssFunction',
@@ -514,6 +539,14 @@ export function read(
514
539
  'Configuration CSS output disagrees with linked theme metadata.',
515
540
  )
516
541
 
542
+ if (
543
+ (options.composition ?? 'ordered') !==
544
+ (definition[Token.definition].contract.composition ?? 'ordered')
545
+ )
546
+ throw new Error(
547
+ 'Configuration composition disagrees with linked theme metadata.',
548
+ )
549
+
517
550
  if (
518
551
  options.defaultLayer !==
519
552
  definition[Token.definition].contract.defaultLayer
@@ -829,6 +862,9 @@ export function write(
829
862
  ...(theme[Token.definition].contract.defaultLayer !== undefined
830
863
  ? { defaultLayer: theme[Token.definition].contract.defaultLayer }
831
864
  : {}),
865
+ ...(theme[Token.definition].contract.composition
866
+ ? { composition: theme[Token.definition].contract.composition }
867
+ : {}),
832
868
  ...(theme[Token.definition].contract.cssOutput
833
869
  ? { cssOutput: theme[Token.definition].contract.cssOutput }
834
870
  : {}),
@@ -843,13 +879,29 @@ export function write(
843
879
  },
844
880
  ]),
845
881
  ),
846
- version: Object.values(links).some(nativeCatalog)
847
- ? 33
848
- : Object.values(links).some(nativeProvider)
849
- ? 32
850
- : 31,
882
+ version: (() => {
883
+ if (
884
+ Object.values(themes).some(
885
+ (theme) => theme[Token.definition].contract.composition,
886
+ ) ||
887
+ Object.values(links).some(composed)
888
+ )
889
+ return 34
890
+
891
+ if (Object.values(links).some(nativeCatalog)) return 33
892
+ if (Object.values(links).some(nativeProvider)) return 32
893
+
894
+ return 31
895
+ })(),
851
896
  })
852
897
 
898
+ function composed(link: Themes.Link): boolean {
899
+ return (
900
+ Boolean(link.style?.style.composition) ||
901
+ Object.values(link.members ?? {}).some(composed)
902
+ )
903
+ }
904
+
853
905
  function nativeProvider(link: Themes.Link): boolean {
854
906
  return (
855
907
  link.call.nativeProvider === true ||
@@ -160,7 +160,16 @@ export function read(
160
160
  item.cssOutput !== 'grouped'
161
161
  )
162
162
  throw new Error('Invalid packed style CSS output mode.')
163
+ if (
164
+ item.composition !== undefined &&
165
+ (version < 34 ||
166
+ (item.composition !== 'independent' && item.composition !== 'ordered'))
167
+ )
168
+ throw new Error('Invalid packed style composition mode.')
163
169
  return {
170
+ ...(item.composition
171
+ ? { composition: item.composition as 'independent' | 'ordered' }
172
+ : {}),
164
173
  ...(item.cssOutput
165
174
  ? { cssOutput: item.cssOutput as 'atomic' | 'grouped' }
166
175
  : {}),
@@ -494,13 +494,31 @@ export function collect(program: Ast.Program, options: collect.Options) {
494
494
  root = root.object
495
495
  }
496
496
  if (root.type !== 'Identifier') return undefined
497
- const local = derived?.name === root.name
498
- const call = names.get(root.name)
499
- if (!local && !call) return undefined
500
- let value: unknown = local ? derived.vars : themes[call!.name]?.tokens
501
- if (!local && !call!.variableSet && !call!.directVariables) {
502
- if (!['vars', 'tokens'].includes(path.shift()!)) return undefined
503
- }
497
+ const name = root.name
498
+ let value: unknown = (() => {
499
+ if (derived?.name === name) return derived.vars
500
+
501
+ const call = names.get(name)
502
+ if (call) {
503
+ if (
504
+ !call.variableSet &&
505
+ !call.directVariables &&
506
+ !['vars', 'tokens'].includes(path.shift()!)
507
+ )
508
+ return undefined
509
+ return themes[call.name]?.tokens
510
+ }
511
+
512
+ // Config-bound vars read the configured variables, so they follow set scoping.
513
+ const config = configs.get(name)
514
+ if (
515
+ !config?.call.variableConfig ||
516
+ (!config.call.selection && path.shift() !== 'vars')
517
+ )
518
+ return undefined
519
+ return themes[config.call.name]?.tokens
520
+ })()
521
+ if (value === undefined) return undefined
504
522
  for (const key of path)
505
523
  value =
506
524
  value && typeof value === 'object'
@@ -63,7 +63,8 @@ export function create(
63
63
  ? (value as Vars.Definition)
64
64
  : Vars.define(value as Vars.Values)
65
65
  const theme = VariableSets.theme(definition, variableMappings)
66
- if (variableMappings === false) return theme
66
+ // Tokens are ambiguous only through an explicit mapping, so skip the property scan without one.
67
+ if (!variableMappings) return theme
67
68
  const metadata = theme[Token.definition]
68
69
  const names = new Map<string, string>()
69
70
  for (const path of Object.keys(metadata.values)) {
@@ -149,6 +150,7 @@ export function create(
149
150
  for (const key of Object.keys(input))
150
151
  if (
151
152
  ![
153
+ 'composition',
152
154
  'cssOutput',
153
155
  'id',
154
156
  'defaultLayer',
@@ -189,6 +191,13 @@ export function create(
189
191
  )
190
192
  throw new InvalidError('cssOutput must be atomic or grouped.')
191
193
 
194
+ if (
195
+ input.composition !== undefined &&
196
+ input.composition !== 'independent' &&
197
+ input.composition !== 'ordered'
198
+ )
199
+ throw new InvalidError('composition must be independent or ordered.')
200
+
192
201
  if (input.theme !== undefined && input.themes !== undefined)
193
202
  throw new InvalidError('Use either theme or themes, not both.')
194
203
 
@@ -252,6 +261,9 @@ export function create(
252
261
 
253
262
  const contract = Object.freeze({
254
263
  ...(variableMode ? { variableSet: true, mappings: variableMappings } : {}),
264
+ ...(input.composition !== undefined
265
+ ? { composition: input.composition as 'independent' | 'ordered' }
266
+ : {}),
255
267
  ...(input.defaultLayer !== undefined
256
268
  ? { defaultLayer: input.defaultLayer as string }
257
269
  : {}),
@@ -451,6 +463,11 @@ export function create(
451
463
  export declare namespace create {
452
464
  /** Optional layer names and mutually exclusive theme modes. */
453
465
  type Options = {
466
+ /**
467
+ * Whether bound styles' class lists may combine. Independent atomic styles
468
+ * share rules for repeated declarations, so combine them only with `cx`.
469
+ */
470
+ readonly composition?: 'independent' | 'ordered' | undefined
454
471
  /** CSS representation inherited by bound helpers; atomic by default. */
455
472
  readonly cssOutput?: 'atomic' | 'grouped' | undefined
456
473
  /** Fallback CSS layer for bound styles and variants; unlayered when omitted. */
@@ -836,6 +853,11 @@ type Validated<options> = Record<
836
853
 
837
854
  /** Vars, optional named alternatives, and category-to-property mappings. */
838
855
  export type VariableOptions = {
856
+ /**
857
+ * Whether bound styles' class lists may combine. Independent atomic styles
858
+ * share rules for repeated declarations, so combine them only with `cx`.
859
+ */
860
+ readonly composition?: 'independent' | 'ordered' | undefined
839
861
  /** CSS representation inherited by bound helpers; atomic by default. */
840
862
  readonly cssOutput?: 'atomic' | 'grouped' | undefined
841
863
  /** Fallback CSS layer for bound styles and variants; unlayered when omitted. */
@@ -393,6 +393,8 @@ export type Contract = {
393
393
  readonly variableSet?: boolean | undefined
394
394
  /** Configuration-local category-to-property mappings. */
395
395
  readonly mappings?: VariableSets.Mappings | false | undefined
396
+ /** Whether bound styles' class lists combine, retained by configuration-bound handles. */
397
+ readonly composition?: 'independent' | 'ordered' | undefined
396
398
  /** Web emission mode retained by configuration-bound theme handles. */
397
399
  readonly cssOutput?: 'atomic' | 'grouped' | undefined
398
400
  /** Fallback layer for declarations without an explicit layer. */
@@ -1,5 +1,6 @@
1
1
  /** Retains filesystem snapshots and an incremental graph within a Next.js loader worker. @module */
2
2
  import * as Contract from '../../compiler/internal/Contract.js'
3
+ import * as Contracts from '../../node/internal/Contracts.js'
3
4
  import * as Fs from 'node:fs/promises'
4
5
  import * as Graph from '../../compiler/Graph.js'
5
6
  import * as Path from 'node:path'
@@ -26,6 +27,7 @@ export type Context = {
26
27
  /** Creates project state owned by a loader configuration in one worker process. */
27
28
  export function create(options: create.Options) {
28
29
  let compiler = Graph.create()
30
+ const packages = Contracts.create()
29
31
  const files = new Map<string, { source: string; version: string }>()
30
32
  const libraries = new Map<
31
33
  string,
@@ -122,6 +124,20 @@ export function create(options: create.Options) {
122
124
  }
123
125
  return pending
124
126
  }
127
+ // Files behind package contracts stay dependencies even when the module is unchanged.
128
+ const inputs = new Set<string>()
129
+ const host: Contracts.read.Options = {
130
+ read: async (file) => {
131
+ const source = await read(file)
132
+ // Next ignores node_modules edits, and tracked manifests stalled webpack rebuilds after a failed compile.
133
+ if (Path.basename(file) === 'package.json') return source
134
+ track(file)
135
+ inputs.add(file)
136
+ return source
137
+ },
138
+ resolve: async (specifier, importer) =>
139
+ (await resolve(Path.dirname(importer), specifier)) || undefined,
140
+ }
125
141
  const id = (file: string) =>
126
142
  `app/${Path.relative(root, file).split(Path.sep).join('/')}`
127
143
  const eligible = (file: string) =>
@@ -322,14 +338,10 @@ export function create(options: create.Options) {
322
338
  await visit(resolved)
323
339
  continue
324
340
  }
325
- const sidecar = `${resolved}.zyzz.json`
326
- try {
327
- contracts[resolved] = await read(sidecar)
328
- track(sidecar)
329
- links[specifier] = resolved
330
- } catch (error) {
331
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
332
- }
341
+ const contract = await packages.read(resolved, host)
342
+ if (contract === undefined) continue
343
+ contracts[resolved] = contract
344
+ links[specifier] = resolved
333
345
  }
334
346
  }
335
347
 
@@ -360,11 +372,10 @@ export function create(options: create.Options) {
360
372
 
361
373
  const links = (imports[owner] ??= Object.create(null))
362
374
  links[specifier] = target
363
- if (!Object.hasOwn(contracts, target)) {
364
- const sidecar = `${target}.zyzz.json`
365
- contracts[target] = await read(sidecar)
366
- track(sidecar)
367
- }
375
+ contracts[target] ??= (await packages.read(target, {
376
+ ...host,
377
+ required: true,
378
+ }))!
368
379
  await dependencies(target)
369
380
  owner = target
370
381
  }
@@ -388,16 +399,10 @@ export function create(options: create.Options) {
388
399
  if (output.code === source && !output.css) {
389
400
  context.addDependency(context.resourcePath)
390
401
  for (const file of runtime) context.addDependency(file)
391
- for (const file of tracked)
392
- if (file.endsWith('.zyzz.json')) context.addDependency(file)
393
- for (const target of Object.values(imports[entry] ?? {})) {
394
- if (!target) continue
395
- context.addDependency(
396
- target.startsWith('app/')
397
- ? Path.join(root, target.slice(4))
398
- : `${target}.zyzz.json`,
399
- )
400
- }
402
+ for (const file of inputs) context.addDependency(file)
403
+ for (const target of Object.values(imports[entry] ?? {}))
404
+ if (target?.startsWith('app/'))
405
+ context.addDependency(Path.join(root, target.slice(4)))
401
406
  } else {
402
407
  for (const file of tracked) context.addDependency(file)
403
408
  }
@@ -161,7 +161,8 @@ async function compile(context: Context, source: string) {
161
161
  placeholder,
162
162
  Path.relative(directory, file)
163
163
  .split(Path.sep)
164
- .map(encodeURIComponent)
164
+ // Bundlers resolve url() paths without decoding, so escape only characters that change URL parsing.
165
+ .map((part) => part.replace(/[%#?]/g, encodeURIComponent))
165
166
  .join('/') + target.slice(raw.length),
166
167
  )
167
168
  }
package/src/node/Host.ts CHANGED
@@ -12,7 +12,10 @@ import { ResolverFactory } from 'oxc-resolver'
12
12
  import * as Graph from '../compiler/Graph.js'
13
13
  import * as AtRules from '../compiler/internal/AtRules.js'
14
14
  import * as Catalogs from '../compiler/internal/Catalogs.js'
15
+ import * as ClassName from '../web/internal/ClassName.js'
16
+ import * as Contracts from './internal/Contracts.js'
15
17
  import * as Relative from '../compiler/internal/Relative.js'
18
+ import * as Scheme from '../internal/Scheme.js'
16
19
  import * as Snapshot from './internal/Snapshot.js'
17
20
  import * as Syntax from '../compiler/internal/Syntax.js'
18
21
  import * as Source from '../compiler/Source.js'
@@ -134,6 +137,7 @@ export async function create(options: create.Options): Promise<Runtime> {
134
137
  const manifestPath = Path.join(outDir, '.zyzz.json')
135
138
 
136
139
  const compiler = Graph.create()
140
+ const packages = Contracts.create()
137
141
  const snapshot = Snapshot.create()
138
142
  const stylesheets = new WeakMap<Transform.compile.ReturnType, Stylesheet>()
139
143
  let closed = false
@@ -315,18 +319,16 @@ export async function create(options: create.Options): Promise<Runtime> {
315
319
  async function contract(file: string, required = false): Promise<boolean> {
316
320
  if (Object.hasOwn(contracts, file)) return true
317
321
 
318
- observe(`${file}.zyzz.json`)
319
- try {
320
- contracts[file] = await snapshot.read(`${file}.zyzz.json`)
321
- } catch (error) {
322
- if (
323
- !required &&
324
- !previousContracts.has(file) &&
325
- (error as NodeJS.ErrnoException).code === 'ENOENT'
326
- )
327
- return false
328
- throw error
329
- }
322
+ const source = await packages.read(file, {
323
+ read(path) {
324
+ observe(path)
325
+ return snapshot.read(path)
326
+ },
327
+ required: required || previousContracts.has(file),
328
+ resolve: async (specifier, importer) => resolve(specifier, importer),
329
+ })
330
+ if (source === undefined) return false
331
+ contracts[file] = source
330
332
 
331
333
  const metadata = (() => {
332
334
  try {
@@ -459,6 +461,7 @@ export async function create(options: create.Options): Promise<Runtime> {
459
461
  }
460
462
 
461
463
  const graph = compiler.compile({
464
+ [ClassName.complete]: true,
462
465
  [ThemeRules.shared]:
463
466
  options.modules === false && !native ? 'all' : undefined,
464
467
  [Syntax.cache]: snapshot.programs(modules),
@@ -469,13 +472,7 @@ export async function create(options: create.Options): Promise<Runtime> {
469
472
  imports,
470
473
  })
471
474
 
472
- snapshot.retain(
473
- new Set([
474
- ...inputs,
475
- ...Object.keys(contracts).map((file) => `${file}.zyzz.json`),
476
- ]),
477
- modules,
478
- )
475
+ snapshot.retain(new Set([...inputs, ...observed]), modules)
479
476
 
480
477
  const artifactNames = [
481
478
  'zyzz.css',
@@ -620,7 +617,10 @@ export async function create(options: create.Options): Promise<Runtime> {
620
617
  artifacts.set('zyzz.shared.css.map', shared.map)
621
618
  }
622
619
 
623
- const moduleStylesheets = new Map<string, Stylesheet>()
620
+ const moduleStylesheets = new Map<
621
+ string,
622
+ { name: string; stylesheet: Stylesheet }
623
+ >()
624
624
 
625
625
  for (const name of Object.keys(sources)) {
626
626
  const output = graph.modules[`${options.packageId}/${name}`]!
@@ -661,23 +661,46 @@ export async function create(options: create.Options): Promise<Runtime> {
661
661
 
662
662
  artifacts.set(`${name}.css`, stylesheet.code)
663
663
  artifacts.set(`${name}.css.map`, stylesheet.map)
664
- // Module URLs resolve beside the module file; the complete stylesheet at
665
- // the output root carries a copy with those URLs rebased.
666
- moduleStylesheets.set(
667
- `${options.packageId}/${name}`,
668
- rebase(stylesheet, name, css === false ? false : css.minify),
669
- )
664
+ moduleStylesheets.set(`${options.packageId}/${name}`, {
665
+ name,
666
+ stylesheet,
667
+ })
670
668
  }
671
669
 
672
670
  // The complete stylesheet follows the module graph so a consumer's rules
673
671
  // cascade over the rules of the modules it imports.
674
672
  if (shared || moduleStylesheets.size) {
675
- const complete = concatenate([
676
- ...(shared ? [shared] : []),
677
- ...order(graph.dependencies, [...moduleStylesheets.keys()]).map(
678
- (id) => moduleStylesheets.get(id)!,
679
- ),
680
- ])
673
+ const parts = shared ? [shared] : []
674
+ const units = new Set<string>()
675
+ let schemes = false
676
+
677
+ // Each module stylesheet declares the scheme classes and shared rules it
678
+ // uses to load alone. The complete stylesheet keeps the first declaration.
679
+ for (const id of order(graph.dependencies, [
680
+ ...moduleStylesheets.keys(),
681
+ ])) {
682
+ const { name, stylesheet } = moduleStylesheets.get(id)!
683
+ const declares = Object.values(Scheme.classes).some((className) =>
684
+ stylesheet.code.includes(`.${className}`),
685
+ )
686
+ const names = [
687
+ ...(graph.modules[id]![ClassName.units]?.values() ?? []),
688
+ ].map((decision) => decision.name)
689
+
690
+ parts.push(
691
+ rebase(
692
+ stylesheet,
693
+ name,
694
+ css === false ? false : css.minify,
695
+ schemes && declares,
696
+ new Set(names.filter((name) => units.has(name))),
697
+ ),
698
+ )
699
+ schemes ||= declares
700
+ for (const name of names) units.add(name)
701
+ }
702
+
703
+ const complete = concatenate(parts)
681
704
 
682
705
  artifacts.set('zyzz.css', complete.code)
683
706
  artifacts.set('zyzz.css.map', complete.map)
@@ -1025,7 +1048,7 @@ function concatenate(parts: readonly { code: string; map: string }[]) {
1025
1048
  let offset = 0
1026
1049
 
1027
1050
  for (const part of parts) {
1028
- if (!part.code) continue
1051
+ if (!part.code.trim()) continue
1029
1052
 
1030
1053
  const code = part.code.endsWith('\n') ? part.code : `${part.code}\n`
1031
1054
  const traced = Mapping.fromMap(part.map)
@@ -1169,15 +1192,27 @@ async function read(
1169
1192
  }
1170
1193
  }
1171
1194
 
1172
- /** Rewrites a nested module stylesheet's relative URLs against the output root. */
1195
+ /**
1196
+ * Copies a module stylesheet into the complete stylesheet at the output root,
1197
+ * rebasing nested relative URLs and optionally dropping the scheme rules.
1198
+ */
1173
1199
  function rebase(
1174
1200
  stylesheet: { code: string; map: string },
1175
1201
  name: string,
1176
1202
  minify: boolean,
1203
+ schemes: boolean,
1204
+ emitted: ReadonlySet<string>,
1177
1205
  ) {
1178
1206
  const directory = Path.posix.dirname(name)
1179
1207
 
1180
- if (directory === '.' || !stylesheet.code.includes('url(')) return stylesheet
1208
+ if (
1209
+ !schemes &&
1210
+ !emitted.size &&
1211
+ (directory === '.' || !stylesheet.code.includes('url('))
1212
+ )
1213
+ return stylesheet
1214
+
1215
+ const classes = Object.values(Scheme.classes)
1181
1216
 
1182
1217
  const result = AtRules.transform({
1183
1218
  code: Buffer.from(stylesheet.code),
@@ -1186,8 +1221,27 @@ function rebase(
1186
1221
  minify,
1187
1222
  sourceMap: true,
1188
1223
  visitor: {
1224
+ Rule: {
1225
+ style(rule) {
1226
+ const [selector, ...others] = rule.value.selectors
1227
+ const [component, ...rest] = selector ?? []
1228
+
1229
+ if (
1230
+ !others.length &&
1231
+ !rest.length &&
1232
+ component?.type === 'class' &&
1233
+ ((schemes && classes.includes(component.name)) ||
1234
+ emitted.has(component.name))
1235
+ )
1236
+ return []
1237
+ },
1238
+ },
1189
1239
  Url(url) {
1190
- if (/^(?:\/|[?#]|[a-z][a-z\d+.-]*:)/i.test(url.url)) return url
1240
+ if (
1241
+ directory === '.' ||
1242
+ /^(?:\/|[?#]|[a-z][a-z\d+.-]*:)/i.test(url.url)
1243
+ )
1244
+ return url
1191
1245
 
1192
1246
  return { ...url, url: Path.posix.join(directory, url.url) }
1193
1247
  },