@saasicat/ui-vue 0.24.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/ui-vue",
3
- "version": "0.24.1",
3
+ "version": "0.25.0",
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.1"
64
+ "@saasicat/types": "^0.25.0"
65
65
  },
66
66
  "peerDependencies": {
67
67
  "pinia": "^2.0.0 || ^3.0.0",
@@ -0,0 +1,125 @@
1
+ <template>
2
+ <div class="sa-accordion" :class="{ 'sa-accordion--open': open }">
3
+ <div class="sa-accordion__head">
4
+ <button
5
+ :id="triggerId"
6
+ type="button"
7
+ class="sa-accordion__trigger"
8
+ :aria-expanded="open"
9
+ :aria-controls="bodyId"
10
+ :disabled="disabled"
11
+ @click="emit('update:open', !open)"
12
+ >
13
+ <span
14
+ v-if="$slots.mark"
15
+ class="sa-accordion__mark"
16
+ :class="`sa-accordion__mark--${markTone}`"
17
+ >
18
+ <slot name="mark" />
19
+ </span>
20
+ <span class="sa-accordion__label"><slot name="header" /></span>
21
+ <q-icon name="chevron_right" size="18px" class="sa-accordion__chev" />
22
+ </button>
23
+ <div v-if="$slots['header-actions']" class="sa-accordion__actions">
24
+ <slot name="header-actions" />
25
+ </div>
26
+ </div>
27
+ <div
28
+ v-if="open"
29
+ :id="bodyId"
30
+ role="region"
31
+ :aria-labelledby="triggerId"
32
+ class="sa-accordion__body"
33
+ >
34
+ <slot />
35
+ </div>
36
+ </div>
37
+ </template>
38
+
39
+ <script setup lang="ts">
40
+ import { useId } from 'vue';
41
+
42
+ // One disclosure: a header that opens a body. Eight surfaces in this package
43
+ // built their own, in three incompatible idioms, and agreed on nothing —
44
+ // three header paddings, three body paddings, two radii, two colours for the
45
+ // open border, two transition durations, and hover feedback on exactly one of
46
+ // six despite five setting `cursor: pointer`.
47
+ //
48
+ // The reason is not that eight decisions were taken. It is that none was: there
49
+ // is an `AdminTable` and an `AdminSection`, and for "open this" there was
50
+ // nothing, so every page wrote it again. `admin-page-shell.test.ts` rule 14
51
+ // records the same story for tables — "nine pages used QTable directly and
52
+ // agreed on nothing".
53
+ //
54
+ // **The trigger is a `<button>`, and that is the point of the component.**
55
+ // Four of the eight were a `<div>` with a click handler: no `tabindex`, no
56
+ // keyboard, and a screen reader announcing text rather than a control. A grep
57
+ // for `aria-expanded` across the whole package returned nothing at all. A
58
+ // shared component has that argument once; eight surfaces have to win it eight
59
+ // times, and the score was 4:4.
60
+ //
61
+ // `header-actions` is a SIBLING of the button rather than a slot inside it, and
62
+ // both halves of that matter. A control inside the trigger would fire the
63
+ // toggle on its way past — the discovery cards put a live status control in
64
+ // their clickable header — and interactive content nested in a `<button>` is
65
+ // not valid HTML, so the browser's own behaviour there is not something to
66
+ // rely on.
67
+ //
68
+ // The state is NOT owned here. Five of the eight take it from the page
69
+ // (`openKey === row.id`), and in the bundle list opening one card while closing
70
+ // another is tied to loading that bundle's versions — a component that owned a
71
+ // boolean would break that. The other four simply hold the id, and lose nothing
72
+ // by the arrangement. `v-model:open` keeps the page in charge either way.
73
+ //
74
+ // The body is `v-if`, which is what all seven JS implementations already did.
75
+ // Uniform by accident until now; uniform on purpose from here.
76
+ const {
77
+ open,
78
+ disabled = false,
79
+ markTone = 'accent',
80
+ } = defineProps<{
81
+ /** Whether the body is shown. The page owns this — see above. */
82
+ open: boolean;
83
+ /** A locked row: the trigger is inert and announces itself as such. */
84
+ disabled?: boolean;
85
+ /**
86
+ * The `mark` badge's tone. `accent` unless the row is in a state the badge
87
+ * itself should report — the discovery quota with no usage provider is the
88
+ * one case, and it swaps its glyph as well.
89
+ *
90
+ * Two values because two exist. The frame, size, radius and position do not
91
+ * change with it: that is what makes the badge the same badge everywhere.
92
+ */
93
+ markTone?: 'accent' | 'negative';
94
+ }>();
95
+
96
+ const emit = defineEmits<{ 'update:open': [value: boolean] }>();
97
+
98
+ defineSlots<{
99
+ /**
100
+ * The icon that identifies the row, in a badge this component draws.
101
+ *
102
+ * A slot rather than a prop, because the glyph is sometimes conditional and
103
+ * sometimes carries its own attributes. The BADGE is not the page's
104
+ * business: three surfaces had three answers — a 34px accent-tinted square,
105
+ * a bare 22px glyph, and a bare 20px glyph coloured by a Quasar palette
106
+ * prop — so the same row on two pages did not look like the same row.
107
+ */
108
+ mark?(): unknown;
109
+ /** The header's content — a title, keys, chips, whatever the row needs. */
110
+ header(): unknown;
111
+ /**
112
+ * Controls on the right of the header that must NOT toggle it.
113
+ * Rendered outside the trigger, so a click here is only a click here.
114
+ */
115
+ 'header-actions'?(): unknown;
116
+ /** The body. Only rendered while open. */
117
+ default(): unknown;
118
+ }>();
119
+
120
+ // `useId`, as in `AdminSection`: `aria-controls` and `aria-labelledby` have to
121
+ // point at THIS instance, and an accordion is by nature rendered many times on
122
+ // one page.
123
+ const triggerId = useId();
124
+ const bodyId = useId();
125
+ </script>
@@ -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>
@@ -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';
@@ -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>
@@ -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">
@@ -687,32 +687,14 @@ const classifyDiff = computed(() => props.classifyDiff);
687
687
  flex-direction: column;
688
688
  gap: 8px;
689
689
  }
