@saasicat/ui-vue 0.23.0 → 0.24.1

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 (50) hide show
  1. package/dist/{chunk-XMMFN2WJ.js → chunk-D74FFCCP.js} +52 -1
  2. package/dist/client/index.cjs +58 -0
  3. package/dist/client/index.d.cts +70 -1
  4. package/dist/client/index.d.ts +70 -1
  5. package/dist/client/index.js +15 -1
  6. package/dist/index.cjs +109 -0
  7. package/dist/index.d.cts +65 -2
  8. package/dist/index.d.ts +65 -2
  9. package/dist/index.js +65 -1
  10. package/package.json +2 -2
  11. package/src/client/identity-accents.ts +167 -0
  12. package/src/client/index.ts +1 -0
  13. package/src/components/MarketingPromotionsTab.vue +6 -2
  14. package/src/components/bundle-editor/BundleVersionInlineEditor.vue +1 -1
  15. package/src/components/dialogs/PromoCodeDialogFields.vue +38 -16
  16. package/src/components/plan/PromoCodeInput.vue +1 -1
  17. package/src/components/plan-create-dialog/PlanCreateDialog.vue +1 -1
  18. package/src/components/plan-detail/PlanDetail.vue +8 -3
  19. package/src/components/plan-list/PlanList.vue +18 -23
  20. package/src/components/plan-matrix/PlanMatrix.vue +9 -26
  21. package/src/components/plan-review/PlanReview.vue +1 -1
  22. package/src/components/plan-version-editor/PlanVersionEditor.vue +22 -3
  23. package/src/pages-standard/AdminManifestErrorPage.vue +4 -1
  24. package/src/pages-standard/BundlesPage.vue +1 -1
  25. package/src/pages-standard/DashboardPage.vue +8 -5
  26. package/src/pages-standard/DiscoveryPage.vue +42 -8
  27. package/src/pages-standard/MarketingCatalogPage.vue +33 -9
  28. package/src/pages-standard/PromoCodesPage.vue +3 -1
  29. package/src/pages-standard/SuperAdminLoginPage.vue +6 -1
  30. package/src/pages-standard/SuperAdminSetupWizard.vue +1 -1
  31. package/src/pages-standard/TenantsPage.vue +5 -4
  32. package/src/pages-standard/discovery-page/discovery-ui.ts +10 -7
  33. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +2 -5
  34. package/src/pages-standard/plan-versions/PlanDiffCard.vue +11 -4
  35. package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +3 -1
  36. package/src/pages-standard/tenants/StatusPill.vue +1 -1
  37. package/src/pages-standard/tenants/format.ts +11 -12
  38. package/src/pages-tenant/MySubscriptionBundlesPage.vue +9 -0
  39. package/src/pages-tenant/OnboardingConfigurator.vue +2 -2
  40. package/src/pages-tenant/PlanChangeWizard.vue +2 -49
  41. package/src/pages-tenant/TenantPlanSection.vue +6 -48
  42. package/src/pages-tenant/default-i18n.ts +113 -0
  43. package/src/ui/theme/_breakpoints.scss +8 -2
  44. package/src/ui/theme/base.css +13 -1
  45. package/src/ui/theme/components/card.css +26 -0
  46. package/src/ui/theme/components/field.css +44 -0
  47. package/src/ui/theme/components/statistics.css +1 -1
  48. package/src/ui/theme/tokens.primitive.css +4 -0
  49. package/src/ui/theme/tokens.semantic.dark.css +12 -0
  50. package/src/ui/theme/tokens.semantic.light.css +30 -0
@@ -188,6 +188,7 @@
188
188
  <script setup lang="ts">
189
189
  import { computed, onMounted, reactive, ref } from 'vue';
190
190
  import type { SubscriptionBundleRecord } from '@saasicat/types';
191
+ import type { HttpClient } from '../client/types.js';
191
192
 
192
193
  import { useSuperAdminI18n } from '../vue/use-super-admin-i18n.js';
