@saasicat/ui-vue 0.24.0 → 0.24.2

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 (32) hide show
  1. package/dist/{chunk-VYMTWCTT.js → chunk-D74FFCCP.js} +2 -1
  2. package/dist/client/index.cjs +2 -1
  3. package/dist/client/index.d.cts +13 -1
  4. package/dist/client/index.d.ts +13 -1
  5. package/dist/client/index.js +1 -1
  6. package/dist/index.cjs +2 -1
  7. package/dist/index.js +1 -1
  8. package/package.json +2 -2
  9. package/src/client/identity-accents.ts +40 -2
  10. package/src/components/bundle-editor/BundleFeaturesEditor.vue +6 -1
  11. package/src/components/dialogs/PilotCreateDialog.vue +2 -1
  12. package/src/components/dialogs/PilotEditDialog.vue +2 -1
  13. package/src/components/dialogs/PromoCodeDialogFields.vue +40 -16
  14. package/src/components/plan/PromoCodeInput.vue +1 -1
  15. package/src/components/plan-detail/PlanDetail.vue +41 -3
  16. package/src/components/plan-detail/PlanVersionDiffPanel.vue +3 -3
  17. package/src/components/plan-matrix/PlanMatrix.vue +1 -1
  18. package/src/components/plan-version-editor/PlanCatalogPreview.vue +7 -3
  19. package/src/pages-standard/AdminManifestErrorPage.vue +4 -1
  20. package/src/pages-standard/DashboardPage.vue +3 -3
  21. package/src/pages-standard/DiscoveryPage.vue +42 -8
  22. package/src/pages-standard/MarketingCatalogPage.vue +20 -7
  23. package/src/pages-standard/SuperAdminLoginPage.vue +6 -1
  24. package/src/pages-standard/SuperAdminSetupWizard.vue +1 -1
  25. package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +17 -3
  26. package/src/pages-standard/tenants/StatusPill.vue +1 -1
  27. package/src/pages-tenant/OnboardingConfigurator.vue +1 -1
  28. package/src/ui/theme/base.css +13 -1
  29. package/src/ui/theme/components/field.css +44 -0
  30. package/src/ui/theme/components/statistics.css +1 -1
  31. package/src/ui/theme/tokens.semantic.dark.css +2 -0
  32. package/src/ui/theme/tokens.semantic.light.css +11 -2
