@svgrid/create 2.7.0 → 2.8.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 (29) hide show
  1. package/index.mjs +5 -1
  2. package/package.json +2 -2
  3. package/templates/pivot-dashboard/README.md +74 -0
  4. package/templates/pivot-dashboard/_gitignore +23 -0
  5. package/templates/pivot-dashboard/_package.json +27 -0
  6. package/templates/pivot-dashboard/src/app.css +15 -0
  7. package/templates/pivot-dashboard/src/app.d.ts +11 -0
  8. package/templates/pivot-dashboard/src/app.html +22 -0
  9. package/templates/pivot-dashboard/src/lib/drill.ts +141 -0
  10. package/templates/pivot-dashboard/src/lib/facts.ts +81 -0
  11. package/templates/pivot-dashboard/src/lib/theme.svelte.ts +87 -0
  12. package/templates/pivot-dashboard/src/routes/+layout.svelte +79 -0
  13. package/templates/pivot-dashboard/src/routes/+page.server.ts +11 -0
  14. package/templates/pivot-dashboard/src/routes/+page.svelte +138 -0
  15. package/templates/pivot-dashboard/src/routes/DrillRail.svelte +82 -0
  16. package/templates/pivot-dashboard/src/routes/TrendChart.svelte +62 -0
  17. package/templates/pivot-dashboard/tsconfig.json +20 -0
  18. package/templates/pivot-dashboard/vite.config.ts +20 -0
  19. package/templates/sveltekit/README.md +33 -3
  20. package/templates/sveltekit/src/app.d.ts +10 -2
  21. package/templates/sveltekit/src/hooks.server.ts +49 -0
  22. package/templates/sveltekit/src/lib/server/auth.ts +177 -0
  23. package/templates/sveltekit/src/routes/+layout.server.ts +10 -0
  24. package/templates/sveltekit/src/routes/+layout.svelte +16 -1
  25. package/templates/sveltekit/src/routes/login/+page.server.ts +51 -0
  26. package/templates/sveltekit/src/routes/login/+page.svelte +55 -0
  27. package/templates/sveltekit/src/routes/logout/+server.ts +24 -0
  28. package/templates/sveltekit/src/routes/people/+page.server.ts +12 -3
  29. package/templates/sveltekit/src/routes/people/+page.svelte +10 -5
package/index.mjs CHANGED
@@ -24,9 +24,13 @@ const TEMPLATES = {
24
24
  bundled: join(__dirname, 'templates', 'minimal'),
25
25
  },
26
26
  sveltekit: {
27
- label: 'SvelteKit - server load, URL-driven sort, form-action edits, theme picker',
27
+ label: 'SvelteKit - server load, URL-driven sort, form-action edits, cookie auth + roles',
28
28
  bundled: join(__dirname, 'templates', 'sveltekit'),
29
29
  },
30
+ 'pivot-dashboard': {
31
+ label: 'Pivot dashboard - pivot cube + linked chart + drill-through (needs @svgrid/enterprise)',
32
+ bundled: join(__dirname, 'templates', 'pivot-dashboard'),
33
+ },
30
34
  'admin-dashboard': {
31
35
  label: 'Admin dashboard - SvelteKit shell, multiple grids, deploy to Vercel',
32
36
  bundled: join(__dirname, 'templates', 'admin-dashboard'),
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "type": "commercial",
5
5
  "url": "https://svgrid.com/pricing"
6
6
  },
7
- "version": "2.7.0",
7
+ "version": "2.8.0",
8
8
  "description": "Scaffold a Svelte app powered by SvGrid in one command: npm create @svgrid@latest",
9
9
  "type": "module",
10
10
  "license": "MIT",
@@ -36,7 +36,7 @@
36
36
  "node": ">=18"
37
37
  },
38
38
  "dependencies": {
39
- "@svgrid/grid": "^2.6.17"
39
+ "@svgrid/grid": "^2.7.0"
40
40
  },
