@saasicat/ui-vue 0.24.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.
@@ -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.1",
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.1"
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
  }
@@ -101,13 +101,13 @@
101
101
  <div class="pc-grid pc-grid--2">
102
102
  <div class="pc-field">
103
103
  <div class="pc-field__label">{{ msg.form.durationLabel }}</div>
104
- <div class="pc-dur">
104
+ <div class="pc-seg pc-seg--fill">
105
105
  <button
106
106
  v-for="o in durationOptions"
107
107
  :key="o.k"
108
108
  type="button"
109
- class="pc-dur-opt"
110
- :class="{ 'pc-dur-opt--active': form.durationType === o.k }"
109
+ class="pc-seg-opt"
110
+ :class="{ 'pc-seg-opt--active': form.durationType === o.k }"
111
111
  @click="form.durationType = o.k"
112
112
  >
113
113
  {{ o.label }}
@@ -161,13 +161,13 @@
161
161
  <div v-if="mode === 'edit'" class="pc-grid pc-grid--2">
162
162
  <div class="pc-field">
163
163
  <div class="pc-field__label">{{ common.status }}</div>
164
- <div class="pc-status">
164
+ <div class="pc-seg pc-status">
165
165
  <button
166
166
  v-for="o in statusOptions"
167
167
  :key="o.k"
168
168
  type="button"
169
- class="pc-status-opt"
170
- :class="{ 'pc-status-opt--active': form.status === o.k }"
169
+ class="pc-seg-opt"
170
+ :class="{ 'pc-seg-opt--active': form.status === o.k }"
171
171
  @click="form.status = o.k"
172
172
  >
173
173
  <q-icon :name="o.icon" size="14px" />
@@ -595,8 +595,8 @@ textarea.pc-input {
595
595
 
596
596
  .pc-plan-opt--on {
597
597
  border-color: var(--sa-color-accent);
598
- background: var(--sa-color-accent-surface);
599
- color: var(--sa-color-accent);
598
+ background: var(--sa-color-accent-surface-strong);
599
+ color: var(--sa-color-accent-strong);
600
600
  }
601
601
 
602
602
  .pc-plan-opt__mark {
@@ -605,13 +605,25 @@ textarea.pc-input {
605
605
  border-radius: 50%;
606
606
  }
607
607
 
608
- .pc-dur {
608
+ /* One segmented control for both the duration and the status row. They are the
609
+ * same thing — a row of buttons of which one is chosen — and the status row had
610
+ * NO rules at all, so it rendered as raw browser buttons: white boxes on a dark
611
+ * dialog. Sharing the recipe is what makes the two look alike without either
612
+ * restating it. */
613
+ .pc-seg {
609
614
  display: flex;
610
615
  gap: 4px;
611
616
  }
612
-
613
- .pc-dur-opt {
617
+ /* The duration options divide the row; the status options size to their label. */
618
+ .pc-seg--fill .pc-seg-opt {
614
619
  flex: 1;
620
+ }
621
+
622
+ .pc-seg-opt {
623
+ display: inline-flex;
624
+ align-items: center;
625
+ justify-content: center;
626
+ gap: var(--sa-gap-inline);
615
627
  border: 1px solid var(--sa-color-border);
616
628
  background: var(--sa-color-bg-surface);
617
629
  border-radius: 7px;
@@ -621,10 +633,19 @@ textarea.pc-input {
621
633
  color: var(--sa-color-fg-secondary);
622
634
  }
623
635
 
624
- .pc-dur-opt--active {
636
+ .pc-seg-opt:hover {
637
+ border-color: var(--sa-color-border-strong);
638
+ }
639
+
640
+ /* `-accent-strong` on `-surface-strong`, which is what the design guide
641
+ * prescribes for accent text on a tint. The chosen option used to pair plain
642
+ * `--sa-color-accent` with the 8 % wash of itself: in dark mode that is the
643
+ * brand blue on a near-black blue, and the selected state was the one you could
644
+ * read least — the opposite of what selecting something should do. */
645
+ .pc-seg-opt--active {
625
646
  border-color: var(--sa-color-accent);
626
- background: var(--sa-color-accent-surface);
627
- color: var(--sa-color-accent);
647
+ background: var(--sa-color-accent-surface-strong);
648
+ color: var(--sa-color-accent-strong);
628
649
  }
629
650
 
630
651
  .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;
@@ -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 {
@@ -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 {
@@ -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>
@@ -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;