@lightdash/query-sdk 1.74.1 → 1.75.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * const lightdash = createClient() // auto-detects from environment
6
6
  */
7
7
  import { createApiTransport } from './apiTransport';
8
+ import { applyColorSchemeSeed, mountColorScheme } from './colorScheme';
8
9
  import { mountInspector } from './inspector';
9
10
  import { mountLineage } from './lineage';
10
11
  import { createPostMessageTransport } from './postMessageTransport';
@@ -80,10 +81,13 @@ export function createClient() {
80
81
  const projectUuid = params.get('projectUuid') ?? '';
81
82
  mountInspector(window.parent);
82
83
  mountLineage(window.parent);
84
+ mountColorScheme(window.parent);
83
85
  return new LightdashClient({ apiKey: '', baseUrl: '', projectUuid }, createPostMessageTransport({ targetWindow: window.parent, projectUuid }));
84
86
  }
85
87
  }
86
- // 2. Env vars → API transport
88
+ // 2. Env vars → API transport. No host to follow, but honour a `?theme=`
89
+ // seed so an author running the app top-level can see both modes.
90
+ applyColorSchemeSeed();
87
91
  const config = configFromEnv();
88
92
  if (!config) {
89
93
  throw new Error('Missing Lightdash client config. ' +
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Host color scheme — the iframe-side half of "data apps follow the host's
3
+ * light/dark mode". The Lightdash host owns the resolved scheme (its own theme
4
+ * toggle, or an embed's `?theme=`) and it reaches the app two ways, each doing
5
+ * a job the other can't:
6
+ *
7
+ * - a `theme=` seed in the iframe URL hash, read **synchronously** by
8
+ * `mountColorScheme()` from `createClient()`. That runs before React
9
+ * renders, so the app's first rendered frame is already in the right
10
+ * scheme. Only the seed can do this: a message reply lands a tick later,
11
+ * by which point React may already have committed the wrong mode.
12
+ * - `lightdash:sdk:theme` messages, which keep the app in step with every
13
+ * later host toggle without reloading the iframe.
14
+ *
15
+ * Delivery is a handshake, like `vizContext`: `mountColorScheme()` posts
16
+ * `lightdash:sdk:theme-request` once its listener is live, and re-posts it
17
+ * whenever the host announces `lightdash:sdk:ready` (the same belt-and-braces
18
+ * `manifest` uses). Whichever side mounts first, the app ends up on the host's
19
+ * scheme. Without it the app would depend on `createClient()` having run before
20
+ * the host's iframe `load` push, which the template happens to guarantee but
21
+ * app-owned `main.jsx` can silently break.
22
+ *
23
+ * Applying means toggling `.dark` on `<html>` (Tailwind's `darkMode: ['class']`
24
+ * hook, and the only scope Radix portals inherit) plus the CSS `color-scheme`
25
+ * property, so form controls and scrollbars follow too.
26
+ *
27
+ * Seed and message both cross a trust boundary — the hash is user-editable and
28
+ * the message is postMessage — so both are validated, and messages are only
29
+ * accepted from the window the client was created against.
30
+ */
31
+ export type HostColorScheme = 'light' | 'dark';
32
+ /** Host → iframe, in reply to a request and on every host theme change. */
33
+ export type HostColorSchemeMessage = {
34
+ type: 'lightdash:sdk:theme';
35
+ colorScheme: HostColorScheme;
36
+ };
37
+ /** Iframe → host, once this module's listener is live. */
38
+ export type HostColorSchemeRequestMessage = {
39
+ type: 'lightdash:sdk:theme-request';
40
+ };
41
+ export declare const HOST_COLOR_SCHEME_MESSAGE = "lightdash:sdk:theme";
42
+ export declare const HOST_COLOR_SCHEME_REQUEST_MESSAGE = "lightdash:sdk:theme-request";
43
+ /**
44
+ * Read the scheme seed. The iframe hash is where the host forwards it; the
45
+ * search param is the top-level (local dev) fallback. Null when absent or not
46
+ * one of the two valid values.
47
+ */
48
+ export declare function parseColorSchemeSeed(location: {
49
+ hash: string;
50
+ search: string;
51
+ }): HostColorScheme | null;
52
+ /** Stamp the scheme onto `<html>`. Idempotent. */
53
+ export declare function applyColorScheme(scheme: HostColorScheme): void;
54
+ export type ColorSchemeStore = {
55
+ getScheme: () => HostColorScheme;
56
+ /**
57
+ * Stamp `<html>` and publish. Always re-applies (the DOM may have been
58
+ * clobbered since), but only notifies when the value actually changed.
59
+ */
60
+ setScheme: (scheme: HostColorScheme) => void;
61
+ subscribe: (listener: () => void) => () => void;
62
+ };
63
+ /**
64
+ * Minimal external store: synchronous reads for useSyncExternalStore. The seed
65
+ * is resolved by the caller and passed in, so the value never depends on when
66
+ * it was first read. Exported for tests — app code uses `useColorScheme`.
67
+ */
68
+ export declare function createColorSchemeStore(options: {
69
+ seed: HostColorScheme;
70
+ apply?: (scheme: HostColorScheme) => void;
71
+ }): ColorSchemeStore;
72
+ /**
73
+ * Apply the URL seed and nothing else. Used by `createClient()` on the API
74
+ * transport (top-level local dev), where there is no host to talk to but
75
+ * `?theme=dark` should still render the app dark — otherwise an author can
76
+ * never see the dark half of the contract `skill.md` asks them to satisfy.
77
+ */
78
+ export declare function applyColorSchemeSeed(): void;
79
+ /**
80
+ * Follow the host's scheme: apply the seed, listen for `lightdash:sdk:theme`
81
+ * from `targetWindow`, and ask the host to send the current value now. Called
82
+ * by `createClient()` when the postMessage transport is detected. One listener
83
+ * per bundle — re-mounting replaces the previous listener rather than stacking.
84
+ */
85
+ export declare function mountColorScheme(targetWindow: Window): () => void;
86
+ /**
87
+ * The scheme the app is currently rendering in. CSS should follow the `.dark`
88
+ * class through the theme tokens; reach for this hook only where a value can't
89
+ * be expressed in CSS — a charting library's theme object, an image swap.
90
+ *
91
+ * const colorScheme = useColorScheme();
92
+ * <ResponsiveContainer theme={colorScheme === 'dark' ? darkTheme : lightTheme} />
93
+ */
94
+ export declare function useColorScheme(): HostColorScheme;
95
+ /**
96
+ * Test-only seam: drops the listener and the shared store so the next mount
97
+ * re-reads the seed. The store itself is covered directly through
98
+ * `createColorSchemeStore`; this exists so the mount tests can vary the URL.
99
+ */
100
+ export declare function resetColorSchemeState(): void;
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Host color scheme — the iframe-side half of "data apps follow the host's
3
+ * light/dark mode". The Lightdash host owns the resolved scheme (its own theme
4
+ * toggle, or an embed's `?theme=`) and it reaches the app two ways, each doing
5
+ * a job the other can't:
6
+ *
7
+ * - a `theme=` seed in the iframe URL hash, read **synchronously** by
8
+ * `mountColorScheme()` from `createClient()`. That runs before React
9
+ * renders, so the app's first rendered frame is already in the right
10
+ * scheme. Only the seed can do this: a message reply lands a tick later,
11
+ * by which point React may already have committed the wrong mode.
12
+ * - `lightdash:sdk:theme` messages, which keep the app in step with every
13
+ * later host toggle without reloading the iframe.
14
+ *
15
+ * Delivery is a handshake, like `vizContext`: `mountColorScheme()` posts
16
+ * `lightdash:sdk:theme-request` once its listener is live, and re-posts it
17
+ * whenever the host announces `lightdash:sdk:ready` (the same belt-and-braces
18
+ * `manifest` uses). Whichever side mounts first, the app ends up on the host's
19
+ * scheme. Without it the app would depend on `createClient()` having run before
20
+ * the host's iframe `load` push, which the template happens to guarantee but
21
+ * app-owned `main.jsx` can silently break.
22
+ *
23
+ * Applying means toggling `.dark` on `<html>` (Tailwind's `darkMode: ['class']`
24
+ * hook, and the only scope Radix portals inherit) plus the CSS `color-scheme`
25
+ * property, so form controls and scrollbars follow too.
26
+ *
27
+ * Seed and message both cross a trust boundary — the hash is user-editable and
28
+ * the message is postMessage — so both are validated, and messages are only
29
+ * accepted from the window the client was created against.
30
+ */
31
+ import { useSyncExternalStore } from 'react';
32
+ export const HOST_COLOR_SCHEME_MESSAGE = 'lightdash:sdk:theme';
33
+ export const HOST_COLOR_SCHEME_REQUEST_MESSAGE = 'lightdash:sdk:theme-request';
34
+ const SDK_READY_MESSAGE = 'lightdash:sdk:ready';
35
+ // Mirrored by the host in packages/frontend/src/features/apps/utils/appIframeUrl.ts
36
+ const COLOR_SCHEME_PARAM = 'theme';
37
+ const DARK_CLASS = 'dark';
38
+ const isHostColorScheme = (value) => value === 'light' || value === 'dark';
39
+ /**
40
+ * Read the scheme seed. The iframe hash is where the host forwards it; the
41
+ * search param is the top-level (local dev) fallback. Null when absent or not
42
+ * one of the two valid values.
43
+ */
44
+ export function parseColorSchemeSeed(location) {
45
+ const raw = new URLSearchParams(location.hash.replace(/^#/, '')).get(COLOR_SCHEME_PARAM) ?? new URLSearchParams(location.search).get(COLOR_SCHEME_PARAM);
46
+ return isHostColorScheme(raw) ? raw : null;
47
+ }
48
+ /** Stamp the scheme onto `<html>`. Idempotent. */
49
+ export function applyColorScheme(scheme) {
50
+ if (typeof document === 'undefined')
51
+ return;
52
+ const root = document.documentElement;
53
+ root.classList.toggle(DARK_CLASS, scheme === 'dark');
54
+ root.style.colorScheme = scheme;
55
+ }
56
+ /**
57
+ * Minimal external store: synchronous reads for useSyncExternalStore. The seed
58
+ * is resolved by the caller and passed in, so the value never depends on when
59
+ * it was first read. Exported for tests — app code uses `useColorScheme`.
60
+ */
61
+ export function createColorSchemeStore(options) {
62
+ const apply = options.apply ?? applyColorScheme;
63
+ let scheme = options.seed;
64
+ const listeners = new Set();
65
+ return {
66
+ getScheme: () => scheme,
67
+ setScheme: (next) => {
68
+ const changed = next !== scheme;
69
+ scheme = next;
70
+ apply(next);
71
+ if (changed)
72
+ listeners.forEach((listener) => listener());
73
+ },
74
+ subscribe: (listener) => {
75
+ listeners.add(listener);
76
+ return () => {
77
+ listeners.delete(listener);
78
+ };
79
+ },
80
+ };
81
+ }
82
+ /**
83
+ * Initial value: the URL seed, else whatever is already on `<html>` — an app
84
+ * that pins its own scheme in `:root`/`index.html` keeps reporting that one
85
+ * until the host says otherwise.
86
+ */
87
+ function readSeed() {
88
+ if (typeof window === 'undefined')
89
+ return 'light';
90
+ const seed = parseColorSchemeSeed(window.location);
91
+ if (seed)
92
+ return seed;
93
+ return document.documentElement.classList.contains(DARK_CLASS)
94
+ ? 'dark'
95
+ : 'light';
96
+ }
97
+ // Lazily created so tests (and SSR) never touch window at import time.
98
+ let sharedStore = null;
99
+ function getSharedStore() {
100
+ if (sharedStore === null) {
101
+ sharedStore = createColorSchemeStore({ seed: readSeed() });
102
+ }
103
+ return sharedStore;
104
+ }
105
+ let activeCleanup = null;
106
+ /**
107
+ * Apply the URL seed and nothing else. Used by `createClient()` on the API
108
+ * transport (top-level local dev), where there is no host to talk to but
109
+ * `?theme=dark` should still render the app dark — otherwise an author can
110
+ * never see the dark half of the contract `skill.md` asks them to satisfy.
111
+ */
112
+ export function applyColorSchemeSeed() {
113
+ applyColorScheme(getSharedStore().getScheme());
114
+ }
115
+ /**
116
+ * Follow the host's scheme: apply the seed, listen for `lightdash:sdk:theme`
117
+ * from `targetWindow`, and ask the host to send the current value now. Called
118
+ * by `createClient()` when the postMessage transport is detected. One listener
119
+ * per bundle — re-mounting replaces the previous listener rather than stacking.
120
+ */
121
+ export function mountColorScheme(targetWindow) {
122
+ if (typeof window === 'undefined')
123
+ return () => { };
124
+ const store = getSharedStore();
125
+ applyColorScheme(store.getScheme());
126
+ const request = {
127
+ type: HOST_COLOR_SCHEME_REQUEST_MESSAGE,
128
+ };
129
+ // Wildcard for the same reason as every other outbound bridge message: the
130
+ // sandboxed iframe has an opaque origin and can't derive the parent's.
131
+ const post = () => targetWindow.postMessage(request, '*');
132
+ const handler = (event) => {
133
+ if (event.source !== targetWindow)
134
+ return;
135
+ const data = event.data;
136
+ // Re-ask on `sdk:ready`: if the host mounted its bridge after our first
137
+ // request, that request went nowhere and this is the recovery.
138
+ if (data?.type === SDK_READY_MESSAGE) {
139
+ post();
140
+ return;
141
+ }
142
+ if (data?.type !== HOST_COLOR_SCHEME_MESSAGE)
143
+ return;
144
+ const { colorScheme } = data;
145
+ if (!isHostColorScheme(colorScheme))
146
+ return;
147
+ store.setScheme(colorScheme);
148
+ };
149
+ activeCleanup?.();
150
+ window.addEventListener('message', handler);
151
+ const cleanup = () => {
152
+ window.removeEventListener('message', handler);
153
+ if (activeCleanup === cleanup)
154
+ activeCleanup = null;
155
+ };
156
+ activeCleanup = cleanup;
157
+ post();
158
+ return cleanup;
159
+ }
160
+ /**
161
+ * The scheme the app is currently rendering in. CSS should follow the `.dark`
162
+ * class through the theme tokens; reach for this hook only where a value can't
163
+ * be expressed in CSS — a charting library's theme object, an image swap.
164
+ *
165
+ * const colorScheme = useColorScheme();
166
+ * <ResponsiveContainer theme={colorScheme === 'dark' ? darkTheme : lightTheme} />
167
+ */
168
+ export function useColorScheme() {
169
+ const store = getSharedStore();
170
+ return useSyncExternalStore(store.subscribe, store.getScheme, () => 'light');
171
+ }
172
+ /**
173
+ * Test-only seam: drops the listener and the shared store so the next mount
174
+ * re-reads the seed. The store itself is covered directly through
175
+ * `createColorSchemeStore`; this exists so the mount tests can vary the URL.
176
+ */
177
+ export function resetColorSchemeState() {
178
+ activeCleanup?.();
179
+ sharedStore = null;
180
+ }
package/dist/features.js CHANGED
@@ -65,6 +65,12 @@ export const SDK_FEATURES = [
65
65
  description: "Let viewers adjust the visualization from the Lightdash config panel — toggles, dropdowns, numbers, text and colours — and take series colours from the chart's palette, without regenerating the app.",
66
66
  wiring: 'Declare configOptions (and colorPalette, if the viz colours series) in the viz schema, then read options[name] and colorPalette from useVizContext().',
67
67
  },
68
+ {
69
+ key: 'follow-host-theme',
70
+ label: 'Follow the host light/dark mode',
71
+ description: "Render in whatever light or dark mode the viewer's Lightdash (or embed) is set to, instead of a fixed theme, and restyle live when they switch.",
72
+ wiring: 'Keep complete light tokens in :root and dark tokens in .dark, remove any fixed dark class from the app shell, and use useColorScheme() only for colours that cannot be expressed in CSS.',
73
+ },
68
74
  ];
69
75
  export const SDK_FEATURE_KEYS = SDK_FEATURES.map((f) => f.key);
70
76
  export const SDK_MANIFEST_MESSAGE_TYPE = 'lightdash:sdk:manifest';
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "1.74.1";
1
+ export declare const SDK_VERSION = "1.75.1";
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/generateSdkVersion.mjs (prebuild) — do not edit.
2
- export const SDK_VERSION = '1.74.1';
2
+ export const SDK_VERSION = '1.75.1';
package/dist/index.d.ts CHANGED
@@ -16,5 +16,7 @@ export { exportToSheets } from './exportToSheets';
16
16
  export type { ExportToSheetsOptions, ExportToSheetsResult, } from './exportToSheets';
17
17
  export { VizContextProvider, useVizContext, getFormatted, getRaw, } from './vizContext';
18
18
  export type { VizContext, VizContextCell, VizContextOptionValue, VizContextRow, DataAppVizContextMessage, VizContextRequestMessage, } from './vizContext';
19
+ export { useColorScheme } from './colorScheme';
20
+ export type { HostColorScheme, HostColorSchemeMessage, HostColorSchemeRequestMessage, } from './colorScheme';
19
21
  export { useUrlState } from './urlState';
20
22
  export type { SdkUrlStateChangeMessage, UrlStateMap } from './urlState';
package/dist/index.js CHANGED
@@ -20,5 +20,7 @@ export { useDelivery } from './delivery';
20
20
  export { exportToSheets } from './exportToSheets';
21
21
  // Data app viz render context (host-pushed rows + field mapping + config options)
22
22
  export { VizContextProvider, useVizContext, getFormatted, getRaw, } from './vizContext';
23
+ // Host light/dark mode (seeded from the iframe URL, updated by the host)
24
+ export { useColorScheme } from './colorScheme';
23
25
  // Shareable URL state (seeded from and written back to the host page URL)
24
26
  export { useUrlState } from './urlState';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightdash/query-sdk",
3
- "version": "1.74.1",
3
+ "version": "1.75.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "SDK for building custom data apps against the Lightdash semantic layer",
@@ -34,7 +34,7 @@
34
34
  "jsdom": "26.1.0",
35
35
  "typescript": "7.0.2",
36
36
  "vitest": "4.1.6",
37
- "@lightdash/common": "1.74.1"
37
+ "@lightdash/common": "1.75.1"
38
38
  },
39
39
  "scripts": {
40
40
  "prebuild": "node ./scripts/generateSdkVersion.mjs",