@saasicat/ui-vue 0.24.2 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +32 -8
  2. package/dist/{catalog-DJcfm5N2.d.cts → catalog-Dch1Ryw0.d.cts} +353 -69
  3. package/dist/{catalog-DJcfm5N2.d.ts → catalog-Dch1Ryw0.d.ts} +353 -69
  4. package/dist/{chunk-F7NRM6KI.js → chunk-BPF2BMCQ.js} +748 -24
  5. package/dist/{chunk-D74FFCCP.js → chunk-NEXTZZRQ.js} +95 -177
  6. package/dist/{chunk-TQQMKWN6.js → chunk-O3J3ITF2.js} +119 -7
  7. package/dist/client/index.cjs +758 -83
  8. package/dist/client/index.d.cts +414 -9
  9. package/dist/client/index.d.ts +414 -9
  10. package/dist/client/index.js +84 -12
  11. package/dist/index.cjs +1325 -294
  12. package/dist/index.d.cts +205 -15
  13. package/dist/index.d.ts +205 -15
  14. package/dist/index.js +566 -240
  15. package/dist/quasar/index.cjs +574 -62
  16. package/dist/quasar/index.d.cts +61 -6
  17. package/dist/quasar/index.d.ts +61 -6
  18. package/dist/quasar/index.js +83 -19
  19. package/dist/{use-sa-theme-OUBlaqgl.d.ts → resource-registry-D7Xjs-5f.d.cts} +157 -7
  20. package/dist/{use-sa-theme-B5t7oXph.d.cts → resource-registry-TaR7rtq0.d.ts} +157 -7
  21. package/package.json +4 -3
  22. package/src/client/admin-error.ts +552 -0
  23. package/src/client/admin-resource-client.ts +9 -12
  24. package/src/client/batch-column-fetcher.ts +2 -2
  25. package/src/client/boot-loader.ts +12 -1
  26. package/src/client/http/axios-http-client.ts +383 -0
  27. package/src/client/http/fetch-http-client.ts +89 -0
  28. package/src/client/http/index.ts +6 -0
  29. package/src/client/http-json.ts +82 -16
  30. package/src/client/i18n/messages/discovery.ts +0 -6
  31. package/src/client/i18n/messages/errors.ts +44 -0
  32. package/src/client/i18n/messages/marketing.ts +0 -6
  33. package/src/client/i18n/messages/promos.ts +0 -2
  34. package/src/client/i18n/messages/shell.ts +12 -0
  35. package/src/client/i18n/messages.ts +3 -0
  36. package/src/client/index.ts +6 -0
  37. package/src/client/manifest-loader.ts +65 -15
  38. package/src/client/resources/audit.resource.ts +25 -0
  39. package/src/client/resources/define-resource.ts +166 -0
  40. package/src/client/resources/index.ts +42 -0
  41. package/src/client/resources/list-resource.ts +176 -0
  42. package/src/client/resources/plans.resource.ts +169 -0
  43. package/src/client/resources/resource-request.ts +117 -0
  44. package/src/client/resources/tenants.resource.ts +27 -0
  45. package/src/client/types.ts +10 -3
  46. package/src/components/BundleVersionPublishDialog.vue +1 -1
  47. package/src/components/MarketingPromotionsTab.vue +263 -241
  48. package/src/components/ThemeSwitcher.vue +85 -0
  49. package/src/components/admin-page/AdminAccordion.vue +125 -0
  50. package/src/components/bundle-editor/BundleCreatePanel.vue +1 -1
  51. package/src/components/dialogs/PromoCodeDialogFields.vue +20 -20
  52. package/src/components/plan/PlanCycleToggle.vue +1 -1
  53. package/src/components/plan-detail/PlanVersionsPanel.vue +5 -2
  54. package/src/index.ts +5 -0
  55. package/src/pages-standard/AdminLayout.vue +57 -0
  56. package/src/pages-standard/AdminManifestErrorPage.vue +4 -3
  57. package/src/pages-standard/BundlesPage.vue +5 -35
  58. package/src/pages-standard/DiscoveryPage.vue +8 -1
  59. package/src/pages-standard/MarketingCatalogPage.vue +14 -3
  60. package/src/pages-standard/SuperAdminLoginPage.vue +23 -2
  61. package/src/pages-standard/SuperAdminSetupWizard.vue +37 -3
  62. package/src/pages-standard/bundles-page/BundleAccordionList.vue +30 -32
  63. package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +80 -88
  64. package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +73 -79
  65. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +44 -6
  66. package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +8 -2
  67. package/src/pages-tenant/PackageSnapshotPanel.vue +31 -2
  68. package/src/quasar/confirm.ts +83 -0
  69. package/src/quasar/create-super-admin-app.ts +84 -10
  70. package/src/quasar/dark-bridge.ts +47 -10
  71. package/src/quasar/index.ts +1 -0
  72. package/src/ui/theme/components/accordion.css +118 -0
  73. package/src/ui/theme/index.css +1 -0
  74. package/src/vue/create-admin-routes.ts +9 -1
  75. package/src/vue/platform-loaders.ts +12 -2
  76. package/src/vue/resource-registry.ts +211 -0
  77. package/src/vue/super-admin-context.ts +20 -5
  78. package/src/vue/ui-confirm.ts +76 -0
  79. package/src/vue/use-api-list.ts +50 -45
  80. package/src/vue/use-async-action.ts +170 -0
  81. package/src/vue/use-async-data.ts +83 -0
  82. package/src/vue/use-bundles.ts +34 -7
  83. package/src/vue/use-catalog-entries.ts +37 -12
  84. package/src/vue/use-discovery.ts +27 -6
  85. package/src/vue/use-marketing-projections.ts +29 -7
  86. package/src/vue/use-plans.ts +36 -8
  87. package/src/vue/use-promotions.ts +27 -7
  88. package/src/vue/use-resource-list.ts +272 -0
  89. package/src/vue/use-sa-theme.ts +34 -5
  90. package/src/vue/use-tenant-subscription-bundles.ts +22 -3
