@saasicat/ui-vue 0.22.2 → 0.23.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 (108) hide show
  1. package/README.md +1 -1
  2. package/dist/{chunk-LY52T4N4.js → chunk-TQQMKWN6.js} +58 -1
  3. package/dist/index.cjs +213 -159
  4. package/dist/index.d.cts +2 -2
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.js +7 -1
  7. package/dist/quasar/index.cjs +116 -20
  8. package/dist/quasar/index.d.cts +48 -3
  9. package/dist/quasar/index.d.ts +48 -3
  10. package/dist/quasar/index.js +58 -3
  11. package/dist/{use-super-admin-i18n-CgTuNi8h.d.cts → use-sa-theme-B5t7oXph.d.cts} +51 -1
  12. package/dist/{use-super-admin-i18n-CHNdsSlm.d.ts → use-sa-theme-OUBlaqgl.d.ts} +51 -1
  13. package/package.json +5 -2
  14. package/src/components/BundleVersionPublishDialog.vue +17 -17
  15. package/src/components/KvBlock.vue +6 -6
  16. package/src/components/MarketingPromotionsTab.vue +78 -78
  17. package/src/components/MfaPromptDialog.vue +2 -2
  18. package/src/components/TenantActionConfirmDialog.vue +5 -5
  19. package/src/components/VersionDiffPreview.vue +18 -18
  20. package/src/components/bundle-editor/BundleCreatePanel.vue +52 -52
  21. package/src/components/bundle-editor/BundleFeaturesEditor.vue +20 -20
  22. package/src/components/bundle-editor/BundlePlanCompatPicker.vue +33 -33
  23. package/src/components/bundle-editor/BundleQuotasEditor.vue +26 -26
  24. package/src/components/bundle-editor/BundleStatusBanner.vue +21 -21
  25. package/src/components/bundle-editor/BundleVersionInlineEditor.vue +48 -48
  26. package/src/components/bundle-editor/BundleVersionStrip.vue +30 -30
  27. package/src/components/dialogs/PilotCreateDialog.vue +20 -20
  28. package/src/components/dialogs/PilotEditDialog.vue +6 -6
  29. package/src/components/dialogs/PromoCodeCreateDialog.vue +9 -9
  30. package/src/components/dialogs/PromoCodeDialogFields.vue +61 -61
  31. package/src/components/dialogs/PromoCodeEditDialog.vue +11 -11
  32. package/src/components/dialogs/pilot-dialog.css +48 -48
  33. package/src/components/plan/PlanCycleToggle.vue +9 -16
  34. package/src/components/plan/PlanGrid.vue +30 -34
  35. package/src/components/plan/PriceSummary.vue +35 -35
  36. package/src/components/plan/PromoCodeInput.vue +26 -26
  37. package/src/components/plan/PublicBundleGrid.vue +32 -28
  38. package/src/components/plan-create-dialog/PlanCreateDialog.vue +49 -49
  39. package/src/components/plan-detail/PlanDetail.vue +172 -156
  40. package/src/components/plan-detail/PlanTitleEdit.vue +11 -11
  41. package/src/components/plan-list/PlanList.vue +98 -77
  42. package/src/components/plan-matrix/PlanMatrix.vue +89 -89
  43. package/src/components/plan-review/PlanReview.vue +60 -56
  44. package/src/components/plan-version-editor/PlanVersionEditor.vue +224 -200
  45. package/src/components/wizard-stepper/WizardStepper.vue +17 -17
  46. package/src/index.ts +1 -0
  47. package/src/pages-standard/AdminLayout.vue +58 -37
  48. package/src/pages-standard/AdminManifestErrorPage.vue +6 -6
  49. package/src/pages-standard/AuditPage.vue +3 -3
  50. package/src/pages-standard/BundlesPage.vue +36 -36
  51. package/src/pages-standard/DashboardPage.vue +17 -17
  52. package/src/pages-standard/DiscoveryPage.vue +55 -49
  53. package/src/pages-standard/EmailHistoryPage.vue +53 -71
  54. package/src/pages-standard/MarketingCatalogPage.vue +226 -218
  55. package/src/pages-standard/PilotsPage.vue +7 -6
  56. package/src/pages-standard/PlatformEmailPage.vue +18 -18
  57. package/src/pages-standard/PromoCodeDetailPage.vue +3 -3
  58. package/src/pages-standard/PromoCodesPage.vue +7 -6
  59. package/src/pages-standard/SuperAdminLoginPage.vue +39 -25
  60. package/src/pages-standard/SuperAdminSetupWizard.vue +44 -33
  61. package/src/pages-standard/TenantDetailPage.vue +7 -7
  62. package/src/pages-standard/TenantsPage.vue +11 -46
  63. package/src/pages-standard/bundles-page/BundleAccordionList.vue +13 -13
  64. package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +24 -24
  65. package/src/pages-standard/discovery-page/DiscoveryCapList.vue +22 -22
  66. package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +39 -39
  67. package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +26 -26
  68. package/src/pages-standard/discovery-page/DiscoveryStatusControl.vue +1 -1
  69. package/src/pages-standard/plan-versions/PlanDiffCard.vue +16 -16
  70. package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +21 -15
  71. package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +45 -45
  72. package/src/pages-standard/plans-page/PlanBundleOverview.vue +21 -15
  73. package/src/pages-standard/plans-page/PlanPublishDialog.vue +4 -4
  74. package/src/pages-standard/plans-page/PlansPageToast.vue +6 -6
  75. package/src/pages-standard/sa-theme.css +9 -687
  76. package/src/pages-standard/tenants/StatusPill.vue +13 -13
  77. package/src/pages-tenant/LimitsRow.vue +4 -4
  78. package/src/pages-tenant/MySubscriptionBundlesPage.vue +61 -61
  79. package/src/pages-tenant/OnboardingConfigurator.vue +24 -24
  80. package/src/pages-tenant/PackageSnapshotPanel.vue +10 -16
  81. package/src/pages-tenant/PendingVersionBanner.vue +12 -12
  82. package/src/pages-tenant/PlanChangeWizard.vue +20 -27
  83. package/src/pages-tenant/TenantPlanSection.vue +13 -21
  84. package/src/pages-tenant/UsageBar.vue +7 -7
  85. package/src/pages-tenant/tenant-plan-section/BundlePreviewDialog.vue +14 -14
  86. package/src/pages-tenant/tenant-plan-section/TenantBundleStore.vue +10 -10
  87. package/src/pages-tenant/tenant-plan-section/TenantFeatureMatrix.vue +2 -2
  88. package/src/quasar/create-super-admin-app.ts +96 -1
  89. package/src/quasar/dark-bridge.ts +66 -0
  90. package/src/quasar/index.ts +1 -0
  91. package/src/ui/theme/_breakpoints.scss +28 -0
  92. package/src/ui/theme/base.css +50 -0
  93. package/src/ui/theme/components/body.css +23 -0
  94. package/src/ui/theme/components/button.css +158 -0
  95. package/src/ui/theme/components/card.css +48 -0
  96. package/src/ui/theme/components/field.css +38 -0
  97. package/src/ui/theme/components/hero.css +46 -0
  98. package/src/ui/theme/components/paginator.css +63 -0
  99. package/src/ui/theme/components/section.css +67 -0
  100. package/src/ui/theme/components/statistics.css +189 -0
  101. package/src/ui/theme/components/table.css +60 -0
  102. package/src/ui/theme/index.css +42 -0
  103. package/src/ui/theme/tokens.legacy.css +147 -0
  104. package/src/ui/theme/tokens.primitive.css +108 -0
  105. package/src/ui/theme/tokens.scale.css +103 -0
  106. package/src/ui/theme/tokens.semantic.dark.css +173 -0
  107. package/src/ui/theme/tokens.semantic.light.css +212 -0
  108. package/src/vue/use-sa-theme.ts +154 -0
