@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
@@ -45,11 +45,7 @@
45
45
  <div class="sa-plan-list-plan-name">
46
46
  <div
47
47
  class="sa-plan-list-plan-mark"
48
- :style="{
49
- background: planAccent(p.planKey) + '15',
50
- color: planAccent(p.planKey),
51
- borderColor: planAccent(p.planKey) + '33',
52
- }"
48
+ :style="identityChipStyle(planAccent(p.planKey))"
53
49
  >
54
50
  {{ p.planKey.slice(0, 3) }}
55
51
  </div>
@@ -415,6 +411,7 @@
415
411
  import { resolvePlans, type ResolvedPlan } from '../../client/resolve-plans.js';
416
412
  import { computed, ref } from 'vue';
417
413
  import type { PlanRow, PlanVersionRow } from '@saasicat/types';
414
+ import { identityAccentFor, identityChipStyle } from '../../client/identity-accents.js';
418
415
  import { formatMessage } from '../../client/i18n/format.js';
419
416
  import { formatCurrency } from '../../client/i18n/currency.js';
420
417
  import { useSaMessages, useSuperAdminI18n } from '../../vue/use-super-admin-i18n.js';
@@ -456,24 +453,12 @@ const common = useSaMessages('common');
456
453
  // `clearable` emits null, not '' — see Quasar's use-field clearValue().
457
454
  const search = ref<string | null>('');
458
455
 
459
- const DEFAULT_ACCENTS: Record<string, string> = {
460
- STARTER: '#64748b',
461
- STANDARD: '#2563eb',
462
- PRO: '#7c3aed',
463
- PROFESSIONAL: '#7c3aed',
464
- BUSINESS: '#0ea5e9',
465
- ENTERPRISE: '#0f766e',
466
- BASIC: '#475569',
467
- };
468
- const FALLBACK_ACCENTS = ['#2563eb', '#7c3aed', '#0f766e', '#f59e0b', '#0ea5e9', '#ef4444'];
469
-
470
456
  function planAccent(planKey: string): string {
471
- const provided = props.planAccents[planKey];
472
- if (provided) return provided;
473
- const def = DEFAULT_ACCENTS[planKey];
474
- if (def) return def;
475
- const idx = props.plans.findIndex((p) => p.planKey === planKey);
476
- return FALLBACK_ACCENTS[idx % FALLBACK_ACCENTS.length] ?? FALLBACK_ACCENTS[0]!;
457
+ return identityAccentFor(
458
+ planKey,
459
+ props.planAccents,
460
+ props.plans.findIndex((p) => p.planKey === planKey),
461
+ );
477
462
  }
478
463
 