41
41
  "scripts": {
42
42
  "sync-templates": "node sync-templates.mjs"
@@ -0,0 +1,74 @@
1
+ # SvGrid pivot dashboard
2
+
3
+ A pivot cube, a linked chart, and a drill-through rail over one fact table -
4
+ the three pieces a reporting screen usually needs, wired so they cannot
5
+ disagree with each other.
6
+
7
+ ```bash
8
+ npm install
9
+ npm run dev # http://localhost:5173/
10
+ ```
11
+
12
+ ## What to try
13
+
14
+ 1. **Drag `Channel` into Rows** in the panel on the right. The cube, the chart
15
+ and any open drill all follow, because all three read the same layout.
16
+ 2. **Click any aggregated cell.** The rail opens with the exact rows behind that
17
+ number. The KPI at the top is recomputed from those rows, so it always equals
18
+ the cell you clicked.
19
+ 3. **Click a subtotal, then the grand total.** Same code path - a subtotal
20
+ simply contributes fewer filter terms, so the slice widens. The grand total
21
+ filters on nothing and returns all 1,800 facts.
22
+ 4. **Switch the measure** to `Units` in the Values well. The chart axis, the
23
+ rail KPI and the formatting all switch with it.
24
+ 5. **Click a bar** in the chart to drill that whole dimension value.
25
+
26
+ ## How the drill works
27
+
28
+ A pivot cell is the intersection of a row path and a column path, and the two
29
+ arrive in different shapes:
30
+
31
+ - the **row** path is the chain of ancestors up to the clicked `PivotRow`,
32
+ matched positionally against `layout.rows`;
33
+ - the **column** path is encoded in the column id - `pv__<dim>__<dim>__m<i>`,
34
+ where the trailing `m<i>` indexes into `layout.values`.
35
+
36
+ `src/lib/drill.ts` decodes both into one `{ field: value }` filter and returns
37
+ the facts that match every entry. Because the total is recomputed from those
38
+ same facts rather than read off the grid, the rail and the cube cannot drift
39
+ apart.
40
+
41
+ That module is deliberately free of Svelte and of the grid packages, so you can
42
+ unit-test your own reporting rules against it.
43
+
44
+ ## Where things are
45
+
46
+ | File | Does |
47
+ | --- | --- |
48
+ | `src/lib/facts.ts` | The fact table. Seeded and deterministic. Swap `loadFacts` for your query. |
49
+ | `src/lib/drill.ts` | Pure drill-through: cell -> filter -> facts. No Svelte, no grid imports. |
50
+ | `src/routes/+page.server.ts` | Builds the facts on the server and sends them with the page. |
51
+ | `src/routes/+page.svelte` | The dashboard: designer, chart and rail over one `facts` array. |
52
+ | `src/routes/DrillRail.svelte` | The rail: KPIs plus a grid of the underlying rows. |
53
+ | `src/routes/TrendChart.svelte` | The bar chart. Inline SVG-free DOM, styled with `--sg-*`. |
54
+
55
+ ## Licensing
56
+
57
+ `SvPivotDesigner` is part of `@svgrid/enterprise`, which is commercial. The app
58
+ runs unlicensed - it just nudges - so you can evaluate it before buying. Replace
59
+ the key in `src/routes/+page.svelte`:
60
+
61
+ ```ts
62
+ setLicenseKey('SVENTERPRISE-DEV-DEMO')
63
+ ```
64
+
65
+ Pricing: https://svgrid.com/pricing
66
+
67
+ ## Going to production
68
+
69
+ The facts are generated in-process. Point `loadFacts` at your warehouse and keep
70
+ the aggregation on the server if the fact table is large - the browser should
71
+ receive facts, not a database connection. Everything downstream takes `Fact[]`,
72
+ so nothing else has to change.
73
+
74
+ Full guide: https://svgrid.com/docs/enterprise/pivot/
@@ -0,0 +1,23 @@
1
+ node_modules
2
+
3
+ # Output
4
+ .output
5
+ .vercel
6
+ .netlify
7
+ .wrangler
8
+ /.svelte-kit
9
+ /build
10
+
11
+ # OS
12
+ .DS_Store
13
+ Thumbs.db
14
+
15
+ # Env
16
+ .env
17
+ .env.*
18
+ !.env.example
19
+ !.env.test
20
+
21
+ # Vite
22
+ vite.config.js.timestamp-*
23
+ vite.config.ts.timestamp-*
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "svgrid-pivot-dashboard",
3
+ "private": true,
4
+ "version": "0.0.1",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite dev",
8
+ "build": "vite build",
9
+ "preview": "vite preview",
10
+ "prepare": "svelte-kit sync || echo ''",
11
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
12
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
13
+ },
14
+ "devDependencies": {
15
+ "@sveltejs/adapter-auto": "^7.0.1",
16
+ "@sveltejs/kit": "^2.63.0",
17
+ "@sveltejs/vite-plugin-svelte": "^7.1.2",
18
+ "svelte": "^5.56.1",
19
+ "svelte-check": "^4.6.0",
20
+ "typescript": "^6.0.3",
21
+ "vite": "^8.0.16"
22
+ },
23
+ "dependencies": {
24
+ "@svgrid/grid": "^2.6.8",
25
+ "@svgrid/enterprise": "^2.6.4"
26
+ }
27
+ }
@@ -0,0 +1,15 @@
1
+ /* SvGrid theme. One of the 20 presets @svgrid/grid ships.
2
+ *
3
+ * Importing it as a stylesheet means the FIRST paint - and the server-rendered
4
+ * HTML - is already themed, before any JavaScript runs. The theme picker in the
5
+ * layout overrides these values at runtime by setting the same --sg-* custom
6
+ * properties on <html>.
7
+ *
8
+ * `npm create @svgrid@latest -- --theme <id>` rewrites the line between the
9
+ * markers, so pick a starting theme at scaffold time if you prefer. */
10
+ /* svgrid-theme:start */
11
+ @import '@svgrid/grid/themes/ember.css';
12
+ /* svgrid-theme:end */
13
+
14
+ * { box-sizing: border-box; }
15
+ body { margin: 0; }
@@ -0,0 +1,11 @@
1
+ // See https://svelte.dev/docs/kit/types#app.d.ts
2
+ declare global {
3
+ namespace App {
4
+ // interface Error {}
5
+ // interface Locals {}
6
+ // interface PageData {}
7
+ // interface Platform {}
8
+ }
9
+ }
10
+
11
+ export {};
@@ -0,0 +1,22 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="text-scale" content="scale" />
7
+ <script>
8
+ // Settle the theme before the first paint. Without this the server-rendered
9
+ // HTML shows one palette and the picker in +layout.svelte swaps it a frame
10
+ // later. With nothing saved yet we follow the OS.
11
+ try {
12
+ var saved = JSON.parse(localStorage.getItem('svgrid-theme') || 'null')
13
+ var fallback = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
14
+ document.documentElement.dataset.theme = (saved && saved.mode) || fallback
15
+ } catch (e) {}
16
+ </script>
17
+ %sveltekit.head%
18
+ </head>
19
+ <body data-sveltekit-preload-data="hover">
20
+ <div style="display: contents">%sveltekit.body%</div>
21
+ </body>
22
+ </html>
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Drill-through: turn a clicked pivot cell back into the facts behind it.
3
+ *
4
+ * A pivot cell is the intersection of a row path and a column path. To find its
5
+ * source rows you need both, and the pivot gives them in two different shapes:
6
+ *
7
+ * - the **row** path is the chain of ancestors up to the clicked `PivotRow`,
8
+ * matched positionally against `layout.rows`;
9
+ * - the **column** path is encoded in the column id, `pv__<dim>__<dim>__m<i>`,
10
+ * where the trailing `m<i>` indexes into `layout.values`.
11
+ *
12
+ * Both decode to a plain `{ field: value }` filter, and the facts that match
13
+ * every entry are the ones that produced the cell. Because the total is
14
+ * recomputed from those same facts, the rail can never disagree with the grid.
15
+ *
16
+ * Kept free of Svelte and of the grid packages so it can be unit-tested on its
17
+ * own - see drill.test.ts.
18
+ */
19
+ import type { Fact } from './facts'
20
+
21
+ /** The subset of `PivotRow` this needs. Structural, so it does not drag the
22
+ * enterprise types into a file that is otherwise pure. */
23
+ export type PivotRowLike = {
24
+ __pivotId?: string
25
+ __pivotParentId?: string | null
26
+ __pivotLabel?: unknown
27
+ __pivotKind?: string
28
+ }
29
+
30
+ /** The subset of `PivotLayout` this needs. */
31
+ export type PivotLayoutLike = {
32
+ rows: string[]
33
+ cols: string[]
34
+ values: { field: string }[]
35
+ }
36
+
37
+ export type Drill = {
38
+ /** Human label for the clicked row, for the rail heading. */
39
+ rowLabel: string
40
+ /** Field/value pairs the facts must match. */
41
+ filter: Record<string, string>
42
+ /** The facts behind the cell. */
43
+ facts: Fact[]
44
+ /** Which measure the clicked column showed. */
45
+ measure: string
46
+ /** That measure summed over `facts` - equal to the cell the user clicked. */
47
+ total: number
48
+ }
49
+
50
+ /**
51
+ * Walk from the clicked row up to the root, then match the chain positionally
52
+ * against `layout.rows`.
53
+ *
54
+ * The grand-total row filters on nothing: every fact contributed to it.
55
+ */
56
+ export function rowFilterFor(
57
+ row: PivotRowLike,
58
+ allRows: PivotRowLike[],
59
+ layout: PivotLayoutLike,
60
+ ): Record<string, string> {
61
+ if (row.__pivotKind === 'grandTotal') return {}
62
+ const chain: PivotRowLike[] = []
63
+ let current: PivotRowLike | undefined = row
64
+ while (current) {
65
+ chain.unshift(current)
66
+ // Annotated: without it TypeScript infers `parentId` from `current`, which
67
+ // this line then reassigns, and the inference becomes circular.
68
+ const parentId: string | null | undefined = current.__pivotParentId
69
+ current = parentId ? allRows.find((r) => r.__pivotId === parentId) : undefined
70
+ }
71
+ const filter: Record<string, string> = {}
72
+ for (let i = 0; i < chain.length && i < layout.rows.length; i += 1) {
73
+ filter[layout.rows[i]!] = String(chain[i]!.__pivotLabel)
74
+ }
75
+ return filter
76
+ }
77
+
78
+ /**
79
+ * Decode a pivot column id into its column-axis filter and its measure.
80
+ *
81
+ * A subtotal column carries fewer dimension segments than `layout.cols` has,
82
+ * which is exactly right: fewer constraints means a wider slice.
83
+ */
84
+ export function colFilterFor(
85
+ colId: string,
86
+ layout: PivotLayoutLike,
87
+ ): { filter: Record<string, string>; measure: string } {
88
+ const parts = colId.startsWith('pv__') ? colId.slice(4).split('__') : []
89
+ const last = parts[parts.length - 1]
90
+ const measureIndex = last?.startsWith('m') ? Number(last.slice(1)) : 0
91
+ const dimensions = parts.slice(0, Math.max(0, parts.length - 1))
92
+
93
+ const filter: Record<string, string> = {}
94
+ for (let i = 0; i < dimensions.length && i < layout.cols.length; i += 1) {
95
+ filter[layout.cols[i]!] = dimensions[i]!
96
+ }
97
+ return { filter, measure: layout.values[measureIndex]?.field ?? layout.values[0]?.field ?? 'revenue' }
98
+ }
99
+
100
+ /**
101
+ * The facts behind one clicked cell, plus the measure total over them.
102
+ *
103
+ * Works for leaf cells, subtotals and the grand total without special-casing:
104
+ * each simply contributes fewer filter entries, so the slice widens.
105
+ */
106
+ export function drillThrough(
107
+ facts: Fact[],
108
+ row: PivotRowLike,
109
+ colId: string,
110
+ allRows: PivotRowLike[],
111
+ layout: PivotLayoutLike,
112
+ ): Drill {
113
+ const filter = { ...rowFilterFor(row, allRows, layout), ...colFilterFor(colId, layout).filter }
114
+ const { measure } = colFilterFor(colId, layout)
115
+
116
+ const matched = facts.filter((fact) => {
117
+ for (const [field, value] of Object.entries(filter)) {
118
+ if (String((fact as unknown as Record<string, unknown>)[field]) !== value) return false
119
+ }
120
+ return true
121
+ })
122
+
123
+ const total = matched.reduce(
124
+ (sum, fact) => sum + Number((fact as unknown as Record<string, unknown>)[measure] ?? 0),
125
+ 0,
126
+ )
127
+
128
+ return {
129
+ rowLabel: row.__pivotKind === 'grandTotal' ? 'Grand total' : String(row.__pivotLabel ?? ''),
130
+ filter,
131
+ facts: matched,
132
+ measure,
133
+ total,
134
+ }
135
+ }
136
+
137
+ /** "EMEA / Germany / Q1" - the filter read back as a breadcrumb. */
138
+ export function drillBreadcrumb(drill: Drill): string {
139
+ const parts = Object.values(drill.filter)
140
+ return parts.length ? parts.join(' / ') : 'All data'
141
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The fact table the whole dashboard is built on.
3
+ *
4
+ * A pivot summarises facts; a drill-through walks back to the exact facts
5
+ * behind one summarised cell. Both read this one array, which is why the number
6
+ * in the grid and the number in the drill rail can never disagree.
7
+ *
8
+ * Stands in for your warehouse. Swap `loadFacts` for a query and nothing
9
+ * downstream changes - the pivot, the chart and the drill all take `Fact[]`.
10
+ */
11
+
12
+ export type Region = 'AMER' | 'EMEA' | 'APAC'
13
+ export type Channel = 'Online' | 'Retail' | 'Wholesale'
14
+ export type Quarter = 'Q1' | 'Q2' | 'Q3' | 'Q4'
15
+
16
+ export type Fact = {
17
+ id: number
18
+ year: number
19
+ quarter: Quarter
20
+ region: Region
21
+ country: string
22
+ city: string
23
+ channel: Channel
24
+ customer: string
25
+ revenue: number
26
+ units: number
27
+ }
28
+
29
+ /** Which country and city belong to which region - keeps the seed coherent, so
30
+ * drilling into EMEA never turns up a US city. */
31
+ const TOPOLOGY: Record<Region, Record<string, string[]>> = {
32
+ AMER: { USA: ['New York', 'Austin', 'Seattle'], Canada: ['Toronto', 'Vancouver'] },
33
+ EMEA: { Germany: ['Berlin', 'Munich'], UK: ['London', 'Manchester'], France: ['Paris'] },
34
+ APAC: { Japan: ['Tokyo', 'Osaka'], India: ['Mumbai', 'Bangalore'] },
35
+ }
36
+
37
+ const CHANNELS: Channel[] = ['Online', 'Retail', 'Wholesale']
38
+ const CUSTOMERS = [
39
+ 'Acme Corp', 'Globex', 'Initech', 'Umbrella', 'Vandelay', 'Pied Piper',
40
+ 'Hooli', 'Stark Industries', 'Tyrell', 'Wayne Ent.', 'Wonka', 'Cyberdyne',
41
+ ]
42
+
43
+ /**
44
+ * Seeded PRNG, so every reload and every server instance produces the same
45
+ * dataset. With `Math.random` the SSR pass and the hydrated client would
46
+ * generate different numbers and Svelte would report a hydration mismatch.
47
+ */
48
+ function makeRandom(seed: number): () => number {
49
+ let state = seed >>> 0
50
+ return () => {
51
+ state = (state * 1664525 + 1013904223) >>> 0
52
+ return state / 0xffffffff
53
+ }
54
+ }
55
+
56
+ /** Build the fact table. Deterministic for a given `count` and `seed`. */
57
+ export function loadFacts(count = 1800, seed = 0xda7a101): Fact[] {
58
+ const rnd = makeRandom(seed)
59
+ const pick = <T>(items: readonly T[]): T => items[Math.floor(rnd() * items.length)]!
60
+ const regions = Object.keys(TOPOLOGY) as Region[]
61
+
62
+ const facts: Fact[] = []
63
+ for (let id = 1; id <= count; id += 1) {
64
+ const region = pick(regions)
65
+ const country = pick(Object.keys(TOPOLOGY[region]))
66
+ const city = pick(TOPOLOGY[region][country]!)
67
+ facts.push({
68
+ id,
69
+ year: pick([2025, 2026] as const),
70
+ quarter: pick(['Q1', 'Q2', 'Q3', 'Q4'] as const),
71
+ region,
72
+ country,
73
+ city,
74
+ channel: pick(CHANNELS),
75
+ customer: pick(CUSTOMERS),
76
+ revenue: Math.round(2_000 + rnd() * 48_000),
77
+ units: Math.round(5 + rnd() * 300),
78
+ })
79
+ }
80
+ return facts
81
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Runtime theme switching.
3
+ *
4
+ * `@svgrid/grid/themes` ships 20 presets and a `resolveThemeTokens(preset, mode)`
5
+ * helper that returns the `--sg-*` custom properties for a given preset and
6
+ * light/dark mode. Writing those onto `<html>` re-themes the grid live - there
7
+ * is nothing to rebuild and no stylesheet to swap.
8
+ *
9
+ * `app.css` imports one preset as a stylesheet so the very first paint (and the
10
+ * server-rendered HTML) already has a theme before any JS runs. The values set
11
+ * here override it once the user picks something.
12
+ */
13
+ import {
14
+ getThemePreset,
15
+ resolveThemeTokens,
16
+ themePresets,
17
+ type ThemeMode,
18
+ } from '@svgrid/grid/themes'
19
+
20
+ /** Every preset, for the picker. */
21
+ export const presets = themePresets.map((p) => ({ id: p.id, name: p.name }))
22
+
23
+ const STORAGE_KEY = 'svgrid-theme'
24
+
25
+ // `npm create @svgrid@latest -- --theme <id> [--dark|--light]` patches the two
26
+ // values between these markers so the scaffolded app starts on the theme you
27
+ // asked for. INITIAL_MODE is only the fallback: a saved choice wins over it, and
28
+ // so does the OS preference when nobody has pinned a mode. The inline script in
29
+ // `app.html` settles the same question before the first paint.
30
+ /* svgrid-initial-theme:start */
31
+ export const INITIAL_THEME = 'ember'
32
+ export const INITIAL_MODE: ThemeMode = 'light'
33
+ /* svgrid-initial-theme:end */
34
+
35
+ type Saved = { id: string; mode: ThemeMode }
36
+
37
+ function restore(): Saved {
38
+ const fallback: Saved = { id: INITIAL_THEME, mode: INITIAL_MODE }
39
+ if (typeof document === 'undefined') return fallback
40
+ // Trust whatever app.html's inline script settled on, so the picker agrees
41
+ // with what is already on screen.
42
+ const painted = document.documentElement.dataset.theme
43
+ if (painted === 'dark' || painted === 'light') fallback.mode = painted
44
+ try {
45
+ const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null') as Saved | null
46
+ if (!saved || typeof saved.id !== 'string') return fallback
47
+ if (!getThemePreset(saved.id)) return fallback
48
+ return { id: saved.id, mode: saved.mode === 'dark' ? 'dark' : 'light' }
49
+ } catch {
50
+ return fallback
51
+ }
52
+ }
53
+
54
+ class ThemeState {
55
+ #initial = restore()
56
+ id = $state(this.#initial.id)
57
+ mode = $state<ThemeMode>(this.#initial.mode)
58
+
59
+ /** Push the current selection onto <html> as CSS custom properties. */
60
+ apply() {
61
+ if (typeof document === 'undefined') return
62
+ const tokens = resolveThemeTokens(getThemePreset(this.id) ?? getThemePreset(INITIAL_THEME)!, this.mode)
63
+ const root = document.documentElement
64
+ for (const [key, value] of Object.entries(tokens)) root.style.setProperty(key, value)
65
+ // Lets the browser theme form controls and scrollbars to match, and keeps
66
+ // any `[data-theme='dark']` rules in your own CSS in step.
67
+ root.style.colorScheme = this.mode
68
+ root.dataset.theme = this.mode
69
+ try {
70
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: this.id, mode: this.mode }))
71
+ } catch {
72
+ // Private mode, quota, a blocked origin - not worth breaking the page over.
73
+ }
74
+ }
75
+
76
+ set(id: string, mode: ThemeMode = this.mode) {
77
+ this.id = id
78
+ this.mode = mode
79
+ this.apply()
80
+ }
81
+
82
+ toggleMode() {
83
+ this.set(this.id, this.mode === 'dark' ? 'light' : 'dark')
84
+ }
85
+ }
86
+
87
+ export const theme = new ThemeState()
@@ -0,0 +1,79 @@
1
+ <script lang="ts">
2
+ import '../app.css'
3
+ import { theme, presets } from '$lib/theme.svelte'
4
+
5
+ let { children } = $props()
6
+
7
+ // Apply on mount so the picker's starting value wins over the stylesheet, and
8
+ // on every later change. Runs client-side only - the server-rendered HTML is
9
+ // already themed by the stylesheet import in app.css.
10
+ $effect(() => {
11
+ theme.apply()
12
+ })
13
+ </script>
14
+
15
+ <header class="bar">
16
+ <strong>Pivot dashboard</strong>
17
+
18
+ <label>
19
+ Theme
20
+ <select
21
+ value={theme.id}
22
+ onchange={(e) => theme.set(e.currentTarget.value)}
23
+ aria-label="Grid theme"
24
+ >
25
+ {#each presets as p (p.id)}
26
+ <option value={p.id}>{p.name}</option>
27
+ {/each}
28
+ </select>
29
+ </label>
30
+
31
+ <button type="button" onclick={() => theme.toggleMode()} aria-pressed={theme.mode === 'dark'}>
32
+ {theme.mode === 'dark' ? 'Dark' : 'Light'}
33
+ </button>
34
+
35
+ </header>
36
+
37
+ <main>
38
+ {@render children?.()}
39
+ </main>
40
+
41
+ <style>
42
+ .bar {
43
+ display: flex;
44
+ align-items: center;
45
+ gap: 1rem;
46
+ flex-wrap: wrap;
47
+ padding: 0.75rem 1.25rem;
48
+ border-bottom: 1px solid var(--sg-border);
49
+ background: var(--sg-header-bg);
50
+ color: var(--sg-header-fg);
51
+ font-family: system-ui, sans-serif;
52
+ }
53
+ .bar label {
54
+ display: flex;
55
+ align-items: center;
56
+ gap: 0.4rem;
57
+ font-size: 0.875rem;
58
+ }
59
+ .bar select,
60
+ .bar button {
61
+ font: inherit;
62
+ padding: 0.3rem 0.5rem;
63
+ border-radius: 6px;
64
+ border: 1px solid var(--sg-border);
65
+ background: var(--sg-bg);
66
+ color: var(--sg-fg);
67
+ }
68
+ .bar button {
69
+ cursor: pointer;
70
+ min-width: 4.5rem;
71
+ }
72
+ main {
73
+ padding: 1.25rem;
74
+ font-family: system-ui, sans-serif;
75
+ color: var(--sg-fg);
76
+ background: var(--sg-bg);
77
+ min-height: 100vh;
78
+ }
79
+ </style>
@@ -0,0 +1,11 @@
1
+ import type { PageServerLoad } from './$types'
2
+ import { loadFacts } from '$lib/facts'
3
+
4
+ /**
5
+ * The fact table is built on the server and sent with the page.
6
+ *
7
+ * A pivot over a warehouse query belongs on the server: the browser should get
8
+ * facts, not a database connection. Swap `loadFacts` for your query and the
9
+ * rest of the dashboard is unchanged.
10
+ */
11
+ export const load: PageServerLoad = () => ({ facts: loadFacts() })