690
- .sa-bd-card {
691
- background: var(--sa-color-bg-surface);
692
- border: 1px solid var(--sa-color-border);
693
- border-radius: 10px;
694
- overflow: hidden;
695
- }
696
- .sa-bd-card.open {
697
- border-color: var(--sa-color-accent);
698
- }
690
+ /* The card's surface, open border, head padding, chevron and body now come
691
+ * from `AdminAccordion` — twelve declarations that were this page's fourth
692
+ * opinion on what an accordion looks like. What is left below is what the
693
+ * BUNDLE row puts inside that header. */
699
694
  .sa-bd-card__head {
700
695
  display: flex;
701
- gap: 12px;
696
+ gap: var(--sa-space-4);
702
697
  align-items: center;
703
- padding: 12px 14px;
704
- cursor: pointer;
705
- }
706
- .sa-bd-card__mark {
707
- width: 34px;
708
- height: 34px;
709
- border-radius: 8px;
710
- background: var(--sa-color-accent-surface-strong);
711
- color: var(--sa-color-accent-strong);
712
- display: flex;
713
- align-items: center;
714
- justify-content: center;
715
- flex-shrink: 0;
716
698
  }
717
699
  .sa-bd-card__titlewrap {
718
700
  flex: 1;
@@ -737,18 +719,6 @@ const classifyDiff = computed(() => props.classifyDiff);
737
719
  font-size: var(--sa-text-sm);
738
720
  color: var(--sa-color-fg-muted);
739
721
  }
740
- .sa-bd-card__chev {
741
- transition: transform 0.15s;
742
- color: var(--sa-color-fg-subtle);
743
- }
744
- .sa-bd-card__chev.open {
745
- transform: rotate(90deg);
746
- }
747
- .sa-bd-card__body {
748
- border-top: 1px solid var(--sa-color-border);
749
- padding: 14px;
750
- background: var(--sa-color-bg-sunken);
751
- }
752
722
  .sa-bd-grid {
753
723
  display: grid;
754
724
  grid-template-columns: minmax(280px, 360px) 1fr;
@@ -1724,8 +1724,13 @@ async function onLocaleChange(loc: string): Promise<void> {
1724
1724
  color 0.12s;
1725
1725
  }
1726
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. */
1727
1733
  font-style: normal;
1728
- color: var(--sa-color-fg-subtle);
1729
1734
  font: 500 var(--sa-text-xs) var(--sa-font-mono);
1730
1735
  }