479
464
  const resolvedPlans = computed(() =>
@@ -566,7 +551,13 @@ function hasAnyPublished(row: ResolvedPlan<PlanRow, PlanVersionRow>): boolean {
566
551
  background: var(--sa-color-bg-surface);
567
552
  border: 1px solid var(--sa-color-border);
568
553
  border-radius: 10px;
569
- overflow: hidden;
554
+ /* Scrolls rather than clips. `overflow: hidden` was here to keep the corner
555
+ * radius, and it also cut the six-column grid off below ~790px — six data
556
+ * columns squeezed into a phone are not readable anyway, so the honest
557
+ * answer is the same as for the tab bar: let it scroll. `hidden` on the
558
+ * cross axis keeps the radius doing its job. */
559
+ overflow-x: auto;
560
+ overflow-y: hidden;
570
561
  }
571
562
  .sa-plan-list-toolbar {
572
563
  display: flex;
@@ -590,6 +581,10 @@ function hasAnyPublished(row: ResolvedPlan<PlanRow, PlanVersionRow>): boolean {
590
581
  .sa-plan-list-list {
591
582
  display: grid;
592
583
  grid-template-columns: 1.6fr 0.9fr 0.7fr 0.9fr 1.4fr 160px;
584
+ /* So the scroll above has something to scroll: without a content floor the
585
+ * tracks shrink to the container and the cells overflow individually,
586
+ * which is the clipped state rather than a scrollable one. */
587
+ min-width: max-content;
593
588
  /* Stretch, not centre. Every row is `display: contents`, so its cells are
594
589
  * grid items in their own right — and a centred item is only as tall as its
595
590
  * own content. The row's hover background is painted per cell, so the band
@@ -272,10 +272,7 @@
272
272
  <span
273
273
  v-if="hasFeature(p, fKey)"
274
274
  class="pm-check"
275
- :style="{
276
- background: planAccent(p.planKey) + '15',
277
- color: planAccent(p.planKey),
278
- }"
275
+ :style="identityChipStyle(planAccent(p.planKey))"
279
276
  >
280
277
  <svg
281
278
  width="12"
@@ -323,10 +320,7 @@
323
320
  <span
324
321
  v-if="hasBundle(p, bKey)"
325
322
  class="pm-check"
326
- :style="{
327
- background: planAccent(p.planKey) + '15',
328
- color: planAccent(p.planKey),
329
- }"
323
+ :style="identityChipStyle(planAccent(p.planKey))"
330
324
  >
331
325
  <svg
332
326
  width="12"
@@ -384,6 +378,7 @@ import { computed } from 'vue';
384
378
  */
385
379
  const NBSP = '\u00A0';
386
380
  import type { PlanRow, PlanVersionRow } from '@saasicat/types';
381
+ import { identityAccentFor, identityChipStyle } from '../../client/identity-accents.js';
387
382
  import { formatMessage } from '../../client/i18n/format.js';
388
383
  import { formatCurrency } from '../../client/i18n/currency.js';
389
384
  import { useSaMessages, useSuperAdminI18n } from '../../vue/use-super-admin-i18n.js';
@@ -477,24 +472,12 @@ function validFromLabel(validFrom: string): string {
477
472
  return formatMessage(msg.value.matrix.validFrom, { date: validFrom.slice(0, 10) });
478
473
  }
479
474
 
480
- const DEFAULT_ACCENTS: Record<string, string> = {
481
- STARTER: '#64748b',
482
- STANDARD: '#2563eb',
483
- PRO: '#7c3aed',
484
- PROFESSIONAL: '#7c3aed',
485
- BUSINESS: '#0ea5e9',
486
- ENTERPRISE: '#0f766e',
487
- BASIC: '#475569',
488
- };
489
- const FALLBACK_ACCENTS = ['#2563eb', '#7c3aed', '#0f766e', '#f59e0b', '#0ea5e9', '#ef4444'];
490
-
491
475
  function planAccent(planKey: string): string {
492
- const provided = props.planAccents[planKey];
493
- if (provided) return provided;
494
- const def = DEFAULT_ACCENTS[planKey];
495
- if (def) return def;
496
- const idx = props.plans.findIndex((p) => p.planKey === planKey);
497
- return FALLBACK_ACCENTS[idx % FALLBACK_ACCENTS.length] ?? FALLBACK_ACCENTS[0]!;
476
+ return identityAccentFor(
477
+ planKey,
478
+ props.planAccents,
479
+ props.plans.findIndex((p) => p.planKey === planKey),
480
+ );
498
481
  }
499
482
 
500
483
  const resolvedPlans = computed<ResolvedPlan[]>(() =>
@@ -828,7 +811,7 @@ function formatQuota(v: number | undefined): string {
828
811
  height: 30px;
829
812
  border-radius: 50%;
830
813
  background: var(--sa-color-accent-surface-strong);
831
- color: var(--sa-color-accent);
814
+ color: var(--sa-color-accent-strong);
832
815
  margin: 0 auto 6px;
833
816
  }
834
817
  .pm-add-title {
@@ -676,7 +676,7 @@ function onPublish(): void {
676
676
  line-height: 1.5;
677
677
  }
678
678
 
679
- @media (max-width: 1100px) {
679
+ @media (max-width: 1023.98px) {
680
680
  .pr-grid {
681
681
  grid-template-columns: 1fr;
682
682
  }
@@ -760,6 +760,10 @@ function emitSave(): void {
760
760
  display: flex;
761
761
  flex-direction: column;
762
762
  box-sizing: border-box;
763
+ /* The editor reflows on ITS OWN width, not the window's — see the
764
+ `@container` rules at the end of this block. */
765
+ container-type: inline-size;
766
+ container-name: pve;
763
767
  /* A full view rather than a modal: the columns stretch to the content area
764
768
  AdminPage already sizes. */
765
769
  height: 100%;
@@ -1546,13 +1550,28 @@ function emitSave(): void {
1546
1550
  display: inline-flex;
1547
1551
  }
1548
1552
 
1549
- /* ── Responsive ────────────────────────────────────────────────── */
1550
- @media (max-width: 1280px) {
1553
+ /* ── Responsive ────────────────────────────────────────────────────
1554
+ *
1555
+ * On the CONTAINER's width, not the viewport's, because the viewport is not
1556
+ * what this grid has to fit into. The admin drawer is 240px and collapsible, so
1557
+ * a viewport threshold is wrong in one of its two states by construction — and
1558
+ * with the drawer open at 1024–1100px the old viewport rule left 784–860px for
1559
+ * `320px 1fr 360px`, squeezing the middle column to 104–180px and clipping its
1560
+ * form controls behind `overflow: hidden`.
1561
+ *
1562
+ * The thresholds are the widths the columns actually need:
1563
+ * 1060px → 320 + 360 fixed leaves ≥ 380px for the basket
1564
+ * 780px → below this the three-column form is not usable at all
1565
+ *
1566
+ * These are container queries, so they are not viewport breakpoints and the
1567
+ * Quasar-band rule does not reach them; `@container` needs no newer browser
1568
+ * than the theme already requires. */
1569
+ @container pve (max-width: 1060px) {
1551
1570
  .pve-body {
1552
1571
  grid-template-columns: 320px 1fr 360px;
1553
1572
  }
1554
1573
  }
1555
- @media (max-width: 1100px) {
1574
+ @container pve (max-width: 780px) {
1556
1575
  .pve-body {
1557
1576
  grid-template-columns: 1fr;
1558
1577
  overflow-y: auto;
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="sa-manifest-error">
2
+ <div class="sa-page sa-manifest-error">
3
3
  <q-card class="sa-manifest-error__card">
4
4
  <q-card-section class="sa-manifest-error__head">
5
5
  <q-icon name="cloud_off" size="32px" color="negative" />
@@ -112,6 +112,9 @@ function logout(): void | Promise<void> {
112
112
  padding: 32px;
113
113
  display: flex;
114
114
  justify-content: center;
115
+ /* The page frame is a full viewport tall, and a stretched flex item would
116
+ * make this card as tall as the screen for four lines of text. */
117
+ align-items: flex-start;
115
118
  }
116
119
  .sa-manifest-error__card {
117
120
  max-width: 640px;
@@ -754,7 +754,7 @@ const classifyDiff = computed(() => props.classifyDiff);
754
754
  grid-template-columns: minmax(280px, 360px) 1fr;
755
755
  gap: 18px;
756
756
  }
757
- @media (max-width: 980px) {
757
+ @media (max-width: 1023.98px) {
758
758
  .sa-bd-grid {
759
759
  grid-template-columns: 1fr;
760
760
  }
@@ -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,
@@ -402,7 +405,7 @@ function barWidth(value: number, max?: number): number {
402
405
  /* Header look comes from the global .sa-page-head — only a margin tweak here. */
403
406
 
404
407
  .sa-kpi__icon :deep(.q-icon) {
405
- color: var(--sa-color-accent);
408
+ color: var(--sa-color-accent-strong);
406
409
  }
407
410
 
408
411
  .sa-dashboard__rows {
@@ -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
  }
@@ -420,7 +423,7 @@ function barWidth(value: number, max?: number): number {
420
423
 
421
424
  .sa-dashboard__count {
422
425
  background: var(--sa-color-accent-surface);
423
- color: var(--sa-color-accent);
426
+ color: var(--sa-color-accent-strong);
424
427
  font-size: var(--sa-text-xs);
425
428
  font-weight: 700;
426
429
  padding: 2px 7px;
@@ -489,7 +492,7 @@ function barWidth(value: number, max?: number): number {
489
492
  border-color: var(--sa-color-accent-border);
490
493
  }
491
494
  .sa-dashboard__shortcut :deep(.q-icon) {
492
- color: var(--sa-color-accent);
495
+ color: var(--sa-color-accent-strong);
493
496
  flex-shrink: 0;
494
497
  }
495
498
  .sa-dashboard__shortcut-title {
@@ -228,25 +228,59 @@ const statusFilterOptions = computed<Array<{ label: string; value: DiscoveryStat
228
228
  ],
229
229
  );
230
230
 
231
- const appKey = computed(() => props.snapshot?.app.key ?? '—');
231
+ // `?.app?.` rather than `?.app.` — the optional chain has to survive the whole
232
+ // path, not just its first step. `useDiscovery` assigns the response body with
233
+ // an unchecked `as DiscoverySnapshot`, so a 200 whose body is not a snapshot
234
+ // (an older backend, a proxy's JSON error page, a partial response) reaches
235
+ // here as a non-null value without `app`. The guard then stopped one step short
236
+ // and the page threw during a computed — which white-screens the route rather
237
+ // than showing the dash these fallbacks exist for.
238
+ // Narrowed to a string, not merely to "present". The type says `key` is a
239
+ // string; the runtime value is whatever the server sent, because `useDiscovery`
240
+ // assigns the body with an unchecked `as DiscoverySnapshot`. A truthy non-string
241
+ // — `{"app":{"key":1}}` — passes an existence check and then throws on
242
+ // `.charAt`, which is the same white-screen one step further in. Guarding
243
+ // presence without guarding type only moves the crash.
244
+ const appKeyText = computed(() => {
245
+ const key = props.snapshot?.app?.key;
246
+ return typeof key === 'string' ? key : '';
247
+ });
248
+ const appKey = computed(() => appKeyText.value || '—');
232
249
  const appLabel = computed(() => {
233
- const k = props.snapshot?.app.key ?? '';
250
+ const k = appKeyText.value;
234
251
  return k ? k.charAt(0).toUpperCase() + k.slice(1) : 'Discovery';
235
252
  });
236
- const appVersion = computed(() => props.snapshot?.app.version ?? '0.0.0');
253
+ const appVersion = computed(() => {
254
+ const version = props.snapshot?.app?.version;
255
+ return typeof version === 'string' && version ? version : '0.0.0';
256
+ });
237
257
  const scanLabel = computed(() => {
238
- if (!props.snapshot?.scannedAt) return msg.value.notScannedYet;
258
+ const scannedAt = props.snapshot?.scannedAt;
259
+ // Same reasoning: the catch below covers a bad DATE, not a bad type — the
260
+ // fallback `return scannedAt` would hand the template a non-string.
261
+ if (typeof scannedAt !== 'string' || !scannedAt) return msg.value.notScannedYet;
239
262
  try {
240
- return new Date(props.snapshot.scannedAt).toLocaleString(intlLocale.value);
263
+ return new Date(scannedAt).toLocaleString(intlLocale.value);
241
264
  } catch {
242
- return props.snapshot.scannedAt;
265
+ return scannedAt;
243
266
  }
244
267
  });
245
268
 
246
269
  const declaredAtByKey = computed<Record<string, string>>(() => {
247
270
  const map: Record<string, string> = {};
248
- for (const c of props.snapshot?.capabilities ?? []) {
249
- map[c.capabilityKey] = c.declaredAt;
271
+ // `Array.isArray`, not `?? []`. The nullish fallback only covers `null` and
272
+ // `undefined`; anything else present goes straight into `for…of`, and a
273
+ // JSON object or a number is not iterable — `for (const c of {})` throws,
274
+ // which takes the route down exactly like the string methods above did.
275
+ //
276
+ // A string would have slipped through both: it IS iterable, so the loop
277
+ // walks its characters and silently builds nonsense. That is why the case
278
+ // covering this in the test suite had to be an object rather than a string —
279
+ // the first version used a string and passed without exercising anything.
280
+ const capabilities = props.snapshot?.capabilities;
281
+ if (!Array.isArray(capabilities)) return map;
282
+ for (const c of capabilities) {
283
+ if (c && typeof c.capabilityKey === 'string') map[c.capabilityKey] = c.declaredAt;
250
284
  }
251
285
  return map;
252
286
  });
@@ -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;
@@ -1513,14 +1529,22 @@ async function onLocaleChange(loc: string): Promise<void> {
1513
1529
  transform: rotate(90deg);
1514
1530
  }
1515
1531
 
1532
+ /* The open editor is a WELL under its row, not another row.
1533
+ *
1534
+ * It used to end on a gradient whose last stop was `--sa-color-bg-surface` —
1535
+ * exactly the colour of the next row — closed by the same soft hairline that
1536
+ * separates any two rows. Nothing then said where the editor stopped, so the
1537
+ * following plan read as part of the plan being edited.
1538
+ *
1539
+ * Three things say it now, and each carries a different half of the message:
1540
+ * the surface is recessed (`bg-sunken`, which is what a well is for), an accent
1541
+ * edge on the left ties the panel to the row it belongs to, and a real border
1542
+ * closes it at the bottom instead of a hairline that means "next row". */
1516
1543
  .sa-marketing-admin-expand {
1517
1544
  grid-column: 1 / -1;
1518
- background: linear-gradient(
1519
- 180deg,
1520
- var(--sa-color-bg-surface-raised) 0%,
1521
- var(--sa-color-bg-surface) 100%
1522
- );
1523
- border-bottom: 1px solid var(--sa-color-border-soft);
1545
+ background: var(--sa-color-bg-sunken);
1546
+ border-left: 2px solid var(--sa-color-accent);
1547
+ border-bottom: 2px solid var(--sa-color-border-strong);
1524
1548
  padding: 18px 20px 22px;
1525
1549
  }
1526
1550
  .sa-marketing-expand-grid {
@@ -1715,7 +1739,7 @@ async function onLocaleChange(loc: string): Promise<void> {
1715
1739
  cursor: not-allowed;
1716
1740
  }
1717
1741
 
1718
- @media (max-width: 980px) {
1742
+ @media (max-width: 1023.98px) {
1719
1743
  .sa-marketing-expand-grid {
1720
1744
  grid-template-columns: 1fr;
1721
1745
  }
@@ -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
@@ -5,7 +5,7 @@
5
5
  :icon="iconText"
6
6
  @done="needsSetup = false"
7
7
  />
8
- <div v-else class="sa-login-wrap">
8
+ <div v-else class="sa-page sa-login-wrap">
9
9
  <div class="sa-login-card">
10
10
  <div class="sa-login-brand">
11
11
  <div v-if="logoUrl" class="sa-login-logo sa-login-logo--img">
@@ -285,7 +285,12 @@ async function handleSubmit(): Promise<void> {
285
285
  line-height: 1.5;
286
286
  }
287
287
 
288
+ /* The heading block is one unit, and the form sits the same distance below it
289
+ * whether or not the optional subtitle is there. Without this the gap was the
290
+ * title's 6px on a login that passes no subtitle — which nobody saw, because
291
+ * Quasar's element-level `h1` line-height was padding it out to 96px. */
288
292
  .sa-login-form {
293
+ margin-top: var(--sa-space-6);
289
294
  display: flex;
290
295
  flex-direction: column;
291
296
  }
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="sa-setup-wrap">
2
+ <div class="sa-page sa-setup-wrap">
3
3
  <div class="sa-setup-card">
4
4
  <div class="sa-setup-head">
5
5
  <div class="sa-setup-badge">{{ iconText }}</div>
@@ -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[]>(() => {
@@ -53,7 +53,7 @@ defineProps<{
53
53
  }
54
54
  .sa-pill--primary {
55
55
  background: var(--sa-color-accent-surface);
56
- color: var(--sa-color-accent);
56
+ color: var(--sa-color-accent-strong);
57
57
  }
58
58
  .sa-pill--accent {
59
59
  background: var(--sa-color-warning-surface-strong);
@@ -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. */