@saasicat/ui-vue 0.22.2 → 0.24.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 (119) hide show
  1. package/README.md +1 -1
  2. package/dist/{chunk-LY52T4N4.js → chunk-TQQMKWN6.js} +58 -1
  3. package/dist/{chunk-XMMFN2WJ.js → chunk-VYMTWCTT.js} +51 -1
  4. package/dist/client/index.cjs +57 -0
  5. package/dist/client/index.d.cts +58 -1
  6. package/dist/client/index.d.ts +58 -1
  7. package/dist/client/index.js +15 -1
  8. package/dist/index.cjs +321 -159
  9. package/dist/index.d.cts +67 -4
  10. package/dist/index.d.ts +67 -4
  11. package/dist/index.js +72 -2
  12. package/dist/quasar/index.cjs +116 -20
  13. package/dist/quasar/index.d.cts +48 -3
  14. package/dist/quasar/index.d.ts +48 -3
  15. package/dist/quasar/index.js +58 -3
  16. package/dist/{use-super-admin-i18n-CgTuNi8h.d.cts → use-sa-theme-B5t7oXph.d.cts} +51 -1
  17. package/dist/{use-super-admin-i18n-CHNdsSlm.d.ts → use-sa-theme-OUBlaqgl.d.ts} +51 -1
  18. package/package.json +5 -2
  19. package/src/client/identity-accents.ts +129 -0
  20. package/src/client/index.ts +1 -0
  21. package/src/components/BundleVersionPublishDialog.vue +17 -17
  22. package/src/components/KvBlock.vue +6 -6
  23. package/src/components/MarketingPromotionsTab.vue +84 -80
  24. package/src/components/MfaPromptDialog.vue +2 -2
  25. package/src/components/TenantActionConfirmDialog.vue +5 -5
  26. package/src/components/VersionDiffPreview.vue +18 -18
  27. package/src/components/bundle-editor/BundleCreatePanel.vue +52 -52
  28. package/src/components/bundle-editor/BundleFeaturesEditor.vue +20 -20
  29. package/src/components/bundle-editor/BundlePlanCompatPicker.vue +33 -33
  30. package/src/components/bundle-editor/BundleQuotasEditor.vue +26 -26
  31. package/src/components/bundle-editor/BundleStatusBanner.vue +21 -21
  32. package/src/components/bundle-editor/BundleVersionInlineEditor.vue +49 -49
  33. package/src/components/bundle-editor/BundleVersionStrip.vue +30 -30
  34. package/src/components/dialogs/PilotCreateDialog.vue +20 -20
  35. package/src/components/dialogs/PilotEditDialog.vue +6 -6
  36. package/src/components/dialogs/PromoCodeCreateDialog.vue +9 -9
  37. package/src/components/dialogs/PromoCodeDialogFields.vue +64 -63
  38. package/src/components/dialogs/PromoCodeEditDialog.vue +11 -11
  39. package/src/components/dialogs/pilot-dialog.css +48 -48
  40. package/src/components/plan/PlanCycleToggle.vue +9 -16
  41. package/src/components/plan/PlanGrid.vue +30 -34
  42. package/src/components/plan/PriceSummary.vue +35 -35
  43. package/src/components/plan/PromoCodeInput.vue +26 -26
  44. package/src/components/plan/PublicBundleGrid.vue +32 -28
  45. package/src/components/plan-create-dialog/PlanCreateDialog.vue +50 -50
  46. package/src/components/plan-detail/PlanDetail.vue +178 -157
  47. package/src/components/plan-detail/PlanTitleEdit.vue +11 -11
  48. package/src/components/plan-list/PlanList.vue +116 -100
  49. package/src/components/plan-matrix/PlanMatrix.vue +97 -114
  50. package/src/components/plan-review/PlanReview.vue +61 -57
  51. package/src/components/plan-version-editor/PlanVersionEditor.vue +246 -203
  52. package/src/components/wizard-stepper/WizardStepper.vue +17 -17
  53. package/src/index.ts +1 -0
  54. package/src/pages-standard/AdminLayout.vue +58 -37
  55. package/src/pages-standard/AdminManifestErrorPage.vue +6 -6
  56. package/src/pages-standard/AuditPage.vue +3 -3
  57. package/src/pages-standard/BundlesPage.vue +37 -37
  58. package/src/pages-standard/DashboardPage.vue +22 -19
  59. package/src/pages-standard/DiscoveryPage.vue +55 -49
  60. package/src/pages-standard/EmailHistoryPage.vue +53 -71
  61. package/src/pages-standard/MarketingCatalogPage.vue +245 -221
  62. package/src/pages-standard/PilotsPage.vue +7 -6
  63. package/src/pages-standard/PlatformEmailPage.vue +18 -18
  64. package/src/pages-standard/PromoCodeDetailPage.vue +3 -3
  65. package/src/pages-standard/PromoCodesPage.vue +10 -7
  66. package/src/pages-standard/SuperAdminLoginPage.vue +39 -25
  67. package/src/pages-standard/SuperAdminSetupWizard.vue +44 -33
  68. package/src/pages-standard/TenantDetailPage.vue +7 -7
  69. package/src/pages-standard/TenantsPage.vue +16 -50
  70. package/src/pages-standard/bundles-page/BundleAccordionList.vue +13 -13
  71. package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +24 -24
  72. package/src/pages-standard/discovery-page/DiscoveryCapList.vue +22 -22
  73. package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +39 -39
  74. package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +26 -26
  75. package/src/pages-standard/discovery-page/DiscoveryStatusControl.vue +1 -1
  76. package/src/pages-standard/discovery-page/discovery-ui.ts +10 -7
  77. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +2 -5
  78. package/src/pages-standard/plan-versions/PlanDiffCard.vue +27 -20
  79. package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +24 -16
  80. package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +45 -45
  81. package/src/pages-standard/plans-page/PlanBundleOverview.vue +21 -15
  82. package/src/pages-standard/plans-page/PlanPublishDialog.vue +4 -4
  83. package/src/pages-standard/plans-page/PlansPageToast.vue +6 -6
  84. package/src/pages-standard/sa-theme.css +9 -687
  85. package/src/pages-standard/tenants/StatusPill.vue +13 -13
  86. package/src/pages-standard/tenants/format.ts +11 -12
  87. package/src/pages-tenant/LimitsRow.vue +4 -4
  88. package/src/pages-tenant/MySubscriptionBundlesPage.vue +70 -61
  89. package/src/pages-tenant/OnboardingConfigurator.vue +25 -25
  90. package/src/pages-tenant/PackageSnapshotPanel.vue +10 -16
  91. package/src/pages-tenant/PendingVersionBanner.vue +12 -12
  92. package/src/pages-tenant/PlanChangeWizard.vue +22 -76
  93. package/src/pages-tenant/TenantPlanSection.vue +19 -69
  94. package/src/pages-tenant/UsageBar.vue +7 -7
  95. package/src/pages-tenant/default-i18n.ts +113 -0
  96. package/src/pages-tenant/tenant-plan-section/BundlePreviewDialog.vue +14 -14
  97. package/src/pages-tenant/tenant-plan-section/TenantBundleStore.vue +10 -10
  98. package/src/pages-tenant/tenant-plan-section/TenantFeatureMatrix.vue +2 -2
  99. package/src/quasar/create-super-admin-app.ts +96 -1
  100. package/src/quasar/dark-bridge.ts +66 -0
  101. package/src/quasar/index.ts +1 -0
  102. package/src/ui/theme/_breakpoints.scss +34 -0
  103. package/src/ui/theme/base.css +50 -0
  104. package/src/ui/theme/components/body.css +23 -0
  105. package/src/ui/theme/components/button.css +158 -0
  106. package/src/ui/theme/components/card.css +74 -0
  107. package/src/ui/theme/components/field.css +38 -0
  108. package/src/ui/theme/components/hero.css +46 -0
  109. package/src/ui/theme/components/paginator.css +63 -0
  110. package/src/ui/theme/components/section.css +67 -0
  111. package/src/ui/theme/components/statistics.css +189 -0
  112. package/src/ui/theme/components/table.css +60 -0
  113. package/src/ui/theme/index.css +42 -0
  114. package/src/ui/theme/tokens.legacy.css +147 -0
  115. package/src/ui/theme/tokens.primitive.css +112 -0
  116. package/src/ui/theme/tokens.scale.css +103 -0
  117. package/src/ui/theme/tokens.semantic.dark.css +185 -0
  118. package/src/ui/theme/tokens.semantic.light.css +242 -0
  119. package/src/vue/use-sa-theme.ts +154 -0