@@ -511,18 +511,11 @@ watch(
511
511
  }
512
512
  /* CSS vars for light + dark mode */
513
513
  .sp-wizard {
514
- --sp-wiz-border: rgba(0, 0, 0, 0.08);
515
- --sp-wiz-text-strong: rgba(0, 0, 0, 0.87);
516
- --sp-wiz-text-muted: rgba(0, 0, 0, 0.6);
517
- --sp-wiz-text-faint: rgba(0, 0, 0, 0.55);
518
- --sp-wiz-border-soft: rgba(0, 0, 0, 0.1);
519
- }
520
- :global(.body--dark) .sp-wizard {
521
- --sp-wiz-border: rgba(255, 255, 255, 0.12);
522
- --sp-wiz-text-strong: rgba(255, 255, 255, 0.92);
523
- --sp-wiz-text-muted: rgba(255, 255, 255, 0.7);
524
- --sp-wiz-text-faint: rgba(255, 255, 255, 0.6);
525
- --sp-wiz-border-soft: rgba(255, 255, 255, 0.18);
514
+ --sp-wiz-border: var(--sa-color-border);
515
+ --sp-wiz-text-strong: var(--sa-color-fg-heading);
516
+ --sp-wiz-text-muted: var(--sa-color-fg-secondary);
517
+ --sp-wiz-text-faint: var(--sa-color-fg-muted);
518
+ --sp-wiz-border-soft: var(--sa-color-border-soft);
526
519
  }