@@ -132,11 +132,9 @@
132
132
  v-if="row.liveVersion"
133
133
  type="button"
134
134
  class="sa-marketing-expand-btn"
135
- :title="
136
- expandedKey === row.plan.planKey
137
- ? common.close
138
- : msg.admin.expandTitle
139
- "
135
+ :title="msg.admin.expandTitle"
136
+ :aria-expanded="expandedKey === row.plan.planKey"
137
+ :aria-controls="panelId(row)"
140
138
  @click="$emit('toggle-expand', row)"
141
139
  >
142
140
  <span
@@ -158,7 +156,11 @@
158
156
  </div>
159
157
  </div>
160
158
 
161
- <div v-if="expandedKey === row.plan.planKey" class="sa-marketing-admin-expand">
159
+ <div
160
+ v-if="expandedKey === row.plan.planKey"
161
+ :id="panelId(row)"
162
+ class="sa-marketing-admin-expand"
163
+ >
162
164
  <div class="sa-marketing-expand-grid">
163
165
  <div class="sa-marketing-expand-col">
164
166
  <div class="sa-marketing-expand-sec">
@@ -457,6 +459,7 @@
457
459
  </template>
458
460
 
459
461
  <script setup lang="ts">
462
+ import { useId } from 'vue';
460
463
  import type { MarketingTopFeature, PlanRow, PlanVersionRow } from '@saasicat/types';
461
464
  import { identityChipStyle } from '../../client/identity-accents.js';
462
465
  import { formatMessage } from '../../client/i18n/format.js';
