apostrophe 4.28.1 → 4.29.0

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 (89) hide show
  1. package/CHANGELOG.md +29 -4
  2. package/README.md +2 -2
  3. package/defaults.js +1 -0
  4. package/lib/safe-json-script.js +27 -0
  5. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarLocale.vue +1 -1
  6. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBar.vue +1 -0
  7. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +3 -5
  8. package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +13 -1
  9. package/modules/@apostrophecms/asset/lib/globalIcons.js +3 -0
  10. package/modules/@apostrophecms/attachment/index.js +43 -1
  11. package/modules/@apostrophecms/color-field/index.js +7 -1
  12. package/modules/@apostrophecms/doc/index.js +11 -1
  13. package/modules/@apostrophecms/doc-type/index.js +165 -32
  14. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocEditor.vue +1 -1
  15. package/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js +104 -59
  16. package/modules/@apostrophecms/file/index.js +109 -8
  17. package/modules/@apostrophecms/i18n/i18n/de.json +0 -2
  18. package/modules/@apostrophecms/i18n/i18n/en.json +40 -1
  19. package/modules/@apostrophecms/i18n/i18n/es.json +0 -1
  20. package/modules/@apostrophecms/i18n/i18n/fr.json +0 -1
  21. package/modules/@apostrophecms/i18n/i18n/it.json +0 -1
  22. package/modules/@apostrophecms/i18n/i18n/pt-BR.json +0 -1
  23. package/modules/@apostrophecms/i18n/i18n/sk.json +0 -1
  24. package/modules/@apostrophecms/i18n/ui/apos/apps/AposI18nBatchReporting.js +18 -1
  25. package/modules/@apostrophecms/i18n/ui/apos/apps/AposI18nLocalizeActions.js +50 -0
  26. package/modules/@apostrophecms/i18n/ui/apos/components/AposI18nLocalize.vue +56 -13
  27. package/modules/@apostrophecms/image/ui/apos/components/AposImageRelationshipEditor.vue +8 -2
  28. package/modules/@apostrophecms/layout-column-widget/index.js +156 -163
  29. package/modules/@apostrophecms/layout-widget/index.js +7 -2
  30. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +6 -11
  31. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridColumn.vue +3 -5
  32. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +4 -4
  33. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +0 -16
  34. package/modules/@apostrophecms/layout-widget/ui/apos/lib/grid-state.mjs +7 -27
  35. package/modules/@apostrophecms/layout-widget/views/column.html +7 -9
  36. package/modules/@apostrophecms/login/index.js +39 -40
  37. package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +17 -2
  38. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +3 -2
  39. package/modules/@apostrophecms/notification/ui/apos/components/AposNotification.vue +1 -0
  40. package/modules/@apostrophecms/page/index.js +2 -0
  41. package/modules/@apostrophecms/piece-type/index.js +3 -1
  42. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManager.vue +1 -0
  43. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerDisplay.vue +5 -0
  44. package/modules/@apostrophecms/recently-edited/index.js +831 -0
  45. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposCellTitle.vue +54 -0
  46. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedCombo.vue +454 -0
  47. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedFilterTag.vue +75 -0
  48. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedFilters.vue +287 -0
  49. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedIcon.vue +16 -0
  50. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedManager.vue +346 -0
  51. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedBatch.js +193 -0
  52. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedData.js +276 -0
  53. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedFetch.js +199 -0
  54. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedFilters.js +100 -0
  55. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRelationship.js +8 -4
  56. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputWrapper.js +1 -1
  57. package/modules/@apostrophecms/styles/index.js +10 -0
  58. package/modules/@apostrophecms/styles/lib/apiRoutes.js +6 -0
  59. package/modules/@apostrophecms/styles/lib/handlers.js +5 -0
  60. package/modules/@apostrophecms/styles/lib/methods.js +9 -3
  61. package/modules/@apostrophecms/styles/lib/presets.js +119 -0
  62. package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +3 -8
  63. package/modules/@apostrophecms/styles/ui/apos/composables/AposStyles.js +1 -3
  64. package/modules/@apostrophecms/styles/ui/apos/render-factory.js +29 -0
  65. package/modules/@apostrophecms/styles/ui/apos/universal/backgroundHelpers.mjs +140 -0
  66. package/modules/@apostrophecms/styles/ui/apos/universal/customRules.mjs +105 -0
  67. package/modules/@apostrophecms/styles/ui/apos/universal/render.mjs +195 -15
  68. package/modules/@apostrophecms/template/index.js +22 -6
  69. package/modules/@apostrophecms/ui/ui/apos/components/AposCellContextMenu.vue +2 -0
  70. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +18 -4
  71. package/modules/@apostrophecms/ui/ui/apos/composables/useInfiniteScroll.js +91 -0
  72. package/modules/@apostrophecms/ui/ui/apos/scss/global/_theme.scss +1 -0
  73. package/modules/@apostrophecms/ui/ui/apos/stores/modal.js +5 -2
  74. package/modules/@apostrophecms/ui/ui/apos/utils/index.js +9 -0
  75. package/modules/@apostrophecms/url/index.js +38 -4
  76. package/modules/@apostrophecms/widget-type/index.js +22 -6
  77. package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +8 -4
  78. package/package.json +17 -17
  79. package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +2 -2
  80. package/test/layout-widget-migration.js +719 -0
  81. package/test/login-requirements.js +1 -1
  82. package/test/pieces-public-api.js +80 -0
  83. package/test/pieces.js +25 -0
  84. package/test/recently-edited.js +2311 -0
  85. package/test/schemas.js +39 -3
  86. package/test/static-build.js +642 -0
  87. package/test/styles.js +2569 -0
  88. package/.claude/settings.local.json +0 -15
  89. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposLayoutColControlDialog.vue +0 -171
package/test/styles.js CHANGED
@@ -3,6 +3,12 @@ const assert = require('assert/strict');
3
3
  const universal = import(
4
4
  '../modules/@apostrophecms/styles/ui/apos/universal/render.mjs'
5
5
  );
6
+ const customRulesImport = import(
7
+ '../modules/@apostrophecms/styles/ui/apos/universal/customRules.mjs'
8
+ );
9
+ const imageHelpersImport = import(
10
+ '../modules/@apostrophecms/styles/ui/apos/universal/backgroundHelpers.mjs'
11
+ );
6
12
 