527
520
  .sp-wizard__head {
528
521
  display: flex;
@@ -531,13 +524,13 @@ watch(
531
524
  border-bottom: 1px solid var(--sp-wiz-border);
532
525
  }
533
526
  .sp-wizard__title {
534
- font-size: 18px;
527
+ font-size: var(--sa-text-xl);
535
528
  font-weight: 600;
536
529
  color: var(--sp-wiz-text-strong);
537
530
  }
538
531
  .sp-wizard__sub {
539
532
  color: var(--sp-wiz-text-muted);
540
- font-size: 13px;
533
+ font-size: var(--sa-text-md);
541
534
  }
542
535
  .sp-wizard__intro {
543
536
  color: var(--sp-wiz-text-muted);
@@ -550,8 +543,8 @@ watch(
550
543
  padding: 24px 0;
551
544
  }
552
545
  .sp-wizard__error {
553
- color: var(--q-negative, #c10015);
554
- background: rgba(193, 0, 21, 0.08);
546
+ color: var(--sa-color-negative);
547
+ background: var(--sa-color-negative-surface);
555
548
  padding: 12px;
556
549
  border-radius: 4px;
557
550
  }
@@ -559,14 +552,14 @@ watch(
559
552
  margin-bottom: 12px;
560
553
  }
561
554
  .sp-wizard__proration {
562
- background: rgba(25, 118, 210, 0.06);
555
+ background: var(--sa-color-info-surface);
563
556
  padding: 12px 14px;
564
557
  border-radius: 4px;
565
558
  margin-top: 12px;
566
559
  }
567
560
  .sp-wizard__proration h4 {
568
561
  margin: 0 0 6px;
569
- font-size: 14px;
562
+ font-size: var(--sa-text-lg);
570
563
  }
571
564
  .sp-wizard__proration p {
572
565
  margin: 0;
@@ -579,7 +572,7 @@ watch(
579
572
  .sp-wizard__limits thead th {
580
573
  padding: 6px 12px;
581
574
  text-align: left;
582
- font-size: 12px;
575
+ font-size: var(--sa-text-sm);
583
576
  color: var(--sp-wiz-text-faint);
584
577
  text-transform: uppercase;
585
578
  border-bottom: 1px solid var(--sp-wiz-border-soft);
@@ -592,33 +585,33 @@ watch(
592
585
  .sp-wizard__feat h4,
593
586
  .sp-wizard__blockers h4 {
594
587
  margin: 0 0 6px;
595
- font-size: 14px;
588
+ font-size: var(--sa-text-lg);
596
589
  }
597
590
  .sp-wizard__feat--warn {
598
- color: rgba(193, 0, 21, 0.8);
591
+ color: var(--sa-color-negative);
599
592
  }
600
593
  .sp-wizard__blockers {
601
- color: var(--q-negative, #c10015);
602
- background: rgba(193, 0, 21, 0.06);
594
+ color: var(--sa-color-negative);
595
+ background: var(--sa-color-negative-surface);
603
596
  padding: 12px 14px;
604
597
  border-radius: 4px;
605
598
  }
606
599
  .sp-wizard__warnings {
607
- color: var(--q-warning, #c87b00);
608
- font-size: 13px;
600
+ color: var(--sa-color-warning);
601
+ font-size: var(--sa-text-md);
609
602
  }
610
603
  .sp-wizard__confirm-line {
611
604
  color: var(--sp-wiz-text-muted);
612
605
  }
613
606
  .sp-wizard__price-summary {
614
607
  margin-top: 16px;
615
- background: rgba(25, 118, 210, 0.06);
608
+ background: var(--sa-color-info-surface);
616
609
  padding: 12px 14px;
617
610
  border-radius: 4px;
618
611
  }
619
612
  .sp-wizard__price-summary h4 {
620
613
  margin: 0 0 8px;
621
- font-size: 14px;
614
+ font-size: var(--sa-text-lg);
622
615
  }
623
616
  .sp-wizard__price-row {
624
617
  display: flex;
@@ -616,25 +616,17 @@ async function changePlan(plan: string, cycle: 'MONTHLY' | 'YEARLY', immediate:
616
616
 
617
617
  <style>
618
618
  .sp-plan-section {
619
- --sp-text-secondary: rgba(0, 0, 0, 0.6);
620
- --sp-text-muted: rgba(0, 0, 0, 0.55);
621
- --sp-text-disabled: rgba(0, 0, 0, 0.5);
622
- --sp-text-strong: rgba(0, 0, 0, 0.7);
623
- --sp-border: rgba(0, 0, 0, 0.08);
624
- --sp-summary-bg: rgba(25, 118, 210, 0.06);
619
+ --sp-text-secondary: var(--sa-color-fg-secondary);
620
+ --sp-text-muted: var(--sa-color-fg-muted);
621
+ --sp-text-disabled: var(--sa-color-fg-subtle);
622
+ --sp-text-strong: var(--sa-color-fg-body);
623
+ --sp-border: var(--sa-color-border);
624
+ --sp-summary-bg: var(--sa-color-info-surface);
625
625
 
626
626
  display: flex;
627
627
  flex-direction: column;
628
628
  gap: 16px;
629
629
  }
630
- body.body--dark .sp-plan-section {
631
- --sp-text-secondary: rgba(255, 255, 255, 0.72);
632
- --sp-text-muted: rgba(255, 255, 255, 0.62);
633
- --sp-text-disabled: rgba(255, 255, 255, 0.5);
634
- --sp-text-strong: rgba(255, 255, 255, 0.85);
635
- --sp-border: rgba(255, 255, 255, 0.16);
636
- --sp-summary-bg: rgba(25, 118, 210, 0.18);
637
- }
638
630
  .sp-plan-section__loading,
639
631
  .sp-plan-section__empty {
640
632
  display: flex;
@@ -644,8 +636,8 @@ body.body--dark .sp-plan-section {
644
636
  color: var(--sp-text-secondary);
645
637
  }
646
638
  .sp-plan-section__error {
647
- color: var(--q-negative, #c10015);
648
- background: rgba(193, 0, 21, 0.08);
639
+ color: var(--sa-color-negative);
640
+ background: var(--sa-color-negative-surface);
649
641
  padding: 12px 16px;
650
642
  border-radius: 4px;
651
643
  }
@@ -657,7 +649,7 @@ body.body--dark .sp-plan-section {
657
649
  flex-wrap: wrap;
658
650
  }
659
651
  .sp-plan-section__eyebrow {
660
- font-size: 12px;
652
+ font-size: var(--sa-text-sm);
661
653
  text-transform: uppercase;
662
654
  letter-spacing: 0.05em;
663
655
  color: var(--sp-text-muted);
@@ -665,7 +657,7 @@ body.body--dark .sp-plan-section {
665
657
  }
666
658
  .sp-plan-section__plan-name {
667
659
  margin: 0 0 8px;
668
- font-size: 22px;
660
+ font-size: var(--sa-text-2xl);
669
661
  font-weight: 600;
670
662
  }
671
663
  .sp-plan-section__meta {
@@ -683,7 +675,7 @@ body.body--dark .sp-plan-section {
683
675
  .sp-plan-section__sub {
684
676
  margin: 6px 0 0;
685
677
  color: var(--sp-text-muted);
686
- font-size: 13px;
678
+ font-size: var(--sa-text-md);
687
679
  }
688
680
  .sp-plan-section__usage-title {
689
681
  font-weight: 600;
@@ -716,10 +708,10 @@ body.body--dark .sp-plan-section {
716
708
  }
717
709
  .sp-plan-section__item-price {
718
710
  color: var(--sp-text-secondary);
719
- font-size: 13px;
711
+ font-size: var(--sa-text-md);
720
712
  }
721
713
  .sp-plan-section__item-canceled {
722
714
  color: var(--sp-text-disabled);
723
- font-size: 13px;
715
+ font-size: var(--sa-text-md);
724
716
  }
725
717
  </style>
@@ -73,20 +73,20 @@ const formattedMax = computed(() => {
73
73
  display: flex;
74
74
  justify-content: space-between;
75
75
  align-items: center;
76
- font-size: 13px;
76
+ font-size: var(--sa-text-md);
77
77
  }
78
78
  .sp-usage-bar__label {
79
- color: var(--q-secondary, #555);
79
+ color: var(--sa-color-fg-secondary);
80
80
  font-weight: 500;
81
81
  }
82
82
  .sp-usage-bar__values {
83
83
  font-variant-numeric: tabular-nums;
84
- color: var(--q-secondary, #555);
84
+ color: var(--sa-color-fg-secondary);
85
85
  }
86
86
  .sp-usage-bar__track {
87
87
  width: 100%;
88
88
  height: 8px;
89
- background: rgba(0, 0, 0, 0.08);
89
+ background: var(--sa-color-bg-sunken);
90
90
  border-radius: 4px;
91
91
  overflow: hidden;
92
92
  }
@@ -96,12 +96,12 @@ const formattedMax = computed(() => {
96
96
  border-radius: 4px;
97
97
  }
98
98
  .sp-usage-bar--ok .sp-usage-bar__fill {
99
- background: var(--q-positive, #21ba45);
99
+ background: var(--sa-color-positive);
100
100
  }
101
101
  .sp-usage-bar--warn .sp-usage-bar__fill {
102
- background: var(--q-warning, #f2c037);
102
+ background: var(--sa-color-warning-strong);
103
103
  }
104
104
  .sp-usage-bar--full .sp-usage-bar__fill {
105
- background: var(--q-negative, #c10015);
105
+ background: var(--sa-color-negative);
106
106
  }
107
107
  </style>
@@ -246,32 +246,32 @@ function close() {
246
246
  padding-bottom: 8px;
247
247
  }
248
248
  .sp-bundle-preview__title {
249
- font-size: 16px;
249
+ font-size: var(--sa-text-lg);
250
250
  font-weight: 600;
251
251
  }
252
252
  .sp-bundle-preview__bundle {
253
253
  margin-top: 2px;
254
- color: var(--sp-text-secondary, rgba(0, 0, 0, 0.6));
254
+ color: var(--sp-text-secondary, var(--sa-color-fg-secondary));
255
255
  }
256
256
  .sp-bundle-preview__loading {
257
257
  display: flex;
258
258
  align-items: center;
259
259
  gap: 12px;
260
- color: var(--sp-text-secondary, rgba(0, 0, 0, 0.6));
260
+ color: var(--sp-text-secondary, var(--sa-color-fg-secondary));
261
261
  }
262
262
  .sp-bundle-preview__error {
263
- color: var(--q-negative, #c10015);
263
+ color: var(--sa-color-negative);
264
264
  }
265
265
  .sp-bundle-preview__blockers {
266
- background: rgba(193, 0, 21, 0.08);
267
- color: var(--q-negative, #c10015);
266
+ background: var(--sa-color-negative-surface);
267
+ color: var(--sa-color-negative);
268
268
  }
269
269
  .sp-bundle-preview__warnings {
270
- background: rgba(242, 192, 55, 0.12);
270
+ background: var(--sa-color-warning-surface-strong);
271
271
  }
272
272
  .sp-bundle-preview__block-title {
273
273
  font-weight: 600;
274
- font-size: 13px;
274
+ font-size: var(--sa-text-md);
275
275
  margin-bottom: 6px;
276
276
  }
277
277
  .sp-bundle-preview__block-subtitle {
@@ -281,26 +281,26 @@ function close() {
281
281
  .sp-bundle-preview__warnings ul {
282
282
  margin: 0;
283
283
  padding-left: 18px;
284
- font-size: 13px;
284
+ font-size: var(--sa-text-md);
285
285
  }
286
286
  .sp-bundle-preview__requires {
287
287
  margin-top: 6px;
288
- font-size: 13px;
288
+ font-size: var(--sa-text-md);
289
289
  }
290
290
  .sp-bundle-preview__price-row {
291
291
  display: flex;
292
292
  justify-content: space-between;
293
293
  gap: 16px;
294
294
  padding: 4px 0;
295
- font-size: 14px;
295
+ font-size: var(--sa-text-lg);
296
296
  }
297
297
  .sp-bundle-preview__price-row small {
298
298
  font-weight: 400;
299
- color: var(--sp-text-muted, rgba(0, 0, 0, 0.55));
299
+ color: var(--sp-text-muted, var(--sa-color-fg-muted));
300
300
  }
301
301
  .sp-bundle-preview__note {
302
- font-size: 13px;
303
- color: var(--sp-text-muted, rgba(0, 0, 0, 0.55));
302
+ font-size: var(--sa-text-md);
303
+ color: var(--sp-text-muted, var(--sa-color-fg-muted));
304
304
  padding: 4px 0;
305
305
  }
306
306
  </style>
@@ -246,9 +246,9 @@ const availableRows = computed<AvailableRow[]>(() =>
246
246
 
247
247
  <style scoped>
248
248
  .sp-bundle-store__subtitle {
249
- font-size: 13px;
249
+ font-size: var(--sa-text-md);
250
250
  font-weight: 600;
251
- color: var(--sp-text-strong, rgba(0, 0, 0, 0.7));
251
+ color: var(--sp-text-strong, var(--sa-color-fg-body));
252
252
  margin: 4px 0 8px;
253
253
  }
254
254
  .sp-bundle-store__available {
@@ -260,8 +260,8 @@ const availableRows = computed<AvailableRow[]>(() =>
260
260
  gap: 12px;
261
261
  }
262
262
  .sp-bundle-store__empty {
263
- color: var(--sp-text-muted, rgba(0, 0, 0, 0.55));
264
- font-size: 13px;
263
+ color: var(--sp-text-muted, var(--sa-color-fg-muted));
264
+ font-size: var(--sa-text-md);
265
265
  }
266
266
  .sp-bundle-store__grid {
267
267
  display: grid;
@@ -288,7 +288,7 @@ const availableRows = computed<AvailableRow[]>(() =>
288
288
  }
289
289
  .sp-bundle-store__card-name {
290
290
  font-weight: 600;
291
- font-size: 15px;
291
+ font-size: var(--sa-text-lg);
292
292
  }
293
293
  .sp-bundle-store__card-price {
294
294
  font-weight: 600;
@@ -296,16 +296,16 @@ const availableRows = computed<AvailableRow[]>(() =>
296
296
  }
297
297
  .sp-bundle-store__card-price small {
298
298
  font-weight: 400;
299
- color: var(--sp-text-muted, rgba(0, 0, 0, 0.55));
299
+ color: var(--sp-text-muted, var(--sa-color-fg-muted));
300
300
  }
301
301
  .sp-bundle-store__card-desc {
302
302
  margin: 0;
303
- font-size: 13px;
304
- color: var(--sp-text-secondary, rgba(0, 0, 0, 0.6));
303
+ font-size: var(--sa-text-md);
304
+ color: var(--sp-text-secondary, var(--sa-color-fg-secondary));
305
305
  }
306
306
  .sp-bundle-store__card-feats {
307
- font-size: 12px;
308
- color: var(--sp-text-muted, rgba(0, 0, 0, 0.55));
307
+ font-size: var(--sa-text-sm);
308
+ color: var(--sp-text-muted, var(--sa-color-fg-muted));
309
309
  }
310
310
  .sp-bundle-store__card-feats-label {
311
311
  font-weight: 500;
@@ -118,8 +118,8 @@ const features = computed<FeatureRow[]>(() => {
118
118
  flex-wrap: wrap;
119
119
  }
120
120
  .sp-feature-matrix__desc {
121
- font-size: 12px;
122
- color: var(--sp-text-muted, rgba(0, 0, 0, 0.55));
121
+ font-size: var(--sa-text-sm);
122
+ color: var(--sp-text-muted, var(--sa-color-fg-muted));
123
123
  margin-top: 2px;
124
124
  }
125
125
  </style>
@@ -44,12 +44,19 @@ import {
44
44
  type SuperAdminLoginAdapter,
45
45
  } from '../vue/super-admin-context.js';
46
46
  import { SUPER_ADMIN_NOTIFY_KEY, type UiNotify } from '../vue/ui-notify.js';
47
+ import {
48
+ SA_THEME_KEY,
49
+ createSaTheme,
50
+ type SaTheme,
51
+ type SaThemeOptions,
52
+ } from '../vue/use-sa-theme.js';
47
53
  import {
48
54
  SUPER_ADMIN_I18N_KEY,
49
55
  createSuperAdminI18n,
50
56
  type SuperAdminI18n,
51
57
  type SuperAdminI18nOptions,
52
58
  } from '../vue/use-super-admin-i18n.js';
59
+ import { bindSaThemeToDocument } from './dark-bridge.js';
53
60
  import { quasarNotify } from './notify.js';
54
61
 
55
62
  export interface CreateSuperAdminAppOptions extends SuperAdminGuardOptions {
@@ -112,6 +119,17 @@ export interface CreateSuperAdminAppOptions extends SuperAdminGuardOptions {
112
119
  * runtime. Consumed via `useSuperAdminI18n()` / `useSaMessages()`.
113
120
  */
114
121
  i18n?: SuperAdminI18nOptions;
122
+ /**
123
+ * Optional: colour scheme. Default follows the operating system and
124
+ * remembers an explicit pick. The context is returned on the handle
125
+ * (`handle.theme`) — set `handle.theme.scheme.value = 'dark'` to switch at
126
+ * runtime, and read it anywhere via `useSaTheme()`.
127
+ *
128
+ * Whatever it resolves to is mirrored onto `<html data-sa-theme>` AND into
129
+ * Quasar's `Dark`, so the platform's surfaces and Quasar's own components
130
+ * never disagree.
131
+ */
132
+ theme?: SaThemeOptions;
115
133
  }
116
134
 
117
135
  export interface SuperAdminAppHandle {
@@ -120,10 +138,29 @@ export interface SuperAdminAppHandle {
120
138
  pinia: Pinia;
121
139
  /** i18n context of the shell — switch locale via `i18n.locale.value`. */
122
140
  i18n: SuperAdminI18n;
141
+ /** Colour scheme of the shell — switch via `theme.scheme.value`. */
142
+ theme: SaTheme;
123
143
  /** Mounts the app on a selector. Returns the root component instance. */
124
144
  mount: (selector: string | Element) => ReturnType<App['mount']>;
145
+ /**
146
+ * Releases what this helper attached to the DOCUMENT — the theme's
147
+ * `prefers-color-scheme` subscription and the bridge that mirrors it onto
148
+ * `data-sa-theme` and Quasar's `Dark`. Idempotent; it does not unmount the
149
+ * app, which is `handle.app.unmount()`.
150
+ *
151
+ * Only matters where a shell is torn down while the document survives: hot
152
+ * reload, a micro-frontend, a second shell in one page. Skip it and the old
153
+ * bridge keeps writing to the one document on the next OS theme change, and
154
+ * can overrule the scheme the new shell was given.
155
+ */
156
+ dispose: () => void;
125
157
  }
126
158
 
159
+ /** Marks Quasar's teleported nodes so the theme can reach them. Exported so a
160
+ * test fixture can install the same config a real app gets — without it the
161
+ * `.sa-portal` rules are exercised by nothing. */
162
+ export const SA_PORTAL_CLASS = 'sa-portal';
163
+
127
164
  const DEFAULT_QUASAR_OPTIONS: QuasarPluginOptions = {
128
165
  plugins: { Notify, Dialog, Loading },
129
166
  config: { notify: { position: 'top-right', timeout: 3000 } },
@@ -138,7 +175,35 @@ const DEFAULT_QUASAR_OPTIONS: QuasarPluginOptions = {
138
175
  export function createSuperAdminApp(options: CreateSuperAdminAppOptions): SuperAdminAppHandle {
139
176
  const app = createApp(options.rootComponent);
140
177
 
141
- app.use(Quasar, options.quasarOptions ?? DEFAULT_QUASAR_OPTIONS);
178
+ // Quasar teleports every dialog, menu and tooltip into a div appended to
179
+ // `<body>`, so a rule prefixed with `.sa-page` never reaches them — which is
180
+ // why dialog cards kept Quasar's neutral grey in dark mode while the page
181
+ // behind them was slate, and why the outlined-field correction had silently
182
+ // never applied inside a dialog.
183
+ //
184
+ // Scope, stated plainly: `globalNodes` is document-wide, not per-owner —
185
+ // `createGlobalNode` stamps the class on every portal Quasar opens, the
186
+ // shell's own and any a contributed project page opens inside it. That is
187
+ // intended: a page mounted in the admin shell is admin UI, and the same
188
+ // theme already repaints its cards via `.sa-page`. What the class buys is
189
+ // that an app which never calls `createSuperAdminApp` stays untouched —
190
+ // which a bare `.q-dialog .q-card` rule could not promise.
191
+ const quasarOptions = options.quasarOptions ?? DEFAULT_QUASAR_OPTIONS;
192
+ // `globalNodes` is a documented Quasar config option that its TypeScript
193
+ // types do not declare (see quasar/src/utils/private.config/nodes.js, which
194
+ // reads `globalConfig.globalNodes.class`). The cast is the exception this
195
+ // codebase allows for an API that exists and is simply untyped upstream.
196
+ const config = quasarOptions.config as Record<string, unknown> | undefined;
197
+ const existingPortalClass = (config?.globalNodes as { class?: string } | undefined)?.class;
198
+ app.use(Quasar, {
199
+ ...quasarOptions,
200
+ config: {
201
+ ...quasarOptions.config,
202
+ globalNodes: {
203
+ class: [existingPortalClass, SA_PORTAL_CLASS].filter(Boolean).join(' '),
204
+ },
205
+ },
206
+ } as QuasarPluginOptions);
142
207
 
143
208
  const pinia = createPinia();
144
209
  app.use(pinia);
@@ -164,9 +229,34 @@ export function createSuperAdminApp(options: CreateSuperAdminAppOptions): SuperA
164
229
  };
165
230
 
166
231
  const i18n = createSuperAdminI18n(options.i18n);
232
+ // An app that configured Quasar's dark mode has STATED a preference; the
233
+ // platform's default of 'system' has not. Seeding from it keeps the
234
+ // documented "Quasar decided" path working here — without it the bridge's
235
+ // first, immediate tick resolved 'system' against the machine and called
236
+ // `Dark.set()` with the answer, erasing the app's own choice one line after
237
+ // it was applied. In both directions: a configured `true` was undone on a
238
+ // light machine, a configured `false` on a dark one.
239
+ //
240
+ // Read from the OPTION rather than from `Dark.isActive`, because the option
241
+ // distinguishes three states and the resulting flag only two: `'auto'` means
242
+ // "follow the machine", which is what 'system' already does, and reading the
243
+ // flag would freeze whatever the machine happened to say at boot.
244
+ //
245
+ // A stored operator pick still outranks this — `createSaTheme` gives storage
246
+ // precedence over the `scheme` option — because that is a person choosing
247
+ // rather than a default.
248
+ const configuredDark = options.quasarOptions?.config?.dark;
249
+ const theme = createSaTheme({
250
+ ...options.theme,
251
+ scheme:
252
+ options.theme?.scheme ??
253
+ (typeof configuredDark === 'boolean' ? (configuredDark ? 'dark' : 'light') : undefined),
254
+ });
255
+ const stopThemeBridge = bindSaThemeToDocument(theme);
167
256
 
168
257
  app.provide(SUPER_ADMIN_BRAND_KEY, { tag: 'SuperAdmin', ...options.brand });
169
258
  app.provide(SUPER_ADMIN_I18N_KEY, i18n);
259
+ app.provide(SA_THEME_KEY, theme);
170
260
  app.provide(SUPER_ADMIN_ENDPOINTS_KEY, endpoints);
171
261
  app.provide(SUPER_ADMIN_EXTENSIONS_KEY, options.extensions ?? {});
172
262
  app.provide(SUPER_ADMIN_ACTIONS_KEY, options.actions ?? {});
@@ -191,6 +281,11 @@ export function createSuperAdminApp(options: CreateSuperAdminAppOptions): SuperA
191
281
  router,
192
282
  pinia,
193
283
  i18n,
284
+ theme,
194
285
  mount: (selector) => app.mount(selector),
286
+ dispose: () => {
287
+ stopThemeBridge();
288
+ theme.dispose();
289
+ },
195
290
  };
196
291
  }
@@ -0,0 +1,66 @@
1
+ // The half of the theme switch that touches the DOM and Quasar.
2
+ //
3
+ // Two things have to agree, or the screen ends up half-dark:
4
+ //
5
+ // `data-sa-theme` on <html> drives the platform's own role tokens
6
+ // Quasar's `Dark.set()` drives q-card, q-table, q-menu, q-dialog …
7
+ //
8
+ // The stylesheet already accepts Quasar's `body--dark` as a dark trigger, so an
9
+ // app that only calls `$q.dark.set(true)` is complete without this module. What
10
+ // the bridge adds is the other direction — an operator switching the theme in
11
+ // the admin's own header — and, once both exist, keeping them from disagreeing:
12
+ // it watches BOTH and writes each into the other.
13
+
14
+ import { Dark } from 'quasar';
15
+ import { watch, type WatchStopHandle } from 'vue';
16
+
17
+ import type { SaTheme } from '../vue/use-sa-theme.js';
18
+
19
+ /**
20
+ * Mirrors a theme context onto the document and Quasar. Returns the stop
21
+ * handle; `createSuperAdminApp()` wires this up for you.
22
+ *
23
+ * `immediate` is not optional: at bootstrap the stored pick is already resolved
24
+ * and nothing has painted yet, so a first-change-only watcher would render one
25
+ * frame in the wrong theme on every reload.
26
+ *
27
+ * That first tick MIRRORS, it does not negotiate — whatever `theme.resolved`
28
+ * says is written to Quasar, including `Dark.set(false)`. So a caller that
29
+ * bootstraps with Quasar already dark has to say so when it builds the theme,
30
+ * or its own choice is erased one line after it was applied.
31
+ * `createSuperAdminApp` does exactly that, seeding from `Dark.isActive`.
32
+ */
33
+ export function bindSaThemeToDocument(theme: SaTheme): WatchStopHandle {
34
+ const toQuasar = watch(
35
+ theme.resolved,
36
+ (resolved) => {
37
+ if (typeof document !== 'undefined') {
38
+ document.documentElement.setAttribute('data-sa-theme', resolved);
39
+ }
40
+ Dark.set(resolved === 'dark');
41
+ },
42
+ { immediate: true },
43
+ );
44
+
45
+ // …and back. An app with its own toggle calls `$q.dark.set(false)`, which
46
+ // this side of the bridge never hears: the theme has not changed, so
47
+ // `data-sa-theme="dark"` stays on `<html>` while Quasar goes light. That is
48
+ // the half-dark screen the bridge exists to prevent, arriving from the
49
+ // other direction.
50
+ //
51
+ // No loop: the write-back only fires when Quasar and the theme actually
52
+ // disagree, and setting the scheme drives `resolved` to the value Quasar
53
+ // already has, so the outbound watcher's `Dark.set` is a no-op.
54
+ const fromQuasar = watch(
55
+ () => Dark.isActive,
56
+ (isDark) => {
57
+ const asScheme = isDark ? 'dark' : 'light';
58
+ if (theme.resolved.value !== asScheme) theme.scheme.value = asScheme;
59
+ },
60
+ );
61
+
62
+ return () => {
63
+ toQuasar();
64
+ fromQuasar();
65
+ };
66
+ }
@@ -8,4 +8,5 @@
8
8
  // exports, not through this bundle (tsup ignores `.vue`).
9
9
 
10
10
  export * from './create-super-admin-app.js';
11
+ export * from './dark-bridge.js';
11
12
  export * from './notify.js';
@@ -0,0 +1,28 @@
1
+ // Breakpoints — the one scale that cannot be a custom property.
2
+ //
3
+ // `@media (min-width: var(--sa-bp-md))` is invalid CSS: custom properties are
4
+ // not substituted inside a media condition. So the values live here for SCSS
5
+ // blocks, and plain-CSS `<style>` blocks use the five literals, which the lint
6
+ // rule allows and nothing else.
7
+ //
8
+ // They are Quasar's own breakpoints, deliberately. A component that changes
9
+ // layout at 980px inside an app whose grid changes at 1024px produces a band
10
+ // 44px wide where the two disagree — and the admin surface had six such
11
+ // values (540, 600, 980, 1100, 1180, 1280), so it had several such bands.
12
+ //
13
+ // Usage:
14
+ // @use '@saasicat/ui-vue/theme/breakpoints' as bp;
15
+ // @media (max-width: bp.$sm-max) { … }
16
+
17
+ $xs: 0;
18
+ $sm: 600px;
19
+ $md: 1024px;
20
+ $lg: 1440px;
21
+ $xl: 1920px;
22
+
23
+ // Upper bounds, for the `max-width` direction. Quasar uses the same 0.02px
24
+ // step back, so the two never both match at an integer viewport width.
25
+ $xs-max: 599.98px;
26
+ $sm-max: 1023.98px;
27
+ $md-max: 1439.98px;
28
+ $lg-max: 1919.98px;