@@ -44,12 +44,19 @@ 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 {
48
+ SA_THEME_KEY,
49
+ createSaTheme,
50
+ type SaTheme,
51
+ type SaThemeOptions,
52
+ } from '../vue/use-sa-theme.js';
47
53
  import {
48
54
  SUPER_ADMIN_I18N_KEY,
49
55
  createSuperAdminI18n,
50
56
  type SuperAdminI18n,
51
57
  type SuperAdminI18nOptions,
52
58
  } from '../vue/use-super-admin-i18n.js';
59
+ import { bindSaThemeToDocument } from './dark-bridge.js';
53
60
  import { quasarNotify } from './notify.js';
54
61
 
55
62
  export interface CreateSuperAdminAppOptions extends SuperAdminGuardOptions {
@@ -112,6 +119,17 @@ export interface CreateSuperAdminAppOptions extends SuperAdminGuardOptions {
112
119
  * runtime. Consumed via `useSuperAdminI18n()` / `useSaMessages()`.
113
120
  */
114
121
  i18n?: SuperAdminI18nOptions;
122
+ /**
123
+ * Optional: colour scheme. Default follows the operating system and
124
+ * remembers an explicit pick. The context is returned on the handle
125
+ * (`handle.theme`) — set `handle.theme.scheme.value = 'dark'` to switch at
126
+ * runtime, and read it anywhere via `useSaTheme()`.
127
+ *
128
+ * Whatever it resolves to is mirrored onto `<html data-sa-theme>` AND into
129
+ * Quasar's `Dark`, so the platform's surfaces and Quasar's own components
130
+ * never disagree.
131
+ */
132
+ theme?: SaThemeOptions;
115
133
  }
116
134
 
117
135
  export interface SuperAdminAppHandle {
@@ -120,10 +138,29 @@ export interface SuperAdminAppHandle {
120
138
  pinia: Pinia;
121
139
  /** i18n context of the shell — switch locale via `i18n.locale.value`. */
122
140
  i18n: SuperAdminI18n;
141
+ /** Colour scheme of the shell — switch via `theme.scheme.value`. */
142
+ theme: SaTheme;
123
143
  /** Mounts the app on a selector. Returns the root component instance. */
124
144
  mount: (selector: string | Element) => ReturnType<App['mount']>;
145
+ /**
146
+ * Releases what this helper attached to the DOCUMENT — the theme's
147
+ * `prefers-color-scheme` subscription and the bridge that mirrors it onto
148
+ * `data-sa-theme` and Quasar's `Dark`. Idempotent; it does not unmount the
149
+ * app, which is `handle.app.unmount()`.
150
+ *
151
+ * Only matters where a shell is torn down while the document survives: hot
152
+ * reload, a micro-frontend, a second shell in one page. Skip it and the old
153
+ * bridge keeps writing to the one document on the next OS theme change, and
154
+ * can overrule the scheme the new shell was given.
155
+ */
156
+ dispose: () => void;
125
157
  }
126
158
 
159
+ /** Marks Quasar's teleported nodes so the theme can reach them. Exported so a
160
+ * test fixture can install the same config a real app gets — without it the
161
+ * `.sa-portal` rules are exercised by nothing. */
162
+ export const SA_PORTAL_CLASS = 'sa-portal';
163
+
127
164
  const DEFAULT_QUASAR_OPTIONS: QuasarPluginOptions = {
128
165
  plugins: { Notify, Dialog, Loading },
129
166
  config: { notify: { position: 'top-right', timeout: 3000 } },
@@ -138,7 +175,35 @@ const DEFAULT_QUASAR_OPTIONS: QuasarPluginOptions = {
138
175
  export function createSuperAdminApp(options: CreateSuperAdminAppOptions): SuperAdminAppHandle {
139
176
  const app = createApp(options.rootComponent);
140
177
 
141
- app.use(Quasar, options.quasarOptions ?? DEFAULT_QUASAR_OPTIONS);
178
+ // Quasar teleports every dialog, menu and tooltip into a div appended to
179
+ // `<body>`, so a rule prefixed with `.sa-page` never reaches them — which is
180
+ // why dialog cards kept Quasar's neutral grey in dark mode while the page
181
+ // behind them was slate, and why the outlined-field correction had silently
182
+ // never applied inside a dialog.
183
+ //
184
+ // Scope, stated plainly: `globalNodes` is document-wide, not per-owner —
185
+ // `createGlobalNode` stamps the class on every portal Quasar opens, the
186
+ // shell's own and any a contributed project page opens inside it. That is
187
+ // intended: a page mounted in the admin shell is admin UI, and the same
188
+ // theme already repaints its cards via `.sa-page`. What the class buys is
189
+ // that an app which never calls `createSuperAdminApp` stays untouched —
190
+ // which a bare `.q-dialog .q-card` rule could not promise.
191
+ const quasarOptions = options.quasarOptions ?? DEFAULT_QUASAR_OPTIONS;
192
+ // `globalNodes` is a documented Quasar config option that its TypeScript
193
+ // types do not declare (see quasar/src/utils/private.config/nodes.js, which
194
+ // reads `globalConfig.globalNodes.class`). The cast is the exception this
195
+ // codebase allows for an API that exists and is simply untyped upstream.
196
+ const config = quasarOptions.config as Record<string, unknown> | undefined;
197
+ const existingPortalClass = (config?.globalNodes as { class?: string } | undefined)?.class;
198
+ app.use(Quasar, {
199
+ ...quasarOptions,
200
+ config: {
201
+ ...quasarOptions.config,
202
+ globalNodes: {
203
+ class: [existingPortalClass, SA_PORTAL_CLASS].filter(Boolean).join(' '),
204
+ },
205
+ },
206
+ } as QuasarPluginOptions);
142
207
 
143
208
  const pinia = createPinia();
144
209
  app.use(pinia);
@@ -164,9 +229,34 @@ export function createSuperAdminApp(options: CreateSuperAdminAppOptions): SuperA
164
229
  };
165
230
 
166
231
  const i18n = createSuperAdminI18n(options.i18n);
232
+ // An app that configured Quasar's dark mode has STATED a preference; the
233
+ // platform's default of 'system' has not. Seeding from it keeps the
234
+ // documented "Quasar decided" path working here — without it the bridge's
235
+ // first, immediate tick resolved 'system' against the machine and called
236
+ // `Dark.set()` with the answer, erasing the app's own choice one line after
237
+ // it was applied. In both directions: a configured `true` was undone on a
238
+ // light machine, a configured `false` on a dark one.
239
+ //
240
+ // Read from the OPTION rather than from `Dark.isActive`, because the option
241
+ // distinguishes three states and the resulting flag only two: `'auto'` means
242
+ // "follow the machine", which is what 'system' already does, and reading the
243
+ // flag would freeze whatever the machine happened to say at boot.
244
+ //
245
+ // A stored operator pick still outranks this — `createSaTheme` gives storage
246
+ // precedence over the `scheme` option — because that is a person choosing
247
+ // rather than a default.
248
+ const configuredDark = options.quasarOptions?.config?.dark;
249
+ const theme = createSaTheme({
250
+ ...options.theme,
251
+ scheme:
252
+ options.theme?.scheme ??
253
+ (typeof configuredDark === 'boolean' ? (configuredDark ? 'dark' : 'light') : undefined),
254
+ });
255
+ const stopThemeBridge = bindSaThemeToDocument(theme);
167
256
 
168
257
  app.provide(SUPER_ADMIN_BRAND_KEY, { tag: 'SuperAdmin', ...options.brand });
169
258
  app.provide(SUPER_ADMIN_I18N_KEY, i18n);
259
+ app.provide(SA_THEME_KEY, theme);
170
260
  app.provide(SUPER_ADMIN_ENDPOINTS_KEY, endpoints);
171
261
  app.provide(SUPER_ADMIN_EXTENSIONS_KEY, options.extensions ?? {});
172
262
  app.provide(SUPER_ADMIN_ACTIONS_KEY, options.actions ?? {});
@@ -191,6 +281,11 @@ export function createSuperAdminApp(options: CreateSuperAdminAppOptions): SuperA
191
281
  router,
192
282
  pinia,
193
283
  i18n,
284
+ theme,
194
285
  mount: (selector) => app.mount(selector),
286
+ dispose: () => {
287
+ stopThemeBridge();
288
+ theme.dispose();
289
+ },
195
290
  };
196
291
  }
@@ -0,0 +1,66 @@
1
+ // The half of the theme switch that touches the DOM and Quasar.
2
+ //
3
+ // Two things have to agree, or the screen ends up half-dark:
4
+ //
5
+ // `data-sa-theme` on <html> drives the platform's own role tokens
6
+ // Quasar's `Dark.set()` drives q-card, q-table, q-menu, q-dialog …
7
+ //
8
+ // The stylesheet already accepts Quasar's `body--dark` as a dark trigger, so an
9
+ // app that only calls `$q.dark.set(true)` is complete without this module. What
10
+ // the bridge adds is the other direction — an operator switching the theme in
11
+ // the admin's own header — and, once both exist, keeping them from disagreeing:
12
+ // it watches BOTH and writes each into the other.
13
+
14
+ import { Dark } from 'quasar';
15
+ import { watch, type WatchStopHandle } from 'vue';
16
+
17
+ import type { SaTheme } from '../vue/use-sa-theme.js';
18
+
19
+ /**
20
+ * Mirrors a theme context onto the document and Quasar. Returns the stop
21
+ * handle; `createSuperAdminApp()` wires this up for you.
22
+ *
23
+ * `immediate` is not optional: at bootstrap the stored pick is already resolved
24
+ * and nothing has painted yet, so a first-change-only watcher would render one
25
+ * frame in the wrong theme on every reload.
26
+ *
27
+ * That first tick MIRRORS, it does not negotiate — whatever `theme.resolved`
28
+ * says is written to Quasar, including `Dark.set(false)`. So a caller that
29
+ * bootstraps with Quasar already dark has to say so when it builds the theme,
30
+ * or its own choice is erased one line after it was applied.
31
+ * `createSuperAdminApp` does exactly that, seeding from `Dark.isActive`.
32
+ */
33
+ export function bindSaThemeToDocument(theme: SaTheme): WatchStopHandle {
34
+ const toQuasar = watch(
35
+ theme.resolved,
36
+ (resolved) => {
37
+ if (typeof document !== 'undefined') {
38
+ document.documentElement.setAttribute('data-sa-theme', resolved);
39
+ }
40
+ Dark.set(resolved === 'dark');
41
+ },
42
+ { immediate: true },
43
+ );
44
+
45
+ // …and back. An app with its own toggle calls `$q.dark.set(false)`, which
46
+ // this side of the bridge never hears: the theme has not changed, so
47
+ // `data-sa-theme="dark"` stays on `<html>` while Quasar goes light. That is
48
+ // the half-dark screen the bridge exists to prevent, arriving from the
49
+ // other direction.
50
+ //
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.
54
+ const fromQuasar = watch(
55
+ () => Dark.isActive,
56
+ (isDark) => {
57
+ const asScheme = isDark ? 'dark' : 'light';
58
+ if (theme.resolved.value !== asScheme) theme.scheme.value = asScheme;
59
+ },
60
+ );
61
+
62
+ return () => {
63
+ toQuasar();
64
+ fromQuasar();
65
+ };
66
+ }
@@ -8,4 +8,5 @@
8
8
  // exports, not through this bundle (tsup ignores `.vue`).
9
9
 
10
10
  export * from './create-super-admin-app.js';
11
+ export * from './dark-bridge.js';
11
12
  export * from './notify.js';
@@ -0,0 +1,34 @@
1
+ // Breakpoints — the one scale that cannot be a custom property.
2
+ //
3
+ // `@media (min-width: var(--sa-bp-md))` is invalid CSS: custom properties are
4
+ // not substituted inside a media condition. So the values live here for SCSS
5
+ // blocks, and plain-CSS `<style>` blocks use the five literals, which the lint
6
+ // rule allows and nothing else.
7
+ //
8
+ // They are Quasar's own breakpoints, deliberately. A component that changes
9
+ // layout at 980px inside an app whose grid changes at 1024px produces a band
10
+ // 44px wide where the two disagree — and the admin surface had six such values
11
+ // (540, 600, 980, 1100, 1180, 1280), so it had several such bands. All six are
12
+ // now on the bounds below; the audit fails on a seventh.
13
+ //
14
+ // Moving 1100 and 1180 DOWN to 1023.98 keeps the wide layout for another 76 and
15
+ // 156 pixels respectively, which is the one direction of this change that could
16
+ // push content off the page. `visual-baseline.spec.ts` measures horizontal
17
+ // overflow at both edges of every band, on every page, for exactly that reason.
18
+ //
19
+ // Usage:
20
+ // @use '@saasicat/ui-vue/theme/breakpoints' as bp;
21
+ // @media (max-width: bp.$sm-max) { … }
22
+
23
+ $xs: 0;
24
+ $sm: 600px;
25
+ $md: 1024px;
26
+ $lg: 1440px;
27
+ $xl: 1920px;
28
+
29
+ // Upper bounds, for the `max-width` direction. Quasar uses the same 0.02px
30
+ // step back, so the two never both match at an integer viewport width.
31
+ $xs-max: 599.98px;
32
+ $sm-max: 1023.98px;
33
+ $md-max: 1439.98px;
34
+ $lg-max: 1919.98px;
@@ -0,0 +1,50 @@
1
+ /* The page canvas and the type baseline. Everything else is a component.
2
+ *
3
+ * Nothing here styles a bare element outside `.sa-page`, which is what makes
4
+ * the whole theme safe to load into an application that has its own design —
5
+ * including the tenant-facing pages, which are embedded in a consumer's own
6
+ * app rather than in the admin shell.
7
+ */
8
+
9
+ /* The app canvas. It sits on the layout rather than on the page so that the
10
+ * gutters beside `max-width: 1600px` are painted too — and so a page can be a
11
+ * transparent frame while sections carry the surfaces. Declared here rather
12
+ * than on `body`: both known consumer apps load their own stylesheet after
13
+ * this one, and a `body` rule would lose to them. */
14
+ .sa-admin-layout,
15
+ .sa-admin-content {
16
+ background: var(--sa-color-bg-app);
17
+ }
18
+
19
+ /* Page frame — min-height, the page padding and the admin type, in one place
20
+ * instead of once per page (see AdminPage). No surface of its own.
21
+ *
22
+ * The font and text colour belong here because the hosting app's stylesheet
23
+ * loads after this one: without a rule on the page itself, admin pages would
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. */
26
+ .sa-page {
27
+ min-height: calc(100vh - 56px);
28
+ padding: 20px 28px 28px;
29
+ font-family: var(--sa-font-body);
30
+ color: var(--sa-color-fg-body);
31
+ }
32
+
33
+ /* Consumer apps load Quasar, whose element-level h1–h6 rules carry display
34
+ * sizes and huge line-heights. Neutralised here so a bare heading inside a
35
+ * page is legible without a class of its own.
36
+ *
37
+ * `:where()` keeps this at the specificity of a single class, so any heading
38
+ * class in the package still wins — this is a baseline, not a verdict. Three
39
+ * views used to carry their own copy of it, each covering a different subset
40
+ * of h1–h6. */
41
+ .sa-page :where(h1, h2, h3, h4, h5, h6) {
42
+ margin: 0;
43
+ line-height: 1.2;
44
+ }
45
+
46
+ /* Border-box for the whole page. Six views declared this for their own
47
+ * subtree, which is the same rule six times with six different roots. */
48
+ .sa-page * {
49
+ box-sizing: border-box;
50
+ }
@@ -0,0 +1,23 @@
1
+ /* Page body — everything below the hero (see AdminBody), including the
2
+ * loading and empty states the pages used to spell out one by one.
3
+ *
4
+ * Inset from the page edge, so the hero frames the page and everything below
5
+ * sits inside it. The inset lives here rather than on the section, because
6
+ * AdminStatistics is the other thing allowed at this level and would otherwise
7
+ * be the only block sticking out.
8
+ */
9
+
10
+ .sa-page-body {
11
+ padding-left: 20px;
12
+ padding-right: 20px;
13
+ }
14
+ .sa-page-body__state {
15
+ padding: 32px 0;
16
+ color: var(--sa-color-fg-muted);
17
+ display: flex;
18
+ align-items: center;
19
+ gap: 12px;
20
+ }
21
+ .sa-page-body__state--empty {
22
+ font-size: var(--sa-text-md);
23
+ }
@@ -0,0 +1,158 @@
1
+ /* One button look across the admin surface, with two ways in.
2
+ *
3
+ * `.sa-btn` is for the hand-built buttons — eight files declared the same
4
+ * bordered pill independently, differing only in which local token they named.
5
+ *
6
+ * The Quasar selectors are the second way: any `q-btn` placed in a hero's or a
7
+ * section's action area adopts the same shape. That is what reaches the buttons
8
+ * a consumer app puts into those slots — a component could only style what the
9
+ * platform itself renders, and autohauspro's `#card-actions` button would have
10
+ * stayed behind in the Quasar look while everything around it changed.
11
+ *
12
+ * Colour is left to Quasar on its own buttons: `color="primary"` stays a filled
13
+ * primary, `flat` becomes the bordered surface below. Only shape and typography
14
+ * are unified, because those are what read as "a different design".
15
+ */
16
+
17
+ .sa-btn,
18
+ .sa-page-head__actions .q-btn,
19
+ .sa-section__actions .q-btn {
20
+ padding: 8px 14px;
21
+ min-height: 0;
22
+ border-radius: var(--sa-radius-control);
23
+ font-family: var(--sa-font-body);
24
+ font-size: var(--sa-text-md);
25
+ font-weight: 500;
26
+ line-height: 1.3;
27
+ letter-spacing: 0;
28
+ text-transform: none;
29
+ }
30
+
31
+ .sa-btn {
32
+ display: inline-flex;
33
+ align-items: center;
34
+ gap: 7px;
35
+ cursor: pointer;
36
+ color: var(--sa-color-fg-body);
37
+ transition:
38
+ background 0.12s,
39
+ border-color 0.12s;
40
+ }
41
+
42
+ /* The default is a bordered surface, not a bare label. */
43
+ .sa-btn,
44
+ .sa-page-head__actions .q-btn--flat,
45
+ .sa-section__actions .q-btn--flat {
46
+ border: 1px solid var(--sa-color-border-strong);
47
+ background: var(--sa-color-bg-surface);
48
+ }
49
+ .sa-btn:hover:not(:disabled),
50
+ .sa-page-head__actions .q-btn--flat:hover,
51
+ .sa-section__actions .q-btn--flat:hover {
52
+ background: var(--sa-color-bg-sunken);
53
+ }
54
+
55
+ .sa-btn--primary {
56
+ background: var(--sa-color-accent);
57
+ border-color: var(--sa-color-accent);
58
+ color: var(--sa-color-fg-on-accent);
59
+ }
60
+ .sa-btn--primary:hover:not(:disabled) {
61
+ background: var(--sa-color-accent-strong);
62
+ border-color: var(--sa-color-accent-strong);
63
+ }
64
+
65
+ .sa-btn:disabled {
66
+ opacity: 0.5;
67
+ cursor: not-allowed;
68
+ }
69
+
70
+ /* Size and intent modifiers, carried over from the families that merged. */
71
+ .sa-btn--sm {
72
+ padding: 5px 8px;
73
+ font-size: var(--sa-text-sm);
74
+ gap: 5px;
75
+ }
76
+ .sa-btn--ghost {
77
+ border-color: transparent;
78
+ background: transparent;
79
+ }
80
+ .sa-btn--ghost:hover:not(:disabled) {
81
+ background: var(--sa-color-bg-sunken);
82
+ }
83
+ .sa-btn--danger {
84
+ color: var(--sa-color-negative);
85
+ }
86
+ .sa-btn--danger:hover:not(:disabled) {
87
+ background: var(--sa-color-negative-surface);
88
+ }
89
+ .sa-btn--flex {
90
+ flex: 1;
91
+ }
92
+
93
+ /* Icon-only — the reload control, which carries no label and therefore needs
94
+ * an `aria-label` at the call site. */
95
+ .sa-btn--icon {
96
+ padding: 8px;
97
+ gap: 0;
98
+ }
99
+ .sa-btn--positive {
100
+ color: var(--sa-color-positive);
101
+ }
102
+ .sa-btn--positive:hover:not(:disabled) {
103
+ background: var(--sa-color-positive-surface);
104
+ }
105
+
106
+ /* The row-action icon — one recipe for the whole admin.
107
+ *
108
+ * There were three. `TenantsPage` had this bordered-less square with a muted
109
+ * icon; `PilotsPage` and `PromoCodesPage` rendered `<q-btn :color="…">`, which
110
+ * paints Quasar's brand colours and made a promo-code row read as a row of
111
+ * traffic lights beside a plans row of quiet greys; `EmailHistoryPage` and
112
+ * `PlatformEmailPage` pinned `color="primary"`.
113
+ *
114
+ * The quiet one wins. A row of actions is chrome, not a set of announcements —
115
+ * the colour belongs on the ONE action that is destructive, which is what the
116
+ * tone modifiers are for.
117
+ */
118
+ .sa-icon-btn {
119
+ display: inline-flex;
120
+ align-items: center;
121
+ justify-content: center;
122
+ width: 28px;
123
+ height: 28px;
124
+ border: none;
125
+ border-radius: var(--sa-radius-control);
126
+ background: transparent;
127
+ color: var(--sa-color-fg-muted);
128
+ cursor: pointer;
129
+ text-decoration: none;
130
+ transition: background 0.12s;
131
+ }
132
+ .sa-icon-btn:hover:not(:disabled) {
133
+ background: var(--sa-color-border-soft);
134
+ }
135
+ .sa-icon-btn:disabled {
136
+ opacity: 0.45;
137
+ cursor: not-allowed;
138
+ }
139
+ /* The icon inherits, so a tone below colours the glyph without a second rule. */
140
+ .sa-icon-btn .q-icon {
141
+ color: inherit;
142
+ }
143
+
144
+ .sa-icon-btn--negative {
145
+ color: var(--sa-color-negative);
146
+ }
147
+ .sa-icon-btn--positive {
148
+ color: var(--sa-color-positive-fg);
149
+ }
150
+ .sa-icon-btn--warning {
151
+ color: var(--sa-color-warning-fg);
152
+ }
153
+ .sa-icon-btn--primary {
154
+ color: var(--sa-color-accent);
155
+ }
156
+ .sa-icon-btn--accent {
157
+ color: var(--sa-color-feature);
158
+ }
@@ -0,0 +1,74 @@
1
+ /* Cards — the generic block inside dashboard and detail sections, and the
2
+ * corner correction for Quasar's own card.
3
+ *
4
+ * Quasar cards round at 4px, which reads sharper than every surface around
5
+ * them. One rule so a card inside the admin agrees with the sections and tiles
6
+ * it sits between, without each page saying so.
7
+ */
8
+
9
+ /* Quasar paints its own card: white in light, and `var(--q-dark)` — a neutral
10
+ * #1d1d1d — as soon as `Dark` is active. That grey sits wrong on a slate page,
11
+ * so the surface is ours.
12
+ *
13
+ * The `.sa-portal` half is not decoration: Quasar teleports every dialog into a
14
+ * div appended to `<body>`, so a rule prefixed with `.sa-page` never reaches a
15
+ * dialog's card. `createSuperAdminApp` marks those portals — every portal in the
16
+ * document, the shell's own and those opened by a page contributed into it,
17
+ * which is the intent: a page inside the admin shell is admin UI. An app that
18
+ * never calls `createSuperAdminApp` is untouched. */
19
+ .sa-page .q-card,
20
+ .sa-portal .q-card {
21
+ border-radius: var(--sa-radius-card);
22
+ background: var(--sa-color-bg-surface);
23
+ color: var(--sa-color-fg-body);
24
+ }
25
+
26
+ /* A dropdown is teleported like a dialog, and QMenu adds `q-dark` to itself in
27
+ * dark mode (QMenu.js), which resolves to the same neutral grey. Without this
28
+ * the select in the paginator under every table opens a #1d1d1d panel over a
29
+ * slate page — the reported defect, one portal type further along. */
30
+ .sa-portal .q-menu {
31
+ background: var(--sa-color-bg-surface);
32
+ color: var(--sa-color-fg-body);
33
+ }
34
+
35
+ /* Quasar's stepper, for the same reason and one component further along.
36
+ *
37
+ * Only the steps you are NOT on. Their titles are a hard-coded grey —
38
+ * `rgb(158,158,158)`, 2.68:1 on a white card — which is below the floor for
39
+ * text whose whole job is to say which steps exist. The ACTIVE step is left
40
+ * alone: Quasar marks it `text-primary`, and that accent is the "you are here"
41
+ * affordance. A first attempt here painted every title with the heading role
42
+ * and quietly took that affordance away; the baseline diff showed it.
43
+ *
44
+ * The theme could not see any of this until the contrast checker learned to
45
+ * look inside teleported nodes: the only stepper in the package is in a dialog.
46
+ */
47
+ .sa-page .q-stepper,
48
+ .sa-portal .q-stepper {
49
+ background: var(--sa-color-bg-surface);
50
+ color: var(--sa-color-fg-body);
51
+ }
52
+ .sa-page .q-stepper__tab:not(.q-stepper__tab--active) .q-stepper__title,
53
+ .sa-portal .q-stepper__tab:not(.q-stepper__tab--active) .q-stepper__title {
54
+ color: var(--sa-color-fg-muted);
55
+ }
56
+ .sa-page .q-stepper__caption,
57
+ .sa-portal .q-stepper__caption {
58
+ color: var(--sa-color-fg-subtle);
59
+ }
60
+
61
+ .sa-card {
62
+ background: var(--sa-color-bg-surface);
63
+ border: 1px solid var(--sa-color-border);
64
+ border-radius: var(--sa-radius-card);
65
+ padding: 16px 18px;
66
+ }
67
+ .sa-card__title {
68
+ margin: 0 0 12px;
69
+ font-family: var(--sa-font-head);
70
+ font-size: var(--sa-text-lg);
71
+ font-weight: 700;
72
+ color: var(--sa-color-fg-heading);
73
+ letter-spacing: -0.005em;
74
+ }
@@ -0,0 +1,38 @@
1
+ /* Form fields and the filter row.
2
+ *
3
+ * Quasar's outlined input rounds at 4px, which reads sharper than everything
4
+ * around it; the admin surface settles on `--sa-radius-field`, the radius the
5
+ * hand-built search boxes already used. Declared on the field control so it
6
+ * covers inputs, selects and date pickers alike.
7
+ */
8
+
9
+ .sa-page .q-field--outlined .q-field__control,
10
+ .sa-page .q-field--outlined .q-field__control:before,
11
+ .sa-page .q-field--outlined .q-field__control:after,
12
+ .sa-portal .q-field--outlined .q-field__control,
13
+ .sa-portal .q-field--outlined .q-field__control:before,
14
+ .sa-portal .q-field--outlined .q-field__control:after {
15
+ border-radius: var(--sa-radius-field);
16
+ }
17
+ /* Quasar's outlined control is transparent, which reads as a hole wherever it
18
+ * sits on a section surface rather than on the page canvas.
19
+ *
20
+ * `.sa-portal` because a dialog is teleported out of `.sa-page` — this
21
+ * correction had silently never applied to a field inside a dialog. */
22
+ .sa-page .q-field--outlined .q-field__control,
23
+ .sa-portal .q-field--outlined .q-field__control {
24
+ background: var(--sa-color-bg-surface);
25
+ }
26
+
27
+ /* The filter row (see AdminFilters). Declared once so no page writes its own
28
+ * flex rule — which is how five pages ended up with five filter bars. */
29
+ .sa-filters .sa-filters__body {
30
+ display: flex;
31
+ gap: 10px;
32
+ flex-wrap: wrap;
33
+ align-items: center;
34
+ }
35
+ .sa-filters .sa-filters__body > * {
36
+ flex: 1;
37
+ min-width: 200px;
38
+ }
@@ -0,0 +1,46 @@
1
+ /* The page hero — the banner that answers "which page am I on" (AdminHero).
2
+ *
3
+ * A tinted, bordered block rather than a first paragraph, so identity is
4
+ * visually separate from content. Tint and border come from the accent role,
5
+ * which reads the host application's brand: an app that sets `$primary` gets
6
+ * its own hero without touching this file.
7
+ */
8
+
9
+ .sa-page-head {
10
+ /* Top-aligned, so the controls sit level with the title rather than
11
+ * drifting down with the subtitle's height. */
12
+ display: flex;
13
+ align-items: flex-start;
14
+ gap: 20px;
15
+ margin-bottom: 20px;
16
+ flex-wrap: wrap;
17
+ padding: 13px 22px 15px;
18
+ border: 1px solid var(--sa-color-accent-border);
19
+ border-radius: var(--sa-radius-hero);
20
+ background: linear-gradient(
21
+ 135deg,
22
+ var(--sa-color-accent-surface) 0%,
23
+ var(--sa-color-accent-surface-soft) 100%
24
+ );
25
+ }
26
+ .sa-page-head__title {
27
+ margin: 0;
28
+ font-family: var(--sa-font-head);
29
+ font-size: var(--sa-text-2xl);
30
+ font-weight: 700;
31
+ letter-spacing: -0.02em;
32
+ line-height: 1.25;
33
+ color: var(--sa-color-fg-heading);
34
+ }
35
+ .sa-page-head__sub {
36
+ margin: 1px 0 0;
37
+ font-size: var(--sa-text-md);
38
+ line-height: 1.35;
39
+ color: var(--sa-color-fg-muted);
40
+ }
41
+ .sa-page-head__actions {
42
+ margin-left: auto;
43
+ display: flex;
44
+ gap: 8px;
45
+ align-items: center;
46
+ }