1731
1736
  .sa-marketing-tf-chip:hover:not(:disabled) {
@@ -1,44 +1,41 @@
1
1
  <template>
2
2
  <div class="sa-bundles__list">
3
- <div
3
+ <AdminAccordion
4
4
  v-for="bundle in filteredBundles"
5
5
  :key="bundle.id"
6
6
  class="sa-bd-card"
7
- :class="{ open: openKey === bundle.id }"
7
+ :open="openKey === bundle.id"
8
+ @update:open="emit('toggle', bundle)"
8
9
  >
9
- <div class="sa-bd-card__head" @click="emit('toggle', bundle)">
10
- <div class="sa-bd-card__mark"><q-icon name="inventory_2" size="18px" /></div>
11
- <div class="sa-bd-card__titlewrap">
12
- <div class="sa-bd-card__titlerow">
13
- <span class="sa-bd-card__key">{{ bundle.bundleKey }}</span>
14
- <span
15
- class="sa-bundle-chip"
16
- :class="`sa-bundle-chip--${statusClass(bundle)}`"
17
- :title="statusTooltip(bundle)"
18
- >
19
- {{ statusLabel(bundle) }}
20
- </span>
21
- <span
22
- v-if="i18nLocaleCount(bundle) > 0"
23
- class="sa-bundle-chip sa-bundle-chip--info"
24
- >
25
- {{ translationCount(bundle) }}
26
- </span>
10
+ <template #mark><q-icon name="inventory_2" size="18px" /></template>
11
+
12
+ <template #header>
13
+ <div class="sa-bd-card__head">
14
+ <div class="sa-bd-card__titlewrap">
15
+ <div class="sa-bd-card__titlerow">
16
+ <span class="sa-bd-card__key">{{ bundle.bundleKey }}</span>
17
+ <span
18
+ class="sa-bundle-chip"
19
+ :class="`sa-bundle-chip--${statusClass(bundle)}`"
20
+ :title="statusTooltip(bundle)"
21
+ >
22
+ {{ statusLabel(bundle) }}
23
+ </span>
24
+ <span
25
+ v-if="i18nLocaleCount(bundle) > 0"
26
+ class="sa-bundle-chip sa-bundle-chip--info"
27
+ >
28
+ {{ translationCount(bundle) }}
29
+ </span>
30
+ </div>
31
+ <div class="sa-bd-card__name">{{ bundle.label }}</div>
32
+ <div class="sa-bd-card__desc">{{ bundle.description || '—' }}</div>
27
33
  </div>
28
- <div class="sa-bd-card__name">{{ bundle.label }}</div>
29
- <div class="sa-bd-card__desc">{{ bundle.description || '—' }}</div>
30
34
  </div>
31
- <q-icon
32
- name="chevron_right"
33
- class="sa-bd-card__chev"
34
- :class="{ open: openKey === bundle.id }"
35
- />
36
- </div>
35
+ </template>
37
36
 
38
- <div v-if="openKey === bundle.id" class="sa-bd-card__body">
39
- <slot name="detail" :bundle="bundle" />
40
- </div>
41
- </div>
37
+ <slot name="detail" :bundle="bundle" />
38
+ </AdminAccordion>
42
39
 
43
40
  <div v-if="bundlesTotal > 0 && filteredBundles.length === 0" class="sa-bd-empty-row">
44
41
  {{ msg.list.emptyNoMatch }}
@@ -48,6 +45,7 @@
48
45
 
49
46
  <script setup lang="ts">
50
47
  import type { BundleRow } from '@saasicat/types';
48
+ import AdminAccordion from '../../components/admin-page/AdminAccordion.vue';
51
49
  import {
52
50
  bundleStatusMeta,
53
51
  type BundleAggregateStatus,
@@ -1,71 +1,79 @@
1
1
  <template>
2
- <div class="sa-fc" :class="{ expanded }">
3
- <div class="sa-fc__head" @click="emit('toggle')">
4
- <q-icon :name="iconValue || 'help_outline'" size="22px" class="sa-fc__icon" />
5
- <div class="sa-fc__main">
6
- <div class="sa-fc__titlerow">
7
- <code class="sa-fc__key">{{ feature.featureKey }}</code>
8
- <span class="sa-fc__label">{{ labelValue || feature.featureKey }}</span>
9
- <span v-if="newCapsCount > 0" class="sa-fc__flag sa-fc__flag--new">
10
- {{ newCapsLabel }}
11
- </span>
12
- <span v-if="deprecatedCapsCount > 0" class="sa-fc__flag sa-fc__flag--dep">
13
- {{ deprecatedCapsLabel }}
14
- </span>
15
- <span
16
- v-if="feature.successorKey"
17
- class="sa-fc__flag sa-fc__flag--succ"
18
- :title="replacedByLabel"
19
- >
20
- {{ replacedByLabel }}
21
- </span>
2
+ <AdminAccordion class="sa-fc" :open="expanded" @update:open="emit('toggle')">
3
+ <template #mark><q-icon :name="iconValue || 'help_outline'" size="18px" /></template>
4
+
5
+ <template #header>
6
+ <div class="sa-fc__head">
7
+ <div class="sa-fc__main">
8
+ <div class="sa-fc__titlerow">
9
+ <code class="sa-fc__key">{{ feature.featureKey }}</code>
10
+ <span class="sa-fc__label">{{ labelValue || feature.featureKey }}</span>
11
+ <span v-if="newCapsCount > 0" class="sa-fc__flag sa-fc__flag--new">
12
+ {{ newCapsLabel }}
13
+ </span>
14
+ <span v-if="deprecatedCapsCount > 0" class="sa-fc__flag sa-fc__flag--dep">
15
+ {{ deprecatedCapsLabel }}
16
+ </span>
17
+ <span
18
+ v-if="feature.successorKey"
19
+ class="sa-fc__flag sa-fc__flag--succ"
20
+ :title="replacedByLabel"
21
+ >
22
+ {{ replacedByLabel }}
23
+ </span>
24
+ <span
25
+ v-if="feature.replaces.length"
26
+ class="sa-fc__flag sa-fc__flag--repl"
27
+ :title="replacesLabel"
28
+ >
29
+ {{ replacesLabel }}
30
+ </span>
31
+ </div>
32
+ <div class="sa-fc__sub">
33
+ <template v-if="owners.length">
34
+ <span class="sa-muted">{{ msg.owner }}</span>
35
+ {{ owners.join(', ') }}
36
+ <span class="sa-fc__dot">·</span>
37
+ </template>
38
+ {{ capabilities.length }}
39
+ {{
40
+ capabilities.length === 1
41
+ ? msg.feature.capabilityOne
42
+ : msg.feature.capabilityMany
43
+ }}
44
+ <template v-if="tierValue">
45
+ <span class="sa-fc__dot">·</span>
46
+ <span class="sa-fc__tier">{{ tierValue }}</span>
47
+ </template>
48
+ </div>
49
+ </div>
50
+
51
+ <div class="sa-fc__coverage">
22
52
  <span
23
- v-if="feature.replaces.length"
24
- class="sa-fc__flag sa-fc__flag--repl"
25
- :title="replacesLabel"
53
+ v-for="lng in targetLocales"
54
+ :key="lng"
55
+ class="sa-cov-pill"
56
+ :class="coverageClass(coverage(lng))"
26
57
  >
27
- {{ replacesLabel }}
58
+ <span>{{ localeShort(lng) }}</span>
59
+ <span>{{ coveragePct(coverage(lng)) }}%</span>
28
60
  </span>
29
61
  </div>
30
- <div class="sa-fc__sub">
31
- <template v-if="owners.length">
32
- <span class="sa-muted">{{ msg.owner }}</span>
33
- {{ owners.join(', ') }}
34
- <span class="sa-fc__dot">·</span>
35
- </template>
36
- {{ capabilities.length }}
37
- {{
38
- capabilities.length === 1
39
- ? msg.feature.capabilityOne
40
- : msg.feature.capabilityMany
41
- }}
42
- <template v-if="tierValue">
43
- <span class="sa-fc__dot">·</span>
44
- <span class="sa-fc__tier">{{ tierValue }}</span>
45
- </template>
46
- </div>
47
- </div>
48
-
49
- <div class="sa-fc__coverage">
50
- <span
51
- v-for="lng in targetLocales"
52
- :key="lng"
53
- class="sa-cov-pill"
54
- :class="coverageClass(coverage(lng))"
55
- >
56
- <span>{{ localeShort(lng) }}</span>
57
- <span>{{ coveragePct(coverage(lng)) }}%</span>
58
- </span>
59
62
  </div>
63
+ </template>
60
64
 
65
+ <!-- Outside the trigger, not merely `@click.stop` inside it. The old
66
+ head was a `<div @click>` with this control nested in it, and the
67
+ only thing keeping a status change from also toggling the card was
68
+ a `.stop` somebody remembered. Structure beats memory. -->
69
+ <template #header-actions>
61
70
  <DiscoveryStatusControl
62
71
  :status="feature.discoveryStatus"
63
72
  @set-status="(target) => emit('review', feature.featureKey, target)"
64
73
  />
65
- <q-icon name="chevron_right" class="sa-fc__chev" :class="{ open: expanded }" />
66
- </div>
74
+ </template>
67
75
 
68
- <div v-if="expanded" class="sa-fc__body">
76
+ <div class="sa-fc__body">
69
77
  <div v-if="feature.discoveryStatus === 'outdated'" class="sa-fc__banner warn">
70
78
  <q-icon name="warning" size="16px" />
71
79
  <span>
@@ -140,7 +148,7 @@
140
148
  "
141
149
  />
142
150
  </div>
143
- </div>
151
+ </AdminAccordion>
144
152
  </template>
145
153
 
146
154
  <script setup lang="ts">
@@ -152,6 +160,7 @@ import type {
152
160
  FeatureCatalogEntryRow,
153
161
  UpdateCatalogEntryBaseData,
154
162
  } from '@saasicat/types';
163
+ import AdminAccordion from '../../components/admin-page/AdminAccordion.vue';
155
164
  import CatalogEntryTransPanel from './CatalogEntryTransPanel.vue';
156
165
  import DiscoveryCapList from './DiscoveryCapList.vue';
157
166
  import DiscoveryStatusControl from './DiscoveryStatusControl.vue';
@@ -268,26 +277,16 @@ function coverage(locale: string): number {
268
277
  </script>
269
278
 
270
279
  <style scoped>
271
- .sa-fc {
272
- border: 1px solid var(--sa-color-border);
273
- border-radius: 10px;
274
- background: var(--sa-color-bg-surface);
275
- overflow: hidden;
276
- }
277
- .sa-fc.expanded {
278
- border-color: var(--sa-color-scheduled-border);
279
- box-shadow: 0 1px 3px var(--sa-shadow-tint-2);
280
- }
280
+ /* Surface, radius, open border, head padding, chevron and body come from
281
+ * `AdminAccordion`. What is left is what a FEATURE row puts in that header.
282
+ *
283
+ * These seven rules were byte-identical to `DiscoveryQuotaCard`'s after a
284
+ * prefix rename — jscpd could not see it, because `minTokens: 50` is larger
285
+ * than any single rule here. The duplication was real and below the threshold. */
281
286
  .sa-fc__head {
282
287
  display: flex;
283
288
  align-items: center;
284
- gap: 12px;
285
- padding: 10px 12px;
286
- cursor: pointer;
287
- }
288
- .sa-fc__icon {
289
- color: var(--sa-color-fg-secondary);
290
- flex-shrink: 0;
289
+ gap: var(--sa-space-4);
291
290
  }
292
291
  .sa-fc__main {
293
292
  flex: 1;
@@ -355,18 +354,6 @@ function coverage(locale: string): number {
355
354
  gap: 6px;
356
355
  flex-shrink: 0;
357
356
  }
358
- .sa-fc__chev {
359
- color: var(--sa-color-fg-subtle);
360
- transition: transform 0.15s;
361
- }
362
- .sa-fc__chev.open {
363
- transform: rotate(90deg);
364
- }
365
- .sa-fc__body {
366
- border-top: 1px solid var(--sa-color-border-soft);
367
- padding: 12px;
368
- background: var(--sa-color-bg-sunken);
369
- }
370
357
  .sa-fc__banner {
371
358
  display: flex;
372
359
  align-items: center;
@@ -1,62 +1,76 @@
1
1
  <template>
2
- <div class="sa-qc" :class="{ expanded, warn: !quota.usageProvider }">
3
- <div class="sa-qc__head" @click="emit('toggle')">
4
- <q-icon
5
- :name="quota.usageProvider ? 'inventory_2' : 'error'"
6
- :color="quota.usageProvider ? 'primary' : 'negative'"
7
- size="20px"
8
- class="sa-qc__icon"
9
- />
10
- <div class="sa-qc__main">
11
- <div class="sa-qc__titlerow">
12
- <span class="sa-qc__label">{{ labelValue || quota.quotaKey }}</span>
13
- <code class="sa-qc__key">{{ quota.quotaKey }}</code>
14
- <span class="sa-chip">{{ quota.enforcementMode }}</span>
15
- <span
16
- v-if="quota.successorKey"
17
- class="sa-qc__flag sa-qc__flag--succ"
18
- :title="replacedByLabel"
19
- >
20
- {{ replacedByLabel }}
21
- </span>
2
+ <AdminAccordion
3
+ class="sa-qc"
4
+ :class="{ warn: !quota.usageProvider }"
5
+ :open="expanded"
6
+ :mark-tone="quota.usageProvider ? 'accent' : 'negative'"
7
+ @update:open="emit('toggle')"
8
+ >
9
+ <!-- The glyph already says it; the tone lets the badge say it too. The
10
+ `:color` this replaces was a Quasar PALETTE name, which reaches past
11
+ the role layer — `negative` there is Quasar's, not the theme's. -->
12
+ <template #mark>
13
+ <q-icon :name="quota.usageProvider ? 'inventory_2' : 'error'" size="18px" />
14
+ </template>
15
+
16
+ <template #header>
17
+ <div class="sa-qc__head">
18
+ <div class="sa-qc__main">
19
+ <div class="sa-qc__titlerow">
20
+ <span class="sa-qc__label">{{ labelValue || quota.quotaKey }}</span>
21
+ <code class="sa-qc__key">{{ quota.quotaKey }}</code>
22
+ <span class="sa-chip">{{ quota.enforcementMode }}</span>
23
+ <span
24
+ v-if="quota.successorKey"
25
+ class="sa-qc__flag sa-qc__flag--succ"
26
+ :title="replacedByLabel"
27
+ >
28
+ {{ replacedByLabel }}
29
+ </span>
30
+ <span
31
+ v-if="quota.replaces.length"
32
+ class="sa-qc__flag sa-qc__flag--repl"
33
+ :title="replacesLabel"
34
+ >
35
+ {{ replacesLabel }}
36
+ </span>
37
+ </div>
38
+ <div class="sa-qc__sub">
39
+ {{ msg.unit }} <code>{{ quota.unit }}</code> · {{ msg.quota.usageProvider }}
40
+ <code v-if="quota.usageProvider">{{ quota.usageProvider }}</code>
41
+ <span v-else class="sa-qc__missing">{{
42
+ msg.quota.usageProviderMissing
43
+ }}</span>
44
+ </div>
45
+ <div v-if="!quota.usageProvider" class="sa-qc__warning">
46
+ {{ msg.quota.noUsageProviderWarning }}
47
+ </div>
48
+ </div>
49
+
50
+ <div class="sa-qc__coverage">
22
51
  <span
23
- v-if="quota.replaces.length"
24
- class="sa-qc__flag sa-qc__flag--repl"
25
- :title="replacesLabel"
52
+ v-for="lng in targetLocales"
53
+ :key="lng"
54
+ class="sa-cov-pill"
55
+ :class="coverageClass(coverage(lng))"
26
56
  >
27
- {{ replacesLabel }}
57
+ <span>{{ localeShort(lng) }}</span>
58
+ <span>{{ coveragePct(coverage(lng)) }}%</span>
28
59
  </span>
29
60
  </div>
30
- <div class="sa-qc__sub">
31
- {{ msg.unit }} <code>{{ quota.unit }}</code> · {{ msg.quota.usageProvider }}
32
- <code v-if="quota.usageProvider">{{ quota.usageProvider }}</code>
33
- <span v-else class="sa-qc__missing">{{ msg.quota.usageProviderMissing }}</span>
34
- </div>
35
- <div v-if="!quota.usageProvider" class="sa-qc__warning">
36
- {{ msg.quota.noUsageProviderWarning }}
37
- </div>
38
- </div>
39
-
40
- <div class="sa-qc__coverage">
41
- <span
42
- v-for="lng in targetLocales"
43
- :key="lng"
44
- class="sa-cov-pill"
45
- :class="coverageClass(coverage(lng))"
46
- >
47
- <span>{{ localeShort(lng) }}</span>
48
- <span>{{ coveragePct(coverage(lng)) }}%</span>
49
- </span>
50
61
  </div>
62
+ </template>
51
63
 
64
+ <!-- See DiscoveryFeatureCard: outside the trigger, not `@click.stop`
65
+ inside it. -->
66
+ <template #header-actions>
52
67
  <DiscoveryStatusControl
53
68
  :status="quota.discoveryStatus"
54
69
  @set-status="(target) => emit('review', quota.quotaKey, target)"
55
70
  />
56
- <q-icon name="chevron_right" class="sa-qc__chev" :class="{ open: expanded }" />
57
- </div>
71
+ </template>
58
72
 
59
- <div v-if="expanded" class="sa-qc__body">
73
+ <div class="sa-qc__body">
60
74
  <div v-if="quota.discoveryStatus === 'outdated'" class="sa-qc__banner">
61
75
  <q-icon name="warning" size="16px" />
62
76
  <span>
@@ -74,7 +88,7 @@
74
88
  "
75
89
  />
76
90
  </div>
77
- </div>
91
+ </AdminAccordion>
78
92
  </template>
79
93
 
80
94
  <script setup lang="ts">
@@ -85,6 +99,7 @@ import type {
85
99
  QuotaCatalogEntryRow,
86
100
  UpdateCatalogEntryBaseData,
87
101
  } from '@saasicat/types';
102
+ import AdminAccordion from '../../components/admin-page/AdminAccordion.vue';
88
103
  import CatalogEntryTransPanel from './CatalogEntryTransPanel.vue';
89
104
  import DiscoveryStatusControl from './DiscoveryStatusControl.vue';
90
105
  import { formatMessage } from '../../client/i18n/format.js';
@@ -153,31 +168,22 @@ function coverage(locale: string): number {
153
168
  </script>
154
169
 
155
170
  <style scoped>
156
- .sa-qc {
157
- border: 1px solid var(--sa-color-border);
158
- border-radius: 10px;
159
- background: var(--sa-color-bg-surface);
160
- overflow: hidden;
161
- }
171
+ /* Surface, radius, open border, head padding, chevron and body come from
172
+ * `AdminAccordion`. What is left is what a QUOTA row puts in that header — and
173
+ * `warn`, which is this card's own idea and not the accordion's.
174
+ *
175
+ * Two values changed by moving: the open border was
176
+ * `--sa-color-scheduled-border` plus a shadow here and `--sa-color-accent`
177
+ * everywhere else, and the head padding was 10/12 against 12/14 next door.
178
+ * Those differences were the finding, not the design. */
162
179
  .sa-qc.warn {
163
180
  border-color: var(--sa-color-negative-border);
164
- }
165
- .sa-qc.expanded {
166
- border-color: var(--sa-color-scheduled-border);
167
- box-shadow: 0 1px 3px var(--sa-shadow-tint-2);
181
+ background: var(--sa-color-negative-surface);
168
182
  }
169
183
  .sa-qc__head {
170
184
  display: flex;
171
185
  align-items: center;
172
- gap: 12px;
173
- padding: 10px 12px;
174
- cursor: pointer;
175
- }
176
- .sa-qc.warn .sa-qc__head {
177
- background: var(--sa-color-negative-surface);
178
- }
179
- .sa-qc__icon {
180
- flex-shrink: 0;
186
+ gap: var(--sa-space-4);
181
187
  }
182
188
  .sa-qc__main {
183
189
  flex: 1;
@@ -230,18 +236,6 @@ function coverage(locale: string): number {
230
236
  gap: 6px;
231
237
  flex-shrink: 0;
232
238
  }
233
- .sa-qc__chev {
234
- color: var(--sa-color-fg-subtle);
235
- transition: transform 0.15s;
236
- }
237
- .sa-qc__chev.open {
238
- transform: rotate(90deg);
239
- }
240
- .sa-qc__body {
241
- border-top: 1px solid var(--sa-color-border-soft);
242
- padding: 12px;
243
- background: var(--sa-color-bg-sunken);
244
- }
245
239
  .sa-qc__banner {
246
240
  display: flex;
247
241
  align-items: center;
@@ -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">
@@ -0,0 +1,118 @@
1
+ /* Accordion — a header that opens a body (see AdminAccordion).
2
+ *
3
+ * Every value here was one of several. The survey that led to this file found
4
+ * three header paddings across the package (0, 10/12, 12/14), three body
5
+ * paddings (12, 14, 18/20/22), two radii (8 and 10), two colours for the open
6
+ * border (`accent` and `scheduled-border`), two transition durations, and hover
7
+ * feedback on one of six surfaces that all set `cursor: pointer`.
8
+ *
9
+ * The values below are the scale's, not the average of the six: a rung exists
10
+ * for each, so a literal here would be a fourth opinion rather than a decision.
11
+ */
12
+
13
+ .sa-accordion {
14
+ background: var(--sa-color-bg-surface);
15
+ border: 1px solid var(--sa-color-border);
16
+ border-radius: var(--sa-radius-head);
17
+ overflow: hidden;
18
+ }
19
+
20
+ /* The open card names itself with the accent rather than with a tone: which
21
+ * row is open is a position, not a status, and a status colour here would
22
+ * compete with the chips the header carries. */
23
+ .sa-accordion--open {
24
+ border-color: var(--sa-color-accent);
25
+ }
26
+
27
+ .sa-accordion__head {
28
+ display: flex;
29
+ align-items: stretch;
30
+ }
31
+
32
+ /* The trigger fills the head so the whole row is the target — the affordance
33
+ * the `<div @click>` versions had, without giving up the button. */
34
+ .sa-accordion__trigger {
35
+ flex: 1;
36
+ min-width: 0;
37
+ display: flex;
38
+ align-items: center;
39
+ gap: var(--sa-space-4);
40
+ padding: var(--sa-space-4) var(--sa-space-5);
41
+ background: none;
42
+ border: 0;
43
+ font: inherit;
44
+ color: inherit;
45
+ text-align: left;
46
+ cursor: pointer;
47
+ }
48
+
49
+ /* Hover feedback, which five of the six surfaces promised with `cursor` and
50
+ * only one delivered. */
51
+ .sa-accordion__trigger:hover:not(:disabled) {
52
+ background: var(--sa-color-bg-sunken);
53
+ }
54
+ .sa-accordion__trigger:disabled {
55
+ cursor: not-allowed;
56
+ }
57
+
58
+ /* The badge that identifies the row.
59
+ *
60
+ * Three surfaces drew this three ways — a 34px accent-tinted square, a bare
61
+ * 22px glyph, and a bare 20px glyph coloured through a Quasar palette prop — so
62
+ * the same kind of row did not look like the same kind of row from one page to
63
+ * the next. The frame is the component's; only the glyph is the page's.
64
+ *
65
+ * Square-ish rather than round: a circle here would read as an avatar, and none
66
+ * of these rows is about a person. */
67
+ .sa-accordion__mark {
68
+ flex: 0 0 auto;
69
+ width: 34px;
70
+ height: 34px;
71
+ display: flex;
72
+ align-items: center;
73
+ justify-content: center;
74
+ border-radius: var(--sa-radius-field);
75
+ background: var(--sa-color-accent-surface-strong);
76
+ color: var(--sa-color-accent-strong);
77
+ }
78
+
79
+ /* Only for a row whose state the badge itself should report. The size, radius
80
+ * and position do not move with it — that is what keeps it the same badge. */
81
+ .sa-accordion__mark--negative {
82
+ background: var(--sa-color-negative-surface-strong);
83
+ color: var(--sa-color-negative-fg);
84
+ }
85
+
86
+ .sa-accordion__label {
87
+ flex: 1;
88
+ min-width: 0;
89
+ }
90
+
91
+ /* Points right when closed, down when open. Not `transform: rotate(90deg)` on a
92
+ * `chevron_down`: the resting state is the one a reader sees most, and a glyph
93
+ * that means "there is more this way" is the honest one to rest on. */
94
+ .sa-accordion__chev {
95
+ flex: 0 0 auto;
96
+ color: var(--sa-color-fg-subtle);
97
+ transition: transform 0.15s;
98
+ }
99
+ .sa-accordion--open .sa-accordion__chev {
100
+ transform: rotate(90deg);
101
+ }
102
+
103
+ /* Outside the trigger, so a control here does not toggle the row. Padded on
104
+ * its own rather than by the trigger, or the two would share a hover surface
105
+ * and the row would look pressed while the pointer is on a button that does
106
+ * something else. */
107
+ .sa-accordion__actions {
108
+ display: flex;
109
+ align-items: center;
110
+ gap: var(--sa-space-3);
111
+ padding-right: var(--sa-space-5);
112
+ }
113
+
114
+ .sa-accordion__body {
115
+ padding: var(--sa-space-5);
116
+ border-top: 1px solid var(--sa-color-border);
117
+ background: var(--sa-color-bg-sunken);
118
+ }
@@ -40,3 +40,4 @@
40
40
  @import './components/card.css';
41
41
  @import './components/paginator.css';
42
42
  @import './components/table.css';
43
+ @import './components/accordion.css';
@@ -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