@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.
- package/README.md +1 -1
- package/dist/{chunk-LY52T4N4.js → chunk-TQQMKWN6.js} +58 -1
- package/dist/index.cjs +213 -159
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +7 -1
- package/dist/quasar/index.cjs +116 -20
- package/dist/quasar/index.d.cts +48 -3
- package/dist/quasar/index.d.ts +48 -3
- package/dist/quasar/index.js +58 -3
- package/dist/{use-super-admin-i18n-CgTuNi8h.d.cts → use-sa-theme-B5t7oXph.d.cts} +51 -1
- package/dist/{use-super-admin-i18n-CHNdsSlm.d.ts → use-sa-theme-OUBlaqgl.d.ts} +51 -1
- package/package.json +5 -2
- package/src/components/BundleVersionPublishDialog.vue +17 -17
- package/src/components/KvBlock.vue +6 -6
- package/src/components/MarketingPromotionsTab.vue +78 -78
- package/src/components/MfaPromptDialog.vue +2 -2
- package/src/components/TenantActionConfirmDialog.vue +5 -5
- package/src/components/VersionDiffPreview.vue +18 -18
- package/src/components/bundle-editor/BundleCreatePanel.vue +52 -52
- package/src/components/bundle-editor/BundleFeaturesEditor.vue +20 -20
- package/src/components/bundle-editor/BundlePlanCompatPicker.vue +33 -33
- package/src/components/bundle-editor/BundleQuotasEditor.vue +26 -26
- package/src/components/bundle-editor/BundleStatusBanner.vue +21 -21
- package/src/components/bundle-editor/BundleVersionInlineEditor.vue +48 -48
- package/src/components/bundle-editor/BundleVersionStrip.vue +30 -30
- package/src/components/dialogs/PilotCreateDialog.vue +20 -20
- package/src/components/dialogs/PilotEditDialog.vue +6 -6
- package/src/components/dialogs/PromoCodeCreateDialog.vue +9 -9
- package/src/components/dialogs/PromoCodeDialogFields.vue +61 -61
- package/src/components/dialogs/PromoCodeEditDialog.vue +11 -11
- package/src/components/dialogs/pilot-dialog.css +48 -48
- package/src/components/plan/PlanCycleToggle.vue +9 -16
- package/src/components/plan/PlanGrid.vue +30 -34
- package/src/components/plan/PriceSummary.vue +35 -35
- package/src/components/plan/PromoCodeInput.vue +26 -26
- package/src/components/plan/PublicBundleGrid.vue +32 -28
- package/src/components/plan-create-dialog/PlanCreateDialog.vue +49 -49
- package/src/components/plan-detail/PlanDetail.vue +172 -156
- package/src/components/plan-detail/PlanTitleEdit.vue +11 -11
- package/src/components/plan-list/PlanList.vue +98 -77
- package/src/components/plan-matrix/PlanMatrix.vue +89 -89
- package/src/components/plan-review/PlanReview.vue +60 -56
- package/src/components/plan-version-editor/PlanVersionEditor.vue +224 -200
- package/src/components/wizard-stepper/WizardStepper.vue +17 -17
- package/src/index.ts +1 -0
- package/src/pages-standard/AdminLayout.vue +58 -37
- package/src/pages-standard/AdminManifestErrorPage.vue +6 -6
- package/src/pages-standard/AuditPage.vue +3 -3
- package/src/pages-standard/BundlesPage.vue +36 -36
- package/src/pages-standard/DashboardPage.vue +17 -17
- package/src/pages-standard/DiscoveryPage.vue +55 -49
- package/src/pages-standard/EmailHistoryPage.vue +53 -71
- package/src/pages-standard/MarketingCatalogPage.vue +226 -218
- package/src/pages-standard/PilotsPage.vue +7 -6
- package/src/pages-standard/PlatformEmailPage.vue +18 -18
- package/src/pages-standard/PromoCodeDetailPage.vue +3 -3
- package/src/pages-standard/PromoCodesPage.vue +7 -6
- package/src/pages-standard/SuperAdminLoginPage.vue +39 -25
- package/src/pages-standard/SuperAdminSetupWizard.vue +44 -33
- package/src/pages-standard/TenantDetailPage.vue +7 -7
- package/src/pages-standard/TenantsPage.vue +11 -46
- package/src/pages-standard/bundles-page/BundleAccordionList.vue +13 -13
- package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +24 -24
- package/src/pages-standard/discovery-page/DiscoveryCapList.vue +22 -22
- package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +39 -39
- package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +26 -26
- package/src/pages-standard/discovery-page/DiscoveryStatusControl.vue +1 -1
- package/src/pages-standard/plan-versions/PlanDiffCard.vue +16 -16
- package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +21 -15
- package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +45 -45
- package/src/pages-standard/plans-page/PlanBundleOverview.vue +21 -15
- package/src/pages-standard/plans-page/PlanPublishDialog.vue +4 -4
- package/src/pages-standard/plans-page/PlansPageToast.vue +6 -6
- package/src/pages-standard/sa-theme.css +9 -687
- package/src/pages-standard/tenants/StatusPill.vue +13 -13
- package/src/pages-tenant/LimitsRow.vue +4 -4
- package/src/pages-tenant/MySubscriptionBundlesPage.vue +61 -61
- package/src/pages-tenant/OnboardingConfigurator.vue +24 -24
- package/src/pages-tenant/PackageSnapshotPanel.vue +10 -16
- package/src/pages-tenant/PendingVersionBanner.vue +12 -12
- package/src/pages-tenant/PlanChangeWizard.vue +20 -27
- package/src/pages-tenant/TenantPlanSection.vue +13 -21
- package/src/pages-tenant/UsageBar.vue +7 -7
- package/src/pages-tenant/tenant-plan-section/BundlePreviewDialog.vue +14 -14
- package/src/pages-tenant/tenant-plan-section/TenantBundleStore.vue +10 -10
- package/src/pages-tenant/tenant-plan-section/TenantFeatureMatrix.vue +2 -2
- package/src/quasar/create-super-admin-app.ts +96 -1
- package/src/quasar/dark-bridge.ts +66 -0
- package/src/quasar/index.ts +1 -0
- package/src/ui/theme/_breakpoints.scss +28 -0
- package/src/ui/theme/base.css +50 -0
- package/src/ui/theme/components/body.css +23 -0
- package/src/ui/theme/components/button.css +158 -0
- package/src/ui/theme/components/card.css +48 -0
- package/src/ui/theme/components/field.css +38 -0
- package/src/ui/theme/components/hero.css +46 -0
- package/src/ui/theme/components/paginator.css +63 -0
- package/src/ui/theme/components/section.css +67 -0
- package/src/ui/theme/components/statistics.css +189 -0
- package/src/ui/theme/components/table.css +60 -0
- package/src/ui/theme/index.css +42 -0
- package/src/ui/theme/tokens.legacy.css +147 -0
- package/src/ui/theme/tokens.primitive.css +108 -0
- package/src/ui/theme/tokens.scale.css +103 -0
- package/src/ui/theme/tokens.semantic.dark.css +173 -0
- package/src/ui/theme/tokens.semantic.light.css +212 -0
- 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
|
+
}
|