@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
@@ -0,0 +1,212 @@
1
+ /* Layer 2 — roles, light theme. What a colour is FOR.
2
+ *
3
+ * This is the only layer that changes between light and dark, and that is the
4
+ * whole reason the split exists: a dark theme is this file with other values,
5
+ * not 62 stylesheets with other values.
6
+ *
7
+ * Two rules, both enforced by the layer-discipline test (rule 22):
8
+ *
9
+ * 1. No colour literal may appear here. Every value is a primitive, a
10
+ * `--q-*` brand variable from the host, or a `color-mix()` of those.
11
+ * A literal here is a value nobody can find from the outside.
12
+ * 2. `tokens.semantic.dark.css` declares exactly the same keys. A role that
13
+ * exists in one theme and not the other is a page that goes unreadable the
14
+ * moment somebody flips the switch (rule 21).
15
+ *
16
+ * ── Where the accent comes from ────────────────────────────────────────────
17
+ *
18
+ * `--sa-color-accent` reads Quasar's `--q-primary`, which Quasar publishes from
19
+ * the host application's `$primary`. That is the fix for the worst finding in
20
+ * the audit: the platform painted `#3f6bff` while the scaffolded app painted
21
+ * `#1e40af`, in the same surface, because each had its own source of truth.
22
+ * Now there is one — the host's — and `--sa-brand-blue` is what it falls back
23
+ * to when the host declares nothing. Setting `$primary` re-tints the admin with
24
+ * it, and there is no second switch to remember.
25
+ *
26
+ * To change the brand AT RUNTIME, write to the root:
27
+ *
28
+ * setCssVar('primary', value, document.documentElement)
29
+ *
30
+ * The third argument is not optional here, and the reason is the same one that
31
+ * shapes the rest of this file: Quasar's `setCssVar` defaults to `<body>`, and
32
+ * this role is computed on `:root`, one level above it. A custom property is
33
+ * resolved where it is DECLARED, so the root substitution never sees a value
34
+ * written below it — Quasar's own components would recolour and the admin would
35
+ * not. Measured, and pinned by a test.
36
+ *
37
+ * Tints derive from the accent with `color-mix()` for the same reason: a fixed
38
+ * `rgba(63, 107, 255, 0.08)` would stay platform-blue on a host that is green.
39
+ */
40
+
41
+ :root {
42
+ /* ── Surfaces, from the page canvas up to what floats above it. ─────── */
43
+ --sa-color-bg-app: var(--sa-neutral-100);
44
+ --sa-color-bg-surface: var(--sa-white);
45
+ --sa-color-bg-surface-raised: var(--sa-neutral-25);
46
+ --sa-color-bg-sunken: var(--sa-neutral-50);
47
+ --sa-color-bg-overlay: color-mix(in srgb, var(--sa-neutral-900) 55%, transparent);
48
+
49
+ /* ── Text, from the loudest to the quietest.
50
+ *
51
+ * Six rungs, not five, because the surface uses six. `-secondary` was very
52
+ * nearly folded into `-muted` on the argument that two greys one step apart
53
+ * are a duplicate — the baseline said otherwise: it carries the figures and
54
+ * meta lines of over a hundred elements, and reading it one step lighter
55
+ * made them quieter than the captions beside them. A rung the product uses
56
+ * for a distinct job is not a duplicate, it is a rung. ───────────────── */
57
+ --sa-color-fg-heading: var(--sa-neutral-900);
58
+ --sa-color-fg-body: var(--sa-neutral-800);
59
+ --sa-color-fg-secondary: var(--sa-neutral-600);
60
+ --sa-color-fg-muted: var(--sa-neutral-500);
61
+ --sa-color-fg-subtle: var(--sa-neutral-450);
62
+ --sa-color-fg-disabled: var(--sa-neutral-300);
63
+ /* Text lying on the accent. White, and that is an ASSUMPTION about your
64
+ * brand rather than a derivation from it: CSS cannot branch on a colour's
65
+ * luminance, so nothing here can notice that `$primary` is a light amber
66
+ * and white on it reads 2.15:1. `contrast-color()` will answer this without
67
+ * JavaScript once it is safe to ship; until then it is a knob.
68
+ *
69
+ * If your brand is light, set this — in BOTH themes, like any role
70
+ * (docs/design-guide.md#overriding-a-role):
71
+ *
72
+ * :root, [data-sa-theme='dark'], body.body--dark {
73
+ * --sa-color-fg-on-accent: #1a1300;
74
+ * }
75
+ */
76
+ --sa-color-fg-on-accent: var(--sa-white);
77
+
78
+ /* ── Lines. ─────────────────────────────────────────────────────────── */
79
+ --sa-color-border: var(--sa-neutral-200);
80
+ --sa-color-border-soft: var(--sa-neutral-100);
81
+ --sa-color-border-strong: var(--sa-neutral-300);
82
+ --sa-color-border-focus: var(--sa-color-accent);
83
+
84
+ /* ── Accent — the host's brand, and the surfaces derived from it. ───── */
85
+ --sa-color-accent: var(--q-primary, var(--sa-brand-blue));
86
+ --sa-color-accent-strong: color-mix(in srgb, var(--sa-color-accent) 82%, var(--sa-black));
87
+ --sa-color-accent-surface: color-mix(in srgb, var(--sa-color-accent) 8%, transparent);
88
+ --sa-color-accent-surface-soft: color-mix(in srgb, var(--sa-color-accent) 4%, transparent);
89
+ --sa-color-accent-surface-strong: color-mix(
90
+ in srgb,
91
+ var(--sa-color-accent) 10%,
92
+ var(--sa-color-bg-surface)
93
+ );
94
+ --sa-color-accent-border: color-mix(in srgb, var(--sa-color-accent) 18%, transparent);
95
+
96
+ /* ── Status tones. Five, and all five have the same six slots, so a page
97
+ * that knows one knows the others:
98
+ *
99
+ * <tone> the solid graphic colour — an icon, a bar, a dot
100
+ * <tone>-strong the brighter variant, for a selected outline
101
+ * <tone>-fg text ON the tinted surface, contrast-checked
102
+ * <tone>-surface the quiet tint — a banner across a section
103
+ * <tone>-surface-strong the loud tint — a chip, a pill, a single cell
104
+ * <tone>-border the tinted line that closes either surface
105
+ * ──────────────────────────────────────────────────────────────────── */
106
+ --sa-color-positive: var(--sa-green-700);
107
+ --sa-color-positive-strong: var(--sa-green-500);
108
+ --sa-color-positive-fg: var(--sa-green-700);
109
+ --sa-color-positive-surface: var(--sa-green-50);
110
+ --sa-color-positive-surface-strong: var(--sa-green-100);
111
+ --sa-color-positive-border: var(--sa-green-200);
112
+
113
+ --sa-color-warning: var(--sa-amber-700);
114
+ --sa-color-warning-strong: var(--sa-amber-500);
115
+ --sa-color-warning-fg: var(--sa-amber-700);
116
+ --sa-color-warning-surface: var(--sa-amber-50);
117
+ --sa-color-warning-surface-strong: var(--sa-amber-100);
118
+ --sa-color-warning-border: var(--sa-amber-200);
119
+
120
+ --sa-color-negative: var(--sa-red-600);
121
+ --sa-color-negative-strong: var(--sa-red-500);
122
+ --sa-color-negative-fg: var(--sa-red-700);
123
+ --sa-color-negative-surface: var(--sa-red-50);
124
+ --sa-color-negative-surface-strong: var(--sa-red-100);
125
+ --sa-color-negative-border: var(--sa-red-200);
126
+
127
+ --sa-color-info: var(--sa-blue-600);
128
+ --sa-color-info-strong: var(--sa-blue-500);
129
+ --sa-color-info-fg: var(--sa-blue-700);
130
+ --sa-color-info-surface: var(--sa-blue-50);
131
+ --sa-color-info-surface-strong: var(--sa-blue-100);
132
+ --sa-color-info-border: var(--sa-blue-200);
133
+
134
+ /* `scheduled` is a tone rather than a status colour reused: a version that
135
+ * is planned but not yet live is neither a warning nor an achievement, and
136
+ * six surfaces had each picked their own blue-ish colour to say so. */
137
+ --sa-color-scheduled: var(--sa-indigo-700);
138
+ --sa-color-scheduled-strong: var(--sa-indigo-400);
139
+ --sa-color-scheduled-fg: var(--sa-indigo-700);
140
+ --sa-color-scheduled-surface: var(--sa-indigo-50);
141
+ --sa-color-scheduled-surface-strong: var(--sa-indigo-100);
142
+ --sa-color-scheduled-border: var(--sa-indigo-200);
143
+
144
+ /* ── Catalogue entities. Not statuses: these two say WHAT a row is, and
145
+ * the plan editor, the matrix, the review and the discovery page all need
146
+ * to agree, or the same dot means two things on two screens. ────────── */
147
+ --sa-color-feature: var(--sa-violet-500);
148
+ --sa-color-feature-fg: var(--sa-violet-700);
149
+ --sa-color-feature-surface: var(--sa-violet-50);
150
+ --sa-color-quota: var(--sa-sky-500);
151
+ --sa-color-quota-surface: var(--sa-sky-50);
152
+ --sa-color-quota-border: var(--sa-sky-200);
153
+ --sa-color-bundle: var(--sa-amber-500);
154
+ --sa-color-bundle-surface: var(--sa-amber-100);
155
+
156
+ /* ── Inverse chrome — the dark header, drawer and login backdrop.
157
+ *
158
+ * Declared identically in both themes ON PURPOSE. This surface is dark in
159
+ * a light theme too, so a role that flipped would turn the drawer's labels
160
+ * black on black the moment somebody enabled dark mode. Same reasoning as
161
+ * for the brand accent: what is dark by design does not follow the theme.
162
+ * ──────────────────────────────────────────────────────────────────── */
163
+ --sa-color-inverse-bg: var(--sa-neutral-950);
164
+ --sa-color-inverse-surface: var(--sa-neutral-800);
165
+ --sa-color-inverse-surface-soft: var(--sa-neutral-600);
166
+ --sa-color-inverse-fg: var(--sa-white);
167
+ --sa-color-inverse-fg-muted: var(--sa-neutral-300);
168
+ --sa-color-inverse-accent: var(--sa-amber-400);
169
+ --sa-color-inverse-accent-strong: var(--sa-amber-600);
170
+ --sa-color-inverse-accent-surface: color-mix(in srgb, var(--sa-amber-500) 15%, transparent);
171
+ --sa-color-inverse-accent-surface-strong: color-mix(
172
+ in srgb,
173
+ var(--sa-amber-500) 22%,
174
+ transparent
175
+ );
176
+ --sa-color-inverse-accent-fg: var(--sa-neutral-900);
177
+ --sa-color-inverse-danger: var(--sa-red-700);
178
+ --sa-color-inverse-danger-strong: var(--sa-red-600);
179
+ --sa-color-inverse-notice: var(--sa-amber-600);
180
+ --sa-color-inverse-notice-strong: var(--sa-amber-700);
181
+ --sa-color-inverse-fg-subtle: color-mix(in srgb, var(--sa-white) 55%, transparent);
182
+ --sa-color-inverse-border: color-mix(in srgb, var(--sa-white) 8%, transparent);
183
+ --sa-color-inverse-border-strong: color-mix(in srgb, var(--sa-white) 16%, transparent);
184
+
185
+ /* ── Focus and selection. ───────────────────────────────────────────── */
186
+ --sa-color-focus-ring: color-mix(in srgb, var(--sa-color-accent) 35%, transparent);
187
+ --sa-color-selection: color-mix(in srgb, var(--sa-color-accent) 22%, transparent);
188
+
189
+ /* ── Shadow. One ink, four tints, five elevations.
190
+ *
191
+ * The ink is a token rather than black because a slate-tinted shadow reads
192
+ * as depth on a slate-tinted page, while pure black reads as dirt. In the
193
+ * dark theme it becomes black and the alphas rise, because a light shadow
194
+ * on a dark surface is invisible at 6%. ─────────────────────────────── */
195
+ --sa-shadow-ink: var(--sa-neutral-900);
196
+ --sa-shadow-tint-1: color-mix(in srgb, var(--sa-shadow-ink) 6%, transparent);
197
+ --sa-shadow-tint-2: color-mix(in srgb, var(--sa-shadow-ink) 8%, transparent);
198
+ --sa-shadow-tint-3: color-mix(in srgb, var(--sa-shadow-ink) 12%, transparent);
199
+ --sa-shadow-tint-4: color-mix(in srgb, var(--sa-shadow-ink) 20%, transparent);
200
+
201
+ --sa-shadow-0: none;
202
+ --sa-shadow-1: 0 1px 2px var(--sa-shadow-tint-1);
203
+ --sa-shadow-2: 0 2px 6px var(--sa-shadow-tint-2);
204
+ --sa-shadow-3: 0 8px 20px var(--sa-shadow-tint-3);
205
+ --sa-shadow-4: 0 20px 48px var(--sa-shadow-tint-4);
206
+ --sa-shadow-focus: 0 0 0 3px var(--sa-color-focus-ring);
207
+
208
+ --sa-elevation-tile: var(--sa-shadow-1);
209
+ --sa-elevation-card: var(--sa-shadow-2);
210
+ --sa-elevation-menu: var(--sa-shadow-3);
211
+ --sa-elevation-dialog: var(--sa-shadow-4);
212
+ }
@@ -0,0 +1,154 @@
1
+ // The colour-scheme switch. Framework-neutral half: no Quasar import here, so
2
+ // the main entry stays free of an optional peer dependency (`src/quasar/
3
+ // dark-bridge.ts` is the half that talks to Quasar and the DOM).
4
+ //
5
+ // Three schemes, and `'system'` is the default because it is the only one that
6
+ // is right without being asked. `resolved` collapses it to the two the
7
+ // stylesheet knows about, so a caller never has to ask "and what does 'system'
8
+ // mean right now".
9
+ //
10
+ // Following the operating system lives HERE and not in the stylesheet, and
11
+ // that is load-bearing rather than tidy. The stylesheet paints the platform's
12
+ // surfaces; Quasar paints its own cards, dialogs and steppers, and follows only
13
+ // `body--dark`. A media query in the stylesheet moves one of them — measured on
14
+ // an embedded page with the OS set to dark, that meant a white Quasar card
15
+ // carrying near-white platform text. Only the application can move both, so
16
+ // only the application decides, and this is where it does.
17
+
18
+ import {
19
+ computed,
20
+ inject,
21
+ isRef,
22
+ ref,
23
+ watch,
24
+ type ComputedRef,
25
+ type InjectionKey,
26
+ type Ref,
27
+ } from 'vue';
28
+
29
+ import { defaultKvStore, type KvStore } from '../client/types.js';
30
+
31
+ export type SaColorScheme = 'light' | 'dark' | 'system';
32
+ export type SaResolvedScheme = 'light' | 'dark';
33
+
34
+ const SCHEMES: readonly SaColorScheme[] = ['light', 'dark', 'system'];
35
+ const STORAGE_KEY = 'saasicat.theme.scheme';
36
+
37
+ export interface SaTheme {
38
+ /** What the operator picked. Writable — assigning switches the theme. */
39
+ scheme: Ref<SaColorScheme>;
40
+ /** What that means right now: `'system'` resolved against the OS. */
41
+ resolved: ComputedRef<SaResolvedScheme>;
42
+ /**
43
+ * Releases the `prefers-color-scheme` subscription and the persistence
44
+ * watcher. Idempotent.
45
+ *
46
+ * Needed because this context outlives no component — nothing unmounts it.
47
+ * A second shell in the same document (hot reload, a micro-frontend, a
48
+ * second test file) would otherwise leave the first one subscribed, and the
49
+ * next change of the operating system's theme would drive TWO bridges, both
50
+ * writing `data-sa-theme` and Quasar's `Dark` onto the one document. The
51
+ * stale one can win, and then the new shell's explicit scheme is overruled
52
+ * by a context nobody holds a reference to any more.
53
+ */
54
+ dispose: () => void;
55
+ }
56
+
57
+ export interface SaThemeOptions {
58
+ /** Starting scheme. Pass a `Ref` to keep the value in your own store. */
59
+ scheme?: SaColorScheme | Ref<SaColorScheme>;
60
+ /**
61
+ * Remember the operator's pick across reloads, default `true`. Ignored when
62
+ * `scheme` is a `Ref`: the app owns that value and persists it where it
63
+ * belongs. Same rule as the locale in `createSuperAdminI18n`.
64
+ */
65
+ persist?: boolean;
66
+ /** Injection seam for tests. */
67
+ storage?: KvStore;
68
+ }
69
+
70
+ function isScheme(value: unknown): value is SaColorScheme {
71
+ return typeof value === 'string' && SCHEMES.includes(value as SaColorScheme);
72
+ }
73
+
74
+ /**
75
+ * Live `prefers-color-scheme`, as a ref that updates when the OS setting does,
76
+ * plus the handle that unsubscribes it.
77
+ *
78
+ * A one-time read would leave every open tab on the old theme when somebody
79
+ * flips their system to dark at sunset — which is exactly when they flip it.
80
+ * Returns a constant `'light'` where `matchMedia` does not exist (SSR, jsdom
81
+ * without a stub), because guessing dark for a missing API is worse than
82
+ * matching the CSS default.
83
+ */
84
+ function systemScheme(): { value: Ref<SaResolvedScheme>; unsubscribe: () => void } {
85
+ const value = ref<SaResolvedScheme>('light');
86
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
87
+ return { value, unsubscribe: () => {} };
88
+ }
89
+
90
+ const query = window.matchMedia('(prefers-color-scheme: dark)');
91
+ value.value = query.matches ? 'dark' : 'light';
92
+ const onChange = (event: MediaQueryListEvent) => {
93
+ value.value = event.matches ? 'dark' : 'light';
94
+ };
95
+ // `addEventListener` since Safari 14; no fallback to the deprecated
96
+ // `addListener`, because the package's Quasar peer needs a newer browser
97
+ // than that anyway.
98
+ query.addEventListener('change', onChange);
99
+ return { value, unsubscribe: () => query.removeEventListener('change', onChange) };
100
+ }
101
+
102
+ /**
103
+ * Creates the theme context. `createSuperAdminApp()` makes one and the Quasar
104
+ * bridge mirrors it onto the document; call it directly only when bootstrapping
105
+ * by hand.
106
+ */
107
+ export function createSaTheme(options: SaThemeOptions = {}): SaTheme {
108
+ const storage = options.storage ?? defaultKvStore();
109
+ const persist = options.persist !== false;
110
+ // `isRef`, like `createSuperAdminI18n` next door. Branched inline rather
111
+ // than through a boolean, because a boolean does not narrow the union and
112
+ // the version that went through one needed three casts to compile.
113
+ const provided = options.scheme;
114
+ const appOwnsIt = isRef(provided);
115
+
116
+ const stored = persist && !appOwnsIt ? storage.get(STORAGE_KEY) : null;
117
+ const scheme = isRef(provided)
118
+ ? provided
119
+ : ref<SaColorScheme>(isScheme(stored) ? stored : (provided ?? 'system'));
120
+ const { value: system, unsubscribe } = systemScheme();
121
+
122
+ const stopPersisting =
123
+ persist && !appOwnsIt ? watch(scheme, (next) => storage.set(STORAGE_KEY, next)) : null;
124
+
125
+ return {
126
+ scheme,
127
+ resolved: computed(() => (scheme.value === 'system' ? system.value : scheme.value)),
128
+ dispose: () => {
129
+ unsubscribe();
130
+ stopPersisting?.();
131
+ },
132
+ };
133
+ }
134
+
135
+ // `Symbol.for` — see super-admin-context.ts: dist- and src-imported module
136
+ // instances must resolve to the same injection key.
137
+ export const SA_THEME_KEY: InjectionKey<SaTheme> = Symbol.for('@saasicat/ui-vue/SA_THEME');
138
+
139
+ let fallbackTheme: SaTheme | null = null;
140
+
141
+ /**
142
+ * The shell's theme context, or a shared unpersisted one outside a shell.
143
+ *
144
+ * Same shape as `useSuperAdminI18n()`, and for the same reason: a component
145
+ * mounted in isolation must render rather than throw, and the fallback must not
146
+ * write to real storage or two tests in one process would decide each other's
147
+ * outcome.
148
+ */
149
+ export function useSaTheme(): SaTheme {
150
+ const injected = inject(SA_THEME_KEY, null);
151
+ if (injected) return injected;
152
+ fallbackTheme ??= createSaTheme({ persist: false });
153
+ return fallbackTheme;
154
+ }