@saasicat/ui-vue 0.23.0 → 0.24.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 (41) hide show
  1. package/dist/{chunk-XMMFN2WJ.js → chunk-VYMTWCTT.js} +51 -1
  2. package/dist/client/index.cjs +57 -0
  3. package/dist/client/index.d.cts +58 -1
  4. package/dist/client/index.d.ts +58 -1
  5. package/dist/client/index.js +15 -1
  6. package/dist/index.cjs +108 -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 +129 -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 +3 -2
  16. package/src/components/plan-create-dialog/PlanCreateDialog.vue +1 -1
  17. package/src/components/plan-detail/PlanDetail.vue +8 -3
  18. package/src/components/plan-list/PlanList.vue +18 -23
  19. package/src/components/plan-matrix/PlanMatrix.vue +8 -25
  20. package/src/components/plan-review/PlanReview.vue +1 -1
  21. package/src/components/plan-version-editor/PlanVersionEditor.vue +22 -3
  22. package/src/pages-standard/BundlesPage.vue +1 -1
  23. package/src/pages-standard/DashboardPage.vue +5 -2
  24. package/src/pages-standard/MarketingCatalogPage.vue +19 -3
  25. package/src/pages-standard/PromoCodesPage.vue +3 -1
  26. package/src/pages-standard/TenantsPage.vue +5 -4
  27. package/src/pages-standard/discovery-page/discovery-ui.ts +10 -7
  28. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +2 -5
  29. package/src/pages-standard/plan-versions/PlanDiffCard.vue +11 -4
  30. package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +3 -1
  31. package/src/pages-standard/tenants/format.ts +11 -12
  32. package/src/pages-tenant/MySubscriptionBundlesPage.vue +9 -0
  33. package/src/pages-tenant/OnboardingConfigurator.vue +1 -1
  34. package/src/pages-tenant/PlanChangeWizard.vue +2 -49
  35. package/src/pages-tenant/TenantPlanSection.vue +6 -48
  36. package/src/pages-tenant/default-i18n.ts +113 -0
  37. package/src/ui/theme/_breakpoints.scss +8 -2
  38. package/src/ui/theme/components/card.css +26 -0
  39. package/src/ui/theme/tokens.primitive.css +4 -0
  40. package/src/ui/theme/tokens.semantic.dark.css +12 -0
  41. package/src/ui/theme/tokens.semantic.light.css +30 -0
@@ -215,7 +215,10 @@ const cards = reactive<KpiCardState[]>([]);
215
215
  const loading = ref(false);
216
216
  const error = ref<Error | null>(null);
217
217
 
218
- const defaultBarColor = 'linear-gradient(90deg, #3f6bff, #1d4ed8)';
218
+ // Two stops of the brand accent rather than two fixed blues: a host that sets
219
+ // its own `$primary` had a brand-coloured admin with platform-blue KPI bars.
220
+ const defaultBarColor =
221
+ 'linear-gradient(90deg, var(--sa-color-accent), var(--sa-color-accent-strong))';
219
222
 