193
194
  import { useTenantSubscriptionBundles } from '../vue/use-tenant-subscription-bundles.js';
@@ -213,6 +214,13 @@ interface BookableBundle {
213
214
  const props = withDefaults(
214
215
  defineProps<{
215
216
  billingEndpoint: string;
217
+ /**
218
+ * App-specific HTTP adapter, as every other tenant-facing component
219
+ * takes. Without it this page fell through to `defaultHttpClient()`,
220
+ * so an app using axios with an auth interceptor got a bare `fetch()`
221
+ * here and nowhere else — and no fixture could reach it either.
222
+ */
223
+ http?: HttpClient;
216
224
  /**
217
225
  * Mapping `bundleVersionId → BundleKey/Label` (the consumer can
218
226
  * preload this from the public catalog). Without a mapping we show the
@@ -253,6 +261,7 @@ const effectiveI18n = computed<TenantPlanSectionI18n>(() => ({
253
261
  const { bundles, loading, error, load, add, cancel } = useTenantSubscriptionBundles({
254
262
  billingEndpoint: props.billingEndpoint,
255
263
  getAuthToken: props.getAuthToken,
264
+ http: props.http,
256
265
  });
257
266
 
258
267
  onMounted(() => load());
@@ -305,7 +305,7 @@ defineExpose({ draft });
305
305
  font-size: var(--sa-text-xs);
306
306
  letter-spacing: 0.1em;
307
307
  text-transform: uppercase;
308
- color: var(--sa-color-accent);
308
+ color: var(--sa-color-accent-strong);
309
309
  background: var(--sa-color-accent-surface);
310
310
  padding: 6px 11px;
311
311
  border-radius: 999px;
@@ -328,7 +328,7 @@ defineExpose({ draft });
328
328
  gap: 24px;
329
329
  align-items: start;
330
330
  }
331
- @media (max-width: 1100px) {
331
+ @media (max-width: 1023.98px) {
332
332
  .sp-onb__layout {
333
333
  grid-template-columns: 1fr;
334
334
  }
@@ -223,6 +223,7 @@ import PlanGrid from '../components/plan/PlanGrid.vue';
223
223
  import { useSuperAdminI18n } from '../vue/use-super-admin-i18n.js';
224
224
  import type { BillingCycleStr, PlanChangePreviewShape } from '../vue/use-tenant-billing.js';
225
225
  import type { CatalogPlan } from '../vue/use-tenant-billing-catalog.js';
226
+ import type { PlanChangeWizardI18n } from './default-i18n.js';
226
227
 
227
228
  // PlanChangeWizard — 3-step dialog for plan changes.
228
229
  //
@@ -231,54 +232,6 @@ import type { CatalogPlan } from '../vue/use-tenant-billing-catalog.js';
231
232
  // the limits check + proration + feature diff + blockers, step 3 calls `changePlan`.
232
233
  // Data-driven via the passed `catalogQuotaKeys[]` — no hard-coded trinity.
233
234
 
234
- interface I18nStrings {
235
- title: string;
236
- close: string;
237
- currentLabel: string;
238
- cycleMonthly: string;
239
- cycleYearly: string;
240
- badgeCurrent: string;
241
- badgePopular: string;
242
- priceUnitMonthly: string;
243
- priceUnitYearly: string;
244
- priceOnRequest: string;
245
- stepChoose: string;
246
- stepChooseIntro: string;
247
- stepPreview: string;
248
- stepConfirm: string;
249
- next: string;
250
- back: string;
251
- previewLoading: string;
252
- effectiveAtLabel: string;
253
- effectiveImmediate: string;
254
- prorationTitle: string;
255
- prorationLine: string;
256
- prorationDays: string;
257
- limitsTitle: string;
258
- limitsUsed: string;
259
- limitsCurrent: string;
260
- limitsTarget: string;
261
- featuresGained: string;
262
- featuresLost: string;
263
- blockersTitle: string;
264
- confirmImmediate: string;
265
- confirmScheduled: string;
266
- confirmAction: string;
267
- confirmInProgress: string;
268
- confirmPriceTitle: string;
269
- confirmProratedNow: string;
270
- confirmRecurringNext: string;
271
- confirmRecurringFrom: string;
272
- perCycleMonthly: string;
273
- perCycleYearly: string;
274
- confirmTrialNote: string;
275
- confirmRecurringTrialEnd: string;
276
- changeTypeUpgrade: string;
277
- changeTypeDowngrade: string;
278
- changeTypeCycle: string;
279
- changeTypeNoop: string;
280
- }
281
-
282
235
  interface Props {
283
236
  modelValue: boolean;
284
237
  plans: CatalogPlan[];
@@ -314,7 +267,7 @@ interface Props {
314
267
  /** Preview caller (passed through from the consumer composable). */
315
268
  previewPlanChange: (plan: string, cycle: BillingCycleStr) => Promise<PlanChangePreviewShape>;
316
269
  changePlan: (plan: string, cycle: BillingCycleStr, immediate: boolean) => Promise<void>;
317
- i18n: I18nStrings;
270
+ i18n: PlanChangeWizardI18n;
318
271
  }
319
272
 
320
273
  const props = defineProps<Props>();
@@ -198,7 +198,11 @@ import { computed, ref } from 'vue';
198
198
  import PackageSnapshotPanel from './PackageSnapshotPanel.vue';
199
199
  import PendingVersionBanner from './PendingVersionBanner.vue';
200
200
  import PlanChangeWizard from './PlanChangeWizard.vue';
201
- import { defaultTenantPlanSectionI18n, type TenantPlanSectionI18n } from './default-i18n.js';
201
+ import {
202
+ defaultTenantPlanSectionI18n,
203
+ planChangeWizardI18n,
204
+ type TenantPlanSectionI18n,
205
+ } from './default-i18n.js';
202
206
  import BundlePreviewDialog from './tenant-plan-section/BundlePreviewDialog.vue';
203
207
  import TenantBundleStore from './tenant-plan-section/TenantBundleStore.vue';
204
208
  import TenantFeatureMatrix from './tenant-plan-section/TenantFeatureMatrix.vue';
@@ -429,53 +433,7 @@ const statusColor = computed(() => {
429
433
  }
430
434
  });
431
435
 
432
- const wizardI18n = computed(() => ({
433
- title: effectiveI18n.value.wizardTitle,
434
- close: effectiveI18n.value.wizardClose,
435
- currentLabel: effectiveI18n.value.wizardCurrent,
436
- cycleMonthly: effectiveI18n.value.cycleMonthly,
437
- cycleYearly: effectiveI18n.value.cycleYearly,
438
- badgeCurrent: effectiveI18n.value.wizardBadgeCurrent,
439
- badgePopular: effectiveI18n.value.wizardBadgePopular,
440
- priceUnitMonthly: effectiveI18n.value.wizardPriceUnitMonthly,
441
- priceUnitYearly: effectiveI18n.value.wizardPriceUnitYearly,
442
- priceOnRequest: effectiveI18n.value.wizardPriceOnRequest,
443
- stepChoose: effectiveI18n.value.wizardStepChoose,
444
- stepChooseIntro: effectiveI18n.value.wizardStepChooseIntro,
445
- stepPreview: effectiveI18n.value.wizardStepPreview,
446
- stepConfirm: effectiveI18n.value.wizardStepConfirm,
447
- next: effectiveI18n.value.wizardNext,
448
- back: effectiveI18n.value.wizardBack,
449
- previewLoading: effectiveI18n.value.wizardPreviewLoading,
450
- effectiveAtLabel: effectiveI18n.value.wizardEffectiveAtLabel,
451
- effectiveImmediate: effectiveI18n.value.wizardEffectiveImmediate,
452
- prorationTitle: effectiveI18n.value.wizardProrationTitle,
453
- prorationLine: effectiveI18n.value.wizardProrationLine,
454
- prorationDays: effectiveI18n.value.wizardProrationDays,
455
- limitsTitle: effectiveI18n.value.wizardLimitsTitle,
456
- limitsUsed: effectiveI18n.value.wizardLimitsUsed,
457
- limitsCurrent: effectiveI18n.value.wizardLimitsCurrent,
458
- limitsTarget: effectiveI18n.value.wizardLimitsTarget,
459
- featuresGained: effectiveI18n.value.wizardFeaturesGained,
460
- featuresLost: effectiveI18n.value.wizardFeaturesLost,
461
- blockersTitle: effectiveI18n.value.wizardBlockersTitle,
462
- confirmImmediate: effectiveI18n.value.wizardConfirmImmediate,
463
- confirmScheduled: effectiveI18n.value.wizardConfirmScheduled,
464
- confirmAction: effectiveI18n.value.wizardConfirmAction,
465
- confirmInProgress: effectiveI18n.value.wizardConfirmInProgress,
466
- confirmPriceTitle: effectiveI18n.value.wizardConfirmPriceTitle,
467
- confirmProratedNow: effectiveI18n.value.wizardConfirmProratedNow,
468
- confirmRecurringNext: effectiveI18n.value.wizardConfirmRecurringNext,
469
- confirmRecurringFrom: effectiveI18n.value.wizardConfirmRecurringFrom,
470
- perCycleMonthly: effectiveI18n.value.wizardConfirmPerCycleMonthly,
471
- perCycleYearly: effectiveI18n.value.wizardConfirmPerCycleYearly,
472
- confirmTrialNote: effectiveI18n.value.wizardConfirmTrialNote,
473
- confirmRecurringTrialEnd: effectiveI18n.value.wizardConfirmRecurringTrialEnd,
474
- changeTypeUpgrade: effectiveI18n.value.wizardChangeTypeUpgrade,
475
- changeTypeDowngrade: effectiveI18n.value.wizardChangeTypeDowngrade,
476
- changeTypeCycle: effectiveI18n.value.wizardChangeTypeCycle,
477
- changeTypeNoop: effectiveI18n.value.wizardChangeTypeNoop,
478
- }));
436
+ const wizardI18n = computed(() => planChangeWizardI18n(effectiveI18n.value));
479
437
 
480
438
  // Helper hooks with defaults
481
439
  function quotaLabelResolved(key: string): string {
@@ -494,3 +494,116 @@ export const DEFAULT_I18N_EN: TenantPlanSectionI18n = {
494
494
  export function defaultTenantPlanSectionI18n(locale: SaLocale): TenantPlanSectionI18n {
495
495
  return locale === 'en' ? DEFAULT_I18N_EN : DEFAULT_I18N_DE;
496
496
  }
497
+
498
+ /**
499
+ * The strings `PlanChangeWizard` takes.
500
+ *
501
+ * A projection of `TenantPlanSectionI18n` with the `wizard` prefix dropped, so
502
+ * the wizard's own template reads `i18n.stepChoose` rather than
503
+ * `i18n.wizardStepChoose`.
504
+ */
505
+ export interface PlanChangeWizardI18n {
506
+ title: string;
507
+ close: string;
508
+ currentLabel: string;
509
+ cycleMonthly: string;
510
+ cycleYearly: string;
511
+ badgeCurrent: string;
512
+ badgePopular: string;
513
+ priceUnitMonthly: string;
514
+ priceUnitYearly: string;
515
+ priceOnRequest: string;
516
+ stepChoose: string;
517
+ stepChooseIntro: string;
518
+ stepPreview: string;
519
+ stepConfirm: string;
520
+ next: string;
521
+ back: string;
522
+ previewLoading: string;
523
+ effectiveAtLabel: string;
524
+ effectiveImmediate: string;
525
+ prorationTitle: string;
526
+ prorationLine: string;
527
+ prorationDays: string;
528
+ limitsTitle: string;
529
+ limitsUsed: string;
530
+ limitsCurrent: string;
531
+ limitsTarget: string;
532
+ featuresGained: string;
533
+ featuresLost: string;
534
+ blockersTitle: string;
535
+ confirmImmediate: string;
536
+ confirmScheduled: string;
537
+ confirmAction: string;
538
+ confirmInProgress: string;
539
+ confirmPriceTitle: string;
540
+ confirmProratedNow: string;
541
+ confirmRecurringNext: string;
542
+ confirmRecurringFrom: string;
543
+ perCycleMonthly: string;
544
+ perCycleYearly: string;
545
+ confirmTrialNote: string;
546
+ confirmRecurringTrialEnd: string;
547
+ changeTypeUpgrade: string;
548
+ changeTypeDowngrade: string;
549
+ changeTypeCycle: string;
550
+ changeTypeNoop: string;
551
+ }
552
+
553
+ /**
554
+ * Builds the wizard's strings from the section's.
555
+ *
556
+ * Here rather than inline in `TenantPlanSection.vue`, because the wizard is an
557
+ * exported component: a consumer who mounts it directly would otherwise have to
558
+ * reproduce all 44 keys of this mapping by hand, and a visual fixture had to do
559
+ * exactly that before it moved.
560
+ */
561
+ export function planChangeWizardI18n(i18n: TenantPlanSectionI18n): PlanChangeWizardI18n {
562
+ return {
563
+ title: i18n.wizardTitle,
564
+ close: i18n.wizardClose,
565
+ currentLabel: i18n.wizardCurrent,
566
+ cycleMonthly: i18n.cycleMonthly,
567
+ cycleYearly: i18n.cycleYearly,
568
+ badgeCurrent: i18n.wizardBadgeCurrent,
569
+ badgePopular: i18n.wizardBadgePopular,
570
+ priceUnitMonthly: i18n.wizardPriceUnitMonthly,
571
+ priceUnitYearly: i18n.wizardPriceUnitYearly,
572
+ priceOnRequest: i18n.wizardPriceOnRequest,
573
+ stepChoose: i18n.wizardStepChoose,
574
+ stepChooseIntro: i18n.wizardStepChooseIntro,
575
+ stepPreview: i18n.wizardStepPreview,
576
+ stepConfirm: i18n.wizardStepConfirm,
577
+ next: i18n.wizardNext,
578
+ back: i18n.wizardBack,
579
+ previewLoading: i18n.wizardPreviewLoading,
580
+ effectiveAtLabel: i18n.wizardEffectiveAtLabel,
581
+ effectiveImmediate: i18n.wizardEffectiveImmediate,
582
+ prorationTitle: i18n.wizardProrationTitle,
583
+ prorationLine: i18n.wizardProrationLine,
584
+ prorationDays: i18n.wizardProrationDays,
585
+ limitsTitle: i18n.wizardLimitsTitle,
586
+ limitsUsed: i18n.wizardLimitsUsed,
587
+ limitsCurrent: i18n.wizardLimitsCurrent,
588
+ limitsTarget: i18n.wizardLimitsTarget,
589
+ featuresGained: i18n.wizardFeaturesGained,
590
+ featuresLost: i18n.wizardFeaturesLost,
591
+ blockersTitle: i18n.wizardBlockersTitle,
592
+ confirmImmediate: i18n.wizardConfirmImmediate,
593
+ confirmScheduled: i18n.wizardConfirmScheduled,
594
+ confirmAction: i18n.wizardConfirmAction,
595
+ confirmInProgress: i18n.wizardConfirmInProgress,
596
+ confirmPriceTitle: i18n.wizardConfirmPriceTitle,
597
+ confirmProratedNow: i18n.wizardConfirmProratedNow,
598
+ confirmRecurringNext: i18n.wizardConfirmRecurringNext,
599
+ confirmRecurringFrom: i18n.wizardConfirmRecurringFrom,
600
+ perCycleMonthly: i18n.wizardConfirmPerCycleMonthly,
601
+ perCycleYearly: i18n.wizardConfirmPerCycleYearly,
602
+ confirmTrialNote: i18n.wizardConfirmTrialNote,
603
+ confirmRecurringTrialEnd: i18n.wizardConfirmRecurringTrialEnd,
604
+ changeTypeUpgrade: i18n.wizardChangeTypeUpgrade,
605
+ changeTypeDowngrade: i18n.wizardChangeTypeDowngrade,
606
+ changeTypeCycle: i18n.wizardChangeTypeCycle,
607
+ changeTypeNoop: i18n.wizardChangeTypeNoop,
608
+ };
609
+ }
@@ -7,8 +7,14 @@
7
7
  //
8
8
  // They are Quasar's own breakpoints, deliberately. A component that changes
9
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.
10
+ // 44px wide where the two disagree — and the admin surface had six such values
11
+ // (540, 600, 980, 1100, 1180, 1280), so it had several such bands. All six are
12
+ // now on the bounds below; the audit fails on a seventh.
13
+ //
14
+ // Moving 1100 and 1180 DOWN to 1023.98 keeps the wide layout for another 76 and
15
+ // 156 pixels respectively, which is the one direction of this change that could
16
+ // push content off the page. `visual-baseline.spec.ts` measures horizontal
17
+ // overflow at both edges of every band, on every page, for exactly that reason.
12
18
  //
13
19
  // Usage:
14
20
  // @use '@saasicat/ui-vue/theme/breakpoints' as bp;
@@ -22,7 +22,19 @@
22
22
  * The font and text colour belong here because the hosting app's stylesheet
23
23
  * loads after this one: without a rule on the page itself, admin pages would
24
24
  * inherit whatever the consumer sets. Two full-page views used to pin both on
25
- * their own root, which is exactly the divergence this prevents. */
25
+ * their own root, which is exactly the divergence this prevents.
26
+ *
27
+ * `.sa-page` is also the theme's REACH MARKER, and that is the larger job: it
28
+ * and `.sa-portal` are the only two prefixes through which the component layer
29
+ * corrects Quasar's own DOM. A screen without one of them gets Quasar's look —
30
+ * its 4px radii, its transparent outlined control, its neutral #1d1d1d card in
31
+ * dark mode — and nothing announces it. The login and setup screens rendered
32
+ * that way for their whole existence, and they are the first thing a user sees.
33
+ *
34
+ * Which is why the screens OUTSIDE the shell carry it too, even though they
35
+ * bring their own frame: their own root class is more specific than this one,
36
+ * so the min-height and padding below stay theirs and only the reach is shared.
37
+ * `tests/theme-reaches-every-page.test.js` holds every page to it. */
26
38
  .sa-page {
27
39
  min-height: calc(100vh - 56px);
28
40
  padding: 20px 28px 28px;
@@ -32,6 +32,32 @@
32
32
  color: var(--sa-color-fg-body);
33
33
  }
34
34
 
35
+ /* Quasar's stepper, for the same reason and one component further along.
36
+ *
37
+ * Only the steps you are NOT on. Their titles are a hard-coded grey —
38
+ * `rgb(158,158,158)`, 2.68:1 on a white card — which is below the floor for
39
+ * text whose whole job is to say which steps exist. The ACTIVE step is left
40
+ * alone: Quasar marks it `text-primary`, and that accent is the "you are here"
41
+ * affordance. A first attempt here painted every title with the heading role
42
+ * and quietly took that affordance away; the baseline diff showed it.
43
+ *
44
+ * The theme could not see any of this until the contrast checker learned to
45
+ * look inside teleported nodes: the only stepper in the package is in a dialog.
46
+ */
47
+ .sa-page .q-stepper,
48
+ .sa-portal .q-stepper {
49
+ background: var(--sa-color-bg-surface);
50
+ color: var(--sa-color-fg-body);
51
+ }
52
+ .sa-page .q-stepper__tab:not(.q-stepper__tab--active) .q-stepper__title,
53
+ .sa-portal .q-stepper__tab:not(.q-stepper__tab--active) .q-stepper__title {
54
+ color: var(--sa-color-fg-muted);
55
+ }
56
+ .sa-page .q-stepper__caption,
57
+ .sa-portal .q-stepper__caption {
58
+ color: var(--sa-color-fg-subtle);
59
+ }
60
+
35
61
  .sa-card {
36
62
  background: var(--sa-color-bg-surface);
37
63
  border: 1px solid var(--sa-color-border);
@@ -36,3 +36,47 @@
36
36
  flex: 1;
37
37
  min-width: 200px;
38
38
  }
39
+
40
+ /* Quasar's own accent-coloured text, painted from the role that is safe on a
41
+ * dark surface.
42
+ *
43
+ * Quasar colours the focused field's floating label and the selected menu item
44
+ * with `--q-primary` — the brand itself, which does not change between themes.
45
+ * On a light card that is fine; on the dark theme's slate surfaces the brand
46
+ * blue lands around 3.4:1 and the shrunk label is small text, so it reads as
47
+ * decoration rather than as the name of the field you are typing in.
48
+ *
49
+ * `--sa-color-accent-strong` is the same brand mixed toward the theme's own
50
+ * extreme — darker in light, lighter in dark — which is exactly what the guide
51
+ * prescribes for accent text. The colour still follows the host's `$primary`,
52
+ * so nothing about branding changes.
53
+ *
54
+ * `q-field--highlighted` is the whole story and `q-field--focused` would be
55
+ * noise: Quasar's only label-colour rule is
56
+ * `.q-field--highlighted .q-field__label { color: currentColor }`, and it sets
57
+ * that class whenever the field is focused OR invalid.
58
+ *
59
+ * Which is why the invalid half has to be carved back out. Quasar paints an
60
+ * invalid field's red by putting `text-negative` on the CONTROL and letting the
61
+ * label inherit it through that `currentColor`. Naming any colour on the label
62
+ * cuts the inheritance, and `!important` on `.text-negative` cannot defend it,
63
+ * because that declaration sits on a different element. Without the `:not()` a
64
+ * field with `rules` or `:error` — the login, setup and confirm forms — would
65
+ * announce its failure in every indicator except the one naming the field.
66
+ *
67
+ * `.sa-portal` alongside `.sa-page` for the usual reason: Quasar teleports every
68
+ * menu to `<body>`, so a `.sa-page`-prefixed rule never reaches a dropdown. */
69
+ .sa-page .q-field--highlighted:not(.q-field--error) .q-field__label,
70
+ .sa-portal .q-field--highlighted:not(.q-field--error) .q-field__label {
71
+ color: var(--sa-color-accent-strong);
72
+ }
73
+
74
+ /* The chosen entry in an open select. Quasar marks it `q-item--active` and
75
+ * colours it with the brand; on the dark menu surface that is the same 3.4:1,
76
+ * and it is the one line in the list that has to be legible. */
77
+ .sa-page .q-item--active,
78
+ .sa-portal .q-item--active,
79
+ .sa-page .q-item.q-manual-focusable--focused,
80
+ .sa-portal .q-item.q-manual-focusable--focused {
81
+ color: var(--sa-color-accent-strong);
82
+ }
@@ -83,7 +83,7 @@
83
83
  flex: none;
84
84
  border-radius: var(--sa-radius-tile);
85
85
  background: var(--sa-color-accent-surface-strong);
86
- color: var(--sa-color-accent);
86
+ color: var(--sa-color-accent-strong);
87
87
  }
88
88
  .sa-kpi--inline .sa-kpi__label {
89
89
  order: 1;
@@ -100,6 +100,10 @@
100
100
  --sa-sky-50: #f0f9ff;
101
101
  --sa-sky-200: #bae6fd;
102
102
  --sa-sky-500: #0ea5e9;
103
+ /* Sky-500 is 2.6:1 on a white card — fine as a dot or a border, too pale to
104
+ * carry a label. The identity ramp needs a readable sky, so here is the
105
+ * rung below it. */
106
+ --sa-sky-700: #0369a1;
103
107
 
104
108
  /* ── Type families. ─────────────────────────────────────────────────── */
105
109
  --sa-font-head: 'Plus Jakarta Sans', 'Inter', system-ui, sans-serif;
@@ -125,6 +125,18 @@ body.body--dark {
125
125
  --sa-color-bundle: var(--sa-amber-500);
126
126
  --sa-color-bundle-surface: color-mix(in srgb, var(--sa-amber-500) 16%, transparent);
127
127
 
128
+ /* The identity ramp, one rung lighter throughout — see the light file for
129
+ * what it is for. This is the half that did not exist while the ramps lived
130
+ * as hex literals in TypeScript, and its absence is the whole reason the
131
+ * plan mark sat at 2.96:1 here. */
132
+ --sa-color-identity-1: var(--sa-blue-300);
133
+ --sa-color-identity-2: var(--sa-violet-200);
134
+ --sa-color-identity-3: var(--sa-green-200);
135
+ --sa-color-identity-4: var(--sa-amber-300);
136
+ --sa-color-identity-5: var(--sa-sky-200);
137
+ --sa-color-identity-6: var(--sa-red-300);
138
+ --sa-color-identity-neutral: var(--sa-neutral-400);
139
+
128
140
  /* Identical to the light theme, and that is the point — see there. */
129
141
  --sa-color-inverse-bg: var(--sa-neutral-950);
130
142
  --sa-color-inverse-surface: var(--sa-neutral-800);
@@ -153,6 +153,36 @@
153
153
  --sa-color-bundle: var(--sa-amber-500);
154
154
  --sa-color-bundle-surface: var(--sa-amber-100);
155
155
 
156
+ /* ── Identity. A CATEGORICAL ramp: the only job of these seven is to be
157
+ * told apart.
158
+ *
159
+ * Use them when a colour distinguishes one row from the next and means
160
+ * nothing beyond that — a plan's mark, a promotion badge, a capability
161
+ * kind. Use `feature`/`quota`/`bundle`/`scheduled` above when the category
162
+ * carries meaning; a reader should never have to guess which of the two a
163
+ * colour is doing.
164
+ *
165
+ * They exist because the package had FIVE of these ramps, all six colours
166
+ * long, none agreeing: `PlanList` and `PlanMatrix` shared one byte for
167
+ * byte, `tenants/format.ts` had a third that gave the same plan a
168
+ * different colour on the tenants page, and `PromoCodesPage`,
169
+ * `MarketingPromotionsTab` and `discovery-ui.ts` each invented their own.
170
+ * Every one of them was a hex literal in TypeScript, so none followed the
171
+ * theme: the plan mark measured 2.96:1 on a dark surface and was carried
172
+ * as a named contrast exception because there was nowhere to fix it.
173
+ *
174
+ * Each rung is chosen to clear 3:1 as TEXT on this theme's card surface,
175
+ * because that is how they are used — as the label, not only as a dot. */
176
+ --sa-color-identity-1: var(--sa-blue-700);
177
+ --sa-color-identity-2: var(--sa-violet-700);
178
+ --sa-color-identity-3: var(--sa-green-700);
179
+ --sa-color-identity-4: var(--sa-amber-700);
180
+ --sa-color-identity-5: var(--sa-sky-700);
181
+ --sa-color-identity-6: var(--sa-red-700);
182
+ /* The entry tier, and the answer for "no identity yet". Quiet on purpose:
183
+ * a starter plan reading as loud as an enterprise one is a wrong signal. */
184
+ --sa-color-identity-neutral: var(--sa-neutral-500);
185
+
156
186
  /* ── Inverse chrome — the dark header, drawer and login backdrop.
157
187
  *
158
188
  * Declared identically in both themes ON PURPOSE. This surface is dark in