@@ -665,10 +665,11 @@ var WELL_KNOWN = {
665
665
  function identityAccentFor(key, overrides = {}, index = -1) {
666
666
  return overrides[key] ?? WELL_KNOWN[key] ?? identityAccentAt(index);
667
667
  }
668
+ var READABLE_ACCENT_SHARE = 50;
668
669
  function identityChipStyle(accent) {
669
670
  return {
670
671
  background: `color-mix(in srgb, ${accent} 8%, transparent)`,
671
- color: accent,
672
+ color: `color-mix(in srgb, ${accent} ${READABLE_ACCENT_SHARE}%, var(--sa-color-fg-heading))`,
672
673
  borderColor: `color-mix(in srgb, ${accent} 20%, transparent)`
673
674
  };
674
675
  }
@@ -3899,10 +3899,11 @@ var WELL_KNOWN = {
3899
3899
  function identityAccentFor(key, overrides = {}, index = -1) {
3900
3900
  return overrides[key] ?? WELL_KNOWN[key] ?? identityAccentAt(index);
3901
3901
  }
3902
+ var READABLE_ACCENT_SHARE = 50;
3902
3903
  function identityChipStyle(accent) {
3903
3904
  return {
3904
3905
  background: `color-mix(in srgb, ${accent} 8%, transparent)`,
3905
- color: accent,
3906
+ color: `color-mix(in srgb, ${accent} ${READABLE_ACCENT_SHARE}%, var(--sa-color-fg-heading))`,
3906
3907
  borderColor: `color-mix(in srgb, ${accent} 20%, transparent)`
3907
3908
  };
3908
3909
  }
@@ -436,13 +436,25 @@ declare function identityAccentAt(index: number): string;
436
436
  declare function identityAccentFor(key: string, overrides?: Readonly<Record<string, string>>, index?: number): string;
437
437
  /**
438
438
  * The three-part chip style every one of these sites was hand-rolling: a wash
439
- * of the accent, the accent as text, a firmer edge of it.
439
+ * of the accent, a readable rendering of the accent as text, a firmer edge.
440
440
  *
441
441
  * `color-mix()` rather than the `accent + '15'` string concatenation it
442
442
  * replaces. That trick only worked on a six-digit hex — it silently produced
443
443
  * garbage for `rgb()`, for a named colour and for anything a consumer might
444
444
  * reasonably pass — and it could never have worked for a `var()`, which is why
445
445
  * these palettes could not follow the theme in the first place.
446
+ *
447
+ * The text is a mix rather than the accent itself. A `var(--sa-color-identity-N)`
448
+ * would not need that — the ramp has a value per theme, so it already suits both
449
+ * surfaces. A concrete hex has one value for both, and a concrete hex is what
450
+ * reaches here from a stored plan colour or from a consumer's `planAccents`; on
451
+ * whichever theme it lands nearest the surface it fades into its own wash.
452
+ *
453
+ * Callers may still override any of the three. `PromoCodeDialogFields` replaces
454
+ * the border with the plan's undiluted colour, and `PlanDiffCard` takes only the
455
+ * wash and the edge — an inline style beats the class beneath it, which is how
456
+ * the chip kept its own unreadable foreground through a fix applied to the
457
+ * selected-state rules around it.
446
458
  */
447
459
  declare function identityChipStyle(accent: string): Record<string, string>;
448
460
 
@@ -436,13 +436,25 @@ declare function identityAccentAt(index: number): string;
436
436
  declare function identityAccentFor(key: string, overrides?: Readonly<Record<string, string>>, index?: number): string;
437
437
  /**
438
438
  * The three-part chip style every one of these sites was hand-rolling: a wash
439
- * of the accent, the accent as text, a firmer edge of it.
439
+ * of the accent, a readable rendering of the accent as text, a firmer edge.
440
440
  *
441
441
  * `color-mix()` rather than the `accent + '15'` string concatenation it
442
442
  * replaces. That trick only worked on a six-digit hex — it silently produced
443
443
  * garbage for `rgb()`, for a named colour and for anything a consumer might
444
444
  * reasonably pass — and it could never have worked for a `var()`, which is why
445
445
  * these palettes could not follow the theme in the first place.
446
+ *
447
+ * The text is a mix rather than the accent itself. A `var(--sa-color-identity-N)`
448
+ * would not need that — the ramp has a value per theme, so it already suits both
449
+ * surfaces. A concrete hex has one value for both, and a concrete hex is what
450
+ * reaches here from a stored plan colour or from a consumer's `planAccents`; on
451
+ * whichever theme it lands nearest the surface it fades into its own wash.
452
+ *
453
+ * Callers may still override any of the three. `PromoCodeDialogFields` replaces
454
+ * the border with the plan's undiluted colour, and `PlanDiffCard` takes only the
455
+ * wash and the edge — an inline style beats the class beneath it, which is how
456
+ * the chip kept its own unreadable foreground through a fix applied to the
457
+ * selected-state rules around it.
446
458
  */
447
459
  declare function identityChipStyle(accent: string): Record<string, string>;
448
460
 
@@ -34,7 +34,7 @@ import {
34
34
  resolvePlans,
35
35
  todayIsoDate,
36
36
  trimTrailingSlashes
37
- } from "../chunk-VYMTWCTT.js";
37
+ } from "../chunk-D74FFCCP.js";
38
38
  import {
39
39
  DEFAULT_SA_LOCALE,
40
40
  SA_INTL_LOCALES,
package/dist/index.cjs CHANGED
@@ -3984,10 +3984,11 @@ var WELL_KNOWN = {
3984
3984
  function identityAccentFor(key, overrides = {}, index = -1) {
3985
3985
  return overrides[key] ?? WELL_KNOWN[key] ?? identityAccentAt(index);
3986
3986
  }
3987
+ var READABLE_ACCENT_SHARE = 50;
3987
3988
  function identityChipStyle(accent) {
3988
3989
  return {
3989
3990
  background: `color-mix(in srgb, ${accent} 8%, transparent)`,
3990
- color: accent,
3991
+ color: `color-mix(in srgb, ${accent} ${READABLE_ACCENT_SHARE}%, var(--sa-color-fg-heading))`,
3991
3992
  borderColor: `color-mix(in srgb, ${accent} 20%, transparent)`
3992
3993
  };
3993
3994
  }
package/dist/index.js CHANGED
@@ -34,7 +34,7 @@ import {
34
34
  resolvePlans,
35
35
  todayIsoDate,
36
36
  trimTrailingSlashes
37
- } from "./chunk-VYMTWCTT.js";
37
+ } from "./chunk-D74FFCCP.js";
38
38
  import {
39
39
  SA_LOCALE_STORAGE_KEY,
40
40
  SA_THEME_KEY,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/ui-vue",
3
- "version": "0.24.0",
3
+ "version": "0.24.2",
4
4
  "description": "Vue 3 components, resource clients and composables for the SuperAdmin UI shell. Provides boot and manifest loaders, navigation, actions and standard pages.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -61,7 +61,7 @@
61
61
  "src"
62
62
  ],
63
63
  "dependencies": {
64
- "@saasicat/types": "^0.24.0"
64
+ "@saasicat/types": "^0.24.2"
65
65
  },
66
66
  "peerDependencies": {
67
67
  "pinia": "^2.0.0 || ^3.0.0",
@@ -110,20 +110,58 @@ export function identityAccentFor(
110
110
  return overrides[key] ?? WELL_KNOWN[key] ?? identityAccentAt(index);
111
111
  }
112
112
 
113
+ /**
114
+ * How much of the accent survives when the accent becomes TEXT.
115
+ *
116
+ * The remainder comes from `--sa-color-fg-heading`, which is near-black in the
117
+ * light theme and near-white in the dark one. So a single expression darkens
118
+ * the accent in one theme and lightens it in the other, which is the same move
119
+ * `--sa-color-accent-strong` makes for the brand.
120
+ *
121
+ * It is not the brand's 82 %, and the difference is the point. The brand is one
122
+ * curated colour, checked by a person against both surfaces. This helper's
123
+ * input is uncontrolled: a plan's stored hex, a consumer's `planAccents`, a
124
+ * colour an operator picked out of a swatch. A share tuned for one colour is
125
+ * not a bound over all of them, and 82 % measurably is not one — three of the
126
+ * six colours the promotion editor itself stores stay under 3:1 on a raised
127
+ * dark card (2.81 to 2.98), and a light brand pick such as lime-400 only
128
+ * reaches 2.03:1 on a raised light one.
129
+ *
130
+ * At half, every colour in sRGB stays at or above 3.3:1 in light and 3.7:1 in
131
+ * dark, both extremes being the degenerate accents — an accent that already IS
132
+ * the theme's own extreme cannot be pushed further from the surface, so pure
133
+ * white decides the light bound and pure black the dark one. That is a bound
134
+ * over the whole cube rather than over a sample, which is what an uncontrolled
135
+ * input needs. `theme-role-contrast` measures it and fails if this share rises.
136
+ */
137
+ const READABLE_ACCENT_SHARE = 50;
138
+
113
139
  /**
114
140
  * The three-part chip style every one of these sites was hand-rolling: a wash
115
- * of the accent, the accent as text, a firmer edge of it.
141
+ * of the accent, a readable rendering of the accent as text, a firmer edge.
116
142
  *
117
143
  * `color-mix()` rather than the `accent + '15'` string concatenation it
118
144
  * replaces. That trick only worked on a six-digit hex — it silently produced
119
145
  * garbage for `rgb()`, for a named colour and for anything a consumer might
120
146
  * reasonably pass — and it could never have worked for a `var()`, which is why
121
147
  * these palettes could not follow the theme in the first place.
148
+ *
149
+ * The text is a mix rather than the accent itself. A `var(--sa-color-identity-N)`
150
+ * would not need that — the ramp has a value per theme, so it already suits both
151
+ * surfaces. A concrete hex has one value for both, and a concrete hex is what
152
+ * reaches here from a stored plan colour or from a consumer's `planAccents`; on
153
+ * whichever theme it lands nearest the surface it fades into its own wash.
154
+ *
155
+ * Callers may still override any of the three. `PromoCodeDialogFields` replaces
156
+ * the border with the plan's undiluted colour, and `PlanDiffCard` takes only the
157
+ * wash and the edge — an inline style beats the class beneath it, which is how
158
+ * the chip kept its own unreadable foreground through a fix applied to the
159
+ * selected-state rules around it.
122
160
  */
123
161
  export function identityChipStyle(accent: string): Record<string, string> {
124
162
  return {
125
163
  background: `color-mix(in srgb, ${accent} 8%, transparent)`,
126
- color: accent,
164
+ color: `color-mix(in srgb, ${accent} ${READABLE_ACCENT_SHARE}%, var(--sa-color-fg-heading))`,
127
165
  borderColor: `color-mix(in srgb, ${accent} 20%, transparent)`,
128
166
  };
129
167
  }
@@ -208,7 +208,12 @@ function onToggle(featureKey: string): void {
208
208
  margin-left: 4px;
209
209
  }
210
210
  .bd-feature-pill.on .bd-feature-key {
211
- color: var(--sa-color-accent);
211
+ /* `-strong`, not the bare accent: the pill's `on` surface is a 24 % tint of
212
+ * blue over the dark card, and the brand does not change between themes —
213
+ * 2.92:1 there. The selected states around this one were moved to
214
+ * `-strong` already; this descendant was missed because it sets a colour
215
+ * and no background, which is a shape no contrast check reads. */
216
+ color: var(--sa-color-accent-strong);
212
217
  }
213
218
  .bd-feature-pill.overlap .bd-feature-key {
214
219
  color: var(--sa-color-negative-fg);
@@ -170,7 +170,7 @@
170
170
  >
171
171
  <span
172
172
  class="pl-plan-opt__dot"
173
- :style="{ background: p.color ?? '#94a3b8' }"
173
+ :style="{ background: p.color ?? IDENTITY_NEUTRAL }"
174
174
  />
175
175
  <div class="pl-plan-opt__text">
176
176
  <span class="pl-plan-opt__key">{{ p.value }}</span>
@@ -263,6 +263,7 @@
263
263
  <script setup lang="ts">
264
264
  import { computed, reactive, ref, watch } from 'vue';
265
265
  import MfaPromptDialog from '../MfaPromptDialog.vue';
266
+ import { IDENTITY_NEUTRAL } from '../../client/identity-accents.js';
266
267
  import { formatMessage } from '../../client/i18n/format.js';
267
268
  import { useSaMessages } from '../../vue/use-super-admin-i18n.js';
268
269
  import type { PilotCopy, PilotCreatePayload, PilotCreateResult } from './types.js';
@@ -46,7 +46,7 @@
46
46
  >
47
47
  <span
48
48
  class="pl-plan-opt__dot"
49
- :style="{ background: p.color ?? '#94a3b8' }"
49
+ :style="{ background: p.color ?? IDENTITY_NEUTRAL }"
50
50
  />
51
51
  <div class="pl-plan-opt__text">
52
52
  <span class="pl-plan-opt__key">{{ p.value }}</span>
@@ -148,6 +148,7 @@
148
148
  <script setup lang="ts">
149
149
  import { computed, reactive, ref, watch } from 'vue';
150
150
  import MfaPromptDialog from '../MfaPromptDialog.vue';
151
+ import { IDENTITY_NEUTRAL } from '../../client/identity-accents.js';
151
152
  import { formatMessage } from '../../client/i18n/format.js';
152
153
  import { useSaMessages } from '../../vue/use-super-admin-i18n.js';
153
154
  import type { PilotRow } from '../../pages-standard/PilotsPage.vue';
@@ -85,7 +85,10 @@
85
85
  :style="planChipStyle(p)"
86
86
  @click="togglePlan(p.key)"
87
87
  >
88
- <span class="pc-plan-opt__mark" :style="{ background: p.color ?? '#64748b' }" />
88
+ <span
89
+ class="pc-plan-opt__mark"
90
+ :style="{ background: p.color ?? IDENTITY_NEUTRAL }"
91
+ />
89
92
  {{ p.label }}
90
93
  </button>
91
94
  </div>
@@ -101,13 +104,13 @@
101
104
  <div class="pc-grid pc-grid--2">
102
105
  <div class="pc-field">
103
106
  <div class="pc-field__label">{{ msg.form.durationLabel }}</div>
104
- <div class="pc-dur">
107
+ <div class="pc-seg pc-seg--fill">
105
108
  <button
106
109
  v-for="o in durationOptions"
107
110
  :key="o.k"
108
111
  type="button"
109
- class="pc-dur-opt"
110
- :class="{ 'pc-dur-opt--active': form.durationType === o.k }"
112
+ class="pc-seg-opt"
113
+ :class="{ 'pc-seg-opt--active': form.durationType === o.k }"
111
114
  @click="form.durationType = o.k"
112
115
  >
113
116
  {{ o.label }}
@@ -161,13 +164,13 @@
161
164
  <div v-if="mode === 'edit'" class="pc-grid pc-grid--2">
162
165
  <div class="pc-field">
163
166
  <div class="pc-field__label">{{ common.status }}</div>
164
- <div class="pc-status">
167
+ <div class="pc-seg pc-status">
165
168
  <button
166
169
  v-for="o in statusOptions"
167
170
  :key="o.k"
168
171
  type="button"
169
- class="pc-status-opt"
170
- :class="{ 'pc-status-opt--active': form.status === o.k }"
172
+ class="pc-seg-opt"
173
+ :class="{ 'pc-seg-opt--active': form.status === o.k }"
171
174
  @click="form.status = o.k"
172
175
  >
173
176
  <q-icon :name="o.icon" size="14px" />
@@ -265,7 +268,7 @@
265
268
 
266
269
  <script setup lang="ts">
267
270
  import { computed } from 'vue';
268
- import { identityChipStyle } from '../../client/identity-accents.js';
271
+ import { IDENTITY_NEUTRAL, identityChipStyle } from '../../client/identity-accents.js';
269
272
  import { formatMessage } from '../../client/i18n/format.js';
270
273
  import { useSaMessages } from '../../vue/use-super-admin-i18n.js';
271
274
  import type { PromoCodeDurationType, PromoCodePlanOption, PromoCodeValueType } from './types.js';
@@ -595,8 +598,8 @@ textarea.pc-input {
595
598
 
596
599
  .pc-plan-opt--on {
597
600
  border-color: var(--sa-color-accent);
598
- background: var(--sa-color-accent-surface);
599
- color: var(--sa-color-accent);
601
+ background: var(--sa-color-accent-surface-strong);
602
+ color: var(--sa-color-accent-strong);
600
603
  }
601
604
 
602
605
  .pc-plan-opt__mark {
@@ -605,13 +608,25 @@ textarea.pc-input {
605
608
  border-radius: 50%;
606
609
  }
607
610
 
608
- .pc-dur {
611
+ /* One segmented control for both the duration and the status row. They are the
612
+ * same thing — a row of buttons of which one is chosen — and the status row had
613
+ * NO rules at all, so it rendered as raw browser buttons: white boxes on a dark
614
+ * dialog. Sharing the recipe is what makes the two look alike without either
615
+ * restating it. */
616
+ .pc-seg {
609
617
  display: flex;
610
618
  gap: 4px;
611
619
  }
612
-
613
- .pc-dur-opt {
620
+ /* The duration options divide the row; the status options size to their label. */
621
+ .pc-seg--fill .pc-seg-opt {
614
622
  flex: 1;
623
+ }
624
+
625
+ .pc-seg-opt {
626
+ display: inline-flex;
627
+ align-items: center;
628
+ justify-content: center;
629
+ gap: var(--sa-gap-inline);
615
630
  border: 1px solid var(--sa-color-border);
616
631
  background: var(--sa-color-bg-surface);
617
632
  border-radius: 7px;
@@ -621,10 +636,19 @@ textarea.pc-input {
621
636
  color: var(--sa-color-fg-secondary);
622
637
  }
623
638
 
624
- .pc-dur-opt--active {
639
+ .pc-seg-opt:hover {
640
+ border-color: var(--sa-color-border-strong);
641
+ }
642
+
643
+ /* `-accent-strong` on `-surface-strong`, which is what the design guide
644
+ * prescribes for accent text on a tint. The chosen option used to pair plain
645
+ * `--sa-color-accent` with the 8 % wash of itself: in dark mode that is the
646
+ * brand blue on a near-black blue, and the selected state was the one you could
647
+ * read least — the opposite of what selecting something should do. */
648
+ .pc-seg-opt--active {
625
649
  border-color: var(--sa-color-accent);
626
- background: var(--sa-color-accent-surface);
627
- color: var(--sa-color-accent);
650
+ background: var(--sa-color-accent-surface-strong);
651
+ color: var(--sa-color-accent-strong);
628
652
  }
629
653
 
630
654
  .pc-check {
@@ -107,7 +107,7 @@ function remove(): void {
107
107
  border: 1px dashed var(--sa-color-accent-border);
108
108
  background: var(--sa-color-accent-surface-soft);
109
109
  border-radius: 10px;
110
- color: var(--sa-color-accent);
110
+ color: var(--sa-color-accent-strong);
111
111
  font-weight: 700;
112
112
  font-size: var(--sa-text-sm);
113
113
  cursor: pointer;
@@ -825,23 +825,61 @@ async function executeTerminate(): Promise<void> {
825
825
  );
826
826
  border-color: var(--sa-color-border);
827
827
  }
828
+ /* The marker block at the head of a diff row.
829
+ *
830
+ * Every rule below carries BOTH halves of its pair, and that is deliberate:
831
+ * the geometry used to live here and the background one rule further down,
832
+ * which made neither rule judgeable on its own. `theme-role-contrast` needs a
833
+ * background and a colour in the same body to measure anything, so the split
834
+ * hid `--sa-color-fg-on-accent` on `--sa-color-positive-strong` — white on
835
+ * green-500, 2.54:1, in BOTH themes — for as long as the component existed.
836
+ *
837
+ * A tint plus the tone's `-fg`, not a solid plus white: `-strong` is the rung
838
+ * chosen to read as a COLOUR against its theme's surface, which puts it in the
839
+ * middle of the lightness range — and nothing reads on the middle. That is
840
+ * what `<tone>-fg` and `-surface-strong` are for. */
828
841
  .pd-diff-icon {
829
842
  width: 32px;
830
843
  align-self: stretch;
831
844
  display: grid;
832
845
  place-items: center;
833
846
  flex: 0 0 32px;
834
- color: var(--sa-color-fg-on-accent);
847
+ background: var(--sa-color-bg-sunken);
848
+ color: var(--sa-color-fg-body);
835
849
  font: 700 var(--sa-text-lg) var(--sa-font-body);
836
850
  }
837
851
  .pd-diff-row.add .pd-diff-icon {
838
- background: var(--sa-color-positive-strong);
852
+ background: var(--sa-color-positive-surface-strong);
853
+ color: var(--sa-color-positive-fg);
839
854
  }
840
855
  .pd-diff-row.rm .pd-diff-icon {
841
- background: var(--sa-color-negative-strong);
856
+ background: var(--sa-color-negative-surface-strong);
857
+ color: var(--sa-color-negative-fg);
842
858
  }
859
+ /* Unchanged, and deliberately not brought into line with the two above: it was
860
+ * already readable (14.6:1) and `--sa-color-bg-sunken` is what the `mod` row
861
+ * itself starts its gradient with, so a neutral tint here would leave the
862
+ * marker with almost nothing to stand against in the light theme. It only
863
+ * gains the foreground it was already inheriting, so the rule can be read. */
843
864
  .pd-diff-row.mod .pd-diff-icon {
844
865
  background: var(--sa-color-inverse-surface);
866
+ color: var(--sa-color-inverse-fg);
867
+ }
868
+ /* The single-version listing marks WHAT a row is rather than what changed, so
869
+ * it reads the catalogue-entity roles. These three were the last colour
870
+ * literals in a template: `style="background: #f59e0b"` and friends, painted
871
+ * white by the base rule above at 2.15:1, 2.77:1 and 4.23:1. */
872
+ .pd-diff-row.plain .pd-diff-icon.feature {
873
+ background: var(--sa-color-feature-surface);
874
+ color: var(--sa-color-feature-fg);
875
+ }
876
+ .pd-diff-row.plain .pd-diff-icon.quota {
877
+ background: var(--sa-color-quota-surface);
878
+ color: var(--sa-color-quota-fg);
879
+ }
880
+ .pd-diff-row.plain .pd-diff-icon.bundle {
881
+ background: var(--sa-color-bundle-surface);
882
+ color: var(--sa-color-bundle-fg);
845
883
  }
846
884
  .pd-diff-body {
847
885
  flex: 1;
@@ -66,7 +66,7 @@
66
66
  :key="'cf-' + f"
67
67
  class="pd-diff-row plain"
68
68
  >
69
- <div class="pd-diff-icon" style="background: #8b5cf6">·</div>
69
+ <div class="pd-diff-icon feature">·</div>
70
70
  <div class="pd-diff-body">
71
71
  <span class="pd-diff-label">{{ featureLabel(f) }}</span>
72
72
  <code class="pd-diff-key">{{ f }}</code>
@@ -88,7 +88,7 @@
88
88
  :key="'cq-' + k"
89
89
  class="pd-diff-row plain"
90
90
  >
91
- <div class="pd-diff-icon" style="background: #0ea5e9">·</div>
91
+ <div class="pd-diff-icon quota">·</div>
92
92
  <div class="pd-diff-body">
93
93
  <span class="pd-diff-label">{{ quotaLabel(k) }}</span>
94
94
  <code class="pd-diff-key">{{ k }}</code>
@@ -111,7 +111,7 @@
111
111
  :key="'cb-' + b"
112
112
  class="pd-diff-row plain"
113
113
  >
114
- <div class="pd-diff-icon" style="background: #f59e0b">·</div>
114
+ <div class="pd-diff-icon bundle">·</div>
115
115
  <div class="pd-diff-body">
116
116
  <span class="pd-diff-label">{{ bundleLabel(b) }}</span>
117
117
  <code class="pd-diff-key">{{ b }}</code>
@@ -811,7 +811,7 @@ function formatQuota(v: number | undefined): string {
811
811
  height: 30px;
812
812
  border-radius: 50%;
813
813
  background: var(--sa-color-accent-surface-strong);
814
- color: var(--sa-color-accent);
814
+ color: var(--sa-color-accent-strong);
815
815
  margin: 0 auto 6px;
816
816
  }
817
817
  .pm-add-title {
@@ -25,9 +25,13 @@
25
25
 
26
26
  <div class="pve-prev-window" :class="`pve-prev-window--${previewMode}`">
27
27
  <div class="pve-prev-chrome">
28
- <span class="pve-prev-dot" style="background: #ef4444" />
29
- <span class="pve-prev-dot" style="background: #f59e0b" />
30
- <span class="pve-prev-dot" style="background: #10b981" />
28
+ <!-- The same window controls as MarketingCatalogPreview, drawn
29
+ a second time, and the same reason for the `-strong`
30
+ rungs. The chrome itself is duplicated markup and belongs
31
+ in one component; that is a Phase 4 job, not a colour one. -->
32
+ <span class="pve-prev-dot" style="background: var(--sa-color-negative-strong)" />
33
+ <span class="pve-prev-dot" style="background: var(--sa-color-warning-strong)" />
34
+ <span class="pve-prev-dot" style="background: var(--sa-color-positive-strong)" />
31
35
  <div class="pve-prev-url">{{ catalogUrl }}</div>
32
36
  </div>
33
37
  <div class="pve-prev-body">
@@ -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;
@@ -405,7 +405,7 @@ function barWidth(value: number, max?: number): number {
405
405
  /* Header look comes from the global .sa-page-head — only a margin tweak here. */
406
406
 
407
407
  .sa-kpi__icon :deep(.q-icon) {
408
- color: var(--sa-color-accent);
408
+ color: var(--sa-color-accent-strong);
409
409
  }
410
410
 
411
411
  .sa-dashboard__rows {
@@ -423,7 +423,7 @@ function barWidth(value: number, max?: number): number {
423
423
 
424
424
  .sa-dashboard__count {
425
425
  background: var(--sa-color-accent-surface);
426
- color: var(--sa-color-accent);
426
+ color: var(--sa-color-accent-strong);
427
427
  font-size: var(--sa-text-xs);
428
428
  font-weight: 700;
429
429
  padding: 2px 7px;
@@ -492,7 +492,7 @@ function barWidth(value: number, max?: number): number {
492
492
  border-color: var(--sa-color-accent-border);
493
493
  }
494
494
  .sa-dashboard__shortcut :deep(.q-icon) {
495
- color: var(--sa-color-accent);
495
+ color: var(--sa-color-accent-strong);
496
496
  flex-shrink: 0;
497
497
  }
498
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
  });
@@ -1529,14 +1529,22 @@ async function onLocaleChange(loc: string): Promise<void> {
1529
1529
  transform: rotate(90deg);
1530
1530
  }
1531
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". */
1532
1543
  .sa-marketing-admin-expand {
1533
1544
  grid-column: 1 / -1;
1534
- background: linear-gradient(
1535
- 180deg,
1536
- var(--sa-color-bg-surface-raised) 0%,
1537
- var(--sa-color-bg-surface) 100%
1538
- );
1539
- 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);
1540
1548
  padding: 18px 20px 22px;
1541
1549
  }
1542
1550
  .sa-marketing-expand-grid {
@@ -1716,8 +1724,13 @@ async function onLocaleChange(loc: string): Promise<void> {
1716
1724
  color 0.12s;
1717
1725
  }
1718
1726
  .sa-marketing-tf-chip em {
1727
+ /* No colour of its own — the mono face and the smaller step already set it
1728
+ * apart from the chip's label, and a colour here does not follow the chip.
1729
+ * `--sa-color-fg-subtle` stayed put when :hover moved the chip's surface to
1730
+ * a 22 % accent tint, which measured 2.92:1 in the dark theme: a foreground
1731
+ * declared without the background it will end up on is a pair nothing can
1732
+ * check, and this one was wrong in exactly the state nobody screenshots. */
1719
1733
  font-style: normal;
1720
- color: var(--sa-color-fg-subtle);
1721
1734
  font: 500 var(--sa-text-xs) var(--sa-font-mono);
1722
1735
  }
1723
1736
  .sa-marketing-tf-chip:hover:not(:disabled) {
@@ -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>
@@ -1,9 +1,23 @@
1
1
  <template>
2
2
  <div class="sa-marketing-window">
3
3
  <div class="sa-marketing-chrome">
4
- <span class="sa-marketing-chrome-dot" style="background: #ef4444" />
5
- <span class="sa-marketing-chrome-dot" style="background: #f59e0b" />
6
- <span class="sa-marketing-chrome-dot" style="background: #10b981" />
4
+ <!-- The window controls of the browser this mock-up draws. Their hue
5
+ is fixed by what they depict rather than by what they mean, and
6
+ the `-strong` rungs are exactly that hue — the three replaced
7
+ the same three literals byte for byte. Only the amber differs
8
+ between themes, and it lifts one rung in the dark one. -->
9
+ <span
10
+ class="sa-marketing-chrome-dot"
11
+ style="background: var(--sa-color-negative-strong)"
12
+ />
13
+ <span
14
+ class="sa-marketing-chrome-dot"
15
+ style="background: var(--sa-color-warning-strong)"
16
+ />
17
+ <span
18
+ class="sa-marketing-chrome-dot"
19
+ style="background: var(--sa-color-positive-strong)"
20
+ />
7
21
  <div class="sa-marketing-chrome-url">{{ previewUrl }}</div>
8
22
  </div>
9
23
  <div class="sa-marketing-canvas">
@@ -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);
@@ -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;
@@ -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;
@@ -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;
@@ -120,9 +120,11 @@ body.body--dark {
120
120
  --sa-color-feature-fg: var(--sa-violet-200);
121
121
  --sa-color-feature-surface: color-mix(in srgb, var(--sa-violet-500) 16%, transparent);
122
122
  --sa-color-quota: var(--sa-sky-500);
123
+ --sa-color-quota-fg: var(--sa-sky-200);
123
124
  --sa-color-quota-surface: color-mix(in srgb, var(--sa-sky-500) 16%, transparent);
124
125
  --sa-color-quota-border: color-mix(in srgb, var(--sa-sky-500) 36%, transparent);
125
126
  --sa-color-bundle: var(--sa-amber-500);
127
+ --sa-color-bundle-fg: var(--sa-amber-200);
126
128
  --sa-color-bundle-surface: color-mix(in srgb, var(--sa-amber-500) 16%, transparent);
127
129
 
128
130
  /* The identity ramp, one rung lighter throughout — see the light file for
@@ -141,16 +141,25 @@
141
141
  --sa-color-scheduled-surface-strong: var(--sa-indigo-100);
142
142
  --sa-color-scheduled-border: var(--sa-indigo-200);
143
143
 
144
- /* ── Catalogue entities. Not statuses: these two say WHAT a row is, and
144
+ /* ── Catalogue entities. Not statuses: these three say WHAT a row is, and
145
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. ────────── */
146
+ * to agree, or the same dot means two things on two screens.
147
+ *
148
+ * Each one has the same three slots a status tone has: the colour itself,
149
+ * a tinted SURFACE, and an `-fg` that is readable on that surface. Only
150
+ * `feature` had the third, and the two that were missing are why a diff
151
+ * marker painted white on `--sa-sky-500` (2.77:1) and on `--sa-amber-500`
152
+ * (2.15:1): with no role to point at, the panel reached past the layer for
153
+ * the primitive and wrote it into the template. ───────────────────────── */
147
154
  --sa-color-feature: var(--sa-violet-500);
148
155
  --sa-color-feature-fg: var(--sa-violet-700);
149
156
  --sa-color-feature-surface: var(--sa-violet-50);
150
157
  --sa-color-quota: var(--sa-sky-500);
158
+ --sa-color-quota-fg: var(--sa-sky-700);
151
159
  --sa-color-quota-surface: var(--sa-sky-50);
152
160
  --sa-color-quota-border: var(--sa-sky-200);
153
161
  --sa-color-bundle: var(--sa-amber-500);
162
+ --sa-color-bundle-fg: var(--sa-amber-700);
154
163
  --sa-color-bundle-surface: var(--sa-amber-100);
155
164
 
156
165
  /* ── Identity. A CATEGORICAL ramp: the only job of these seven is to be