220
223
  watch(
221
224
  () => props.manifest,
@@ -412,7 +415,7 @@ function barWidth(value: number, max?: number): number {
412
415
  margin-bottom: 14px;
413
416
  align-items: start;
414
417
  }
415
- @media (max-width: 1280px) {
418
+ @media (max-width: 1439.98px) {
416
419
  .sa-dashboard__rows {
417
420
  grid-template-columns: 1fr;
418
421
  }
@@ -164,7 +164,9 @@ import type {
164
164
  ResolvedMarketing,
165
165
  } from './marketing-catalog/types.js';
166
166
 
167
- const DEFAULT_ACCENT = '#64748b';
167
+ import { IDENTITY_NEUTRAL, identityAccentAt } from '../client/identity-accents.js';
168
+
169
+ const DEFAULT_ACCENT = IDENTITY_NEUTRAL;
168
170
 
169
171
  const props = defineProps<{
170
172
  adminEndpoint: string;
@@ -650,7 +652,7 @@ function promoFineprintOf(row: MarketingRow): string {
650
652
  return promo.i18n?.[activeLocale.value]?.fineprint || promo.i18n?.de?.fineprint || '';
651
653
  }
652
654
  function promoColorOf(row: MarketingRow): string {
653
- return promoOf(row)?.color ?? '#10b981';
655
+ return promoOf(row)?.color ?? identityAccentAt(0);
654
656
  }
655
657
 
656
658
  // ─── CTA ───
@@ -999,12 +1001,22 @@ async function onLocaleChange(loc: string): Promise<void> {
999
1001
 
1000
1002
  .sa-marketing-toolbar {
1001
1003
  display: flex;
1004
+ /* The tab bar and the meta line do not fit side by side below ~672px, and
1005
+ * without this they simply pushed the page off its own right edge — at every
1006
+ * viewport narrower than that, not just the smallest. Nothing measured it
1007
+ * until the overflow guard started asking at each breakpoint band. */
1008
+ flex-wrap: wrap;
1002
1009
  gap: 12px;
1003
1010
  align-items: center;
1004
1011
  margin: 0px;
1005
1012
  }
1006
1013
  .sa-marketing-tabbar {
1007
1014
  display: inline-flex;
1015
+ /* Scrolls rather than wraps: a row of tabs broken across two lines reads as
1016
+ * two groups. It fits every band except the narrowest phone, where the four
1017
+ * tabs need 389px. */
1018
+ overflow-x: auto;
1019
+ max-width: 100%;
1008
1020
  gap: 2px;
1009
1021
  background: var(--sa-color-bg-surface);
1010
1022
  border: 1px solid var(--sa-color-border);
@@ -1043,6 +1055,10 @@ async function onLocaleChange(loc: string): Promise<void> {
1043
1055
  }
1044
1056
  .sa-marketing-meta {
1045
1057
  margin-left: auto;
1058
+ /* Its own contents wrap too: `catalogVersion <code> · locale <code>` is
1059
+ * wider than a phone on its own, so wrapping only the toolbar would move
1060
+ * the overflow one level down instead of removing it. */
1061
+ flex-wrap: wrap;
1046
1062
  font-size: var(--sa-text-sm);
1047
1063
  color: var(--sa-color-fg-secondary);
1048
1064
  display: flex;
@@ -1715,7 +1731,7 @@ async function onLocaleChange(loc: string): Promise<void> {
1715
1731
  cursor: not-allowed;
1716
1732
  }
1717
1733
 
1718
- @media (max-width: 980px) {
1734
+ @media (max-width: 1023.98px) {
1719
1735
  .sa-marketing-expand-grid {
1720
1736
  grid-template-columns: 1fr;
1721
1737
  }
@@ -110,7 +110,9 @@
110
110
  import AdminTable from '../components/admin-page/AdminTable.vue';
111
111
  import type { PromoCodePlanOption } from '../components/dialogs/types.js';
112
112
 
113
- const PLAN_COLOR_PALETTE = ['#0ea5e9', '#10b981', '#f59e0b', '#7c3aed', '#dc2626', '#64748b'];
113
+ import { IDENTITY_ACCENTS } from '../client/identity-accents.js';
114
+
115
+ const PLAN_COLOR_PALETTE = IDENTITY_ACCENTS;
114
116
 
115
117
  /**
116
118
  * Heuristic from a consumer wrapper: assigns each plan a stable color so plan
@@ -176,7 +176,8 @@ import {
176
176
  import MfaPromptDialog from '../components/MfaPromptDialog.vue';
177
177
  import TenantActionConfirmDialog from '../components/TenantActionConfirmDialog.vue';
178
178
  import StatusPill, { type PillTone } from './tenants/StatusPill.vue';
179
- import { DEFAULT_PLAN_ACCENTS, formatDate, planAccent, tenantInitials } from './tenants/format.js';
179
+ import { identityChipStyle } from '../client/identity-accents.js';
180
+ import { formatDate, planAccent, tenantInitials } from './tenants/format.js';
180
181
 
181
182
  // Platform standard page: tenant list.
182
183
  //
@@ -323,7 +324,7 @@ const props = withDefaults(
323
324
  pageSize: 25,
324
325
  showPlanColumn: true,
325
326
  planLabelField: 'plan',
326
- planAccents: () => DEFAULT_PLAN_ACCENTS,
327
+ planAccents: () => ({}),
327
328
  usageFields: () => [],
328
329
  manifest: null,
329
330
  },
@@ -484,8 +485,8 @@ const combinedActions = computed<TenantRowAction[]>(() => {
484
485
  const hasActions = computed(() => combinedActions.value.length > 0);
485
486
 
486
487
  function avatarStyle(row: TenantRow): Record<string, string> {
487
- const accent = planAccentFor(row);
488
- return { background: `${accent}18`, color: accent };
488
+ const { background, color } = identityChipStyle(planAccentFor(row));
489
+ return { background, color };
489
490
  }
490
491
 
491
492
  function planAccentFor(row: TenantRow): string {
@@ -122,16 +122,19 @@ export function reviewMenuActions(
122
122
 
123
123
  // ─── Capability kind styling (read-only code facts, #20) ─────────────────────
124
124
 
125
- const KIND_COLORS: Record<string, string> = {
126
- endpoint: '#2563eb',
127
- service: '#7c3aed',
128
- job: '#0891b2',
129
- event: '#f59e0b',
125
+ // A capability kind is a category with no ranking, so it takes the identity
126
+ // ramp rather than a status colour: an endpoint is not "better" than a job.
127
+ import { IDENTITY_NEUTRAL, identityChipStyle } from '../../client/identity-accents.js';
128
+
129
+ const KIND_ACCENTS: Record<string, string> = {
130
+ endpoint: 'var(--sa-color-identity-1)',
131
+ service: 'var(--sa-color-identity-2)',
132
+ job: 'var(--sa-color-identity-5)',
133
+ event: 'var(--sa-color-identity-4)',
130
134
  };
131
135
 
132
136
  export function kindStyle(kind: string): Record<string, string> {
133
- const c = KIND_COLORS[kind] ?? '#64748b';
134
- return { background: `${c}1a`, color: c, borderColor: `${c}33` };
137
+ return identityChipStyle(KIND_ACCENTS[kind] ?? IDENTITY_NEUTRAL);
135
138
  }
136
139
 
137
140
  /**
@@ -26,11 +26,7 @@
26
26
  <div class="sa-marketing-plan-cell">
27
27
  <div
28
28
  class="sa-marketing-plan-mark"
29
- :style="{
30
- background: row.accent + '15',
31
- color: row.accent,
32
- borderColor: row.accent + '33',
33
- }"
29
+ :style="identityChipStyle(row.accent)"
34
30
  >
35
31
  {{ row.plan.planKey.slice(0, 3) }}
36
32
  </div>
@@ -462,6 +458,7 @@
462
458
 
463
459
  <script setup lang="ts">
464
460
  import type { MarketingTopFeature, PlanRow, PlanVersionRow } from '@saasicat/types';
461
+ import { identityChipStyle } from '../../client/identity-accents.js';
465
462
  import { formatMessage } from '../../client/i18n/format.js';
466
463
  import { useSaMessages } from '../../vue/use-super-admin-i18n.js';
467
464
  import type { FeatureSuggestion, MarketingRow, ResolvedMarketing } from './types.js';
@@ -11,10 +11,7 @@
11
11
  </div>
12
12
  <div v-else class="sa-pv-diff-card">
13
13
  <header class="sa-pv-diff-card__head">
14
- <span
15
- class="sa-pv-diff-card__accent"
16
- :style="{ background: `${accent}14`, borderColor: `${accent}40` }"
17
- >
14
+ <span class="sa-pv-diff-card__accent" :style="accentWashStyle">
18
15
  <q-icon name="inventory_2" size="14px" :style="{ color: accent }" />
19
16
  </span>
20
17
  <span class="sa-pv-diff-card__name">{{ title }}</span>
@@ -38,6 +35,7 @@
38
35
  <script setup lang="ts">
39
36
  import { computed } from 'vue';
40
37
  import type { VersionChange } from '@saasicat/types';
38
+ import { identityChipStyle } from '../../client/identity-accents.js';
41
39
  import { formatMessage } from '../../client/i18n/format.js';
42
40
  import { useSaMessages } from '../../vue/use-super-admin-i18n.js';
43
41
  import VersionDiffPreview from '../../components/VersionDiffPreview.vue';
@@ -52,6 +50,15 @@ const props = defineProps<{
52
50
  }>();
53
51
 
54
52
  const msg = useSaMessages('planVersions');
53
+
54
+ // The wash and the edge this badge always had — deliberately not the chip
55
+ // helper's third property. `identityChipStyle` also sets a text colour, and
56
+ // the icon inside already sets its own; no fixture renders this card, so a
57
+ // change nobody can observe is a change nobody should make.
58
+ const accentWashStyle = computed(() => {
59
+ const { background, borderColor } = identityChipStyle(props.accent);
60
+ return { background, borderColor };
61
+ });
55
62
  const common = useSaMessages('common');
56
63
 
57
64
  const changeCountLabel = computed(() => {
@@ -66,8 +66,10 @@ const props = defineProps<{
66
66
 
67
67
  const msg = useSaMessages('planVersions');
68
68
 
69
+ import { identityAccentFor } from '../../client/identity-accents.js';
70
+
69
71
  function planAccent(planId: string): string {
70
- return props.planAccents?.[planId] ?? '#3f6bff';
72
+ return identityAccentFor(planId, props.planAccents ?? {});
71
73
  }
72
74
 
73
75
  const planDiffs = computed<SnapshotEntityDiff[]>(() => {
@@ -1,16 +1,15 @@
1
1
  // Formatting helpers for the tenants page.
2
2
  //
3
3
  // Shared between TenantsPage.vue and subcomponents (Avatar, Pill).
4
- // Apps with their own plan accents pass them through via the `planAccents` prop;
5
- // the default map covers the standard plan IDs (BASIC..ENTERPRISE).
4
+ // Apps with their own plan accents pass them through via the `planAccents` prop.
5
+ //
6
+ // The default map used to live here as five hex literals, and it disagreed with
7
+ // the two in `PlanList`/`PlanMatrix`: `STANDARD` was the brand blue on this page
8
+ // and a plain blue on the plans page, `ENTERPRISE` near-black here and teal
9
+ // there. Same plan, three colours, depending on which screen you were looking
10
+ // at. It now comes from the shared ramp, so the answer is one.
6
11
 
7
- export const DEFAULT_PLAN_ACCENTS: Record<string, string> = {
8
- BASIC: '#94a3b8',
9
- STANDARD: '#3f6bff',
10
- PROFESSIONAL: '#7c3aed',
11
- BUSINESS: '#0ea5e9',
12
- ENTERPRISE: '#0f172a',
13
- };
12
+ import { IDENTITY_NEUTRAL, identityAccentFor } from '../../client/identity-accents.js';
14
13
 
15
14
  export function tenantInitials(name: string | null | undefined): string {
16
15
  if (!name) return '?';
@@ -22,10 +21,10 @@ export function tenantInitials(name: string | null | undefined): string {
22
21
 
23
22
  export function planAccent(
24
23
  planId: string | null | undefined,
25
- accents: Record<string, string> = DEFAULT_PLAN_ACCENTS,
24
+ accents: Record<string, string> = {},
26
25
  ): string {
27
- if (!planId) return '#94a3b8';
28
- return accents[planId] ?? '#3f6bff';
26
+ if (!planId) return IDENTITY_NEUTRAL;
27
+ return identityAccentFor(planId, accents);
29
28
  }
30
29
 
31
30
  /** `locale` is a BCP-47 tag — pages pass `intlLocale.value` of the active UI locale. */
@@ -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());
@@ -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;
@@ -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);
@@ -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);