@happyvertical/smrt-template-sveltekit 0.38.26 → 0.39.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.
@@ -1,267 +1,114 @@
1
1
  /**
2
- * Tenant resolution for SvelteKit requests.
2
+ * Tenant selection for SvelteKit requests.
3
3
  *
4
- * This file is intentionally pluggable — consumers can swap the strategy
5
- * function without forking. The shipped default is subdomain-based:
4
+ * Selection and authorization are deliberately separate:
6
5
  *
7
- * acme.demo.local → tenantId='acme'
8
- * www.demo.local → tenantId=null
9
- * demo.local → tenantId=null
10
- * localhost / 127.0.0.1 → tenantId=null
6
+ * - this module maps a trusted URL shape (the leading subdomain) to an active
7
+ * tenant record;
8
+ * - `createSessionHandler({ enterTenantContext: true })` authorizes the signed-
9
+ * in user's active membership and establishes the request tenant context.
11
10
  *
12
- * The resolver's signature is intentionally minimal — it accepts a
13
- * SvelteKit-like `RequestEvent` (typed structurally so this module has zero
14
- * `@sveltejs/kit` runtime dependency) and returns a synchronous result.
15
- *
16
- * @example Swap to path-prefix resolution
17
- * ```ts
18
- * // src/lib/server/tenancy.ts (your project)
19
- * import { createTenantResolver, pathPrefixStrategy } from './tenancy';
20
- *
21
- * export const resolveTenant = createTenantResolver(pathPrefixStrategy);
22
- * ```
23
- *
24
- * @example Swap to header-based resolution
25
- * ```ts
26
- * import { createTenantResolver, headerStrategy } from './tenancy';
27
- *
28
- * export const resolveTenant = createTenantResolver(
29
- * headerStrategy({ headerName: 'x-tenant-id' }),
30
- * );
31
- * ```
32
- *
33
- * @example Compose your own
34
- * ```ts
35
- * import { createTenantResolver, subdomainStrategy } from './tenancy';
36
- *
37
- * export const resolveTenant = createTenantResolver((event) => {
38
- * // Try subdomain first, then fall back to a header
39
- * const fromSubdomain = subdomainStrategy(event);
40
- * if (fromSubdomain.tenantId) return fromSubdomain;
41
- * return { tenantId: event.request.headers.get('x-tenant-id') };
42
- * });
43
- * ```
11
+ * A selected tenant is useful for login and membership-gated switch flows, but
12
+ * it never becomes ambient query authority by itself. In particular, this
13
+ * resolver ignores client-provided tenant headers.
44
14
  */
45
15
 
46
- /**
47
- * Structural SvelteKit RequestEvent — only the bits we need for tenant
48
- * resolution. Keeping this minimal means the module has no @sveltejs/kit
49
- * runtime import, which keeps tests + non-SvelteKit consumers happy.
50
- */
16
+ import {
17
+ TenantCollection,
18
+ TenantStatus,
19
+ } from '@happyvertical/smrt-users';
20
+
21
+ import { getSmrtConfig } from './smrt.js';
22
+
51
23
  export interface TenantResolverEvent {
52
24
  url: URL;
53
25
  request: { headers: Headers };
54
- params?: Record<string, string | undefined>;
55
26
  }
56
27
 