@@ -495,6 +498,41 @@ defineEmits<{
495
498
  const msg = useSaMessages('marketing');
496
499
  const common = useSaMessages('common');
497
500
 
501
+ // sa-disclosure-exempt(writes `aria-expanded`):
502
+ // this row is six grid cells, not a header that opens a body
503
+ //
504
+ // Every other disclosure in the package is an `AdminAccordion`. This one cannot
505
+ // be, and the obstacle is structural rather than a matter of taste.
506
+ //
507
+ // The rows here are `display: contents` inside a six-column grid, and the open
508
+ // editor is a sibling at `grid-column: 1 / -1` (both declared in
509
+ // `MarketingCatalogPage.vue`). A self-contained wrapper around either would put
510
+ // a box between the grid and its cells, and the columns would stop lining up.
511
+ // A wrapper-less `AdminAccordion` variant would not rescue it either: the
512
+ // component's recipe is a trigger that FILLS the header, and this header is six
513
+ // cells carrying two checkboxes and two text inputs — interactive content
514
+ // nested in a `<button>` is not valid HTML. What would be left of the component
515
+ // after removing its wrapper, its full-width trigger, its badge and its body
516
+ // padding is a second component wearing the first one's name.
517
+ //
518
+ // So the disclosure stays here, and what it takes from the shared one is the
519
+ // part that has nothing to do with layout: the WAI-ARIA disclosure pattern —
520
+ // a `<button>` (it already was one) that says whether it is expanded and which
521
+ // element it controls. Not the `role="region"` half of `AdminAccordion`'s body:
522
+ // its trigger is named by the row it opens, whereas this one is a bare chevron,
523
+ // and a screenful of regions all named "Edit teaser, trial & top features"
524
+ // would be worse than none.
525
+ //
526
+ // The title no longer flips to "Close" when open. `aria-expanded` says that
527
+ // now, and a control whose NAME changes with its state is announced as a
528
+ // different control each time.
529
+ const uid = useId();
530
+
531
+ /** The panel a row's chevron controls — unique per row, and per instance of this list. */
532
+ function panelId(row: MarketingRow): string {
533
+ return `${uid}-marketing-panel-${row.plan.id}`;
534
+ }
535
+
498
536
  /** Hint below the trial toggle — shows the auto CTA text for the set trial days. */
499
537
  function trialCtaHint(row: MarketingRow): string {
500
538
  const cta = formatMessage(msg.value.cta.trial, { days: row.m.trialDays });
@@ -64,12 +64,18 @@
64
64
 
65
65
  <div class="sa-marketing-card-price">
66
66
  <template v-if="row.m.priceTag">
67
- <span class="sa-marketing-card-price-big" style="font-size: 22px">
67
+ <span
68
+ class="sa-marketing-card-price-big"
69
+ style="font-size: var(--sa-text-2xl)"
70
+ >
68
71
  {{ row.m.priceTag }}
69
72
  </span>
70
73
  </template>
71
74
  <template v-else-if="!row.liveVersion">
72
- <span class="sa-marketing-card-price-big" style="font-size: 22px">
75
+ <span
76
+ class="sa-marketing-card-price-big"
77
+ style="font-size: var(--sa-text-2xl)"
78
+ >
73
79
  {{ msg.preview.priceOnRequest }}
74
80
  </span>
75
81
  </template>
@@ -95,16 +95,20 @@
95
95
  color="primary"
96
96
  :label="showRaw ? i18n.packageSnapshotHideRaw : i18n.packageSnapshotShowRaw"
97
97
  :icon="showRaw ? 'expand_less' : 'expand_more'"
98
+ :aria-expanded="showRaw"
99
+ :aria-controls="rawId"
98
100
  @click="showRaw = !showRaw"
99
101
  />
100
- <pre v-if="showRaw" class="sp-package-snapshot__raw-body">{{ rawJson }}</pre>
102
+ <pre v-if="showRaw" :id="rawId" class="sp-package-snapshot__raw-body">{{
103
+ rawJson
104
+ }}</pre>
101
105
  </div>
102
106
  </q-card-section>
103
107
  </q-card>
104
108
  </template>
105
109
 
106
110
  <script setup lang="ts">
107
- import { computed, ref } from 'vue';
111
+ import { computed, ref, useId } from 'vue';
108
112
  import type { PackageSnapshotShape } from '../vue/use-tenant-billing.js';
109
113
  import type { TenantPlanSectionI18n } from './default-i18n.js';
110
114
 
@@ -125,7 +129,32 @@ interface Props {
125
129
 
126
130
  const props = defineProps<Props>();
127
131
 
132
+ // sa-disclosure-exempt(toggles `showRaw`, writes `aria-expanded`):
133
+ // a tenant-facing raw payload toggle, in a directory that is leaving
134
+ //
135
+ // The package has one disclosure, `AdminAccordion`, and every surface inside
136
+ // the admin uses it. This one is outside the admin twice over.
137
+ //
138
+ // It is outside by AUDIENCE: `pages-tenant/*` renders inside the customer's own
139
+ // application, for the paying tenant rather than the operator, and is planned
140
+ // to ship as its own package with its own translations. Reaching into the
141
+ // admin's component layer from here adds a dependency that the split would
142
+ // have to unpick, for a card that is already built from Quasar's own.
143
+ //
144
+ // It is outside by SHAPE: `AdminAccordion` is a row in a list whose header
145
+ // opens its body — the design guide calls the pattern "rows that open". This is
146
+ // a `q-card` with a `<pre>` of raw JSON behind a link-styled button at its foot,
147
+ // and wrapping that in an accordion card would seat a bordered card inside a
148
+ // bordered card to say "this opens".
149
+ //
150
+ // What it does take from the shared one is the half that is not layout. A
151
+ // `q-btn` was already a real `<button>` with a label — the accessibility gap
152
+ // here was never the keyboard, only that nothing said the button expands
153
+ // something or what it expands. `role="region"` is deliberately not copied: the
154
+ // accordion's body is named by a trigger that carries the row's title, and this
155
+ // trigger's name is "Show raw JSON".
128
156
  const showRaw = ref(false);
157
+ const rawId = useId();
129
158
 
130
159
  const capturedAtIso = computed(() => {
131
160
  if (!props.snapshot) return null;
@@ -0,0 +1,83 @@
1
+ // Quasar implementation of the UI confirm port.
2
+ //
3
+ // The port is wired but not yet consumed by this package's own pages.
4
+ // `createSuperAdminApp()` provides it under `SUPER_ADMIN_CONFIRM_KEY` and
5
+ // `useSuperAdminConfirm()` below resolves it — but nothing under `src/` calls
6
+ // that hook. Its only call site in the repository is
7
+ // `tests-component/ui-confirm-port.test.ts`.
8
+ //
9
+ // The pages that do ask a yes/no question ask it in one of three other ways:
10
+ // Quasar's `q.dialog()`, `window.confirm()`, or a dialog the page mounts
11
+ // itself. Which page uses which shape moves as pages are reworked, so the
12
+ // pages are not listed here — grepping those three shapes under
13
+ // `src/pages-standard/` and `src/pages-tenant/` finds every one of them.
14
+ //
15
+ // Passing `createSuperAdminApp({ confirm })` therefore registers an
16
+ // implementation and nothing more. It reaches exactly those pages that ask
17
+ // through the port — an app's own, once they call `useSuperAdminConfirm()` or
18
+ // inject `SUPER_ADMIN_CONFIRM_KEY` themselves — and none of this package's. By
19
+ // the call-site note above, no page in this repository does either, the example
20
+ // app included, so an integrator who passes the option and changes nothing else
21
+ // replaces no confirmation at all. Moving the pages onto the hook is the page
22
+ // migration; the "Confirm port" section of this package's README carries the
23
+ // same caveat for integrators.
24
+ //
25
+ // The injection default keeps a page working that is mounted without
26
+ // `createSuperAdminApp()` — and it is only safe as a default because it still
27
+ // asks the operator. A default that resolved `{ ok: true }` would silently
28
+ // approve every delete, revoke and deactivation.
29
+
30
+ import { inject } from 'vue';
31
+ import { Dialog, type QDialogOptions } from 'quasar';
32
+
33
+ import {
34
+ SUPER_ADMIN_CONFIRM_KEY,
35
+ type UiConfirm,
36
+ type UiConfirmRequest,
37
+ type UiConfirmResult,
38
+ } from '../vue/ui-confirm.js';
39
+
40
+ /**
41
+ * Translates a request into Quasar's dialog options.
42
+ *
43
+ * Exported because it is the whole of the mapping and the only part of this
44
+ * file that can be checked without driving a real dialog.
45
+ */
46
+ export function quasarConfirmOptions(request: UiConfirmRequest): QDialogOptions {
47
+ return {
48
+ title: request.title,
49
+ message: request.message,
50
+ cancel: request.cancelLabel,
51
+ ok: { label: request.confirmLabel, color: request.tone ?? 'primary' },
52
+ ...(request.prompt
53
+ ? {
54
+ prompt: {
55
+ model: request.prompt.initial ?? '',
56
+ type: request.prompt.type ?? 'text',
57
+ },
58
+ }
59
+ : {}),
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Quasar-backed confirm port.
65
+ *
66
+ * `onOk` and `onCancel` are mutually exclusive and one of them always runs —
67
+ * dismissing with Escape or a backdrop click reaches `onCancel` — so the
68
+ * promise cannot be left hanging.
69
+ */
70
+ export const quasarConfirm: UiConfirm = (request) =>
71
+ new Promise<UiConfirmResult>((resolve) => {
72
+ Dialog.create(quasarConfirmOptions(request))
73
+ .onOk((value: unknown) => {
74
+ // Without a `prompt` Quasar calls back with no argument at all.
75
+ resolve({ ok: true, value: typeof value === 'string' ? value : undefined });
76
+ })
77
+ .onCancel(() => resolve({ ok: false }));
78
+ });
79
+
80
+ /** Returns the app-provided confirm port, falling back to the Quasar default. */
81
+ export function useSuperAdminConfirm(): UiConfirm {
82
+ return inject(SUPER_ADMIN_CONFIRM_KEY, quasarConfirm);
83
+ }
@@ -44,6 +44,13 @@ import {
44
44
  type SuperAdminLoginAdapter,
45
45
  } from '../vue/super-admin-context.js';
46
46
  import { SUPER_ADMIN_NOTIFY_KEY, type UiNotify } from '../vue/ui-notify.js';
47
+ import { SUPER_ADMIN_CONFIRM_KEY, type UiConfirm } from '../vue/ui-confirm.js';
48
+ import {
49
+ SUPER_ADMIN_RESOURCES_KEY,
50
+ createResourceRegistry,
51
+ type ResourceOverrides,
52
+ } from '../vue/resource-registry.js';
53
+ import { platformResources } from '../client/resources/index.js';
47
54
  import {
48
55
  SA_THEME_KEY,
49
56
  createSaTheme,
@@ -57,7 +64,9 @@ import {
57
64
  type SuperAdminI18nOptions,
58
65
  } from '../vue/use-super-admin-i18n.js';
59
66
  import { bindSaThemeToDocument } from './dark-bridge.js';
67
+ import { resolveSuperAdminEndpoints } from '../vue/platform-loaders.js';
60
68
  import { quasarNotify } from './notify.js';
69
+ import { quasarConfirm } from './confirm.js';
61
70
 
62
71
  export interface CreateSuperAdminAppOptions extends SuperAdminGuardOptions {
63
72
  /** App root component (`App.vue`). */
@@ -100,6 +109,22 @@ export interface CreateSuperAdminAppOptions extends SuperAdminGuardOptions {
100
109
  * center replace it here without touching the pages.
101
110
  */
102
111
  notify?: UiNotify;
112
+ /**
113
+ * Optional: confirm port for the standard pages' "are you sure" step.
114
+ * Default is the Quasar `Dialog` implementation — apps with their own
115
+ * modal system replace it here without touching the pages.
116
+ *
117
+ * An implementation must actually ask. Resolving `{ ok: true }` outright
118
+ * turns every guarded action into an unguarded one.
119
+ */
120
+ confirm?: UiConfirm;
121
+ /**
122
+ * Optional: per-resource adjustments to the platform's own endpoint
123
+ * definitions — a different path for one resource, a different transport,
124
+ * or a single operation wrapped. Everything not named keeps the platform
125
+ * implementation.
126
+ */
127
+ resourceOverrides?: ResourceOverrides<typeof platformResources>;
103
128
  /**
104
129
  * Optional: additional Vue plugins (e.g. an app's own NotificationCenter)
105
130
  * that are installed after the platform setup, before the mount.
@@ -123,7 +148,9 @@ export interface CreateSuperAdminAppOptions extends SuperAdminGuardOptions {
123
148
  * Optional: colour scheme. Default follows the operating system and
124
149
  * remembers an explicit pick. The context is returned on the handle
125
150
  * (`handle.theme`) — set `handle.theme.scheme.value = 'dark'` to switch at
126
- * runtime, and read it anywhere via `useSaTheme()`.
151
+ * runtime, and read it anywhere via `useSaTheme()`. The shell chrome
152
+ * renders a three-way switcher for it; `theme: { switcher: false }` drops
153
+ * it, as `i18n: { switcher: false }` drops the language one.
127
154
  *
128
155
  * Whatever it resolves to is mirrored onto `<html data-sa-theme>` AND into
129
156
  * Quasar's `Dark`, so the platform's surfaces and Quasar's own components
@@ -166,6 +193,31 @@ const DEFAULT_QUASAR_OPTIONS: QuasarPluginOptions = {
166
193
  config: { notify: { position: 'top-right', timeout: 3000 } },
167
194
  };
168
195
 
196
+ /**
197
+ * Merges an app's Quasar options over the platform's, keeping the plugins the
198
+ * shell's own ports depend on.
199
+ *
200
+ * The bootstrap provides `quasarConfirm`, which calls `Dialog.create`, and the
201
+ * notify port's fallback calls `Notify.create`. Taking the app's options whole
202
+ * — as this did — left those uninstalled for anything passing
203
+ * `{ plugins: {}, … }`, a shape this package's own tests use: the first
204
+ * confirmation threw instead of asking, turning a guarded action into a broken
205
+ * one rather than a stricter one.
206
+ *
207
+ * An app's own entry still wins on a key collision, so its build of a plugin
208
+ * replaces the platform's.
209
+ *
210
+ * Exported because it is the whole of the decision, and the only part of the
211
+ * bootstrap that can be checked without driving a real Quasar install.
212
+ */
213
+ export function resolveQuasarOptions(own?: Partial<QuasarPluginOptions>): QuasarPluginOptions {
214
+ if (!own) return DEFAULT_QUASAR_OPTIONS;
215
+ return {
216
+ ...own,
217
+ plugins: { ...DEFAULT_QUASAR_OPTIONS.plugins, ...own.plugins },
218
+ } as QuasarPluginOptions;
219
+ }
220
+
169
221
  /**
170
222
  * Universal bootstrap function for SuperAdmin apps. Replaces the `main.ts`
171
223
  * boilerplate duplicated per app today (Quasar + Pinia + Router + manifest
@@ -188,7 +240,7 @@ export function createSuperAdminApp(options: CreateSuperAdminAppOptions): SuperA
188
240
  // theme already repaints its cards via `.sa-page`. What the class buys is
189
241
  // that an app which never calls `createSuperAdminApp` stays untouched —
190
242
  // which a bare `.q-dialog .q-card` rule could not promise.
191
- const quasarOptions = options.quasarOptions ?? DEFAULT_QUASAR_OPTIONS;
243
+ const quasarOptions = resolveQuasarOptions(options.quasarOptions);
192
244
  // `globalNodes` is a documented Quasar config option that its TypeScript
193
245
  // types do not declare (see quasar/src/utils/private.config/nodes.js, which
194
246
  // reads `globalConfig.globalNodes.class`). The cast is the exception this
@@ -220,13 +272,8 @@ export function createSuperAdminApp(options: CreateSuperAdminAppOptions): SuperA
220
272
 
221
273
  app.use(router);
222
274
 
223
- const endpoints: Required<SuperAdminEndpoints> = {
224
- apiBase: options.endpoints.apiBase,
225
- publicBootEndpoint:
226
- options.endpoints.publicBootEndpoint ?? `${options.endpoints.apiBase}/boot`,
227
- manifestEndpoint:
228
- options.endpoints.manifestEndpoint ?? `${options.endpoints.apiBase}/manifest`,
229
- };
275
+ const endpoints = resolveSuperAdminEndpoints(options.endpoints);
276
+ const http = options.http ?? defaultHttpClient();
230
277
 
231
278
  const i18n = createSuperAdminI18n(options.i18n);
232
279
  // An app that configured Quasar's dark mode has STATED a preference; the
@@ -261,6 +308,33 @@ export function createSuperAdminApp(options: CreateSuperAdminAppOptions): SuperA
261
308
  app.provide(SUPER_ADMIN_EXTENSIONS_KEY, options.extensions ?? {});
262
309
  app.provide(SUPER_ADMIN_ACTIONS_KEY, options.actions ?? {});
263
310
  app.provide(SUPER_ADMIN_NOTIFY_KEY, options.notify ?? quasarNotify);
311
+ app.provide(SUPER_ADMIN_CONFIRM_KEY, options.confirm ?? quasarConfirm);
312
+ // Only when the app named its client. `createResourceRegistry` refuses to
313
+ // be built without one, because a bare `fetch` sends every request without
314
+ // the app's Authorization header and the failure is silent. Handing it the
315
+ // `defaultHttpClient()` fallback here would have defeated exactly that
316
+ // guarantee from inside the bootstrap: existing pages would keep working
317
+ // through their own `getAuthToken` options while anything reaching for
318
+ // `useResource()` collected 401s.
319
+ //
320
+ // Without a client the registry is simply absent, and `useResource` says
321
+ // so — a named failure at the first call beats a page that renders empty.
322
+ if (options.http) {
323
+ app.provide(
324
+ SUPER_ADMIN_RESOURCES_KEY,
325
+ createResourceRegistry({
326
+ http: options.http,
327
+ // Read per call: the locale changes while the app runs.
328
+ context: () => ({
329
+ apiBase: endpoints.apiBase,
330
+ projectKey: endpoints.projectKey,
331
+ locale: i18n.locale.value,
332
+ }),
333
+ resources: platformResources,
334
+ overrides: options.resourceOverrides,
335
+ }),
336
+ );
337
+ }
264
338
  if (options.manifestGuard?.getManifest) {
265
339
  app.provide(SUPER_ADMIN_MANIFEST_KEY, options.manifestGuard.getManifest);
266
340
  }
@@ -270,7 +344,7 @@ export function createSuperAdminApp(options: CreateSuperAdminAppOptions): SuperA
270
344
  if (options.loginAdapter) {
271
345
  app.provide(SUPER_ADMIN_LOGIN_ADAPTER_KEY, options.loginAdapter);
272
346
  }
273
- app.provide(SUPER_ADMIN_HTTP_KEY, options.http ?? defaultHttpClient());
347
+ app.provide(SUPER_ADMIN_HTTP_KEY, http);
274
348
 
275
349
  for (const plugin of options.installPlugins ?? []) {
276
350
  plugin(app);
@@ -14,7 +14,7 @@
14
14
  import { Dark } from 'quasar';
15
15
  import { watch, type WatchStopHandle } from 'vue';
16
16
 
17
- import type { SaTheme } from '../vue/use-sa-theme.js';
17
+ import type { SaColorScheme, SaTheme } from '../vue/use-sa-theme.js';
18
18
 
19
19
  /**
20
20
  * Mirrors a theme context onto the document and Quasar. Returns the stop
@@ -28,16 +28,27 @@ import type { SaTheme } from '../vue/use-sa-theme.js';
28
28
  * says is written to Quasar, including `Dark.set(false)`. So a caller that
29
29
  * bootstraps with Quasar already dark has to say so when it builds the theme,
30
30
  * or its own choice is erased one line after it was applied.
31
- * `createSuperAdminApp` does exactly that, seeding from `Dark.isActive`.
31
+ * `createSuperAdminApp` does exactly that, seeding the scheme from the
32
+ * `quasarOptions.config.dark` it was handed.
32
33
  */
33
34
  export function bindSaThemeToDocument(theme: SaTheme): WatchStopHandle {
35
+ // Raised only while this bridge is inside `Dark.set()`. It is what the
36
+ // write-back below reads to tell the bridge's own echo apart from a
37
+ // decision the application made — see the comment on that watcher.
38
+ let mirroring = false;
39
+
34
40
  const toQuasar = watch(
35
41
  theme.resolved,
36
42
  (resolved) => {
37
43
  if (typeof document !== 'undefined') {
38
44
  document.documentElement.setAttribute('data-sa-theme', resolved);
39
45
  }
40
- Dark.set(resolved === 'dark');
46
+ mirroring = true;
47
+ try {
48
+ Dark.set(resolved === 'dark');
49
+ } finally {
50
+ mirroring = false;
51
+ }
41
52
  },
42
53
  { immediate: true },
43
54
  );
@@ -48,15 +59,41 @@ export function bindSaThemeToDocument(theme: SaTheme): WatchStopHandle {
48
59
  // the half-dark screen the bridge exists to prevent, arriving from the
49
60
  // other direction.
50
61
  //
51
- // No loop: the write-back only fires when Quasar and the theme actually
52
- // disagree, and setting the scheme drives `resolved` to the value Quasar
53
- // already has, so the outbound watcher's `Dark.set` is a no-op.
62
+ // `Dark.mode` rather than `Dark.isActive`, because the mode has the three
63
+ // states the scheme has and the flag has two. `Dark.set('auto')` is
64
+ // Quasar's spelling of 'system', and read through the flag it arrives as
65
+ // whatever the machine happens to say at that moment — so an operator who
66
+ // picked 'system' had it silently frozen into a hard 'light' or 'dark' by
67
+ // an app doing nothing wrong, and the tab stopped following the OS. The
68
+ // seed in `createSuperAdminApp` reads the same option for the same reason.
69
+ //
70
+ // No loop, and the question that closes it is "who wrote this?", not "does
71
+ // it look different?". The outbound leg mirrors 'system' into Quasar as the
72
+ // colour it currently resolves to, so a colour comparison cannot tell that
73
+ // echo apart from an application naming the same colour on purpose: moving
74
+ // a control from `set('auto')` to `set(true)` on a dark machine changes
75
+ // `Dark.mode` while the resolved colour stays 'dark'. Dropping that write
76
+ // left the pick on 'system' with nothing visibly wrong — until the next OS
77
+ // change moved a theme somebody had just fixed.
78
+ //
79
+ // `Dark.set()` assigns `Dark.mode` synchronously and this watcher runs on
80
+ // the sync flush, so the flag is still raised when the bridge's own write
81
+ // arrives here, and is down for every write that came from anywhere else.
82
+ //
83
+ // One case stays unanswerable, because Quasar leaves no trace of it: a
84
+ // `set(true)` while the mode is already `true` — an operator on 'system'
85
+ // and a dark machine, whose application then pins dark. Nothing in `Dark`
86
+ // changes, so there is no event to read and the pick stays 'system'. An
87
+ // application that wants that pick to stick sets `theme.scheme` instead.
54
88
  const fromQuasar = watch(
55
- () => Dark.isActive,
56
- (isDark) => {
57
- const asScheme = isDark ? 'dark' : 'light';
58
- if (theme.resolved.value !== asScheme) theme.scheme.value = asScheme;
89
+ () => Dark.mode,
90
+ (mode) => {
91
+ if (mirroring) return;
92
+ const picked: SaColorScheme =
93
+ mode === 'auto' ? 'system' : mode === true ? 'dark' : 'light';
94
+ if (theme.scheme.value !== picked) theme.scheme.value = picked;
59
95
  },
96
+ { flush: 'sync' },
60
97
  );
61
98
 
62
99
  return () => {
@@ -10,3 +10,4 @@
10
10
  export * from './create-super-admin-app.js';
11
11
  export * from './dark-bridge.js';
12
12
  export * from './notify.js';
13
+ export * from './confirm.js';
@@ -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';
@@ -17,7 +17,15 @@ export interface CreateAdminRoutesOptions {
17
17
  /**
18
18
  * Loader for the shared login page. The app passes it through as a
19
19
  * package-path import (resolved by the app bundler):
20
- * `() => import('@saasicat/ui-vue/pages-standard/SuperAdminLoginPage.vue')`.
20
+ * `() => import('@saasicat/ui-vue/pages/SuperAdminLoginPage.vue')`.
21
+ *
22
+ * That subpath has two spellings today: the export map maps both
23
+ * `./pages/*` and `./pages-standard/*` onto `src/pages-standard/`, so the
24
+ * two imports are the same file. The scaffolder template
25
+ * (`create-saasicat-admin`) and `examples/notesapp` spell it `/pages/*`,
26
+ * which is why the example above does; the package README lists the SFC
27
+ * entries under `/pages-standard/*`. Collapsing the pair is a breaking
28
+ * change to the export map and is deliberately not done here.
21
29
  */
22
30
  loginPage: RouteComponent;
23
31
  /** Layout component for `/admin` (e.g. `() => import('@/layouts/AdminLayout.vue')`). */
@@ -36,11 +36,21 @@ export interface PlatformLoaders {
36
36
  manifestLoader: ManifestLoader;
37
37
  }
38
38
 
39
- function resolveEndpoints(endpoints: SuperAdminEndpoints): Required<SuperAdminEndpoints> {
39
+ /**
40
+ * Fills in what an app left out.
41
+ *
42
+ * Exported because the bootstrap needs the same answer, and two copies of a
43
+ * default are two places for it to drift — which is how `projectKey` was
44
+ * added to one of them and not the other.
45
+ */
46
+ export function resolveSuperAdminEndpoints(
47
+ endpoints: SuperAdminEndpoints,
48
+ ): Required<SuperAdminEndpoints> {
40
49
  return {
41
50
  apiBase: endpoints.apiBase,
42
51
  publicBootEndpoint: endpoints.publicBootEndpoint ?? `${endpoints.apiBase}/boot`,
43
52
  manifestEndpoint: endpoints.manifestEndpoint ?? `${endpoints.apiBase}/manifest`,
53
+ projectKey: endpoints.projectKey ?? '',
44
54
  };
45
55
  }
46
56
 
@@ -49,7 +59,7 @@ function resolveEndpoints(endpoints: SuperAdminEndpoints): Required<SuperAdminEn
49
59
  * Apps use the same constant for `createSuperAdminApp({ endpoints })`.
50
60
  */
51
61
  export function createPlatformLoaders(options: CreatePlatformLoadersOptions): PlatformLoaders {
52
- const resolved = resolveEndpoints(options.endpoints);
62
+ const resolved = resolveSuperAdminEndpoints(options.endpoints);
53
63
 
54
64
  const bootLoader = new BootLoader({
55
65
  endpoint: resolved.publicBootEndpoint,