7
13
  describe('Styles', function () {
8
14
  this.timeout(t.timeout);
@@ -216,6 +222,513 @@ describe('Styles', function () {
216
222
  }
217
223
  });
218
224
 
225
+ it('should skip field with skipFalsyValues when value is falsy', async function () {
226
+ const { NORMALIZERS } = await universal;
227
+
228
+ // Value 0 with skipFalsyValues: true — should skip
229
+ {
230
+ const field = {
231
+ name: 'blur',
232
+ type: 'range',
233
+ selector: '.bg',
234
+ property: '--bg-blur',
235
+ unit: 'px',
236
+ skipFalsyValues: true
237
+ };
238
+ const actual = NORMALIZERS._(field, { blur: 0 });
239
+ assert.deepEqual(
240
+ actual,
241
+ {
242
+ raw: field,
243
+ selectors: [],
244
+ properties: [],
245
+ value: null,
246
+ unit: ''
247
+ },
248
+ 'Field with skipFalsyValues and value 0 should produce empty output'
249
+ );
250
+ }
251
+
252
+ // Value null with skipFalsyValues: true — should skip
253
+ {
254
+ const field = {
255
+ name: 'blur',
256
+ type: 'range',
257
+ selector: '.bg',
258
+ property: '--bg-blur',
259
+ unit: 'px',
260
+ skipFalsyValues: true
261
+ };
262
+ const actual = NORMALIZERS._(field, { blur: null });
263
+ assert.deepEqual(
264
+ actual,
265
+ {
266
+ raw: field,
267
+ selectors: [],
268
+ properties: [],
269
+ value: null,
270
+ unit: ''
271
+ },
272
+ 'Field with skipFalsyValues and value null should produce empty output'
273
+ );
274
+ }
275
+
276
+ // Value undefined with skipFalsyValues: true — should skip
277
+ {
278
+ const field = {
279
+ name: 'blur',
280
+ type: 'range',
281
+ selector: '.bg',
282
+ property: '--bg-blur',
283
+ unit: 'px',
284
+ skipFalsyValues: true
285
+ };
286
+ const actual = NORMALIZERS._(field, {});
287
+ assert.deepEqual(
288
+ actual,
289
+ {
290
+ raw: field,
291
+ selectors: [],
292
+ properties: [],
293
+ value: null,
294
+ unit: ''
295
+ },
296
+ 'Field with skipFalsyValues and value undefined should produce empty output'
297
+ );
298
+ }
299
+
300
+ // Value empty string with skipFalsyValues: true — should skip
301
+ {
302
+ const field = {
303
+ name: 'blur',
304
+ type: 'range',
305
+ selector: '.bg',
306
+ property: '--bg-blur',
307
+ unit: 'px',
308
+ skipFalsyValues: true
309
+ };
310
+ const actual = NORMALIZERS._(field, { blur: '' });
311
+ assert.deepEqual(
312
+ actual,
313
+ {
314
+ raw: field,
315
+ selectors: [],
316
+ properties: [],
317
+ value: null,
318
+ unit: ''
319
+ },
320
+ 'Field with skipFalsyValues and empty string should produce empty output'
321
+ );
322
+ }
323
+
324
+ // Truthy value with skipFalsyValues: true — should produce normal output
325
+ {
326
+ const field = {
327
+ name: 'blur',
328
+ type: 'range',
329
+ selector: '.bg',
330
+ property: '--bg-blur',
331
+ unit: 'px',
332
+ skipFalsyValues: true
333
+ };
334
+ const actual = NORMALIZERS._(field, { blur: 7 });
335
+ assert.deepEqual(
336
+ actual,
337
+ {
338
+ raw: field,
339
+ selectors: [ '.bg' ],
340
+ properties: [ '--bg-blur' ],
341
+ value: 7,
342
+ unit: 'px'
343
+ },
344
+ 'Field with skipFalsyValues and truthy value should produce normal output'
345
+ );
346
+ }
347
+
348
+ // Value 0 without skipFalsyValues — should produce
349
+ // normal output (default behavior)
350
+ {
351
+ const field = {
352
+ name: 'blur',
353
+ type: 'range',
354
+ selector: '.bg',
355
+ property: '--bg-blur',
356
+ unit: 'px'
357
+ };
358
+ const actual = NORMALIZERS._(field, { blur: 0 });
359
+ assert.deepEqual(
360
+ actual,
361
+ {
362
+ raw: field,
363
+ selectors: [ '.bg' ],
364
+ properties: [ '--bg-blur' ],
365
+ value: 0,
366
+ unit: 'px'
367
+ },
368
+ 'Field without skipFalsyValues and value 0 should produce normal output'
369
+ );
370
+ }
371
+
372
+ // Class field with skipFalsyValues: true and falsy value — should
373
+ // skip (no class toggle)
374
+ {
375
+ const field = {
376
+ name: 'hasBlur',
377
+ type: 'boolean',
378
+ class: 'has-bg-blur',
379
+ skipFalsyValues: true
380
+ };
381
+ const actual = NORMALIZERS._(field, { hasBlur: false });
382
+ assert.deepEqual(
383
+ actual,
384
+ {
385
+ raw: field,
386
+ selectors: [],
387
+ properties: [],
388
+ value: null,
389
+ unit: ''
390
+ },
391
+ 'Class field with skipFalsyValues and falsy value should produce empty output'
392
+ );
393
+ }
394
+ });
395
+
396
+ it('should handle class + property combo field', async function () {
397
+ const {
398
+ NORMALIZERS, renderScopedStyles
399
+ } = await universal;
400
+
401
+ // Both class and property, truthy value — should produce both outputs
402
+ {
403
+ const field = {
404
+ name: 'blur',
405
+ type: 'range',
406
+ selector: '.bg',
407
+ property: '--my-var',
408
+ unit: 'px',
409
+ class: 'my-class'
410
+ };
411
+ const storage = {
412
+ classes: new Set(),
413
+ styles: new Map(),
414
+ inlineVotes: new Set()
415
+ };
416
+ const actual = NORMALIZERS._(field, { blur: 5 }, { storage });
417
+ assert.deepEqual(
418
+ actual.selectors,
419
+ [ '.bg' ],
420
+ 'Combo field should have selectors'
421
+ );
422
+ assert.deepEqual(
423
+ actual.properties,
424
+ [ '--my-var' ],
425
+ 'Combo field should have properties'
426
+ );
427
+ assert.equal(actual.value, 5, 'Combo field should have value');
428
+ assert.equal(actual.unit, 'px', 'Combo field should have unit');
429
+ assert.ok(
430
+ storage.classes.has('my-class'),
431
+ 'Combo field should apply the class toggle'
432
+ );
433
+ }
434
+
435
+ // Only class, no property — should apply class only (existing behavior)
436
+ {
437
+ const field = {
438
+ name: 'active',
439
+ type: 'boolean',
440
+ class: 'is-active'
441
+ };
442
+ const storage = {
443
+ classes: new Set(),
444
+ styles: new Map(),
445
+ inlineVotes: new Set()
446
+ };
447
+ const actual = NORMALIZERS._(field, { active: true }, { storage });
448
+ assert.deepEqual(actual.properties, [], 'Class-only field should have no properties');
449
+ assert.ok(
450
+ storage.classes.has('is-active'),
451
+ 'Class-only field should apply the class'
452
+ );
453
+ }
454
+
455
+ // Only property, no class — should produce CSS only (existing behavior)
456
+ {
457
+ const field = {
458
+ name: 'blur',
459
+ type: 'range',
460
+ selector: '.bg',
461
+ property: '--my-var',
462
+ unit: 'px'
463
+ };
464
+ const storage = {
465
+ classes: new Set(),
466
+ styles: new Map(),
467
+ inlineVotes: new Set()
468
+ };
469
+ const actual = NORMALIZERS._(field, { blur: 5 }, { storage });
470
+ assert.deepEqual(actual.properties, [ '--my-var' ], 'Property-only field should have properties');
471
+ assert.equal(storage.classes.size, 0, 'Property-only field should not add classes');
472
+ }
473
+
474
+ // Full render: class + property + skipFalsyValues, value = 0 — neither
475
+ // class nor CSS
476
+ {
477
+ const schema = [
478
+ {
479
+ name: 'blur',
480
+ type: 'range',
481
+ selector: '.bg',
482
+ property: '--my-var',
483
+ unit: 'px',
484
+ class: 'has-bg-blur',
485
+ skipFalsyValues: true
486
+ }
487
+ ];
488
+ const result = renderScopedStyles(schema, { blur: 0 }, { rootSelector: '#id' });
489
+ assert.equal(result.css, '', 'skipFalsyValues + combo with 0 should produce no CSS');
490
+ assert.deepEqual(result.classes, [], 'skipFalsyValues + combo with 0 should produce no classes');
491
+ }
492
+
493
+ // Full render: class + property + skipFalsyValues, truthy value — both
494
+ // class and CSS
495
+ {
496
+ const schema = [
497
+ {
498
+ name: 'blur',
499
+ type: 'range',
500
+ selector: '.bg',
501
+ property: '--my-var',
502
+ unit: 'px',
503
+ class: 'has-bg-blur',
504
+ skipFalsyValues: true
505
+ }
506
+ ];
507
+ const result = renderScopedStyles(schema, { blur: 10 }, { rootSelector: '#id' });
508
+ assert.ok(
509
+ result.css.includes('--my-var: 10px'),
510
+ 'skipFalsyValues + combo with truthy value should produce CSS'
511
+ );
512
+ assert.ok(
513
+ result.classes.includes('has-bg-blur'),
514
+ 'skipFalsyValues + combo with truthy value should produce class'
515
+ );
516
+ }
517
+ });
518
+
519
+ it('should dispatch object field with customType to custom rule', async function () {
520
+ const { renderScopedStyles } = await universal;
521
+ const customRules = (await customRulesImport).default;
522
+
523
+ // Register a mock custom rule
524
+ customRules._testObjectRule = function ({
525
+ field, subfields, options
526
+ }) {
527
+ // Consume only 'color' subfield, leave 'size' for normal processing
528
+ const colorSub = subfields.color;
529
+ const rules = [];
530
+ if (colorSub?.value) {
531
+ rules.push(`background-color: ${colorSub.value}`);
532
+ }
533
+ return {
534
+ rules,
535
+ processedFields: [ 'color' ]
536
+ };
537
+ };
538
+
539
+ try {
540
+ const schema = [
541
+ {
542
+ name: 'bg',
543
+ type: 'object',
544
+ customType: '_testObjectRule',
545
+ selector: '.bg',
546
+ schema: [
547
+ {
548
+ name: 'color',
549
+ type: 'color',
550
+ property: '--bg-color'
551
+ },
552
+ {
553
+ name: 'size',
554
+ type: 'range',
555
+ property: '--bg-size',
556
+ unit: 'px'
557
+ }
558
+ ]
559
+ }
560
+ ];
561
+ const result = renderScopedStyles(
562
+ schema,
563
+ {
564
+ bg: {
565
+ color: '#ff0000',
566
+ size: 12
567
+ }
568
+ },
569
+ { rootSelector: '#id' }
570
+ );
571
+
572
+ // Custom rule's output should be present
573
+ assert.ok(
574
+ result.css.includes('background-color: #ff0000'),
575
+ 'Custom rule should produce its CSS declarations'
576
+ );
577
+ // 'color' was consumed by rule — should NOT appear as standard --bg-color
578
+ assert.ok(
579
+ !result.css.includes('--bg-color'),
580
+ 'Consumed subfield should not be double-processed'
581
+ );
582
+ // 'size' was NOT consumed — should be processed normally
583
+ assert.ok(
584
+ result.css.includes('--bg-size: 12px'),
585
+ 'Non-consumed subfield should be processed normally'
586
+ );
587
+ } finally {
588
+ delete customRules._testObjectRule;
589
+ }
590
+ });
591
+
592
+ it('should dispatch non-object field with customType to custom rule', async function () {
593
+ const { renderScopedStyles } = await universal;
594
+ const customRules = (await customRulesImport).default;
595
+
596
+ customRules._testScalarRule = function ({
597
+ field, subfields, options
598
+ }) {
599
+ return {
600
+ rules: [ `--custom: ${field.value}${field.unit}` ],
601
+ processedFields: []
602
+ };
603
+ };
604
+
605
+ try {
606
+ const schema = [
607
+ {
608
+ name: 'myVal',
609
+ type: 'range',
610
+ customType: '_testScalarRule',
611
+ selector: '.target',
612
+ property: '--my-val',
613
+ unit: 'px'
614
+ }
615
+ ];
616
+ const result = renderScopedStyles(
617
+ schema,
618
+ { myVal: 42 },
619
+ { rootSelector: '#id' }
620
+ );
621
+
622
+ assert.ok(
623
+ result.css.includes('--custom: 42px'),
624
+ 'Non-object customType should produce CSS from custom rule'
625
+ );
626
+ // Standard extract should NOT run (no --my-val output)
627
+ assert.ok(
628
+ !result.css.includes('--my-val:'),
629
+ 'Non-object customType should not go through standard extract'
630
+ );
631
+ } finally {
632
+ delete customRules._testScalarRule;
633
+ }
634
+ });
635
+
636
+ it('should skip unknown customType with console error', async function () {
637
+ const { renderScopedStyles } = await universal;
638
+ // eslint-disable-next-line no-console
639
+ const originalError = console.error;
640
+ let errorMsg = '';
641
+ // eslint-disable-next-line no-console
642
+ console.error = (msg) => {
643
+ errorMsg = msg;
644
+ };
645
+
646
+ try {
647
+ const schema = [
648
+ {
649
+ name: 'myVal',
650
+ type: 'range',
651
+ customType: '_nonExistentRule',
652
+ selector: '.target',
653
+ property: '--my-val',
654
+ unit: 'px'
655
+ }
656
+ ];
657
+ const result = renderScopedStyles(
658
+ schema,
659
+ { myVal: 5 },
660
+ { rootSelector: '#id' }
661
+ );
662
+
663
+ assert.ok(
664
+ errorMsg.includes('Unknown customType "_nonExistentRule"'),
665
+ 'Should log error for unknown customType'
666
+ );
667
+ assert.equal(result.css, '', 'Unknown customType should produce no CSS');
668
+ } finally {
669
+ // eslint-disable-next-line no-console
670
+ console.error = originalError;
671
+ }
672
+ });
673
+
674
+ it('should thread engine options to custom rule', async function () {
675
+ const { renderScopedStyles } = await universal;
676
+ const customRules = (await customRulesImport).default;
677
+ let receivedOptions = null;
678
+
679
+ customRules._testOptionsRule = function ({
680
+ field, subfields, options
681
+ }) {
682
+ receivedOptions = options;
683
+ return {
684
+ rules: [],
685
+ processedFields: []
686
+ };
687
+ };
688
+
689
+ try {
690
+ const schema = [
691
+ {
692
+ name: 'myVal',
693
+ type: 'range',
694
+ customType: '_testOptionsRule',
695
+ selector: '.target',
696
+ property: '--my-val',
697
+ unit: 'px'
698
+ }
699
+ ];
700
+ renderScopedStyles(
701
+ schema,
702
+ { myVal: 1 },
703
+ {
704
+ rootSelector: '#id',
705
+ imageSizes: [ {
706
+ name: 'small',
707
+ width: 100
708
+ } ]
709
+ }
710
+ );
711
+
712
+ assert.ok(receivedOptions, 'Custom rule should receive options');
713
+ assert.deepEqual(
714
+ receivedOptions.imageSizes,
715
+ [ {
716
+ name: 'small',
717
+ width: 100
718
+ } ],
719
+ 'Engine options should be threaded to custom rule'
720
+ );
721
+ // Internal render keys should NOT leak
722
+ assert.equal(
723
+ receivedOptions.rootSelector,
724
+ undefined,
725
+ 'Internal render keys should not leak to custom rule options'
726
+ );
727
+ } finally {
728
+ delete customRules._testOptionsRule;
729
+ }
730
+ });
731
+
219
732
  it('should normalize object field (UI container)', async function () {
220
733
  const { NORMALIZERS } = await universal;
221
734
  const subField1 = {
@@ -426,6 +939,2062 @@ describe('Styles', function () {
426
939
  'First subfield should be normalized correctly'
427
940
  );
428
941
  });
942
+
943
+ it('should export createRenderer factory', async function () {
944
+ const { createRenderer } = await universal;
945
+ assert.equal(
946
+ typeof createRenderer,
947
+ 'function',
948
+ 'createRenderer should be a function'
949
+ );
950
+
951
+ const renderer = createRenderer({
952
+ imageSizes: [ {
953
+ name: 'small',
954
+ width: 100
955
+ } ]
956
+ });
957
+ assert.equal(
958
+ typeof renderer.renderGlobalStyles,
959
+ 'function',
960
+ 'renderer.renderGlobalStyles should be a function'
961
+ );
962
+ assert.equal(
963
+ typeof renderer.renderScopedStyles,
964
+ 'function',
965
+ 'renderer.renderScopedStyles should be a function'
966
+ );
967
+ });
968
+
969
+ it('should thread createRenderer memoized options to custom rule', async function () {
970
+ const { createRenderer } = await universal;
971
+ const customRules = (await customRulesImport).default;
972
+ let receivedOptions = null;
973
+
974
+ customRules._testMemoRule = function ({ options }) {
975
+ receivedOptions = options;
976
+ return {
977
+ rules: [],
978
+ processedFields: []
979
+ };
980
+ };
981
+
982
+ try {
983
+ const schema = [
984
+ {
985
+ name: 'val',
986
+ type: 'range',
987
+ customType: '_testMemoRule',
988
+ selector: '.t',
989
+ property: '--v',
990
+ unit: 'px'
991
+ }
992
+ ];
993
+
994
+ const renderer = createRenderer({
995
+ imageSizes: [ {
996
+ name: 'full',
997
+ width: 1140,
998
+ height: 760
999
+ } ]
1000
+ });
1001
+ renderer.renderScopedStyles(
1002
+ schema,
1003
+ { val: 1 },
1004
+ { rootSelector: '#id' }
1005
+ );
1006
+
1007
+ assert.ok(receivedOptions, 'Custom rule should receive options');
1008
+ assert.deepEqual(
1009
+ receivedOptions.imageSizes,
1010
+ [ {
1011
+ name: 'full',
1012
+ width: 1140,
1013
+ height: 760
1014
+ } ],
1015
+ 'Memoized imageSizes should be threaded to custom rule'
1016
+ );
1017
+ } finally {
1018
+ delete customRules._testMemoRule;
1019
+ }
1020
+ });
1021
+
1022
+ it('should allow createRenderer call options to override memoized options', async function () {
1023
+ const { createRenderer } = await universal;
1024
+ const customRules = (await customRulesImport).default;
1025
+ let receivedOptions = null;
1026
+
1027
+ customRules._testOverrideRule = function ({ options }) {
1028
+ receivedOptions = options;
1029
+ return {
1030
+ rules: [],
1031
+ processedFields: []
1032
+ };
1033
+ };
1034
+
1035
+ try {
1036
+ const schema = [
1037
+ {
1038
+ name: 'val',
1039
+ type: 'range',
1040
+ customType: '_testOverrideRule',
1041
+ selector: '.t',
1042
+ property: '--v',
1043
+ unit: 'px'
1044
+ }
1045
+ ];
1046
+
1047
+ const renderer = createRenderer({
1048
+ imageSizes: [ {
1049
+ name: 'small',
1050
+ width: 100
1051
+ } ]
1052
+ });
1053
+ renderer.renderScopedStyles(
1054
+ schema,
1055
+ { val: 1 },
1056
+ {
1057
+ rootSelector: '#id',
1058
+ imageSizes: [ {
1059
+ name: 'large',
1060
+ width: 1000
1061
+ } ]
1062
+ }
1063
+ );
1064
+
1065
+ assert.deepEqual(
1066
+ receivedOptions.imageSizes,
1067
+ [ {
1068
+ name: 'large',
1069
+ width: 1000
1070
+ } ],
1071
+ 'Call-level imageSizes should override memoized imageSizes'
1072
+ );
1073
+ } finally {
1074
+ delete customRules._testOverrideRule;
1075
+ }
1076
+ });
1077
+
1078
+ it('should work without imageSizes (backward compatible)', async function () {
1079
+ const { renderGlobalStyles, renderScopedStyles } = await universal;
1080
+
1081
+ const schema = [
1082
+ {
1083
+ name: 'color',
1084
+ type: 'color',
1085
+ selector: ':root',
1086
+ property: '--color'
1087
+ }
1088
+ ];
1089
+
1090
+ const globalResult = renderGlobalStyles(schema, { color: '#abc' });
1091
+ assert.ok(
1092
+ globalResult.css.includes('--color: #abc'),
1093
+ 'renderGlobalStyles should work without imageSizes'
1094
+ );
1095
+
1096
+ const scopedResult = renderScopedStyles(schema, { color: '#abc' }, {
1097
+ rootSelector: '#id'
1098
+ });
1099
+ assert.ok(
1100
+ scopedResult.css.includes('--color: #abc'),
1101
+ 'renderScopedStyles should work without imageSizes'
1102
+ );
1103
+ });
1104
+
1105
+ it('should include imageSizes in attachment getBrowserData', function () {
1106
+ const browserData = apos.attachment.getBrowserData(apos.task.getReq());
1107
+ assert.ok(
1108
+ Array.isArray(browserData.imageSizes),
1109
+ 'imageSizes should be an array in browser data'
1110
+ );
1111
+ assert.ok(
1112
+ browserData.imageSizes.length > 0,
1113
+ 'imageSizes should not be empty'
1114
+ );
1115
+ assert.ok(
1116
+ browserData.imageSizes[0].name,
1117
+ 'Each image size should have a name'
1118
+ );
1119
+ assert.ok(
1120
+ browserData.imageSizes[0].width,
1121
+ 'Each image size should have a width'
1122
+ );
1123
+ assert.deepEqual(
1124
+ browserData.imageSizes,
1125
+ apos.attachment.imageSizes,
1126
+ 'Browser data imageSizes should match server-side imageSizes'
1127
+ );
1128
+ });
1129
+
1130
+ describe('Image helpers (customRules)', function () {
1131
+ let extractImageData;
1132
+ let buildResponsiveImageRules;
1133
+
1134
+ before(async function () {
1135
+ ({
1136
+ extractImageData,
1137
+ buildResponsiveImageRules
1138
+ } = await imageHelpersImport);
1139
+ });
1140
+
1141
+ describe('extractImageData', function () {
1142
+ it('should return urls map for a valid image attachment', function () {
1143
+ const value = {
1144
+ group: 'images',
1145
+ _urls: {
1146
+ original: '/attachments/abc.original.jpg',
1147
+ full: '/attachments/abc.full.jpg',
1148
+ max: '/attachments/abc.max.jpg',
1149
+ 'one-sixth': '/attachments/abc.one-sixth.jpg'
1150
+ }
1151
+ };
1152
+ const result = extractImageData(value);
1153
+ assert.deepEqual(
1154
+ result,
1155
+ value._urls,
1156
+ 'Should return the _urls map directly'
1157
+ );
1158
+ });
1159
+
1160
+ it('should return null for non-image group', function () {
1161
+ const value = {
1162
+ group: 'office',
1163
+ _urls: {
1164
+ original: '/attachments/doc.pdf'
1165
+ }
1166
+ };
1167
+ assert.equal(
1168
+ extractImageData(value),
1169
+ null,
1170
+ 'Office files should return null'
1171
+ );
1172
+ });
1173
+
1174
+ it('should return null when _urls is missing', function () {
1175
+ const value = { group: 'images' };
1176
+ assert.equal(
1177
+ extractImageData(value),
1178
+ null,
1179
+ 'Missing _urls should return null'
1180
+ );
1181
+ });
1182
+
1183
+ it('should return null for null/undefined value', function () {
1184
+ assert.equal(extractImageData(null), null, 'null should return null');
1185
+ assert.equal(extractImageData(undefined), null, 'undefined should return null');
1186
+ });
1187
+
1188
+ it('should return urls map for SVG (only original in _urls)', function () {
1189
+ const value = {
1190
+ group: 'images',
1191
+ _urls: {
1192
+ original: '/attachments/icon.svg'
1193
+ }
1194
+ };
1195
+ const result = extractImageData(value);
1196
+ assert.deepEqual(
1197
+ result,
1198
+ value._urls,
1199
+ 'SVG should return the _urls map'
1200
+ );
1201
+ });
1202
+
1203
+ it('should return urls map regardless of which sizes are present', function () {
1204
+ const value = {
1205
+ group: 'images',
1206
+ _urls: {
1207
+ original: '/attachments/abc.original.jpg',
1208
+ max: '/attachments/abc.max.jpg',
1209
+ 'one-third': '/attachments/abc.one-third.jpg'
1210
+ }
1211
+ };
1212
+ const result = extractImageData(value);
1213
+ assert.deepEqual(
1214
+ result,
1215
+ value._urls,
1216
+ 'Should return the _urls map directly'
1217
+ );
1218
+ });
1219
+
1220
+ it('should return urls map even with non-standard size names', function () {
1221
+ const value = {
1222
+ group: 'images',
1223
+ _urls: {
1224
+ original: '/attachments/abc.original.jpg',
1225
+ 'custom-size': '/attachments/abc.custom.jpg'
1226
+ }
1227
+ };
1228
+ const result = extractImageData(value);
1229
+ assert.deepEqual(
1230
+ result,
1231
+ value._urls,
1232
+ 'Should return the _urls map regardless of size names'
1233
+ );
1234
+ });
1235
+
1236
+ it('should return null when _urls is empty', function () {
1237
+ const value = {
1238
+ group: 'images',
1239
+ _urls: {}
1240
+ };
1241
+ assert.equal(
1242
+ extractImageData(value),
1243
+ null,
1244
+ 'Empty _urls should return null'
1245
+ );
1246
+ });
1247
+ });
1248
+
1249
+ describe('buildResponsiveImageRules', function () {
1250
+ it('should return base url rule for single size', function () {
1251
+ const urls = {
1252
+ original: '/attachments/icon.svg'
1253
+ };
1254
+ const result = buildResponsiveImageRules('--bg-image', urls);
1255
+ assert.deepEqual(result.rules, [
1256
+ '--bg-image: url(/attachments/icon.svg)'
1257
+ ], 'Should have only base url rule');
1258
+ assert.deepEqual(result.mediaRules, [], 'Should have no media rules');
1259
+ });
1260
+
1261
+ it('should produce media query breakpoints for multiple sizes', function () {
1262
+ const urls = {
1263
+ 'one-sixth': '/attachments/abc.one-sixth.jpg',
1264
+ 'one-third': '/attachments/abc.one-third.jpg',
1265
+ 'one-half': '/attachments/abc.one-half.jpg',
1266
+ 'two-thirds': '/attachments/abc.two-thirds.jpg',
1267
+ full: '/attachments/abc.full.jpg',
1268
+ max: '/attachments/abc.max.jpg',
1269
+ original: '/attachments/abc.original.jpg'
1270
+ };
1271
+ const result = buildResponsiveImageRules('--bg-image', urls);
1272
+ assert.deepEqual(result.rules, [
1273
+ '--bg-image: url(/attachments/abc.max.jpg)'
1274
+ ], 'Base rule should use the largest available sized image');
1275
+ assert.ok(result.mediaRules.length > 0, 'Should have media rules');
1276
+ for (const mr of result.mediaRules) {
1277
+ assert.ok(
1278
+ mr.query.includes('width'),
1279
+ 'Media rule query should use range syntax'
1280
+ );
1281
+ assert.ok(
1282
+ mr.rules[0].startsWith('--bg-image: url('),
1283
+ 'Media rule should set --bg-image'
1284
+ );
1285
+ }
1286
+ });
1287
+
1288
+ it('should not produce media rules that duplicate the default url', function () {
1289
+ const urls = {
1290
+ full: '/attachments/abc.full.jpg',
1291
+ max: '/attachments/abc.max.jpg'
1292
+ };
1293
+ const result = buildResponsiveImageRules('--bg-image', urls);
1294
+ // Base is now max (largest). Mobile BP selects full (≠ base) → emitted.
1295
+ // Tablet BP selects max (= base) → skipped.
1296
+ for (const mr of result.mediaRules) {
1297
+ assert.notEqual(
1298
+ mr.rules[0],
1299
+ '--bg-image: url(/attachments/abc.max.jpg)',
1300
+ 'Should not duplicate the base url in a media query'
1301
+ );
1302
+ }
1303
+ });
1304
+
1305
+ it('should use correct property name in declarations', function () {
1306
+ const urls = {
1307
+ full: '/attachments/abc.full.jpg',
1308
+ max: '/attachments/abc.max.jpg',
1309
+ 'one-third': '/attachments/abc.one-third.jpg'
1310
+ };
1311
+ const result = buildResponsiveImageRules('--hero-bg-image', urls);
1312
+ assert.ok(
1313
+ result.rules[0].startsWith('--hero-bg-image:'),
1314
+ 'Should use provided property name in base rule'
1315
+ );
1316
+ if (result.mediaRules.length > 0) {
1317
+ assert.ok(
1318
+ result.mediaRules[0].rules[0].startsWith('--hero-bg-image:'),
1319
+ 'Should use provided property name in media rules'
1320
+ );
1321
+ }
1322
+ });
1323
+
1324
+ it('should use custom imageSizes when provided', function () {
1325
+ const urls = {
1326
+ small: '/attachments/abc.small.jpg',
1327
+ medium: '/attachments/abc.medium.jpg',
1328
+ large: '/attachments/abc.large.jpg'
1329
+ };
1330
+ const imageSizes = [
1331
+ {
1332
+ name: 'small',
1333
+ width: 400
1334
+ },
1335
+ {
1336
+ name: 'medium',
1337
+ width: 1000
1338
+ },
1339
+ {
1340
+ name: 'large',
1341
+ width: 1800
1342
+ }
1343
+ ];
1344
+ const result = buildResponsiveImageRules('--bg-image', urls, imageSizes);
1345
+ assert.deepEqual(result.rules, [
1346
+ '--bg-image: url(/attachments/abc.large.jpg)'
1347
+ ], 'Base rule should use largest sized image');
1348
+ // With sizes 400, 1000, 1800 and breakpoints 480, 768 (×2 DPR):
1349
+ // - 480px → target 960 → best match >= 960 is 1000 (medium) → emitted
1350
+ // - 768px → target 1536 → best match >= 1536 is 1800 (large) = base → skipped
1351
+ assert.equal(result.mediaRules.length, 1, 'Only mobile breakpoint should emit');
1352
+ assert.ok(
1353
+ result.mediaRules[0].rules[0].includes('abc.medium.jpg'),
1354
+ 'Should use medium image for mobile breakpoint'
1355
+ );
1356
+ assert.equal(
1357
+ result.mediaRules[0].query,
1358
+ '(width <= 480px)',
1359
+ 'Mobile breakpoint should use range query'
1360
+ );
1361
+ });
1362
+
1363
+ it('should skip original and uncropped entries', function () {
1364
+ const urls = {
1365
+ original: '/attachments/abc.original.jpg',
1366
+ uncropped: { full: '/attachments/abc.uncropped.full.jpg' },
1367
+ full: '/attachments/abc.full.jpg',
1368
+ 'one-third': '/attachments/abc.one-third.jpg'
1369
+ };
1370
+ const result = buildResponsiveImageRules('--bg-image', urls);
1371
+ const allUrls = [
1372
+ ...result.rules,
1373
+ ...result.mediaRules.flatMap(mr => mr.rules)
1374
+ ].join(' ');
1375
+ assert.ok(
1376
+ !allUrls.includes('original'),
1377
+ 'Should not reference original in any rule'
1378
+ );
1379
+ assert.ok(
1380
+ !allUrls.includes('uncropped'),
1381
+ 'Should not reference uncropped in any rule'
1382
+ );
1383
+ });
1384
+ });
1385
+ });
1386
+
1387
+ describe('background composite rule (customRules)', function () {
1388
+ let renderScopedStyles;
1389
+
1390
+ before(async function () {
1391
+ ({ renderScopedStyles } = await universal);
1392
+ });
1393
+
1394
+ function makeBackgroundSchema(subfields) {
1395
+ return [
1396
+ {
1397
+ name: 'bg',
1398
+ type: 'object',
1399
+ customType: 'background',
1400
+ selector: '.s-w123',
1401
+ property: '--bg',
1402
+ schema: [
1403
+ {
1404
+ name: 'enabled',
1405
+ type: 'boolean',
1406
+ def: false
1407
+ },
1408
+ {
1409
+ name: 'backgroundType',
1410
+ type: 'select',
1411
+ def: 'color'
1412
+ },
1413
+ ...subfields
1414
+ ]
1415
+ }
1416
+ ];
1417
+ }
1418
+
1419
+ describe('Color mode', function () {
1420
+ it('should produce background-color for valid color', function () {
1421
+ const schema = makeBackgroundSchema([
1422
+ {
1423
+ name: 'color',
1424
+ type: 'color'
1425
+ }
1426
+ ]);
1427
+ const result = renderScopedStyles(
1428
+ schema,
1429
+ {
1430
+ bg: {
1431
+ enabled: true,
1432
+ backgroundType: 'color',
1433
+ color: '#1a1a2e'
1434
+ }
1435
+ },
1436
+ { rootSelector: '#id' }
1437
+ );
1438
+ assert.ok(
1439
+ result.css.includes('background-color: #1a1a2e'),
1440
+ 'Should produce background-color declaration'
1441
+ );
1442
+ });
1443
+
1444
+ it('should produce empty rules when no color is set', function () {
1445
+ const schema = makeBackgroundSchema([
1446
+ {
1447
+ name: 'color',
1448
+ type: 'color'
1449
+ }
1450
+ ]);
1451
+ const result = renderScopedStyles(
1452
+ schema,
1453
+ {
1454
+ bg: {
1455
+ enabled: true,
1456
+ backgroundType: 'color',
1457
+ color: null
1458
+ }
1459
+ },
1460
+ { rootSelector: '#id' }
1461
+ );
1462
+ assert.ok(
1463
+ !result.css.includes('background-color'),
1464
+ 'Should not produce background-color when no color set'
1465
+ );
1466
+ });
1467
+ });
1468
+
1469
+ describe('Gradient mode', function () {
1470
+ it('should produce linear-gradient with all values', function () {
1471
+ const schema = makeBackgroundSchema([
1472
+ {
1473
+ name: 'gradientStart',
1474
+ type: 'color'
1475
+ },
1476
+ {
1477
+ name: 'gradientEnd',
1478
+ type: 'color'
1479
+ },
1480
+ {
1481
+ name: 'gradientAngle',
1482
+ type: 'range',
1483
+ unit: 'deg'
1484
+ }
1485
+ ]);
1486
+ const result = renderScopedStyles(
1487
+ schema,
1488
+ {
1489
+ bg: {
1490
+ enabled: true,
1491
+ backgroundType: 'gradient',
1492
+ gradientStart: '#ff0000',
1493
+ gradientEnd: '#0000ff',
1494
+ gradientAngle: 135
1495
+ }
1496
+ },
1497
+ { rootSelector: '#id' }
1498
+ );
1499
+ assert.ok(
1500
+ result.css.includes('background: linear-gradient(135deg, #ff0000, #0000ff)'),
1501
+ 'Should produce linear-gradient declaration'
1502
+ );
1503
+ });
1504
+
1505
+ it('should apply defaults when gradient values are missing', function () {
1506
+ const schema = makeBackgroundSchema([
1507
+ {
1508
+ name: 'gradientStart',
1509
+ type: 'color'
1510
+ },
1511
+ {
1512
+ name: 'gradientEnd',
1513
+ type: 'color'
1514
+ },
1515
+ {
1516
+ name: 'gradientAngle',
1517
+ type: 'range',
1518
+ unit: 'deg'
1519
+ }
1520
+ ]);
1521
+ const result = renderScopedStyles(
1522
+ schema,
1523
+ {
1524
+ bg: {
1525
+ enabled: true,
1526
+ backgroundType: 'gradient'
1527
+ }
1528
+ },
1529
+ { rootSelector: '#id' }
1530
+ );
1531
+ assert.ok(
1532
+ result.css.includes('background: linear-gradient(180deg, #000000, #ffffff)'),
1533
+ 'Should use default gradient values'
1534
+ );
1535
+ });
1536
+ });
1537
+
1538
+ describe('Image mode', function () {
1539
+ const imageAttachment = {
1540
+ group: 'images',
1541
+ _urls: {
1542
+ 'one-sixth': '/attachments/abc.one-sixth.jpg',
1543
+ 'one-third': '/attachments/abc.one-third.jpg',
1544
+ 'one-half': '/attachments/abc.one-half.jpg',
1545
+ 'two-thirds': '/attachments/abc.two-thirds.jpg',
1546
+ full: '/attachments/abc.full.jpg',
1547
+ max: '/attachments/abc.max.jpg',
1548
+ original: '/attachments/abc.original.jpg'
1549
+ }
1550
+ };
1551
+
1552
+ function makeImageSchema(extraSubfields = []) {
1553
+ return makeBackgroundSchema([
1554
+ {
1555
+ name: '_image',
1556
+ type: 'relationship'
1557
+ },
1558
+ {
1559
+ name: 'overlay',
1560
+ type: 'boolean'
1561
+ },
1562
+ {
1563
+ name: 'overlayColor',
1564
+ type: 'color'
1565
+ },
1566
+ {
1567
+ name: 'overlayOpacity',
1568
+ type: 'range'
1569
+ },
1570
+ ...extraSubfields
1571
+ ]);
1572
+ }
1573
+
1574
+ it('should produce CSS variables and background shorthand for valid image', function () {
1575
+ const schema = makeImageSchema();
1576
+ const result = renderScopedStyles(
1577
+ schema,
1578
+ {
1579
+ bg: {
1580
+ enabled: true,
1581
+ backgroundType: 'image',
1582
+ _image: [ { attachment: imageAttachment } ]
1583
+ }
1584
+ },
1585
+ { rootSelector: '#id' }
1586
+ );
1587
+ assert.ok(
1588
+ result.css.includes('--bg-image: url(/attachments/abc.full.jpg)'),
1589
+ 'Should export --bg-image CSS variable'
1590
+ );
1591
+ assert.ok(
1592
+ result.css.includes('background: var(--bg-image-layer,'),
1593
+ 'Should produce background shorthand with override hook'
1594
+ );
1595
+ assert.ok(
1596
+ result.css.includes('center / cover no-repeat'),
1597
+ 'Should include background sizing'
1598
+ );
1599
+ });
1600
+
1601
+ it('should emit initial values for override hooks to prevent inheritance in nested widgets', function () {
1602
+ const schema = makeImageSchema();
1603
+ const result = renderScopedStyles(
1604
+ schema,
1605
+ {
1606
+ bg: {
1607
+ enabled: true,
1608
+ backgroundType: 'image',
1609
+ _image: [ { attachment: imageAttachment } ]
1610
+ }
1611
+ },
1612
+ { rootSelector: '#id' }
1613
+ );
1614
+ assert.ok(
1615
+ result.css.includes('--bg-image-layer: initial'),
1616
+ 'Should reset --bg-image-layer to initial for nested widget isolation'
1617
+ );
1618
+ assert.ok(
1619
+ result.css.includes('--bg-overlay-layer: initial'),
1620
+ 'Should reset --bg-overlay-layer to initial for nested widget isolation'
1621
+ );
1622
+ });
1623
+
1624
+ it('should produce empty rules when no image is set', function () {
1625
+ const schema = makeImageSchema();
1626
+ const result = renderScopedStyles(
1627
+ schema,
1628
+ {
1629
+ bg: {
1630
+ enabled: true,
1631
+ backgroundType: 'image',
1632
+ _image: []
1633
+ }
1634
+ },
1635
+ { rootSelector: '#id' }
1636
+ );
1637
+ assert.ok(
1638
+ !result.css.includes('--bg-image'),
1639
+ 'Should not produce image variables when no image set'
1640
+ );
1641
+ });
1642
+
1643
+ it('should produce empty rules when image has no attachment', function () {
1644
+ const schema = makeImageSchema();
1645
+ const result = renderScopedStyles(
1646
+ schema,
1647
+ {
1648
+ bg: {
1649
+ enabled: true,
1650
+ backgroundType: 'image',
1651
+ _image: [ { title: 'missing attachment' } ]
1652
+ }
1653
+ },
1654
+ { rootSelector: '#id' }
1655
+ );
1656
+ assert.ok(
1657
+ !result.css.includes('--bg-image'),
1658
+ 'Should not produce image variables when attachment is missing'
1659
+ );
1660
+ });
1661
+
1662
+ it('should produce overlay CSS variable and layer when overlay is enabled', function () {
1663
+ const schema = makeImageSchema();
1664
+ const result = renderScopedStyles(
1665
+ schema,
1666
+ {
1667
+ bg: {
1668
+ enabled: true,
1669
+ backgroundType: 'image',
1670
+ _image: [ { attachment: imageAttachment } ],
1671
+ overlay: true,
1672
+ overlayColor: '#000000',
1673
+ overlayOpacity: 50
1674
+ }
1675
+ },
1676
+ { rootSelector: '#id' }
1677
+ );
1678
+ assert.ok(
1679
+ result.css.includes('--bg-overlay: linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5))'),
1680
+ 'Should export --bg-overlay CSS variable'
1681
+ );
1682
+ assert.ok(
1683
+ result.css.includes('var(--bg-overlay-layer, var(--bg-overlay))'),
1684
+ 'Should include overlay layer with override hook in background'
1685
+ );
1686
+ assert.ok(
1687
+ result.css.includes('var(--bg-image-layer,'),
1688
+ 'Should include image layer in background'
1689
+ );
1690
+ });
1691
+
1692
+ it('should not produce overlay when overlay is disabled', function () {
1693
+ const schema = makeImageSchema();
1694
+ const result = renderScopedStyles(
1695
+ schema,
1696
+ {
1697
+ bg: {
1698
+ enabled: true,
1699
+ backgroundType: 'image',
1700
+ _image: [ { attachment: imageAttachment } ],
1701
+ overlay: false
1702
+ }
1703
+ },
1704
+ { rootSelector: '#id' }
1705
+ );
1706
+ assert.ok(
1707
+ !result.css.includes('--bg-overlay:'),
1708
+ 'Should not produce overlay variable when overlay is disabled'
1709
+ );
1710
+ assert.ok(
1711
+ !result.css.includes('var(--bg-overlay-layer,'),
1712
+ 'Should not produce overlay layer reference in background shorthand when overlay is disabled'
1713
+ );
1714
+ });
1715
+
1716
+ it('should handle SVG image (only url, no image-set)', function () {
1717
+ const svgAttachment = {
1718
+ group: 'images',
1719
+ _urls: {
1720
+ original: '/attachments/icon.svg'
1721
+ }
1722
+ };
1723
+ const schema = makeImageSchema();
1724
+ const result = renderScopedStyles(
1725
+ schema,
1726
+ {
1727
+ bg: {
1728
+ enabled: true,
1729
+ backgroundType: 'image',
1730
+ _image: [ { attachment: svgAttachment } ]
1731
+ }
1732
+ },
1733
+ { rootSelector: '#id' }
1734
+ );
1735
+ assert.ok(
1736
+ result.css.includes('--bg-image: url(/attachments/icon.svg)'),
1737
+ 'Should export --bg-image for SVG'
1738
+ );
1739
+ assert.ok(
1740
+ !result.css.includes('@media'),
1741
+ 'Should not produce media query breakpoints for SVG (single rendition)'
1742
+ );
1743
+ });
1744
+ });
1745
+
1746
+ describe('Partial processing', function () {
1747
+ it('should not include blur in processedFields', function () {
1748
+ const schema = makeBackgroundSchema([
1749
+ {
1750
+ name: '_image',
1751
+ type: 'relationship'
1752
+ },
1753
+ {
1754
+ name: 'overlay',
1755
+ type: 'boolean'
1756
+ },
1757
+ {
1758
+ name: 'overlayColor',
1759
+ type: 'color'
1760
+ },
1761
+ {
1762
+ name: 'overlayOpacity',
1763
+ type: 'range'
1764
+ },
1765
+ {
1766
+ name: 'blur',
1767
+ type: 'range',
1768
+ unit: 'px',
1769
+ property: '--bg-blur',
1770
+ class: 'has-bg-blur',
1771
+ skipFalsyValues: true
1772
+ }
1773
+ ]);
1774
+ const result = renderScopedStyles(
1775
+ schema,
1776
+ {
1777
+ bg: {
1778
+ enabled: true,
1779
+ backgroundType: 'image',
1780
+ _image: [ {
1781
+ attachment: {
1782
+ group: 'images',
1783
+ _urls: {
1784
+ full: '/attachments/abc.full.jpg',
1785
+ max: '/attachments/abc.max.jpg',
1786
+ original: '/attachments/abc.original.jpg'
1787
+ }
1788
+ }
1789
+ } ],
1790
+ blur: 7
1791
+ }
1792
+ },
1793
+ { rootSelector: '#id' }
1794
+ );
1795
+ assert.ok(
1796
+ result.css.includes('--bg-blur: 7px'),
1797
+ 'Blur should be extracted by the engine (not consumed by background rule)'
1798
+ );
1799
+ assert.ok(
1800
+ result.classes.includes('has-bg-blur'),
1801
+ 'Blur class should be applied by the engine'
1802
+ );
1803
+ });
1804
+
1805
+ it('should let additional project fields be processed by the engine', function () {
1806
+ const schema = makeBackgroundSchema([
1807
+ {
1808
+ name: 'color',
1809
+ type: 'color'
1810
+ },
1811
+ {
1812
+ name: 'textColor',
1813
+ type: 'color',
1814
+ property: 'color'
1815
+ }
1816
+ ]);
1817
+ const result = renderScopedStyles(
1818
+ schema,
1819
+ {
1820
+ bg: {
1821
+ enabled: true,
1822
+ backgroundType: 'color',
1823
+ color: '#1a1a2e',
1824
+ textColor: '#ffffff'
1825
+ }
1826
+ },
1827
+ { rootSelector: '#id' }
1828
+ );
1829
+ assert.ok(
1830
+ result.css.includes('color: #ffffff'),
1831
+ 'Additional fields should be processed by the engine normally'
1832
+ );
1833
+ assert.ok(
1834
+ result.css.includes('background-color: #1a1a2e'),
1835
+ 'Background rule output should still be present'
1836
+ );
1837
+ });
1838
+ });
1839
+
1840
+ describe('Variable base name', function () {
1841
+ it('should use default --bg prefix', function () {
1842
+ const schema = [
1843
+ {
1844
+ name: 'bg',
1845
+ type: 'object',
1846
+ customType: 'background',
1847
+ selector: '.s-w123',
1848
+ property: '--bg',
1849
+ schema: [
1850
+ {
1851
+ name: 'enabled',
1852
+ type: 'boolean',
1853
+ def: false
1854
+ },
1855
+ {
1856
+ name: 'backgroundType',
1857
+ type: 'select',
1858
+ def: 'color'
1859
+ },
1860
+ {
1861
+ name: 'color',
1862
+ type: 'color'
1863
+ }
1864
+ ]
1865
+ }
1866
+ ];
1867
+ const result = renderScopedStyles(
1868
+ schema,
1869
+ {
1870
+ bg: {
1871
+ enabled: true,
1872
+ backgroundType: 'color',
1873
+ color: '#abc123'
1874
+ }
1875
+ },
1876
+ { rootSelector: '#id' }
1877
+ );
1878
+ assert.ok(
1879
+ result.css.includes('background-color: #abc123'),
1880
+ 'Should produce background-color with default prefix'
1881
+ );
1882
+ });
1883
+
1884
+ it('should use custom prefix for CSS variables', function () {
1885
+ const heroAttachment = {
1886
+ group: 'images',
1887
+ _urls: {
1888
+ full: '/attachments/hero.full.jpg',
1889
+ max: '/attachments/hero.max.jpg',
1890
+ original: '/attachments/hero.original.jpg'
1891
+ }
1892
+ };
1893
+ const schema = [
1894
+ {
1895
+ name: 'heroBg',
1896
+ type: 'object',
1897
+ customType: 'background',
1898
+ selector: '.s-hero',
1899
+ property: '--hero-bg',
1900
+ schema: [
1901
+ {
1902
+ name: 'enabled',
1903
+ type: 'boolean',
1904
+ def: false
1905
+ },
1906
+ {
1907
+ name: 'backgroundType',
1908
+ type: 'select',
1909
+ def: 'color'
1910
+ },
1911
+ {
1912
+ name: '_image',
1913
+ type: 'relationship'
1914
+ },
1915
+ {
1916
+ name: 'overlay',
1917
+ type: 'boolean'
1918
+ },
1919
+ {
1920
+ name: 'overlayColor',
1921
+ type: 'color'
1922
+ },
1923
+ {
1924
+ name: 'overlayOpacity',
1925
+ type: 'range'
1926
+ }
1927
+ ]
1928
+ }
1929
+ ];
1930
+ const result = renderScopedStyles(
1931
+ schema,
1932
+ {
1933
+ heroBg: {
1934
+ enabled: true,
1935
+ backgroundType: 'image',
1936
+ _image: [ { attachment: heroAttachment } ],
1937
+ overlay: true,
1938
+ overlayColor: '#ff0000',
1939
+ overlayOpacity: 80
1940
+ }
1941
+ },
1942
+ { rootSelector: '#id' }
1943
+ );
1944
+ assert.ok(
1945
+ result.css.includes('--hero-bg-image: url(/attachments/hero.full.jpg)'),
1946
+ 'Should use custom prefix for image variable'
1947
+ );
1948
+ assert.ok(
1949
+ result.css.includes('--hero-bg-overlay: linear-gradient('),
1950
+ 'Should use custom prefix for overlay variable'
1951
+ );
1952
+ assert.ok(
1953
+ result.css.includes('var(--hero-bg-overlay-layer,'),
1954
+ 'Should use custom prefix for overlay hook'
1955
+ );
1956
+ assert.ok(
1957
+ result.css.includes('var(--hero-bg-image-layer,'),
1958
+ 'Should use custom prefix for image hook'
1959
+ );
1960
+ });
1961
+ });
1962
+
1963
+ describe('Inline vote and media queries', function () {
1964
+ const imageAttachment = {
1965
+ group: 'images',
1966
+ _urls: {
1967
+ 'one-sixth': '/attachments/abc.one-sixth.jpg',
1968
+ 'one-third': '/attachments/abc.one-third.jpg',
1969
+ 'one-half': '/attachments/abc.one-half.jpg',
1970
+ 'two-thirds': '/attachments/abc.two-thirds.jpg',
1971
+ full: '/attachments/abc.full.jpg',
1972
+ max: '/attachments/abc.max.jpg',
1973
+ original: '/attachments/abc.original.jpg'
1974
+ }
1975
+ };
1976
+
1977
+ function makeImageSchemaNoSelector(extraSubfields = []) {
1978
+ return [
1979
+ {
1980
+ name: 'bg',
1981
+ type: 'object',
1982
+ customType: 'background',
1983
+ property: '--bg',
1984
+ schema: [
1985
+ {
1986
+ name: 'enabled',
1987
+ type: 'boolean',
1988
+ def: false
1989
+ },
1990
+ {
1991
+ name: 'backgroundType',
1992
+ type: 'select',
1993
+ def: 'color'
1994
+ },
1995
+ {
1996
+ name: 'color',
1997
+ type: 'color'
1998
+ },
1999
+ {
2000
+ name: '_image',
2001
+ type: 'relationship'
2002
+ },
2003
+ {
2004
+ name: 'overlay',
2005
+ type: 'boolean'
2006
+ },
2007
+ {
2008
+ name: 'overlayColor',
2009
+ type: 'color'
2010
+ },
2011
+ {
2012
+ name: 'overlayOpacity',
2013
+ type: 'range'
2014
+ },
2015
+ ...extraSubfields
2016
+ ]
2017
+ }
2018
+ ];
2019
+ }
2020
+
2021
+ it('should force scoped CSS when media queries are present (no field selector)', function () {
2022
+ const schema = makeImageSchemaNoSelector();
2023
+ const result = renderScopedStyles(
2024
+ schema,
2025
+ {
2026
+ bg: {
2027
+ enabled: true,
2028
+ backgroundType: 'image',
2029
+ _image: [ { attachment: imageAttachment } ]
2030
+ }
2031
+ },
2032
+ { rootSelector: '#w123' }
2033
+ );
2034
+ // Media queries present → must produce scoped CSS, not inline
2035
+ assert.ok(
2036
+ result.css.includes('@media'),
2037
+ 'Should produce media queries for responsive images'
2038
+ );
2039
+ assert.equal(
2040
+ result.inline,
2041
+ '',
2042
+ 'Should not produce inline styles when media queries are present'
2043
+ );
2044
+ assert.ok(
2045
+ result.css.includes('#w123{'),
2046
+ 'Should scope rules under rootSelector'
2047
+ );
2048
+ });
2049
+
2050
+ it('should produce inline styles for color mode (no selector, no media queries)', function () {
2051
+ const schema = makeImageSchemaNoSelector();
2052
+ const result = renderScopedStyles(
2053
+ schema,
2054
+ {
2055
+ bg: {
2056
+ enabled: true,
2057
+ backgroundType: 'color',
2058
+ color: '#ff0000'
2059
+ }
2060
+ },
2061
+ { rootSelector: '#w123' }
2062
+ );
2063
+ assert.ok(
2064
+ result.inline.includes('background-color: #ff0000'),
2065
+ 'Color mode without selector should produce inline styles'
2066
+ );
2067
+ assert.equal(
2068
+ result.css,
2069
+ '',
2070
+ 'Should not produce scoped CSS for inline-eligible color mode'
2071
+ );
2072
+ });
2073
+
2074
+ it('should produce media query breakpoints with correct image overrides', function () {
2075
+ const schema = makeImageSchemaNoSelector();
2076
+ const result = renderScopedStyles(
2077
+ schema,
2078
+ {
2079
+ bg: {
2080
+ enabled: true,
2081
+ backgroundType: 'image',
2082
+ _image: [ { attachment: imageAttachment } ]
2083
+ }
2084
+ },
2085
+ { rootSelector: '#w123' }
2086
+ );
2087
+ // Base is now max (largest entry). Mobile BP selects full (≠ base)
2088
+ // → emitted. Tablet BP selects max (= base) → skipped.
2089
+ const hasMobile = result.css.includes('@media (width <= 480px)');
2090
+ assert.ok(
2091
+ hasMobile,
2092
+ 'Should produce mobile responsive breakpoint with range query'
2093
+ );
2094
+ // Media queries should override --bg-image with smaller images
2095
+ assert.ok(
2096
+ result.css.includes('--bg-image: url(/attachments/abc'),
2097
+ 'Media query should override --bg-image variable'
2098
+ );
2099
+ });
2100
+ });
2101
+
2102
+ describe('Subfield filtering', function () {
2103
+ it('should not emit CSS for unprocessed subfields with null values', function () {
2104
+ const schema = [
2105
+ {
2106
+ name: 'bg',
2107
+ type: 'object',
2108
+ customType: 'background',
2109
+ selector: '.s-test',
2110
+ property: '--bg',
2111
+ schema: [
2112
+ {
2113
+ name: 'enabled',
2114
+ type: 'boolean',
2115
+ def: false
2116
+ },
2117
+ {
2118
+ name: 'backgroundType',
2119
+ type: 'select',
2120
+ def: 'color'
2121
+ },
2122
+ {
2123
+ name: 'color',
2124
+ type: 'color'
2125
+ },
2126
+ {
2127
+ name: 'blur',
2128
+ type: 'range',
2129
+ unit: 'px',
2130
+ property: '--bg-blur'
2131
+ }
2132
+ ]
2133
+ }
2134
+ ];
2135
+ const result = renderScopedStyles(
2136
+ schema,
2137
+ {
2138
+ bg: {
2139
+ enabled: true,
2140
+ backgroundType: 'color',
2141
+ color: '#aabbcc',
2142
+ blur: null
2143
+ }
2144
+ },
2145
+ { rootSelector: '#id' }
2146
+ );
2147
+ assert.ok(
2148
+ result.css.includes('background-color: #aabbcc'),
2149
+ 'Should render the color rule'
2150
+ );
2151
+ assert.ok(
2152
+ !result.css.includes('--bg-blur'),
2153
+ 'Should NOT emit --bg-blur when value is null'
2154
+ );
2155
+ });
2156
+
2157
+ it('should not emit CSS for unprocessed subfields without property or selector', function () {
2158
+ const schema = [
2159
+ {
2160
+ name: 'bg',
2161
+ type: 'object',
2162
+ customType: 'background',
2163
+ selector: '.s-test',
2164
+ property: '--bg',
2165
+ schema: [
2166
+ {
2167
+ name: 'enabled',
2168
+ type: 'boolean',
2169
+ def: false
2170
+ },
2171
+ {
2172
+ name: 'backgroundType',
2173
+ type: 'select',
2174
+ def: 'color'
2175
+ },
2176
+ {
2177
+ name: 'color',
2178
+ type: 'color'
2179
+ },
2180
+ {
2181
+ name: 'noPropertyField',
2182
+ type: 'string'
2183
+ }
2184
+ ]
2185
+ }
2186
+ ];
2187
+ const result = renderScopedStyles(
2188
+ schema,
2189
+ {
2190
+ bg: {
2191
+ enabled: true,
2192
+ backgroundType: 'color',
2193
+ color: '#aabbcc',
2194
+ noPropertyField: 'some-value'
2195
+ }
2196
+ },
2197
+ { rootSelector: '#id' }
2198
+ );
2199
+ assert.ok(
2200
+ result.css.includes('background-color: #aabbcc'),
2201
+ 'Should render the color rule'
2202
+ );
2203
+ assert.ok(
2204
+ !result.css.includes('some-value'),
2205
+ 'Should NOT emit a field without property or selector'
2206
+ );
2207
+ });
2208
+
2209
+ it('should emit CSS for valid unprocessed subfields with values', function () {
2210
+ const schema = [
2211
+ {
2212
+ name: 'bg',
2213
+ type: 'object',
2214
+ customType: 'background',
2215
+ selector: '.s-test',
2216
+ property: '--bg',
2217
+ schema: [
2218
+ {
2219
+ name: 'enabled',
2220
+ type: 'boolean',
2221
+ def: false
2222
+ },
2223
+ {
2224
+ name: 'backgroundType',
2225
+ type: 'select',
2226
+ def: 'color'
2227
+ },
2228
+ {
2229
+ name: 'color',
2230
+ type: 'color'
2231
+ },
2232
+ {
2233
+ name: 'blur',
2234
+ type: 'range',
2235
+ unit: 'px',
2236
+ property: '--bg-blur',
2237
+ class: 'has-bg-blur',
2238
+ skipFalsyValues: true
2239
+ }
2240
+ ]
2241
+ }
2242
+ ];
2243
+ const result = renderScopedStyles(
2244
+ schema,
2245
+ {
2246
+ bg: {
2247
+ enabled: true,
2248
+ backgroundType: 'color',
2249
+ color: '#aabbcc',
2250
+ blur: 5
2251
+ }
2252
+ },
2253
+ { rootSelector: '#id' }
2254
+ );
2255
+ assert.ok(
2256
+ result.css.includes('--bg-blur: 5px'),
2257
+ 'Should emit --bg-blur for valid value'
2258
+ );
2259
+ assert.ok(
2260
+ result.classes.includes('has-bg-blur'),
2261
+ 'Should apply blur class for valid value'
2262
+ );
2263
+ });
2264
+ });
2265
+
2266
+ describe('Unprocessed subfield inline vote', function () {
2267
+ // Edge case: a customType object rule does NOT process a subfield,
2268
+ // but that subfield carries its own `selector` which should force
2269
+ // scoped CSS. The vote must still happen because normalizeObject
2270
+ // normalizes (and votes) ALL subfields before the rule runs.
2271
+ it('should force scoped CSS when an unprocessed subfield has a selector', function () {
2272
+ const schema = [
2273
+ {
2274
+ name: 'bg',
2275
+ type: 'object',
2276
+ customType: 'background',
2277
+ property: '--bg',
2278
+ // NO selector on parent
2279
+ schema: [
2280
+ {
2281
+ name: 'enabled',
2282
+ type: 'boolean',
2283
+ def: false
2284
+ },
2285
+ {
2286
+ name: 'backgroundType',
2287
+ type: 'select',
2288
+ def: 'color'
2289
+ },
2290
+ {
2291
+ name: 'color',
2292
+ type: 'color'
2293
+ },
2294
+ // Unprocessed by the background rule, has its own selector
2295
+ {
2296
+ name: 'customExtra',
2297
+ type: 'range',
2298
+ unit: 'px',
2299
+ property: '--custom-extra',
2300
+ selector: '.extra-scope'
2301
+ }
2302
+ ]
2303
+ }
2304
+ ];
2305
+ const result = renderScopedStyles(
2306
+ schema,
2307
+ {
2308
+ bg: {
2309
+ enabled: true,
2310
+ backgroundType: 'color',
2311
+ color: '#aabbcc',
2312
+ customExtra: 10
2313
+ }
2314
+ },
2315
+ { rootSelector: '#w999' }
2316
+ );
2317
+ // The subfield has a selector → it votes inline: false during
2318
+ // normalizeObject, even though the background rule never touches it.
2319
+ assert.ok(
2320
+ result.css.includes('--custom-extra: 10px'),
2321
+ 'Unprocessed subfield should produce scoped CSS'
2322
+ );
2323
+ assert.ok(
2324
+ result.css.includes('.extra-scope'),
2325
+ 'Subfield selector should appear in scoped output'
2326
+ );
2327
+ assert.equal(
2328
+ result.inline,
2329
+ '',
2330
+ 'Subfield selector should force scoped CSS, not inline'
2331
+ );
2332
+ });
2333
+
2334
+ it('should force scoped CSS when an unprocessed subfield has a mediaQuery', function () {
2335
+ const schema = [
2336
+ {
2337
+ name: 'bg',
2338
+ type: 'object',
2339
+ customType: 'background',
2340
+ property: '--bg',
2341
+ schema: [
2342
+ {
2343
+ name: 'enabled',
2344
+ type: 'boolean',
2345
+ def: false
2346
+ },
2347
+ {
2348
+ name: 'backgroundType',
2349
+ type: 'select',
2350
+ def: 'color'
2351
+ },
2352
+ {
2353
+ name: 'color',
2354
+ type: 'color'
2355
+ },
2356
+ // Unprocessed by the background rule, has its own media query
2357
+ {
2358
+ name: 'mqExtra',
2359
+ type: 'range',
2360
+ unit: 'rem',
2361
+ property: '--mq-extra',
2362
+ mediaQuery: '(min-width: 1024px)'
2363
+ }
2364
+ ]
2365
+ }
2366
+ ];
2367
+ const result = renderScopedStyles(
2368
+ schema,
2369
+ {
2370
+ bg: {
2371
+ enabled: true,
2372
+ backgroundType: 'color',
2373
+ color: '#112233',
2374
+ mqExtra: 3
2375
+ }
2376
+ },
2377
+ { rootSelector: '#w888' }
2378
+ );
2379
+ assert.ok(
2380
+ result.css.includes('@media (min-width: 1024px)'),
2381
+ 'Unprocessed subfield media query should produce scoped CSS'
2382
+ );
2383
+ assert.ok(
2384
+ result.css.includes('--mq-extra: 3rem'),
2385
+ 'Unprocessed subfield should emit its rule inside the media query'
2386
+ );
2387
+ assert.equal(
2388
+ result.inline,
2389
+ '',
2390
+ 'Subfield mediaQuery should force scoped CSS, not inline'
2391
+ );
2392
+ });
2393
+
2394
+ it('should still allow inline when unprocessed subfields have no selector or mediaQuery', function () {
2395
+ const schema = [
2396
+ {
2397
+ name: 'bg',
2398
+ type: 'object',
2399
+ customType: 'background',
2400
+ property: '--bg',
2401
+ schema: [
2402
+ {
2403
+ name: 'enabled',
2404
+ type: 'boolean',
2405
+ def: false
2406
+ },
2407
+ {
2408
+ name: 'backgroundType',
2409
+ type: 'select',
2410
+ def: 'color'
2411
+ },
2412
+ {
2413
+ name: 'color',
2414
+ type: 'color'
2415
+ },
2416
+ // Unprocessed, no selector, no mediaQuery
2417
+ {
2418
+ name: 'simpleExtra',
2419
+ type: 'range',
2420
+ unit: 'px',
2421
+ property: '--simple-extra'
2422
+ }
2423
+ ]
2424
+ }
2425
+ ];
2426
+ const result = renderScopedStyles(
2427
+ schema,
2428
+ {
2429
+ bg: {
2430
+ enabled: true,
2431
+ backgroundType: 'color',
2432
+ color: '#445566',
2433
+ simpleExtra: 8
2434
+ }
2435
+ },
2436
+ { rootSelector: '#w777' }
2437
+ );
2438
+ // No subfield selector/mediaQuery, no image mode → inline
2439
+ assert.ok(
2440
+ result.inline.includes('--simple-extra: 8px'),
2441
+ 'Unprocessed subfield without selector should render inline'
2442
+ );
2443
+ assert.ok(
2444
+ result.inline.includes('background-color: #445566'),
2445
+ 'Color output should also be inline'
2446
+ );
2447
+ assert.equal(
2448
+ result.css,
2449
+ '',
2450
+ 'Nothing should force scoped CSS'
2451
+ );
2452
+ });
2453
+ });
2454
+ });
2455
+ });
2456
+
2457
+ describe('Background preset (e2e)', function () {
2458
+ let apos;
2459
+
2460
+ before(async function () {
2461
+ apos = await t.create({
2462
+ root: module,
2463
+ modules: {
2464
+ '@apostrophecms/styles': {
2465
+ extendMethods(self) {
2466
+ return {
2467
+ registerPresets(_super) {
2468
+ _super();
2469
+ const bg = self.getPreset('background');
2470
+ bg.fields.add.blur = {
2471
+ label: 'Blur',
2472
+ type: 'range',
2473
+ min: 0,
2474
+ max: 20,
2475
+ def: 0,
2476
+ if: {
2477
+ enabled: true,
2478
+ backgroundType: 'image'
2479
+ },
2480
+ unit: 'px',
2481
+ property: '--preset-bg-blur',
2482
+ class: 'has-bg-blur',
2483
+ skipFalsyValues: true
2484
+ };
2485
+ self.setPreset('background', bg);
2486
+ }
2487
+ };
2488
+ }
2489
+ },
2490
+ 'test-bg-widget': {
2491
+ extend: '@apostrophecms/widget-type',
2492
+ options: {
2493
+ label: 'Test Background Widget'
2494
+ },
2495
+ styles: {
2496
+ add: {
2497
+ background: 'background'
2498
+ }
2499
+ }
2500
+ },
2501
+ 'test-bg-custom-prefix-widget': {
2502
+ extend: '@apostrophecms/widget-type',
2503
+ options: {
2504
+ label: 'Test Background Custom Prefix Widget'
2505
+ },
2506
+ styles: {
2507
+ add: {
2508
+ background: {
2509
+ preset: 'background',
2510
+ property: '--hero-bg'
2511
+ }
2512
+ }
2513
+ }
2514
+ }
2515
+ }
2516
+ });
2517
+ });
2518
+
2519
+ after(async function () {
2520
+ return t.destroy(apos);
2521
+ });
2522
+
2523
+ describe('Preset schema validation', function () {
2524
+ it('should register the background preset with correct structure', function () {
2525
+ const preset = apos.styles.getPreset('background');
2526
+ assert.ok(preset, 'Background preset should be registered');
2527
+ assert.equal(preset.type, 'object', 'Should be an object type');
2528
+ assert.equal(preset.customType, 'background', 'Should have customType background');
2529
+ assert.equal(preset.property, '--preset-bg', 'Should have --preset-bg property');
2530
+ });
2531
+
2532
+ it('should have all required subfields', function () {
2533
+ const preset = apos.styles.getPreset('background');
2534
+ const fieldNames = Object.keys(preset.fields.add);
2535
+ const required = [
2536
+ 'enabled', 'backgroundType', 'color',
2537
+ 'gradientStart', 'gradientEnd', 'gradientAngle',
2538
+ '_image', 'overlay', 'overlayColor', 'overlayOpacity'
2539
+ ];
2540
+ for (const name of required) {
2541
+ assert.ok(
2542
+ fieldNames.includes(name),
2543
+ `Should have ${name} subfield`
2544
+ );
2545
+ }
2546
+ });
2547
+
2548
+ it('should include extended blur field via registerPresets', function () {
2549
+ const preset = apos.styles.getPreset('background');
2550
+ assert.ok(
2551
+ preset.fields.add.blur,
2552
+ 'Extended blur field should be present'
2553
+ );
2554
+ assert.equal(
2555
+ preset.fields.add.blur.type,
2556
+ 'range',
2557
+ 'Blur should be a range field'
2558
+ );
2559
+ assert.equal(
2560
+ preset.fields.add.blur.property,
2561
+ '--preset-bg-blur',
2562
+ 'Blur should use --preset-bg-blur property'
2563
+ );
2564
+ });
2565
+
2566
+ it('should have gradientAngle with step 5', function () {
2567
+ const preset = apos.styles.getPreset('background');
2568
+ assert.equal(
2569
+ preset.fields.add.gradientAngle.step,
2570
+ 5,
2571
+ 'Gradient angle should have step 5'
2572
+ );
2573
+ });
2574
+
2575
+ it('should compile background preset into widget schema', function () {
2576
+ const schema = apos.modules['test-bg-widget'].schema;
2577
+ const bgField = schema.find(field => field.name === 'background');
2578
+ assert.ok(bgField, 'Background field should exist in widget schema');
2579
+ assert.equal(bgField.type, 'object', 'Should be compiled as object');
2580
+ assert.equal(bgField.customType, 'background', 'Should preserve customType');
2581
+ assert.ok(
2582
+ Array.isArray(bgField.schema),
2583
+ 'Should have compiled schema array (from fields.add)'
2584
+ );
2585
+ const subfieldNames = bgField.schema.map(f => f.name);
2586
+ assert.ok(
2587
+ subfieldNames.includes('enabled'),
2588
+ 'Compiled schema should include enabled'
2589
+ );
2590
+ assert.ok(
2591
+ subfieldNames.includes('backgroundType'),
2592
+ 'Compiled schema should include backgroundType'
2593
+ );
2594
+ assert.ok(
2595
+ subfieldNames.includes('blur'),
2596
+ 'Compiled schema should include extended blur field'
2597
+ );
2598
+ });
2599
+ });
2600
+
2601
+ describe('Conditional field gating', function () {
2602
+ it('should gate all fields when enabled is false', function () {
2603
+ const result = apos.modules['test-bg-widget'].getStylesheet(
2604
+ { background: { enabled: false } },
2605
+ 'gate-test-1'
2606
+ );
2607
+ assert.equal(result.css, '', 'Should produce no CSS when disabled');
2608
+ assert.equal(result.inline, '', 'Should produce no inline when disabled');
2609
+ });
2610
+
2611
+ it('should show only color field when backgroundType is color', function () {
2612
+ const result = apos.modules['test-bg-widget'].getStylesheet(
2613
+ {
2614
+ background: {
2615
+ enabled: true,
2616
+ backgroundType: 'color',
2617
+ color: '#ff0000',
2618
+ // These should be gated out
2619
+ gradientStart: '#111111',
2620
+ gradientEnd: '#222222'
2621
+ }
2622
+ },
2623
+ 'gate-test-2'
2624
+ );
2625
+ assert.ok(
2626
+ result.inline.includes('background-color: #ff0000'),
2627
+ 'Should produce color output'
2628
+ );
2629
+ assert.ok(
2630
+ !result.inline.includes('linear-gradient'),
2631
+ 'Should NOT produce gradient output when backgroundType is color'
2632
+ );
2633
+ });
2634
+
2635
+ it('should show gradient fields when backgroundType is gradient', function () {
2636
+ const result = apos.modules['test-bg-widget'].getStylesheet(
2637
+ {
2638
+ background: {
2639
+ enabled: true,
2640
+ backgroundType: 'gradient',
2641
+ gradientStart: '#ff0000',
2642
+ gradientEnd: '#0000ff',
2643
+ gradientAngle: 90
2644
+ }
2645
+ },
2646
+ 'gate-test-3'
2647
+ );
2648
+ assert.ok(
2649
+ result.inline.includes('linear-gradient(90deg, #ff0000, #0000ff)'),
2650
+ 'Should produce gradient output'
2651
+ );
2652
+ });
2653
+
2654
+ it('should gate overlay fields when overlay is false', function () {
2655
+ const imageAttachment = {
2656
+ group: 'images',
2657
+ _urls: {
2658
+ 'one-third': '/attachments/gate.one-third.jpg',
2659
+ full: '/attachments/gate.full.jpg',
2660
+ max: '/attachments/gate.max.jpg',
2661
+ original: '/attachments/gate.original.jpg'
2662
+ }
2663
+ };
2664
+ const result = apos.modules['test-bg-widget'].getStylesheet(
2665
+ {
2666
+ background: {
2667
+ enabled: true,
2668
+ backgroundType: 'image',
2669
+ _image: [ { attachment: imageAttachment } ],
2670
+ overlay: false,
2671
+ overlayColor: '#000000',
2672
+ overlayOpacity: 50
2673
+ }
2674
+ },
2675
+ 'gate-test-4'
2676
+ );
2677
+ assert.ok(
2678
+ !result.css.includes('--preset-bg-overlay:'),
2679
+ 'Should NOT produce overlay in scoped CSS when overlay toggle is false'
2680
+ );
2681
+ assert.equal(
2682
+ result.inline,
2683
+ '',
2684
+ 'Image mode should not produce inline styles'
2685
+ );
2686
+ });
2687
+
2688
+ it('should show overlay fields when overlay is true', function () {
2689
+ const imageAttachment = {
2690
+ group: 'images',
2691
+ _urls: {
2692
+ 'one-third': '/attachments/gate.one-third.jpg',
2693
+ full: '/attachments/gate.full.jpg',
2694
+ max: '/attachments/gate.max.jpg',
2695
+ original: '/attachments/gate.original.jpg'
2696
+ }
2697
+ };
2698
+ const result = apos.modules['test-bg-widget'].getStylesheet(
2699
+ {
2700
+ background: {
2701
+ enabled: true,
2702
+ backgroundType: 'image',
2703
+ _image: [ { attachment: imageAttachment } ],
2704
+ overlay: true,
2705
+ overlayColor: '#000000',
2706
+ overlayOpacity: 50
2707
+ }
2708
+ },
2709
+ 'gate-test-5'
2710
+ );
2711
+ assert.ok(
2712
+ result.css.includes('--preset-bg-overlay'),
2713
+ 'Should produce overlay in scoped CSS when overlay toggle is true'
2714
+ );
2715
+ assert.equal(
2716
+ result.inline,
2717
+ '',
2718
+ 'Image mode should not produce inline styles'
2719
+ );
2720
+ });
2721
+ });
2722
+
2723
+ describe('End-to-end rendering', function () {
2724
+ it('should render color mode correctly', function () {
2725
+ const result = apos.modules['test-bg-widget'].getStylesheet(
2726
+ {
2727
+ background: {
2728
+ enabled: true,
2729
+ backgroundType: 'color',
2730
+ color: '#336699'
2731
+ }
2732
+ },
2733
+ 'e2e-color'
2734
+ );
2735
+ assert.ok(
2736
+ result.inline.includes('background-color: #336699'),
2737
+ 'Should produce background-color'
2738
+ );
2739
+ assert.equal(
2740
+ result.css,
2741
+ '',
2742
+ 'Color mode should produce inline, not scoped CSS'
2743
+ );
2744
+ });
2745
+
2746
+ it('should render gradient mode correctly', function () {
2747
+ const result = apos.modules['test-bg-widget'].getStylesheet(
2748
+ {
2749
+ background: {
2750
+ enabled: true,
2751
+ backgroundType: 'gradient',
2752
+ gradientStart: '#ff0000',
2753
+ gradientEnd: '#0000ff',
2754
+ gradientAngle: 45
2755
+ }
2756
+ },
2757
+ 'e2e-gradient'
2758
+ );
2759
+ assert.ok(
2760
+ result.inline.includes('background: linear-gradient(45deg, #ff0000, #0000ff)'),
2761
+ 'Should produce linear-gradient'
2762
+ );
2763
+ });
2764
+
2765
+ it('should render image mode with responsive breakpoints', function () {
2766
+ const imageAttachment = {
2767
+ group: 'images',
2768
+ _urls: {
2769
+ 'one-sixth': '/attachments/e2e.one-sixth.jpg',
2770
+ 'one-third': '/attachments/e2e.one-third.jpg',
2771
+ 'one-half': '/attachments/e2e.one-half.jpg',
2772
+ 'two-thirds': '/attachments/e2e.two-thirds.jpg',
2773
+ full: '/attachments/e2e.full.jpg',
2774
+ max: '/attachments/e2e.max.jpg',
2775
+ original: '/attachments/e2e.original.jpg'
2776
+ }
2777
+ };
2778
+ const result = apos.modules['test-bg-widget'].getStylesheet(
2779
+ {
2780
+ background: {
2781
+ enabled: true,
2782
+ backgroundType: 'image',
2783
+ _image: [ { attachment: imageAttachment } ]
2784
+ }
2785
+ },
2786
+ 'e2e-image'
2787
+ );
2788
+ assert.ok(
2789
+ result.css.includes('--preset-bg-image: url(/attachments/e2e.full.jpg)'),
2790
+ 'Should export --preset-bg-image variable'
2791
+ );
2792
+ assert.ok(
2793
+ result.css.includes('background:'),
2794
+ 'Should produce background shorthand'
2795
+ );
2796
+ assert.ok(
2797
+ result.css.includes('@media'),
2798
+ 'Should produce responsive media queries'
2799
+ );
2800
+ assert.equal(
2801
+ result.inline,
2802
+ '',
2803
+ 'Image mode with media queries should not produce inline styles'
2804
+ );
2805
+ });
2806
+
2807
+ it('should render image mode with overlay', function () {
2808
+ const imageAttachment = {
2809
+ group: 'images',
2810
+ _urls: {
2811
+ 'one-third': '/attachments/e2e-ov.one-third.jpg',
2812
+ full: '/attachments/e2e-ov.full.jpg',
2813
+ max: '/attachments/e2e-ov.max.jpg',
2814
+ original: '/attachments/e2e-ov.original.jpg'
2815
+ }
2816
+ };
2817
+ const result = apos.modules['test-bg-widget'].getStylesheet(
2818
+ {
2819
+ background: {
2820
+ enabled: true,
2821
+ backgroundType: 'image',
2822
+ _image: [ { attachment: imageAttachment } ],
2823
+ overlay: true,
2824
+ overlayColor: '#ff0000',
2825
+ overlayOpacity: 80
2826
+ }
2827
+ },
2828
+ 'e2e-overlay'
2829
+ );
2830
+ assert.ok(
2831
+ result.css.includes('--preset-bg-overlay: linear-gradient(rgba(255, 0, 0, 0.8), rgba(255, 0, 0, 0.8))'),
2832
+ 'Should produce overlay CSS variable in scoped CSS'
2833
+ );
2834
+ assert.ok(
2835
+ result.css.includes('var(--preset-bg-overlay-layer, var(--preset-bg-overlay))'),
2836
+ 'Should include overlay layer in background shorthand'
2837
+ );
2838
+ assert.ok(
2839
+ result.css.includes('var(--preset-bg-image-layer,'),
2840
+ 'Should include image layer in background shorthand'
2841
+ );
2842
+ assert.equal(
2843
+ result.inline,
2844
+ '',
2845
+ 'Image mode with overlay should not produce inline styles'
2846
+ );
2847
+ });
2848
+
2849
+ it('should render extended blur field alongside background', function () {
2850
+ const imageAttachment = {
2851
+ group: 'images',
2852
+ _urls: {
2853
+ 'one-third': '/attachments/e2e-blur.one-third.jpg',
2854
+ full: '/attachments/e2e-blur.full.jpg',
2855
+ max: '/attachments/e2e-blur.max.jpg',
2856
+ original: '/attachments/e2e-blur.original.jpg'
2857
+ }
2858
+ };
2859
+ const result = apos.modules['test-bg-widget'].getStylesheet(
2860
+ {
2861
+ background: {
2862
+ enabled: true,
2863
+ backgroundType: 'image',
2864
+ _image: [ { attachment: imageAttachment } ],
2865
+ blur: 10
2866
+ }
2867
+ },
2868
+ 'e2e-blur'
2869
+ );
2870
+ assert.ok(
2871
+ result.css.includes('--preset-bg-blur: 10px'),
2872
+ 'Extended blur field should appear in scoped CSS'
2873
+ );
2874
+ assert.ok(
2875
+ result.classes.includes('has-bg-blur'),
2876
+ 'Extended blur field should toggle class'
2877
+ );
2878
+ assert.ok(
2879
+ result.css.includes('--preset-bg-image:'),
2880
+ 'Background image output should still be present in scoped CSS'
2881
+ );
2882
+ assert.equal(
2883
+ result.inline,
2884
+ '',
2885
+ 'Image mode with blur should not produce inline styles'
2886
+ );
2887
+ });
2888
+
2889
+ it('should skip blur output when blur is 0 (skipFalsyValues)', function () {
2890
+ const imageAttachment = {
2891
+ group: 'images',
2892
+ _urls: {
2893
+ 'one-third': '/attachments/e2e-noblur.one-third.jpg',
2894
+ full: '/attachments/e2e-noblur.full.jpg',
2895
+ max: '/attachments/e2e-noblur.max.jpg',
2896
+ original: '/attachments/e2e-noblur.original.jpg'
2897
+ }
2898
+ };
2899
+ const result = apos.modules['test-bg-widget'].getStylesheet(
2900
+ {
2901
+ background: {
2902
+ enabled: true,
2903
+ backgroundType: 'image',
2904
+ _image: [ { attachment: imageAttachment } ],
2905
+ blur: 0
2906
+ }
2907
+ },
2908
+ 'e2e-noblur'
2909
+ );
2910
+ assert.ok(
2911
+ !result.css.includes('--preset-bg-blur'),
2912
+ 'Should not emit --preset-bg-blur in scoped CSS when value is 0 (skipFalsyValues)'
2913
+ );
2914
+ assert.ok(
2915
+ !result.classes.includes('has-bg-blur'),
2916
+ 'Should not toggle blur class when value is 0'
2917
+ );
2918
+ assert.equal(
2919
+ result.inline,
2920
+ '',
2921
+ 'Image mode should not produce inline styles'
2922
+ );
2923
+ });
2924
+ });
2925
+
2926
+ describe('Custom property prefix', function () {
2927
+ it('should use overridden prefix for all CSS variables', function () {
2928
+ const imageAttachment = {
2929
+ group: 'images',
2930
+ _urls: {
2931
+ 'one-third': '/attachments/hero.one-third.jpg',
2932
+ full: '/attachments/hero.full.jpg',
2933
+ max: '/attachments/hero.max.jpg',
2934
+ original: '/attachments/hero.original.jpg'
2935
+ }
2936
+ };
2937
+ const result = apos.modules['test-bg-custom-prefix-widget'].getStylesheet(
2938
+ {
2939
+ background: {
2940
+ enabled: true,
2941
+ backgroundType: 'image',
2942
+ _image: [ { attachment: imageAttachment } ],
2943
+ overlay: true,
2944
+ overlayColor: '#000000',
2945
+ overlayOpacity: 50
2946
+ }
2947
+ },
2948
+ 'e2e-prefix'
2949
+ );
2950
+ assert.ok(
2951
+ result.css.includes('--hero-bg-image:'),
2952
+ 'Should use custom prefix for image variable in scoped CSS'
2953
+ );
2954
+ assert.ok(
2955
+ result.css.includes('--hero-bg-overlay:'),
2956
+ 'Should use custom prefix for overlay variable in scoped CSS'
2957
+ );
2958
+ assert.ok(
2959
+ result.css.includes('var(--hero-bg-overlay-layer,'),
2960
+ 'Should use custom prefix in overlay hook'
2961
+ );
2962
+ assert.ok(
2963
+ result.css.includes('var(--hero-bg-image-layer,'),
2964
+ 'Should use custom prefix in image hook'
2965
+ );
2966
+ assert.equal(
2967
+ result.inline,
2968
+ '',
2969
+ 'Image mode with custom prefix should not produce inline styles'
2970
+ );
2971
+ });
2972
+ });
2973
+
2974
+ describe('i18n labels', function () {
2975
+ it('should resolve all background label keys', function () {
2976
+ const keys = [
2977
+ 'styleBackground',
2978
+ 'styleBackgroundColor',
2979
+ 'styleBackgroundGradient',
2980
+ 'styleBackgroundImage',
2981
+ 'styleBackgroundOverlay',
2982
+ 'styleBackgroundType',
2983
+ 'styleGradientAngle',
2984
+ 'styleGradientEnd',
2985
+ 'styleGradientStart',
2986
+ 'styleOverlayColor',
2987
+ 'styleOverlayOpacity'
2988
+ ];
2989
+ for (const key of keys) {
2990
+ const resolved = apos.i18n.i18next.t(`apostrophe:${key}`);
2991
+ assert.ok(
2992
+ resolved && !resolved.startsWith('apostrophe:'),
2993
+ `Label key apostrophe:${key} should resolve (got "${resolved}")`
2994
+ );
2995
+ }
2996
+ });
2997
+ });
429
2998
  });
430
2999
 
431
3000
  describe('Setup', function () {