57
- /**
58
- * Resolution result. `null` means "no tenant for this request".
59
- */
60
- export interface TenantResolution {
28
+ export interface TenantSelection {
29
+ /** Database UUID for an active tenant, or null when none resolves. */
61
30
  tenantId: string | null;
31
+ /** URL-derived slug. Informational until membership authorizes a switch. */
32
+ tenantSlug: string | null;
62
33
  }
63
34
 
64
- /**
65
- * A resolver strategy is just a function from event → resolution. Sync or
66
- * async — the dispatcher awaits the return value.
67
- */
68
- export type TenantResolverStrategy = (
69
- event: TenantResolverEvent,
70
- ) => TenantResolution | Promise<TenantResolution>;
71
-
72
- /**
73
- * Hostnames that should never produce a tenant id, regardless of how many
74
- * dots they contain. Treats `localhost`, `127.0.0.1`, and `::1` as
75
- * tenant-less so local dev "just works" until you set up a wildcard DNS
76
- * entry like `*.demo.local → 127.0.0.1`.
77
- */
78
35
  const ROOT_LIKE_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
36
+ const RESERVED_SUBDOMAINS = new Set(['www', 'api', 'app', 'admin']);
79
37
 
80
38
  /**
81
- * Subdomain labels that are *never* tenant slugs even if they appear in the
82
- * leading position. Consumers can pass their own list to
83
- * {@link subdomainStrategyWith} if they need to extend this.
84
- */
85
- const DEFAULT_RESERVED_SUBDOMAINS = new Set(['www', 'api', 'app']);
86
-
87
- /**
88
- * Strategy: extract the tenant id from the leading subdomain.
39
+ * Pure URL parser used by the default resolver.
89
40
  *
90
- * Behavior:
91
- * - `acme.demo.local` → `{ tenantId: 'acme' }`
92
- * - `acme.beta.demo.local` → `{ tenantId: 'acme' }` (only leading label)
93
- * - `www.demo.local` → `{ tenantId: null }` (reserved)
94
- * - `demo.local` → `{ tenantId: null }` (no leading subdomain)
95
- * - `localhost` → `{ tenantId: null }`
96
- * - `127.0.0.1` or any IPv4/IPv6 → `{ tenantId: null }`
41
+ * Set `TENANT_BASE_DOMAIN` in production so multi-label public suffixes are
42
+ * never guessed. Without it, the fallback is intended only for local domains
43
+ * such as `acme.demo.local`.
97
44
  */
98
- export const subdomainStrategy: TenantResolverStrategy = (event) => {
99
- return subdomainStrategyWith()(event);
100
- };
101
-
102
- /**
103
- * Customizable subdomain strategy. Use this if you need to extend the
104
- * reserved-subdomain list (e.g. to also reject `admin.demo.local`) or
105
- * to deploy on a multi-label public suffix like `example.co.uk` where
106
- * a naive label count would treat the apex itself as a tenant.
107
- *
108
- * The supplied `reservedSubdomains` are **merged with** the built-in
109
- * defaults (`www`, `api`, `app`) — `www`, `api`, and `app` always stay
110
- * reserved regardless of what you pass. Pass `[]` or omit the option
111
- * if you only need the defaults; the merge is additive-only. Replacing
112
- * the default list outright is not currently supported via this
113
- * strategy — fork the function or wire your own `TenantResolverStrategy`
114
- * if you need that.
115
- *
116
- * `baseDomain` (strongly recommended for production) anchors the apex.
117
- * When set, the strategy strips the matching suffix and treats whatever
118
- * leading labels remain as the tenant. `example.co.uk` with
119
- * `baseDomain: 'example.co.uk'` → no tenant; `acme.example.co.uk` →
120
- * `'acme'`. Without `baseDomain` the strategy falls back to a label-
121
- * count heuristic that works for `.com`/`.net`/`.dev`-style single-
122
- * label TLDs but mis-identifies multi-label public suffixes (`.co.uk`,
123
- * `.com.au`, `.gov.uk`, …) as tenant subdomains.
124
- */
125
- export function subdomainStrategyWith(opts?: {
126
- reservedSubdomains?: Iterable<string>;
127
- /**
128
- * Apex domain to anchor tenant detection against. Highly recommended
129
- * for production deployments on multi-label public suffixes
130
- * (`example.co.uk`, `example.com.au`, …). When set, a request for
131
- * exactly that host returns `{ tenantId: null }`, and a request for
132
- * `<tenant>.<baseDomain>` returns `{ tenantId: '<tenant>' }`. Subdomain
133
- * resolution stops being label-count-dependent.
134
- */
135
- baseDomain?: string;
136
- }): TenantResolverStrategy {
137
- const reserved = new Set<string>(DEFAULT_RESERVED_SUBDOMAINS);
138
- if (opts?.reservedSubdomains) {
139
- for (const s of opts.reservedSubdomains) {
140
- reserved.add(s.toLowerCase());
141
- }
45
+ export function selectTenantSlug(
46
+ url: URL,
47
+ baseDomain = process.env.TENANT_BASE_DOMAIN,
48
+ ): string | null {
49
+ const hostname = url.hostname.toLowerCase();
50
+ if (
51
+ ROOT_LIKE_HOSTS.has(hostname) ||
52
+ /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)
53
+ ) {
54
+ return null;
142
55
  }
143
- const baseDomain = opts?.baseDomain?.toLowerCase().replace(/^\.+|\.+$/g, '');
144
-
145
- return (event) => {
146
- const hostname = event.url.hostname.toLowerCase();
147
-
148
- if (ROOT_LIKE_HOSTS.has(hostname)) {
149
- return { tenantId: null };
150
- }
151
56
 
152
- // Reject bare IPv4 (e.g. `192.168.1.10`) — never a tenant subdomain.
153
- if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) {
154
- return { tenantId: null };
155
- }
156
-
157
- // Preferred path: caller supplied a baseDomain. Strip it and read
158
- // whatever's left as the tenant. This works correctly for both
159
- // simple TLDs (`example.com`) and multi-label public suffixes
160
- // (`example.co.uk`).
161
- if (baseDomain) {
162
- if (hostname === baseDomain) {
163
- return { tenantId: null };
164
- }
165
- const suffix = `.${baseDomain}`;
166
- if (hostname.endsWith(suffix)) {
167
- const leading = hostname.slice(0, -suffix.length);
168
- // Take only the first label of whatever's left so
169
- // `acme.beta.example.co.uk` still resolves to `acme`.
170
- const candidate = leading.split('.')[0];
171
- if (!candidate || reserved.has(candidate)) {
172
- return { tenantId: null };
173
- }
174
- return { tenantId: candidate };
175
- }
176
- // Host doesn't match the configured apex — out of scope for this
177
- // strategy, treat as tenant-less rather than guess.
178
- return { tenantId: null };
179
- }
180
-
181
- // Fallback path: no baseDomain configured. Use a label-count
182
- // heuristic that works for single-label TLDs. **This is incorrect
183
- // for multi-label public suffixes** (`example.co.uk` has three
184
- // labels and would be misread as `example` being a tenant on the
185
- // `co.uk` root). Pass `baseDomain` to opt out of the heuristic on
186
- // any production deployment that uses such a TLD.
57
+ const normalizedBase = baseDomain
58
+ ?.trim()
59
+ .toLowerCase()
60
+ .replace(/^\.+|\.+$/g, '');
61
+
62
+ let candidate: string | undefined;
63
+ if (normalizedBase) {
64
+ if (hostname === normalizedBase) return null;
65
+ const suffix = `.${normalizedBase}`;
66
+ if (!hostname.endsWith(suffix)) return null;
67
+ candidate = hostname.slice(0, -suffix.length).split('.')[0];
68
+ } else {
187
69
  const labels = hostname.split('.');
70
+ if (labels.length < 3) return null;
71
+ candidate = labels[0];
72
+ }
188
73
 
189
- // Need at least 3 labels for a tenant subdomain: <tenant>.<root>.<tld>.
190
- // `demo.local` (2 labels) has no subdomain.
191
- if (labels.length < 3) {
192
- return { tenantId: null };
193
- }
194
-
195
- const candidate = labels[0];
196
- if (!candidate || reserved.has(candidate)) {
197
- return { tenantId: null };
198
- }
199
-
200
- return { tenantId: candidate };
201
- };
74
+ return candidate && !RESERVED_SUBDOMAINS.has(candidate) ? candidate : null;
202
75
  }
203
76
 
204
- /**
205
- * Strategy: extract the tenant id from a path prefix like `/t/<slug>/...`.
206
- * The prefix is configurable; default is `/t/`.
207
- *
208
- * @example
209
- * ```ts
210
- * // matches /t/acme/dashboard → tenantId='acme'
211
- * createTenantResolver(pathPrefixStrategy());
212
- *
213
- * // matches /tenant/acme/... → tenantId='acme'
214
- * createTenantResolver(pathPrefixStrategy({ prefix: '/tenant/' }));
215
- * ```
216
- */
217
- export function pathPrefixStrategy(opts?: {
218
- prefix?: string;
219
- }): TenantResolverStrategy {
220
- const prefix = opts?.prefix ?? '/t/';
221
- return (event) => {
222
- const path = event.url.pathname;
223
- if (!path.startsWith(prefix)) {
224
- return { tenantId: null };
225
- }
226
- const rest = path.slice(prefix.length);
227
- const slug = rest.split('/')[0];
228
- return { tenantId: slug && slug.length > 0 ? slug : null };
229
- };
77
+ function isMissingTenantTable(error: unknown): boolean {
78
+ if (!(error instanceof Error)) return false;
79
+ const code = (error as Error & { code?: string }).code;
80
+ return (
81
+ code === '42P01' ||
82
+ error.message.includes('relation "tenants" does not exist') ||
83
+ error.message.includes('no such table: tenants')
84
+ );
230
85
  }
231
86
 
232
- /**
233
- * Strategy: read the tenant id from a request header.
234
- *
235
- * @example
236
- * ```ts
237
- * createTenantResolver(headerStrategy({ headerName: 'x-tenant-id' }));
238
- * ```
239
- */
240
- export function headerStrategy(opts?: {
241
- headerName?: string;
242
- }): TenantResolverStrategy {
243
- const headerName = opts?.headerName ?? 'x-tenant-id';
244
- return (event) => {
245
- const value = event.request.headers.get(headerName);
246
- return { tenantId: value && value.length > 0 ? value : null };
247
- };
87
+ async function findActiveTenantId(slug: string): Promise<string | null> {
88
+ try {
89
+ const tenants = await TenantCollection.create(getSmrtConfig('Tenant'));
90
+ const tenant = await tenants.findBySlug(slug);
91
+ return tenant?.status === TenantStatus.ACTIVE ? (tenant.id ?? null) : null;
92
+ } catch (error) {
93
+ // A fresh project can render before its first `pnpm db:migrate`.
94
+ if (isMissingTenantTable(error)) return null;
95
+ throw error;
96
+ }
248
97
  }
249
98
 
250
99
  /**
251
- * Wrap any strategy into a resolver. This is a thin factory — its purpose
252
- * is to give consumers a single, stable entry point (`resolveTenant`) they
253
- * can swap by changing only the argument.
100
+ * Default selection extension point. Replace this function when your tenant
101
+ * key comes from a path, signed cookie, or trusted gateway assertion. Preserve
102
+ * the rule that selection alone does not establish authorization context.
254
103
  */
255
- export function createTenantResolver(
256
- strategy: TenantResolverStrategy,
257
- ): (event: TenantResolverEvent) => Promise<TenantResolution> {
258
- return async (event) => {
259
- return await strategy(event);
104
+ export async function resolveTenant(
105
+ event: TenantResolverEvent,
106
+ ): Promise<TenantSelection> {
107
+ const tenantSlug = selectTenantSlug(event.url);
108
+ if (!tenantSlug) return { tenantId: null, tenantSlug: null };
109
+
110
+ return {
111
+ tenantId: await findActiveTenantId(tenantSlug),
112
+ tenantSlug,
260
113
  };
261
114
  }
262
-
263
- /**
264
- * Default resolver used by `hooks.server.ts`. Swap the argument to
265
- * `createTenantResolver()` (or replace this export) to change strategies.
266
- */
267
- export const resolveTenant = createTenantResolver(subdomainStrategy);
@@ -1,69 +1,15 @@
1
- /**
2
- * Root layout server load — builds the AdminShell tenant navigation.
3
- *
4
- * The WASD AdminShell (`src/routes/+layout.svelte`) is the default chrome for
5
- * this app. Its left "tenant" rail is driven by a nav tree derived from the
6
- * SMRT manifest, and that tree is built HERE — server-side — rather than with a
7
- * client-side fetch. This mirrors the home page's data pattern
8
- * (`+page.server.ts`): data is produced during SSR, serialized into the initial
9
- * HTML, and hydrated on the client with no duplicate request.
10
- *
11
- * `tenantNavFromManifest()` is a pure function (data in → data out). It reads
12
- * the same generated manifest the runtime uses (`.smrt/manifest.json`, written
13
- * by `smrtPlugin()` and already loaded by `src/lib/server/smrt.ts`), so the nav
14
- * stays in sync with your `@smrt()` classes automatically instead of being
15
- * hand-maintained.
16
- *
17
- * This load reads no URL/params and declares no `depends()`, so after its
18
- * initial SSR run SvelteKit does not re-run it on client navigations — only on
19
- * a full reload or `invalidateAll()`. The manifest-derived nav is therefore
20
- * effectively static for the session, which is fine (the manifest is fixed at
21
- * build time). It is independent of each page's own load, so the home page's
22
- * `depends('smrt:items')` / `invalidate('smrt:items')` refresh flow is
23
- * untouched by this file.
24
- */
25
-
26
- import { existsSync } from 'node:fs';
27
- import { join } from 'node:path';
28
-
29
- import { loadManifestFromPathSync } from '@happyvertical/smrt-core/manifest';
30
- import {
31
- type ShellNavItem,
32
- type SmrtManifestLike,
33
- tenantNavFromManifest,
34
- } from '@happyvertical/smrt-svelte/workspace';
35
1
  import type { LayoutServerLoad } from './$types';
36
2
 
37
- export const load: LayoutServerLoad = async () => {
38
- // Same manifest file `src/lib/server/smrt.ts` seeds the registry from. Reading
39
- // it here returns the parsed object we hand to the pure nav builder; if it is
40
- // not generated yet (fresh checkout before the first `vite dev`/`vite build`)
41
- // we fall back to an empty rail rather than failing the whole layout.
42
- const manifestPath = join(process.cwd(), '.smrt', 'manifest.json');
43
-
44
- let nav: ShellNavItem[] = [];
45
- if (existsSync(manifestPath)) {
46
- const manifest = loadManifestFromPathSync(manifestPath);
47
- if (manifest) {
48
- // `tenantNavFromManifest` reads a documented subset of the manifest
49
- // (`SmrtManifestLike`). The core `SmartObjectManifest` carries richer
50
- // field types (e.g. `ApiConfig` has no index signature), so narrow it to
51
- // the structural shape the pure helper consumes.
52
- //
53
- // `basePath: ''` emits page-style hrefs (`/items`) for the developer to
54
- // wire to their own list routes. `sectionHints` groups classes by
55
- // package into readable section titles — extend it as you add packages.
56
- // `NavSection[]` is structurally a superset of `ShellNavItem[]`, so it
57
- // feeds straight into <TenantNav items={nav}>.
58
- nav = tenantNavFromManifest(manifest as unknown as SmrtManifestLike, {
59
- basePath: '',
60
- sectionHints: {
61
- '@happyvertical/smrt-content': 'Content',
62
- '@happyvertical/smrt-users': 'Users',
63
- },
64
- });
65
- }
66
- }
67
-
68
- return { nav };
3
+ /**
4
+ * Keep session and tenant state server-owned. The selected tenant is displayed
5
+ * separately from the tenant authorized by the active session.
6
+ */
7
+ export const load: LayoutServerLoad = async ({ locals }) => {
8
+ return {
9
+ session: {
10
+ authenticated: Boolean(locals.user),
11
+ activeTenantId: locals.tenantId,
12
+ selectedTenantSlug: locals.selectedTenantSlug,
13
+ },
14
+ };
69
15
  };
@@ -1,110 +1,98 @@
1
1
  <script lang="ts">
2
2
  import { page } from '$app/state';
3
+ import { Provider } from '@happyvertical/smrt-svelte';
3
4
  import {
4
5
  AdminShell,
5
6
  AppScopePanel,
6
- type ShellStatusChip,
7
- SystemStatusChips,
7
+ type ShellNavItem,
8
8
  TenantNav,
9
9
  } from '@happyvertical/smrt-svelte/workspace';
10
10
  import { ThemeProvider } from '@happyvertical/smrt-ui/themes';
11
- // Self-hosted @font-face rules for the SMRT type stack (Space Grotesk /
12
- // Inter / JetBrains Mono woff2). `<ThemeProvider>` supplies every `--smrt-*`
13
- // token variable at runtime, but not the font FILES — this import loads them
14
- // so `--smrt-font-family` renders as the real stack instead of falling back
15
- // to system-ui. Bundled with the app; no CDN request.
16
11
  import '@happyvertical/smrt-ui/themes/styles/fonts.css';
17
12
  import type { LayoutProps } from './$types';
18
13
 
19
- // `data.nav` is built server-side in `+layout.server.ts` from the SMRT
20
- // manifest and hydrated here — no client-side nav fetch. `children` is the
21
- // active page, which renders inside AdminShell's `<main>` and keeps its own
22
- // server load + `invalidate` flow (see `+page.server.ts`).
23
14
  let { data, children }: LayoutProps = $props();
24
15
 
25
- // Both wrappers are SSR-safe. ThemeProvider emits its `--smrt-*` token
26
- // variables as an inline style computed during render (no `window`), so the
27
- // tokens are present in the server HTML with no unstyled flash; it only reads
28
- // `matchMedia` after mount to resolve `colorScheme="system"`. AdminShell's
29
- // public core likewise renders statically and only wires WASD hotkeys /
30
- // localStorage persistence after mount. Nothing here reads `window` or
31
- // `localStorage` at module or render time. `$app/state`'s `page` is populated
32
- // on the server too, so `currentHref` is correct on first paint.
33
16
  const currentHref = $derived(page.url.pathname);
17
+ const activeTenantLabel = $derived(
18
+ data.session.activeTenantId ? 'Authorized session tenant' : 'No active tenant',
19
+ );
34
20
 
35
- // Minimal, static system chips. Full live wiring (job counts, dispatch
36
- // depth, connection state) is a documented next step: feed real values
37
- // through `systemFeed` (`@happyvertical/smrt-svelte/workspace/live`) or
38
- // `activityFeed` (`@happyvertical/smrt-svelte/web`)
39
- // — see the migration guide and the playground `admin-shell-system-feed`
40
- // demo. Keeping these static here avoids loading the client engine on every
41
- // page of the scaffold.
42
- const statusChips: ShellStatusChip[] = [
43
- { id: 'env', label: 'Local', tone: 'info' },
44
- { id: 'connection', label: 'Ready', tone: 'success' },
21
+ // Add application routes here. Generated REST routes live under /api and do
22
+ // not automatically imply a human-facing page.
23
+ const nav: ShellNavItem[] = [
24
+ {
25
+ href: '/',
26
+ label: 'Items',
27
+ description: 'The example s-m-r-t object',
28
+ },
29
+ {
30
+ href: '/settings',
31
+ label: 'Settings',
32
+ description: 'Workspace layout and shortcuts',
33
+ },
45
34
  ];
46
35
  </script>
47
36
 
48
- <!--
49
- ThemeProvider is the standard SMRT theming wrapper (mirrors the reference app
50
- `@happyvertical/smrt-content` → `src/routes/+layout.svelte`). It injects the
51
- full `--smrt-*` token set the shell and pages consume, and `colorScheme="system"`
52
- follows the OS light/dark preference at runtime; `persist` remembers a user's
53
- explicit choice. Wrap it OUTERMOST so AdminShell and every page render themed.
54
- -->
55
- <ThemeProvider colorScheme="system" persist={true}>
56
- <AdminShell title="SMRT App" subtitle="SvelteKit" storageKey="smrt-app-shell">
57
- {#snippet appPanel()}
58
- <AppScopePanel
59
- appName="SMRT App"
60
- tenantName="Local development"
61
- environment="local"
62
- showSettings={false}
37
+ <Provider>
38
+ <ThemeProvider preset="smrt" colorScheme="system" persist={true}>
39
+ <AdminShell
40
+ title="s-m-r-t app"
41
+ subtitle="SvelteKit"
42
+ storageKey="smrt-app-shell"
63
43
  >
64
- {#snippet docs()}
65
- <nav class="app-scope-links">
66
- <a href="/settings">Shell settings</a>
67
- </nav>
44
+ {#snippet appPanel()}
45
+ <AppScopePanel
46
+ appName="s-m-r-t app"
47
+ tenantName={activeTenantLabel}
48
+ environment="local"
49
+ showSettings={false}
50
+ >
51
+ {#snippet docs()}
52
+ {#if data.session.selectedTenantSlug}
53
+ <p class="tenant-selection">
54
+ Selected URL tenant: <strong>{data.session.selectedTenantSlug}</strong>
55
+ </p>
56
+ {/if}
57
+ <a href="/settings">Shell settings</a>
58
+ {/snippet}
59
+ </AppScopePanel>
68
60
  {/snippet}
69
- </AppScopePanel>
70
- {/snippet}
71
61
 
72
- {#snippet tenantPanel()}
73
- <TenantNav items={data.nav} {currentHref} />
74
- {/snippet}
75
-
76
- {#snippet systemBar()}
77
- <div class="system-bar">
78
- <SystemStatusChips chips={statusChips} />
79
- <a class="system-bar__settings" href="/settings">Settings</a>
80
- </div>
81
- {/snippet}
62
+ {#snippet tenantPanel()}
63
+ <TenantNav items={nav} {currentHref} />
64
+ {/snippet}
82
65
 
83
- {@render children()}
84
- </AdminShell>
85
- </ThemeProvider>
66
+ {@render children()}
67
+ </AdminShell>
68
+ </ThemeProvider>
69
+ </Provider>
86
70
 
87
71
  <style>
72
+ :global(*),
73
+ :global(*::before),
74
+ :global(*::after) {
75
+ box-sizing: border-box;
76
+ }
77
+
78
+ :global(html),
88
79
  :global(body) {
80
+ min-height: 100%;
89
81
  margin: 0;
90
82
  }
91
83
 
92
- .app-scope-links {
93
- display: grid;
94
- gap: var(--smrt-spacing-1);
84
+ :global(body) {
85
+ background: var(--smrt-color-background);
86
+ color: var(--smrt-color-on-background);
87
+ font-family: var(--smrt-typography-body-font-family, Inter, system-ui, sans-serif);
95
88
  }
96
89
 
97
- .app-scope-links a,
98
- .system-bar__settings {
99
- color: var(--smrt-color-primary);
100
- text-decoration: none;
90
+ .tenant-selection {
91
+ margin: 0 0 var(--smrt-spacing-2);
92
+ color: var(--smrt-color-on-surface-variant);
101
93
  }
102
94
 
103
- .system-bar {
104
- display: flex;
105
- align-items: center;
106
- justify-content: space-between;
107
- gap: var(--smrt-spacing-3);
108
- inline-size: 100%;
95
+ a {
96
+ color: var(--smrt-color-primary);
109
97
  }
110
98
  </style>