@malloydata/malloyyo 0.2.24 → 0.2.25

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.
@@ -10,14 +10,28 @@ import { mountInPage } from "./frame-runtime/index";
10
10
  const info = window.__DASHBOARD__ || {};
11
11
  const name = info.name;
12
12
 
13
- // Reflect committed givens into the shell URL as `?d=<name>&$NAME=…`.
14
- const givensToUrl = (dashboard, givens) => {
13
+ // Reflect committed givens into the shell URL as `?d=<name>&$NAME=…`, plus any
14
+ // `~key` view-state (useUrlState). A tag-only dashboard runs no custom code, so
15
+ // there's normally no view-state here — but the two namespaces share one query
16
+ // string, so each write re-emits the other's params rather than erasing them.
17
+ let lastGivens = {};
18
+ // Seeded (`~key` -> value, prefix stripped) from what the shell put in the page,
19
+ // so a link's view-state survives the very first givens sync.
20
+ let lastUrlState = Object.fromEntries(
21
+ Object.entries(window.__INITIAL_URLSTATE__ || {}).map(([k, v]) => [k[0] === "~" ? k.slice(1) : k, v]),
22
+ );
23
+ const shareUrl = (dashboard) => {
15
24
  const u = new URL(location.href);
16
25
  u.search = "";
17
26
  u.searchParams.set("d", dashboard);
18
- for (const [k, v] of Object.entries(givens)) if (v != null && String(v) !== "") u.searchParams.set("$" + k, String(v));
27
+ for (const [k, v] of Object.entries(lastGivens)) if (v != null && String(v) !== "") u.searchParams.set("$" + k, String(v));
28
+ for (const [k, v] of Object.entries(lastUrlState)) if (v != null) u.searchParams.set("~" + k, String(v));
19
29
  return u.pathname + u.search;
20
30
  };
31
+ const givensToUrl = (dashboard, givens) => {
32
+ lastGivens = givens;
33
+ return shareUrl(dashboard);
34
+ };
21
35
 
22
36
  mountInPage({
23
37
  root: document.getElementById("root"),
@@ -32,7 +46,13 @@ mountInPage({
32
46
  .then((r) => r.json())
33
47
  .catch((e) => ({ ok: false, problems: [{ message: String(e) }] })),
34
48
  navigate: (dashboard, givens) => {
49
+ // Leaving this dashboard: carry the drilled givens, drop the view-state.
50
+ lastUrlState = {};
35
51
  location.href = givensToUrl(dashboard, givens);
36
52
  },
37
53
  syncGivens: (givens) => history.replaceState(null, "", givensToUrl(name, givens)),
54
+ syncUrlState: (state) => {
55
+ lastUrlState = state;
56
+ history.replaceState(null, "", shareUrl(name));
57
+ },
38
58
  });
@@ -18,6 +18,7 @@ export {
18
18
  useGiven,
19
19
  useOptions,
20
20
  useQuery,
21
+ useUrlState,
21
22
  mount,
22
23
  setHost,
23
24
  dashboardInfo,
@@ -59,8 +60,17 @@ export function mountInPage(opts: {
59
60
  run: (req: { query?: string; malloy?: string }, givens: Record<string, unknown>) => Promise<unknown>;
60
61
  navigate: (dashboard: string, givens: Record<string, unknown>) => void;
61
62
  syncGivens: (givens: Record<string, unknown>) => void;
63
+ /** Mirror useUrlState view-state into the URL as `~key` params. Optional: a
64
+ host that omits it simply has no shareable view-state (tag-only
65
+ dashboards run no custom code, so none exists). */
66
+ syncUrlState?: (state: Record<string, string>) => void;
62
67
  }): { unmount: () => void } {
63
- setHost({ run: opts.run, navigate: opts.navigate, syncGivens: opts.syncGivens });
68
+ setHost({
69
+ run: opts.run,
70
+ navigate: opts.navigate,
71
+ syncGivens: opts.syncGivens,
72
+ syncUrlState: opts.syncUrlState,
73
+ });
64
74
  // bodyReset:false — the dashboard is one element in the app shell, so it must
65
75
  // not restyle <body> (the iframe host DOES own the whole document, so it keeps
66
76
  // the reset). Returns the React root so the caller can unmount() on teardown.
@@ -80,8 +90,16 @@ export function mountStatic(
80
90
  run: (req: { query?: string; malloy?: string }, givens: Record<string, unknown>) => Promise<unknown>;
81
91
  navigate: (dashboard: string, givens: Record<string, unknown>) => void;
82
92
  syncGivens: (givens: Record<string, unknown>) => void;
93
+ /** Mirror useUrlState view-state into the URL as `~key` params — a static
94
+ site's custom components DO use it, so this host should supply it. */
95
+ syncUrlState?: (state: Record<string, string>) => void;
83
96
  },
84
97
  ): { unmount: () => void } {
85
- setHost({ run: opts.run, navigate: opts.navigate, syncGivens: opts.syncGivens });
98
+ setHost({
99
+ run: opts.run,
100
+ navigate: opts.navigate,
101
+ syncGivens: opts.syncGivens,
102
+ syncUrlState: opts.syncUrlState,
103
+ });
86
104
  return mount(Dashboard ?? DefaultDashboard, WIDGETS, opts.root, { bodyReset: false });
87
105
  }
@@ -9,9 +9,10 @@
9
9
  // and posts results back.
10
10
  //
11
11
  // Injected frame globals (read lazily — script order differs between hosts):
12
- // window.__DASHBOARD__ { name, query, title, description? } (the # artifact tag)
13
- // window.__GIVENS__ given specs introspected from the model's given: decls
14
- // window.__INITIAL_GIVENS__ URL-seeded given values for shareable links
12
+ // window.__DASHBOARD__ { name, query, title, description? } (the # artifact tag)
13
+ // window.__GIVENS__ given specs introspected from the model's given: decls
14
+ // window.__INITIAL_GIVENS__ URL-seeded given values ($-prefixed) for shareable links
15
+ // window.__INITIAL_URLSTATE__ URL-seeded useUrlState view-state (~-prefixed)
15
16
  import React, {
16
17
  createContext,
17
18
  useCallback,
@@ -35,9 +36,10 @@ export const dashboardInfo = () => window.__DASHBOARD__ || {};
35
36
  export const givenSpecs = () => window.__GIVENS__ || [];
36
37
 
37
38
  // ── host bridge ─────────────────────────────────────────────────────
38
- // The runtime performs three privileged actions it can't do itself: run a
39
- // governed query, navigate to a sibling dashboard, and mirror the committed
40
- // givens into the shareable URL. It delegates all three to a HOST. Two hosts
39
+ // The runtime performs four privileged actions it can't do itself: run a
40
+ // governed query, navigate to a sibling dashboard, mirror the committed givens
41
+ // into the shareable URL, and mirror a custom component's view-state
42
+ // (useUrlState) into it too. It delegates all of them to a HOST. Two hosts
41
43
  // implement the contract:
42
44
  // • postMessage host (default) — the sandboxed iframe: posts to the trusted
43
45
  // parent shell, which holds the runner. This is the CUSTOM-dashboard path.
@@ -74,6 +76,9 @@ let host = {
74
76
  syncGivens(givens) {
75
77
  parent.postMessage({ type: "givens", givens }, "*");
76
78
  },
79
+ syncUrlState(state) {
80
+ parent.postMessage({ type: "urlstate", state }, "*");
81
+ },
77
82
  };
78
83
 
79
84
  /** Swap the host — a tag-only dashboard mounted in the trusted page (mountInPage)
@@ -133,6 +138,74 @@ export function useGiven(name) {
133
138
  };
134
139
  }
135
140
 
141
+ // ── view-state in the URL (useUrlState) ─────────────────────────────
142
+ // Givens are the GOVERNED QUERY CONTRACT: declared in the model, filter-typed,
143
+ // they drive the auto-rendered controls and are visible to MCP. A custom
144
+ // component that computes its query inputs in JS has other state that belongs
145
+ // in the URL but is not a query parameter — an anagram rack, a Scrabble board,
146
+ // a "reuse letters" checkbox. Stuffing those into givens overloads what a given
147
+ // is (and they aren't in givenSpecs(), so they never rehydrate).
148
+ //
149
+ // useUrlState is that second channel: a useState twin whose value lives in the
150
+ // URL under a `~key` param, on the same parent<->frame transport as givens. The
151
+ // component runs in a sandboxed cross-origin iframe and CANNOT touch the
152
+ // top-level URL itself, so only the runtime + trusted parent can do this.
153
+ const UrlStateCtx = createContext(null);
154
+
155
+ /** value -> URL string. Strings pass through verbatim (a readable `~rack=cats`
156
+ beats `~rack=%22cats%22`); numbers/booleans stringify; everything else is
157
+ JSON. */
158
+ export function serializeUrlState(v) {
159
+ if (typeof v === "string") return v;
160
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
161
+ return JSON.stringify(v ?? null);
162
+ }
163
+
164
+ /** URL string -> value, typed by the `initial` the caller declared. A value the
165
+ URL can't produce (a bad number, malformed JSON) falls back to `initial`
166
+ rather than throwing — a hand-edited link must not white-screen a page. */
167
+ export function deserializeUrlState(raw, initial) {
168
+ if (typeof initial === "string") return raw;
169
+ if (typeof initial === "number") {
170
+ const n = Number(raw);
171
+ return Number.isFinite(n) ? n : initial;
172
+ }
173
+ if (typeof initial === "boolean") return raw === "true" || raw === "1";
174
+ try {
175
+ return JSON.parse(raw);
176
+ } catch {
177
+ return initial;
178
+ }
179
+ }
180
+
181
+ /** A `useState` twin backed by the browser URL — the dashboard-runtime
182
+ equivalent of useSearchParams, abstracted over the iframe boundary:
183
+
184
+ const [rack, setRack] = useUrlState("rack", ""); // string
185
+ const [reuse, setReuse] = useUrlState("reuse", false); // boolean
186
+ const [board, setBoard] = useUrlState("board", "........");
187
+
188
+ The value is read from `?~rack=…` on load (else `initial`), and every change
189
+ is mirrored back (debounced, replaceState — no history spam), so the URL is
190
+ always shareable/bookmarkable. `setValue` takes a value or an updater fn.
191
+ Typing follows `initial` (string / number / boolean / JSON-serializable);
192
+ it has nothing to do with Malloy given types. A value equal to `initial` is
193
+ dropped from the URL, so defaults never clutter the link. */
194
+ export function useUrlState(key, initial) {
195
+ const ctx = useContext(UrlStateCtx);
196
+ if (!ctx) throw new Error("useUrlState must run inside the dashboard runtime");
197
+ const { state, setKey } = ctx;
198
+ const raw = state[key];
199
+ const value = raw === undefined ? initial : deserializeUrlState(raw, initial);
200
+ // `initial` is usually an inline literal (a fresh array each render), so it
201
+ // can't be a dep — capture it in a ref the setter reads. The default a key is
202
+ // declared with doesn't change over a component's life.
203
+ const initialRef = useRef(initial);
204
+ initialRef.current = initial;
205
+ const set = useCallback((next) => setKey(key, next, initialRef.current), [key, setKey]);
206
+ return [value, set];
207
+ }
208
+
136
209
  // ── queries as hooks ────────────────────────────────────────────────
137
210
  /** Run a query and get plain data back: { rows, result, loading, error }.
138
211
  req: { query?: string, malloy?: string, givens?: object }. For charting
@@ -821,25 +894,61 @@ function Root({ Dashboard, extraProps }) {
821
894
  useEffect(() => {
822
895
  host.syncGivens(committed);
823
896
  }, [committed]);
897
+
898
+ // useUrlState's store: `~key` -> serialized string, seeded from the URL. It
899
+ // starts as the WHOLE seed (not just keys some hook has claimed) so params
900
+ // belonging to a component that hasn't mounted yet survive the first sync.
901
+ const urlSeed = useMemo(() => {
902
+ const s = {};
903
+ for (const [k, v] of Object.entries(window.__INITIAL_URLSTATE__ || {})) {
904
+ s[k[0] === "~" ? k.slice(1) : k] = v;
905
+ }
906
+ return s;
907
+ }, []);
908
+ const [urlState, setUrlState] = useState(urlSeed);
909
+ const setUrlKey = useCallback((key, next, initial) => {
910
+ setUrlState((prev) => {
911
+ const cur = prev[key] === undefined ? initial : deserializeUrlState(prev[key], initial);
912
+ const v = typeof next === "function" ? next(cur) : next;
913
+ const s = serializeUrlState(v);
914
+ if (s === prev[key]) return prev; // no-op set: don't re-render or re-sync
915
+ const out = { ...prev };
916
+ // A value back at its default is absent, not empty — keeps links clean.
917
+ if (s === serializeUrlState(initial)) delete out[key];
918
+ else out[key] = s;
919
+ return out;
920
+ });
921
+ }, []);
922
+ // Debounced: view-state is typed into (a rack, a board cell), and Safari
923
+ // throttles replaceState. Givens commit discretely, so they sync immediately.
924
+ useEffect(() => {
925
+ const t = setTimeout(() => host.syncUrlState?.(urlState), 150);
926
+ return () => clearTimeout(t);
927
+ }, [urlState]);
928
+ const urlCtx = useMemo(() => ({ state: urlState, setKey: setUrlKey }), [urlState, setUrlKey]);
929
+
824
930
  const ctx = useMemo(
825
931
  () => ({ givens: committed, draft, setGiven, apply, reset, dirty, autorun }),
826
932
  [committed, draft, setGiven, apply, reset, dirty, autorun],
827
933
  );
828
934
  return (
829
935
  <Ctx.Provider value={ctx}>
830
- <Dashboard
831
- dashboard={dashboardInfo()}
832
- givenSpecs={givenSpecs()}
833
- givens={committed}
834
- setGiven={setGiven}
835
- Panel={Panel}
836
- filters={filters}
837
- useGiven={useGiven}
838
- useOptions={useOptions}
839
- useQuery={useQuery}
840
- runData={runData}
841
- {...extraProps}
842
- />
936
+ <UrlStateCtx.Provider value={urlCtx}>
937
+ <Dashboard
938
+ dashboard={dashboardInfo()}
939
+ givenSpecs={givenSpecs()}
940
+ givens={committed}
941
+ setGiven={setGiven}
942
+ Panel={Panel}
943
+ filters={filters}
944
+ useGiven={useGiven}
945
+ useOptions={useOptions}
946
+ useQuery={useQuery}
947
+ useUrlState={useUrlState}
948
+ runData={runData}
949
+ {...extraProps}
950
+ />
951
+ </UrlStateCtx.Provider>
843
952
  </Ctx.Provider>
844
953
  );
845
954
  }
@@ -16,7 +16,7 @@ import * as duckdb from "@duckdb/duckdb-wasm";
16
16
  import { DuckDBWASMConnection } from "@malloydata/db-duckdb/wasm";
17
17
  import { API, SingleConnectionRuntime } from "@malloydata/malloy";
18
18
  import { mountStatic } from "./frame-runtime/index";
19
- import { givensFromSearch, givensToParams } from "./shared/givens-url";
19
+ import { givensFromSearch, shareSearch, urlStateFromSearch } from "./shared/givens-url";
20
20
 
21
21
  const info = window.__DASHBOARD__ || {};
22
22
  const MODEL_FILES = window.__MODEL_FILES__ || {};
@@ -138,11 +138,22 @@ async function run(req: { query?: string; malloy?: string }, givens: Record<stri
138
138
 
139
139
  // Same param encoding as the dev server (shared/givens-url); only the path
140
140
  // shape differs — sibling .html pages here vs `/?d=` there.
141
- const givensToUrl = (dashboard: string, givens: Record<string, unknown>) => {
141
+ //
142
+ // The URL carries TWO namespaces — `$given` and `~view-state` (useUrlState) —
143
+ // written by two independent syncs, so each write must re-emit the other's
144
+ // params or it would erase them. Both are cached here, seeded from the URL this
145
+ // page opened with, and every write rebuilds the whole query string.
146
+ let lastGivens: Record<string, unknown> = givensFromSearch(location.search);
147
+ let lastUrlState: Record<string, unknown> = urlStateFromSearch(location.search);
148
+
149
+ const dashUrl = (dashboard: string, givens: Record<string, unknown>, urlState: Record<string, unknown>) => {
142
150
  const u = new URL(`./${dashboard}.html`, document.baseURI);
143
- u.search = givensToParams(givens).toString();
144
- return u.pathname + u.search;
151
+ return u.pathname + shareSearch({ givens, urlState });
145
152
  };
153
+ // A drill LEAVES this dashboard: carry the givens it seeded, but not this
154
+ // component's view-state — that belongs to the component being left.
155
+ const navigateUrl = (dashboard: string, givens: Record<string, unknown>) =>
156
+ dashUrl(dashboard, givens, {});
146
157
 
147
158
  // Custom dashboards drive drills by posting to `parent` directly:
148
159
  // parent.postMessage({ type: "navigate", dashboard, givens }, "*")
@@ -157,11 +168,17 @@ function installMessageBridge(name: string) {
157
168
  const m = e.data;
158
169
  if (!m || typeof m !== "object") return;
159
170
  if (m.type === "givens" && m.givens) {
160
- history.replaceState(null, "", givensToUrl(name, m.givens));
171
+ lastGivens = m.givens;
172
+ history.replaceState(null, "", dashUrl(name, lastGivens, lastUrlState));
173
+ return;
174
+ }
175
+ if (m.type === "urlstate" && m.state) {
176
+ lastUrlState = m.state;
177
+ history.replaceState(null, "", dashUrl(name, lastGivens, lastUrlState));
161
178
  return;
162
179
  }
163
180
  if (m.type === "navigate" && typeof m.dashboard === "string") {
164
- location.href = givensToUrl(m.dashboard, m.givens || {});
181
+ location.href = navigateUrl(m.dashboard, m.givens || {});
165
182
  }
166
183
  });
167
184
  }
@@ -173,14 +190,24 @@ export function boot(Dashboard: unknown) {
173
190
  // there, here from our own query string, but through the SAME encoder, so the
174
191
  // `$` prefix the runtime keys off is preserved. Set before mount: the runtime
175
192
  // reads this global lazily when it computes initial given values.
176
- (window as any).__INITIAL_GIVENS__ = givensFromSearch(location.search);
193
+ (window as any).__INITIAL_GIVENS__ = lastGivens;
194
+ // …and the `~` half: a custom component's useUrlState (a rack, a board) —
195
+ // view-state, not a query parameter, but just as shareable.
196
+ (window as any).__INITIAL_URLSTATE__ = lastUrlState;
177
197
  installMessageBridge(info.name);
178
198
  return mountStatic(Dashboard, {
179
199
  root: document.getElementById("root") as HTMLElement,
180
200
  run,
181
201
  navigate: (dashboard, givens) => {
182
- location.href = givensToUrl(dashboard, givens);
202
+ location.href = navigateUrl(dashboard, givens);
203
+ },
204
+ syncGivens: (givens) => {
205
+ lastGivens = givens;
206
+ history.replaceState(null, "", dashUrl(info.name, lastGivens, lastUrlState));
207
+ },
208
+ syncUrlState: (state) => {
209
+ lastUrlState = state;
210
+ history.replaceState(null, "", dashUrl(info.name, lastGivens, lastUrlState));
183
211
  },
184
- syncGivens: (givens) => history.replaceState(null, "", givensToUrl(info.name, givens)),
185
212
  });
186
213
  }
package/dist/index.js CHANGED
@@ -148,7 +148,7 @@ import url from "node:url";
148
148
  var HOST_ONLY = "host_only";
149
149
  var contentFiles = {
150
150
  "dashboards/authoring.md": '---\ndescription: How to author a dashboard \u2014 a self-contained file in the dashboards/ directory that defines its own query; the entry point for dashboard how-to\n---\n\n# Authoring dashboards\n\nA dashboard is a **self-contained `.malloy` file in the `dashboards/`\ndirectory**. The file IS the dashboard: it imports the model parts it needs,\ndefines its query (with the filtering it applies), and tags it. No manifest, and\nfor the basic case no JavaScript. Preview with `malloyyo dashboard dev`; check\nwith `malloyyo lint`. Requires `@malloydata/malloy` 0.0.423+.\n\nRelated: `yo_help dashboards/givens-and-controls` (filter controls),\n`dashboards/grid-layout` (columns/colspan/break), `dashboards/custom-components`\n(a flat `<name>.jsx`), `dashboards/vega-charts` (`<VegaChart>`).\n\n> **Need a chart the `# bar_chart`/`# line_chart`/`# shape_map` tags can\'t do?**\n> Use the `<VegaChart>` COMPONENT (a Vega-Lite spec over query rows) \u2014 NOT a `#`\n> tag; there is no `# vega_lite`. See `yo_help dashboards/vega-charts`.\n\n## The layout\n\n```\nmodel/\n ecommerce.malloy # sources, reusable views/measures, # drill tags\n givens.malloy # given: declarations (the filter controls)\n index.malloy # imports/exports sources \u2014 the MCP/data surface ONLY\n dashboards/\n overview.malloy # one dashboard; the FILENAME is its name/slug\n overview.jsx # optional custom component for overview\n```\n\n**The filename is the dashboard\'s name** \u2014 its URL slug, its `# drill` target,\nand the basename of its optional component. Discovery globs\n`dashboards/*.malloy` and compiles EACH as its own entry \u2014 so dashboards are NOT\ndeclared in, or exported through, `index.malloy` (`index.malloy` is just the\n`query`/`describe_source` data surface).\n\n## Preferred: put the query IN the dashboard file\n\nDefine the query right in `dashboards/<name>.malloy` and tag it `# artifact`, so\nthe given mapping (the `where: \u2026 ~ $GIVEN`) is visible next to the dashboard:\n\n```malloy\n// dashboards/overview.malloy\n##! experimental.givens\nimport "../ecommerce.malloy" // BARE import: source + givens in scope\n\n#" Business health at a glance \u2014 sales, margin, orders.\n# artifact { title="Business Overview" } dashboard {columns=6}\nquery: overview is order_items -> {\n where: // the given mapping, HERE\n inventory_items.product_brand ~ $BRAND, // multi-filter where: is\n inventory_items.product_category ~ $CATEGORY, // COMMA separated\n created_at ~ $PERIOD\n # colspan=2\n aggregate: total_sales, total_gross_margin, order_count\n # colspan=3\n nest:\n # line_chart\n sales_trend is by_month\n top_brands\n # shape_map\n sales_by_state\n}\n```\n\nThat\'s a complete dashboard: the runtime auto-renders a title (the tag\'s\n`title`, else the `#"` doc), a control for every given the query references, and\nthe result panel. `# artifact` DECLARES the dashboard; `# dashboard {columns=6}`\nis the renderer tag that draws it as KPI tiles + a card grid \u2014 partners, on the\nsame line. Grid rules: `yo_help dashboards/grid-layout`.\n\n**You know you\'re doing it right when the `where: foo ~ $FOO` is in the DASHBOARD\nfile, not the model.** Keep the model\'s sources/views reusable and given-free;\neach dashboard decides its own filtering.\n\n**The bare import is required for controls.** A control renders only when the\ngiven\'s DECLARATION is in the dashboard file\'s scope \u2014 a bare\n`import "../ecommerce.malloy"` (or `import "../givens.malloy"`) brings them all;\nthe runtime shows a control for exactly the givens the query references. A\nselective `import { order_items } from \u2026` brings the filter but NOT the control.\n\nKeep the FILENAME as the name \u2014 don\'t set `name=`, so the URL, the\n`# drill { to=\u2026 }`, and the component basename all agree (one source of truth).\n\n## Other forms\n\n- **A view of a source you extend in the file** \u2014 tag the dashboard `view:` with\n `# artifact` (runs as `<source> -> <view>`). Good when the dashboard needs\n helper views defined alongside it.\n- **Compose existing views**: a model-level `## artifact { tiles=["a -> b", "c -> d"]\n dashboard_columns=6 }` (`##`, ONE line) names several views. Each tile runs as\n its own query (in parallel), and the results are combined into ONE\n `# dashboard` that Malloy\'s dashboard renderer lays out \u2014 so it looks exactly\n like the equivalent single-query dashboard: `dashboard_columns=N` sets the grid\n and `# colspan=N` / `# break` on the tile VIEWS place them. A tile that returns\n a SINGLE ROW with no group-by (an aggregate view) is merged in as top-level KPI\n tiles rather than a card (its `# colspan` is spread across those KPIs). The\n dashboard paints once the tiles are ready, with a single early paint if one tile\n straggles so a slow tile can\'t hold up the rest. Use for multi-tile /\n cross-source; prefer the inline query whenever a dashboard has its own filtering.\n- A `dashboards/*.malloy` with NO `# artifact`/`## artifact` is a shared INCLUDE\n (skipped by discovery) \u2014 put helper sources/views there for several dashboards\n to import.\n\n## Givens (filter controls)\n\nDeclare givens as `filter<T>` in the MODEL (`givens.malloy` or the source file) \u2014\nthey\'re shared and used by the MCP surface too; each dashboard APPLIES them in\nits `where:`. Full control reference: `yo_help dashboards/givens-and-controls`.\nPer-dashboard starting values go in the tag:\n\n```malloy\n# artifact { title="Ford recalls" givens { MANUFACTURER=f\'Ford Motor Company\' } }\n```\n\n## Drill from a dimension\n\n`# drill` on a source `dimension:` (in the model) makes its cells clickable \u2014\nopening another dashboard (seeding the value) or filtering in place:\n\n```malloy\ndimension:\n # drill { to=[category_explorer, self] }\n category is inventory_items.product_category\n```\n\n`to` is a list; each is a **dashboard slug** (a `dashboards/<slug>.malloy`\nfilename) \u2192 opens it, seeding the value into the given named like the dimension\nUPPER-cased (`category` \u2192 `CATEGORY`), or **`self`** \u2192 filter the current\ndashboard in place. Add `given=` when the target given differs. `lint` VERIFIES\nevery `to=` slug resolves to a real dashboard file (a typo/renamed dashboard\nfails loudly, not at click time).\n\n> **malloy#2979 (fixed in 0.0.423):** a `# drill` on a bare `group_by: name` was\n> dropped when nested through `+ {\u2026}`. Put it on the source `dimension:`, or use\n> `group_by: name is concat(name,\'\')`.\n\n## Custom component (optional)\n\nFor bespoke layout/charts, add a flat sibling `dashboards/<name>.jsx` (or\n`.tsx`). Only React + `@malloyyo/dashboard` importable (sandboxed). A bare\n`<Panel/>` renders the whole dashboard; a `<Panel query="\u2026"/>` /\n`<VegaChart query="\u2026"/>` runs a query DEFINED in this dashboard file (by name) or\na `source -> view`. `lint` checks each `query="\u2026"` still resolves. See `yo_help\ndashboards/custom-components`.\n\n## Rules\n- Each dashboard is one `dashboards/<name>.malloy`; the filename is the slug.\n Prefer the inline `query: \u2026 # artifact` form \u2014 the `where: ~ $GIVEN` lives in\n the dashboard file.\n- Bare-import the model (and/or `givens.malloy`) so the controls render.\n- Givens are `filter<T>` declared in the model; options come from `# suggest {\u2026}`;\n interactivity = setting given values, not rewriting query text.\n- `index.malloy` is the data surface, NOT where dashboards live.\n- If a query/given you need is missing, add it (check with `describe_source`).\n\n## Preview & validate\n`malloyyo dashboard dev` \u2192 open the URL; `.malloy`/`.jsx` edits hot-reload.\n`malloyyo lint` checks each dashboard file on its own: it compiles as its entry;\neach tile/query and `# suggest` compiles; `dashboard_columns` is a positive int;\nthe component compiles and its `query="\u2026"` resolve; no duplicate names, no\norphaned component; every `# drill { to=\u2026 }` resolves. Tight loop: the local\n`malloyyo mcp --develop` server hot-reloads edits \u2014 `query(execute:false)` to\ncompile-check, `execute:true` to run. Don\'t validate against a hosted/claude.ai\nconnector \u2014 it serves the PUBLISHED model (stale until `malloyyo publish`).\n',
151
- "dashboards/custom-components.md": '---\ndescription: Custom dashboard UI \u2014 a flat dashboards/<name>.jsx|tsx sibling composing @malloyyo/dashboard widgets/hooks/helpers with your own React\n---\n\n# Custom dashboard components (`dashboards/<name>.jsx`)\n\nThe default UI (auto-rendered controls + panel) covers most dashboards. When it\nisn\'t enough, add ONE file \u2014 a **flat sibling** `dashboards/<name>.jsx` (or\n`.tsx`) next to the dashboard\'s `dashboards/<name>.malloy` (same basename) \u2014 that\ncomposes the runtime\'s widgets/hooks with your own React. You own layout, copy,\nand theming; the `.malloy` file still owns every query and filter. See also\n`yo_help dashboards/authoring` and `dashboards/vega-charts`.\n\n```tsx\nimport React from "react";\nimport { Controls, Given, Search, Select, TimeRange, Panel, filters, useGiven } from "@malloyyo/dashboard";\n\nexport default function Dashboard({ dashboard, givens }) {\n return (\n <div style={{ maxWidth: 860, margin: "0 auto", padding: 24 }}>\n <h1>{dashboard.title}</h1>\n <Controls>\n <Given name="STATE" /> {/* picks the control from the declaration */}\n <Search given="NAME" /> {/* committing input + typeahead + validation */}\n <TimeRange given="PERIOD" presets={[\n { value: "", text: "All time" },\n { value: filters.lastN(1, "day"), text: "Last day" },\n { value: filters.lastN(1, "week"), text: "Last week" },\n { value: filters.lastN(1, "month"), text: "Last month" },\n ]} /> {/* "Custom range\u2026" is always appended */}\n <Select given="MIN_SAMPLE"\n options={[10, 200, 1000].map(n => ({ value: filters.greaterThan(n), text: `> ${n}` }))} />\n </Controls>\n <Panel givens={givens} /> {/* the dashboard itself (its tiles/query) */}\n <Panel query="baby_names -> births_by_decade" givens={givens} /> {/* a specific query */}\n </div>\n );\n}\n```\n\nFrom `@malloyyo/dashboard` (also handed to the component as props):\n- **Widgets** (headless-ish; restyle via className/style or the `--dash-*` CSS\n vars \u2014 see Theming below): `<Controls/>` (all givens, or compose children;\n grows Apply/Reset under `autorun=false`), `<Given name/>`,\n `<Select given [options]/>`, `<Search given/>` (committing input + typeahead +\n inline \u2715 clear), `<MultiSelect given [options]/>` (chip multi-select for a\n `filter<string>` \u2014 commits an exact-match list via `filters.oneOf`),\n `<Range given [min max]/>`, `<TimeRange given [presets]/>` (temporal presets +\n custom range), `<Checkbox given/>` (bound to a boolean given),\n `<VegaChart spec query|malloy|data givens/>` (a Vega-Lite chart over query\n rows \u2014 see `yo_help dashboards/vega-charts`)\n- **Hooks**: `useGiven(name)` \u2192 {value, set, spec};\n `useOptions(name, typed?)` \u2192 {options, loading} (typeahead);\n `useQuery({query|malloy, givens})` \u2192 {rows, loading, error} \u2014 plain rows\n for your own visuals\n- **Helpers**: `filters.oneOf/contains/between/atLeast/\u2026` build\n filter-expression strings with correct escaping; temporal:\n `filters.lastN(7, "day")` \u2192 `\'7 days\'`, `filters.dateRange("2026-01-01",\n "2026-07-01")`, `filters.afterDate/beforeDate`; read back with\n `filters.values/numberRange/threshold/inLast/temporalRange`;\n `filters.isValid(type, src)` checks typed input.\n Never hand-concatenate a filter string.\n **Escaping rule for custom controls:** a filter given\'s value is an\n EXPRESSION, so committing a raw column value is wrong the moment it contains\n a comma/percent/dash (\'Tesla, Inc.\' parses as two alternatives and matches\n nothing). Commit `filters.oneOf(value)` (exact) or\n `filters.contains(term)` (substring), and unwrap for display with\n `filters.values(src)`. The stock `<Select/>` does this automatically;\n `<Search/>` deliberately commits raw text (its input IS a filter\n expression).\n- `<Panel/>` runs against the DASHBOARD\'s own file: a bare `<Panel/>` renders\n the whole dashboard (its tiles); `<Panel query="\u2026"/>` runs a query defined in\n the dashboard file (by name) or a `source -> view`; `<Panel malloy="\u2026"/>` and\n `runData(text, givens)` run arbitrary Malloy as a RESTRICTED query (no import /\n given: / connection.* / raw SQL / ##! flags \u2014 the model\'s governed surface\n only). `lint` checks each hard-coded `query="\u2026"` still resolves.\n\n## Theming\n\nEvery widget is styled by the runtime\'s **default Malloyyo theme** (system\nfont, neutral grays, blue accent, auto light/dark following the viewer\'s OS) \u2014\na bare component looks styled with zero effort, so DON\'T hand-hardcode\n`fontFamily`/colors. The theme is CSS custom properties; override any subset by\nsetting them on a wrapper element (more specific than the runtime\'s `:root`):\n\n```tsx\n<div style={{ "--dash-accent": "#e11d48", "--dash-controls-bg": "#faf5ff" }}>\n <Controls /> \u2026\n</div>\n```\n\nVars: `--dash-font`, `--dash-bg`, `--dash-fg`, `--dash-muted`, `--dash-border`,\n`--dash-accent`, `--dash-accent-fg`, `--dash-control-bg`, `--dash-controls-bg`,\n`--dash-chip-bg`, `--dash-chip-fg`, `--dash-panel-bg`, `--dash-radius`,\n`--dash-danger`. `DefaultDashboard` also takes a `theme={{ accent, controlsBg }}`\nprop (camelCase keys \u2192 `--dash-*`). The results `<Panel>` keeps a light surface\nin both light/dark (the Malloy renderer has no dark theme) \u2014 override\n`--dash-panel-bg` if your renderer output is dark-safe.\n\nFor charts beyond the Malloy renderer\'s `#` tags, use `<VegaChart>` \u2014\n`yo_help dashboards/vega-charts`.\n',
151
+ "dashboards/custom-components.md": '---\ndescription: Custom dashboard UI \u2014 a flat dashboards/<name>.jsx|tsx sibling composing @malloyyo/dashboard widgets/hooks/helpers with your own React\n---\n\n# Custom dashboard components (`dashboards/<name>.jsx`)\n\nThe default UI (auto-rendered controls + panel) covers most dashboards. When it\nisn\'t enough, add ONE file \u2014 a **flat sibling** `dashboards/<name>.jsx` (or\n`.tsx`) next to the dashboard\'s `dashboards/<name>.malloy` (same basename) \u2014 that\ncomposes the runtime\'s widgets/hooks with your own React. You own layout, copy,\nand theming; the `.malloy` file still owns every query and filter. See also\n`yo_help dashboards/authoring` and `dashboards/vega-charts`.\n\n```tsx\nimport React from "react";\nimport { Controls, Given, Search, Select, TimeRange, Panel, filters, useGiven } from "@malloyyo/dashboard";\n\nexport default function Dashboard({ dashboard, givens }) {\n return (\n <div style={{ maxWidth: 860, margin: "0 auto", padding: 24 }}>\n <h1>{dashboard.title}</h1>\n <Controls>\n <Given name="STATE" /> {/* picks the control from the declaration */}\n <Search given="NAME" /> {/* committing input + typeahead + validation */}\n <TimeRange given="PERIOD" presets={[\n { value: "", text: "All time" },\n { value: filters.lastN(1, "day"), text: "Last day" },\n { value: filters.lastN(1, "week"), text: "Last week" },\n { value: filters.lastN(1, "month"), text: "Last month" },\n ]} /> {/* "Custom range\u2026" is always appended */}\n <Select given="MIN_SAMPLE"\n options={[10, 200, 1000].map(n => ({ value: filters.greaterThan(n), text: `> ${n}` }))} />\n </Controls>\n <Panel givens={givens} /> {/* the dashboard itself (its tiles/query) */}\n <Panel query="baby_names -> births_by_decade" givens={givens} /> {/* a specific query */}\n </div>\n );\n}\n```\n\nFrom `@malloyyo/dashboard` (also handed to the component as props):\n- **Widgets** (headless-ish; restyle via className/style or the `--dash-*` CSS\n vars \u2014 see Theming below): `<Controls/>` (all givens, or compose children;\n grows Apply/Reset under `autorun=false`), `<Given name/>`,\n `<Select given [options]/>`, `<Search given/>` (committing input + typeahead +\n inline \u2715 clear), `<MultiSelect given [options]/>` (chip multi-select for a\n `filter<string>` \u2014 commits an exact-match list via `filters.oneOf`),\n `<Range given [min max]/>`, `<TimeRange given [presets]/>` (temporal presets +\n custom range), `<Checkbox given/>` (bound to a boolean given),\n `<VegaChart spec query|malloy|data givens/>` (a Vega-Lite chart over query\n rows \u2014 see `yo_help dashboards/vega-charts`)\n- **Hooks**: `useGiven(name)` \u2192 {value, set, spec};\n `useOptions(name, typed?)` \u2192 {options, loading} (typeahead);\n `useQuery({query|malloy, givens})` \u2192 {rows, loading, error} \u2014 plain rows\n for your own visuals;\n `useUrlState(key, initial)` \u2192 [value, setValue] \u2014 shareable view-state (below)\n- **Helpers**: `filters.oneOf/contains/between/atLeast/\u2026` build\n filter-expression strings with correct escaping; temporal:\n `filters.lastN(7, "day")` \u2192 `\'7 days\'`, `filters.dateRange("2026-01-01",\n "2026-07-01")`, `filters.afterDate/beforeDate`; read back with\n `filters.values/numberRange/threshold/inLast/temporalRange`;\n `filters.isValid(type, src)` checks typed input.\n Never hand-concatenate a filter string.\n **Escaping rule for custom controls:** a filter given\'s value is an\n EXPRESSION, so committing a raw column value is wrong the moment it contains\n a comma/percent/dash (\'Tesla, Inc.\' parses as two alternatives and matches\n nothing). Commit `filters.oneOf(value)` (exact) or\n `filters.contains(term)` (substring), and unwrap for display with\n `filters.values(src)`. The stock `<Select/>` does this automatically;\n `<Search/>` deliberately commits raw text (its input IS a filter\n expression).\n- `<Panel/>` runs against the DASHBOARD\'s own file: a bare `<Panel/>` renders\n the whole dashboard (its tiles); `<Panel query="\u2026"/>` runs a query defined in\n the dashboard file (by name) or a `source -> view`; `<Panel malloy="\u2026"/>` and\n `runData(text, givens)` run arbitrary Malloy as a RESTRICTED query (no import /\n given: / connection.* / raw SQL / ##! flags \u2014 the model\'s governed surface\n only). `lint` checks each hard-coded `query="\u2026"` still resolves.\n\n## Shareable view-state: `useUrlState`\n\n`useState` is invisible to the page that owns the URL, so a component built on\nit has an address bar that never changes \u2014 the result can\'t be shared or\nbookmarked. **`useUrlState(key, initial)` is a `useState` twin whose value lives\nin the URL**, under a `~key` param:\n\n```jsx\nimport { useUrlState } from "@malloyyo/dashboard";\n\nconst [rack, setRack] = useUrlState("rack", ""); // string\nconst [reuse, setReuse] = useUrlState("reuse", false); // boolean\nconst [board, setBoard] = useUrlState("board", "........"); // string\nconst [topN, setTopN] = useUrlState("n", 20); // number\n```\n\n- Same shape as `useState`: `[value, setValue]`, and `setValue` takes a value\n **or** an updater fn (`setBoard(b => \u2026)`).\n- The value comes from the URL on load, else `initial`. Every change is written\n back (debounced, `replaceState` \u2014 no history spam), so the address bar is\n always a shareable link.\n- Typed by `initial`: string / number / boolean / any JSON-serializable value.\n Strings stay readable in the URL (`~rack=retinas`); objects and arrays are\n JSON. A value equal to `initial` is dropped from the URL, so defaults never\n clutter it, and a malformed value falls back to `initial` instead of throwing.\n- Works identically in `malloyyo dashboard dev`, on a bundled static site, and\n on a hosted instance \u2014 including inside the sandboxed iframe, which cannot\n reach the top-level URL on its own. That\'s why this is a hook and not\n something a component can do with `history.replaceState`.\n\n**Use it for view-state, not query parameters.** A `given:` is the governed,\nfilter-typed query contract: it\'s declared in the model, drives the default\ncontrols, and is visible over MCP \u2014 bind those with `useGiven` and they already\nround-trip through the URL as `$NAME`. `useUrlState` is for everything else a\ncustom component needs to make shareable: a letter rack whose real query inputs\n(allowed letters, min/max length) are computed from it in JS, a board layout, a\nmode toggle. The two namespaces (`$NAME` vs `~key`) never collide.\n\n## Theming\n\nEvery widget is styled by the runtime\'s **default Malloyyo theme** (system\nfont, neutral grays, blue accent, auto light/dark following the viewer\'s OS) \u2014\na bare component looks styled with zero effort, so DON\'T hand-hardcode\n`fontFamily`/colors. The theme is CSS custom properties; override any subset by\nsetting them on a wrapper element (more specific than the runtime\'s `:root`):\n\n```tsx\n<div style={{ "--dash-accent": "#e11d48", "--dash-controls-bg": "#faf5ff" }}>\n <Controls /> \u2026\n</div>\n```\n\nVars: `--dash-font`, `--dash-bg`, `--dash-fg`, `--dash-muted`, `--dash-border`,\n`--dash-accent`, `--dash-accent-fg`, `--dash-control-bg`, `--dash-controls-bg`,\n`--dash-chip-bg`, `--dash-chip-fg`, `--dash-panel-bg`, `--dash-radius`,\n`--dash-danger`. `DefaultDashboard` also takes a `theme={{ accent, controlsBg }}`\nprop (camelCase keys \u2192 `--dash-*`). The results `<Panel>` keeps a light surface\nin both light/dark (the Malloy renderer has no dark theme) \u2014 override\n`--dash-panel-bg` if your renderer output is dark-safe.\n\nFor charts beyond the Malloy renderer\'s `#` tags, use `<VegaChart>` \u2014\n`yo_help dashboards/vega-charts`.\n',
152
152
  "dashboards/givens-and-controls.md": "---\ndescription: Dashboard filter controls \u2014 declare filter<T> givens with # label / suggest / control tags; faceted (related) suggestions\n---\n\n# Dashboard givens & controls\n\nA dashboard's filters are `filter<T>` **givens** declared in the model; the\n`#` tags on each declaration drive its control. This is part of authoring a\ndashboard \u2014 see also `yo_help dashboards/authoring`.\n\n**Declare the filters as `filter<T>` givens** \u2014 never raw strings/numbers.\nA `filter<string>` value accepts one value ('NY'), alternatives ('NY, CA'),\nwildcards ('Ann%'), negation ('-NY'); a `filter<number>` accepts ranges\n('[1910 to 1930]') and comparisons ('> 200'); a `filter<timestamp>` /\n`filter<date>` accepts relative windows ('7 days' = the last 7 days, 'today',\n'last month') and literal ranges ('2026-01-01 to 2026-07-01' \u2014 NO `@` in\nfilter literals). Apply with `~`; `f''` = empty = no filter (the natural\n\"All\"/\"all time\" \u2014 just `col ~ $X`, no `$X = '' or \u2026` dance):\n\n```malloy\n##! experimental { givens }\ngiven:\n # label=\"State\" control=select suggest { source=baby_names dimension=state }\n STATE :: filter<string> is f'NY'\n # label=\"Brand\" suggest { query=brand_suggest dimension=product_brand }\n BRAND :: filter<string> is f''\n # label=\"Names\" control=multiselect suggest { query=name_suggest dimension=name }\n NAMES :: filter<string> is f''\n # label=\"Years\" range_min=1910 range_max=2025\n YEAR_RANGE :: filter<number> is f'[1910 to 1930]'\n # label=\"Time period\"\n PERIOD :: filter<timestamp> is f''\n # label=\"Include rare names\"\n INCLUDE_RARE :: boolean is false\n```\n\nTags on the declaration drive the control (tag syntax is `key=\"value\"` \u2014\nequals, not colon):\n- `label` \u2014 control caption (defaults to the given's name)\n- `suggest { \u2026 }` \u2014 where the control's options come from. NO Malloy code in\n strings \u2014 just names:\n - `suggest { query=brand_suggest dimension=product_brand }` \u2014 the FIRST\n COLUMN of a named query (declare the query in the model \u2014 governed and\n reviewable). PREFER THIS FORM. The query must be in scope where the dashboard\n runs (bring it in with the dashboard file's bare `import`).\n - `suggest { source=baby_names dimension=state }` \u2014 the DISTINCT VALUES of\n a dimension on a source (the source must be in the dashboard's scope)\n A `dimension` (in either form) is what enables SERVER-SIDE TYPEAHEAD: the\n runtime refines the base query with what the user has typed\n (`\u2026 + { where: lower(field) ~ f'll%'; limit: 50 }`, case-insensitive,\n escaped). Without a dimension the fetched list is filtered client-side.\n Runs as a restricted query; lint checks the declaration compiles.\n\n **RELATED (faceted) filters** \u2014 query-form only: a suggest query may\n reference the OTHER givens, and the runtime runs it with the dashboard's\n current values (the suggested given itself is excluded, so the list never\n collapses to the current pick). Brand suggestions narrow when Category is\n set:\n\n ```malloy\n query: brand_suggest is inventory_items -> product_brand + {\n where:\n product_category ~ $CATEGORY, // NOT product_brand ~ $BRAND\n product_department ~ $DEPARTMENT\n limit: 500\n }\n ```\n\n Declare one `*_suggest` per filter, each referencing the others; `f''`\n defaults mean unset filters don't constrain. `source=` suggests can't do\n this (no place for a `where:`) \u2014 another reason to prefer `query=`.\n- `control=select` \u2014 a fixed dropdown instead of a typeahead search box\n- `control=multiselect` \u2014 a tokenized multi-select for a `filter<string>`:\n each pick is a removable chip, the committed value is an exact-match list\n (`Emma, Olivia, Sophia`). Ideal for \"pick several\" filters (names, brands).\n Suggestions come from the given's `suggest {\u2026}` (server-side typeahead when\n it names a dimension). Empty (start from `f''`) = no filter (all).\n- `range_min` / `range_max` \u2014 bounds; makes a filter<number> given a\n dual-thumb range slider\n- anything else passes through in `spec.tags` for custom components\n\nControl picked from the declaration automatically: numeric range tags \u2192\ndual-thumb slider; `filter<timestamp|timestamptz|date>` \u2192 the TimeRange\nwidget (relative presets: Today / Last 7 days / Last 30 days / \u2026 plus a\n\"Custom range\u2026\" from/to date picker); `control=multiselect` \u2192 chip\nmulti-select; suggest + control=select \u2192 dropdown; boolean \u2192 checkbox;\nanything else \u2192 committing search box with typeahead (an inline \u2715 clears it;\na \"Press \u21B5 to apply\" hint shows while the typed draft differs from what's\nrunning \u2014 free text can't safely re-run per keystroke).\nThe suggest-driven options are DATA VALUES only \u2014 options that aren't column\nvalues (custom time presets, threshold buckets) need a custom component\n(`yo_help dashboards/custom-components`) with explicit `{value, text}` options\nwhere value is a filter expression built with `filters.*`.\n\n## When the query re-runs: live (default) vs. Apply\n\nBy default a dashboard is **live** \u2014 every control change re-runs the query\nimmediately (the committing search box is the exception: free text commits on\nEnter/blur, since a half-typed filter is invalid). To batch changes behind an\n**Apply** button instead, set `autorun=false` on the `# artifact` tag:\n\n```malloy\n# artifact { name=\"births-by-name\" title=\"Births by name\" autorun=false }\n```\n\n`autorun=false` makes `<Controls>` grow an Apply/Reset pair \u2014 controls edit a\ndraft and nothing re-runs until Apply. Reach for it when the query is expensive\nor several filters are usually changed together; leave it off (live) otherwise.\n",
153
153
  "dashboards/grid-layout.md": "---\ndescription: Dashboard grid layout \u2014 # dashboard {columns=N} with # colspan and # break to place KPI tiles and charts\n---\n\n# Dashboard grid layout (`# dashboard {columns=N}`)\n\nBy default a `# dashboard` result flows its KPI tiles and cards and wraps.\nAdd `{columns=N}` to place them on a fixed **N-column grid** instead \u2014 use\n`columns=6`, which divides evenly into 2- and 3-wide cards.\n\n**Key mechanic:** a tag placed ABOVE `aggregate:` or `nest:` applies to EVERY\nitem declared in that block. So you set card widths once per block, not per\nfield.\n\n```malloy\n# artifact { title=\"Customer Insights\" } dashboard {columns=6}\nview: customer_insights is {\n where: created_at ~ $PERIOD\n # colspan=2\n aggregate: total_sales, user_count, order_count, average_order_value\n # colspan=3\n nest:\n # break\n # bar_chart\n users_by_spend_tier\n sales_by_traffic_source\n # shape_map\n sales_by_state\n # colspan=6\n recent_orders // wide detail table \u2192 full width\n}\n```\n\n## The conventions\n\n- **`# colspan=2` above `aggregate:`** \u2014 each KPI / measure tile spans 2 of 6\n columns \u2192 3 tiles per row.\n- **`# colspan=3` above `nest:`** \u2014 each graph or small table spans 3 \u2192 2 per\n row. Per-item render tags (`# line_chart`, `# bar_chart`, `# shape_map`) still\n go on the individual nested items.\n- **`# colspan=6`** \u2014 a single wide / many-column table gets its own full-width\n line. Tag that one item; a per-item `# colspan` overrides the block default.\n- **`# break` on the FIRST nest item** \u2014 starts the graphs on a fresh row, so\n KPI tiles and charts never share one. The renderer splits fields into a new\n grid at each `# break`. Just always add it: it's a no-op when the tiles\n already fill complete rows, and the fix when they don't (e.g. 4 measures\n leave a lone tile a colspan-3 chart would otherwise pack in beside).\n\n`# colspan` only does anything in columns mode \u2014 without `{columns=N}` the\nlayout is free-flow wrap and colspan is ignored. Clamp colspans to `1..N`.\n\nSee also `yo_help dashboards/vega-charts` for custom charts, and the fuller\nauthoring guide surfaced by the local `malloyyo mcp` server.\n",
154
154
  "dashboards/vega-charts.md": '---\ndescription: Custom dashboard charts with Vega-Lite \u2014 the <VegaChart> component, for charts the # renderer tags can\'t do\n---\n\n# Custom charts with Vega-Lite (`<VegaChart>`)\n\nWhen Malloy\'s renderer tags (`# bar_chart`, `# line_chart`, `# shape_map`, \u2026)\ndon\'t cover the chart you want, a dashboard can draw a **Vega-Lite** spec with\nthe `<VegaChart>` component. The chart engine ships in the dashboard runtime, so\nyou author only a JSON spec + a Malloy query \u2014 no library to load.\n\n**It is a COMPONENT, not a `#` tag.** There is no `# vega_lite` or\n`# scatter_chart` tag. `<VegaChart>` lives in a custom component \u2014 a flat sibling\n`dashboards/<name>.jsx` (or `.tsx`) next to the dashboard\'s\n`dashboards/<name>.malloy` \u2014 a different layer from the `#` renderer tags. (The\ndashboard\'s query is declared in the `.malloy` file; the component only\ncustomizes presentation. Preview with `malloyyo dashboard dev`, validate with\n`malloyyo lint`.)\n\n## The recipe\n\n```tsx\nimport { VegaChart } from "@malloyyo/dashboard";\n\n// Encodings point at the query\'s OUTPUT COLUMN NAMES (here: name, births).\nconst spec = {\n mark: { type: "bar", tooltip: true },\n encoding: {\n y: { field: "name", type: "nominal", sort: "-x" },\n x: { field: "births", type: "quantitative" },\n },\n};\n\nexport default function Dashboard({ givens }) {\n return <VegaChart spec={spec} query="births_by_name" givens={givens} />;\n}\n```\n\nThree ways to feed it data:\n- `<VegaChart spec={spec} query="births_by_name" givens={givens}/>` \u2014 a query\n defined in the dashboard\'s `.malloy` file (by name), or a `source -> view`\n- `<VegaChart spec={spec} malloy="source -> view" givens={givens}/>` \u2014 restricted\n Malloy text (same governance as the explore surface: no import / given: /\n connection.* / raw SQL / ##! flags)\n- `<VegaChart spec={spec} data={rows}/>` \u2014 rows you already have from `useQuery`\n\n## Gotchas (the ones that actually bite)\n\n- **Shape the data in Malloy; return FLAT rows.** Do ranking, share/percent\n (`all(x, dim)`), and label lookups (a `pick` for month names) in the QUERY.\n The spec just encodes columns \u2014 it is not the place to reshape data.\n- **Match column names character-for-character.** Run the query once with\n `query(execute:true)` and read the exact output column names; the spec\'s\n `field` values must match them exactly.\n- **The spec\'s `data` is ignored / any `url` is stripped.** The frame has no\n network \u2014 remote data URLs, transform lookups, and remote `image` marks are\n removed. Adapting a Vega-Lite gallery example = delete its\n `"data": {"url": \u2026}` and repoint the encodings; the query rows are inlined for\n you as the dataset.\n- **Nests come back as arrays.** Flatten to plottable rows in the query, or bind\n a nest to its own chart: `<VegaChart data={row.my_nest}/>`.\n- **Interactivity = setting given values**, never rewriting query text per\n interaction. Client-side chart interactions (tooltip, zoom, brush) work;\n anything that calls a server does not.\n- **Reads well:** for normalized/share data use a diverging color scale with\n `domainMid` (e.g. `1/12` for month-share), and sort a discrete axis by a\n companion numeric field (`month_name` sorted by `month_num`) rather than\n alphabetically.\n\n## Validate\n\n`malloyyo lint` checks the query, the givens, AND the component (it compiles,\nand each `query="\u2026"` it references resolves) \u2014 your only pre-browser check. Then\n`malloyyo dashboard dev` to see it render.\n',
@@ -2765,13 +2765,21 @@ import path5 from "node:path";
2765
2765
  import * as esbuild2 from "esbuild";
2766
2766
 
2767
2767
  // src/shared/givens-url.ts
2768
+ var URL_STATE_PREFIX = "~";
2768
2769
  function givensFromSearch(search) {
2769
2770
  const g = {};
2770
2771
  for (const [k, v] of new URLSearchParams(search)) {
2771
- if (k !== "d") g[k] = v;
2772
+ if (k !== "d" && k.charAt(0) !== URL_STATE_PREFIX) g[k] = v;
2772
2773
  }
2773
2774
  return g;
2774
2775
  }
2776
+ function urlStateFromSearch(search) {
2777
+ const s = {};
2778
+ for (const [k, v] of new URLSearchParams(search)) {
2779
+ if (k.charAt(0) === URL_STATE_PREFIX) s[k] = v;
2780
+ }
2781
+ return s;
2782
+ }
2775
2783
 
2776
2784
  // src/shared/nav.ts
2777
2785
  var esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -2957,7 +2965,7 @@ var html = (body, title) => `<!doctype html><html><head><meta charset="utf-8"><t
2957
2965
  function navHtml2(dash, all) {
2958
2966
  return navHtml(dash.name, all, (n) => `/?d=${encodeURIComponent(n)}`);
2959
2967
  }
2960
- function inPageShell(dash, all, givenSpecs, initialGivens, tileSpecs) {
2968
+ function inPageShell(dash, all, givenSpecs, initialGivens, initialUrlState, tileSpecs) {
2961
2969
  const info = {
2962
2970
  name: dash.name,
2963
2971
  query: dash.query,
@@ -2970,12 +2978,12 @@ function inPageShell(dash, all, givenSpecs, initialGivens, tileSpecs) {
2970
2978
  autorun: dash.autorun
2971
2979
  };
2972
2980
  return html(
2973
- navHtml2(dash, all) + `<div id="root"></div><script>window.__DASHBOARD__=${JSON.stringify(info)};window.__GIVENS__=${JSON.stringify(givenSpecs)};window.__INITIAL_GIVENS__=${JSON.stringify(initialGivens)}</script><script>try{new EventSource('/events').onmessage=()=>location.reload();}catch(e){}</script><script src="/inpage.js?d=${encodeURIComponent(dash.name)}"></script>`,
2981
+ navHtml2(dash, all) + `<div id="root"></div><script>window.__DASHBOARD__=${JSON.stringify(info)};window.__GIVENS__=${JSON.stringify(givenSpecs)};window.__INITIAL_GIVENS__=${JSON.stringify(initialGivens)};window.__INITIAL_URLSTATE__=${JSON.stringify(initialUrlState)}</script><script>try{new EventSource('/events').onmessage=()=>location.reload();}catch(e){}</script><script src="/inpage.js?d=${encodeURIComponent(dash.name)}"></script>`,
2974
2982
  dash.title
2975
2983
  );
2976
2984
  }
2977
- function parentShell(dash, frameBase, all, initialGivens) {
2978
- const givensQs = Object.entries(initialGivens).map(([k, v]) => `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("");
2985
+ function parentShell(dash, frameBase, all, initialGivens, initialUrlState) {
2986
+ const givensQs = Object.entries({ ...initialGivens, ...initialUrlState }).map(([k, v]) => `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("");
2979
2987
  const d = JSON.stringify(dash.name);
2980
2988
  const fb = JSON.stringify(frameBase);
2981
2989
  const nav = navHtml2(dash, all);
@@ -2985,21 +2993,37 @@ function parentShell(dash, frameBase, all, initialGivens) {
2985
2993
  `<iframe id="f" sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox" src="${frameBase}/frame?d=${encodeURIComponent(dash.name)}${givensQs}" style="border:0;flex:1;width:100%"></iframe></div><script>
2986
2994
  const f=document.getElementById('f');
2987
2995
  try{new EventSource('/events').onmessage=()=>location.reload();}catch(e){}
2996
+ // The shareable URL has TWO namespaces the frame syncs independently:
2997
+ // '$NAME' = a given (the governed query contract), '~key' = a custom
2998
+ // component's useUrlState view-state. Each write must re-emit the other's
2999
+ // params, so both are cached and every write rebuilds the whole query string.
3000
+ let G=${JSON.stringify(Object.fromEntries(Object.entries(initialGivens).map(([k, v]) => [k.replace(/^\$/, ""), v])))};
3001
+ let U=${JSON.stringify(Object.fromEntries(Object.entries(initialUrlState).map(([k, v]) => [k.replace(/^~/, ""), v])))};
3002
+ function shareUrl(dashboard){
3003
+ const u=new URL(location.href); u.search='';
3004
+ u.searchParams.set('d',dashboard);
3005
+ for(const [k,v] of Object.entries(G)) if(v!=null&&String(v)!=='') u.searchParams.set('$'+k,String(v));
3006
+ for(const [k,v] of Object.entries(U)) if(v!=null) u.searchParams.set('~'+k,String(v));
3007
+ return u.pathname+u.search;
3008
+ }
2988
3009
  window.addEventListener('message',async(e)=>{
2989
3010
  if(e.source!==f.contentWindow||e.origin!==${fb})return;
2990
3011
  const m=e.data;
2991
3012
  if(m&&m.type==='givens'){
2992
- const u=new URL(location.href); u.search='';
2993
- u.searchParams.set('d',${d});
2994
- for(const [k,v] of Object.entries(m.givens)) if(v!=null&&String(v)!=='') u.searchParams.set('$'+k,String(v));
2995
- history.replaceState(null,'',u.pathname+u.search);
3013
+ G=m.givens||{};
3014
+ history.replaceState(null,'',shareUrl(${d}));
3015
+ return;
3016
+ }
3017
+ if(m&&m.type==='urlstate'){
3018
+ U=m.state||{};
3019
+ history.replaceState(null,'',shareUrl(${d}));
2996
3020
  return;
2997
3021
  }
2998
3022
  if(m&&m.type==='navigate'&&typeof m.dashboard==='string'){
2999
- const u=new URL(location.href); u.search='';
3000
- u.searchParams.set('d',m.dashboard);
3001
- for(const [k,v] of Object.entries(m.givens||{})) if(v!=null&&String(v)!=='') u.searchParams.set('$'+k,String(v));
3002
- location.href=u.pathname+u.search;
3023
+ // A drill leaves this dashboard: carry the givens it seeded, but NOT this
3024
+ // component's view-state \u2014 that belongs to the component being left.
3025
+ G=m.givens||{}; U={};
3026
+ location.href=shareUrl(m.dashboard);
3003
3027
  return;
3004
3028
  }
3005
3029
  if(!m||m.type!=='run')return;
@@ -3018,7 +3042,10 @@ window.addEventListener('message',async(e)=>{
3018
3042
  function givensFromUrl(url5) {
3019
3043
  return givensFromSearch(url5.search);
3020
3044
  }
3021
- function frameDoc(dash, givenSpecs, initialGivens, tileSpecs) {
3045
+ function urlStateFromUrl(url5) {
3046
+ return urlStateFromSearch(url5.search);
3047
+ }
3048
+ function frameDoc(dash, givenSpecs, initialGivens, initialUrlState, tileSpecs) {
3022
3049
  const info = {
3023
3050
  name: dash.name,
3024
3051
  query: dash.query,
@@ -3033,7 +3060,7 @@ function frameDoc(dash, givenSpecs, initialGivens, tileSpecs) {
3033
3060
  autorun: dash.autorun
3034
3061
  };
3035
3062
  return html(
3036
- `<div id="root"></div><script>window.__DASHBOARD__=${JSON.stringify(info)};window.__GIVENS__=${JSON.stringify(givenSpecs)};window.__INITIAL_GIVENS__=${JSON.stringify(initialGivens)}</script><script src="/bundle.js?d=${encodeURIComponent(dash.name)}"></script>`,
3063
+ `<div id="root"></div><script>window.__DASHBOARD__=${JSON.stringify(info)};window.__GIVENS__=${JSON.stringify(givenSpecs)};window.__INITIAL_GIVENS__=${JSON.stringify(initialGivens)};window.__INITIAL_URLSTATE__=${JSON.stringify(initialUrlState)}</script><script src="/bundle.js?d=${encodeURIComponent(dash.name)}"></script>`,
3037
3064
  dash.title
3038
3065
  );
3039
3066
  }
@@ -3112,7 +3139,11 @@ async function serveDashboard(opts) {
3112
3139
  html(`<pre style="color:crimson;padding:16px">model error: ${esc2(g.error)}</pre>`, dash.title)
3113
3140
  );
3114
3141
  }
3115
- return send(200, "text/html; charset=utf-8", frameDoc(dash, g.union, givensFromUrl(url5), g.tiles));
3142
+ return send(
3143
+ 200,
3144
+ "text/html; charset=utf-8",
3145
+ frameDoc(dash, g.union, givensFromUrl(url5), urlStateFromUrl(url5), g.tiles)
3146
+ );
3116
3147
  }
3117
3148
  if (url5.pathname === "/bundle.js") {
3118
3149
  return send(200, "application/javascript; charset=utf-8", await bundle(pick(url5)));
@@ -3137,9 +3168,17 @@ async function serveDashboard(opts) {
3137
3168
  html(`<pre style="color:crimson;padding:16px">model error: ${esc2(g.error)}</pre>`, dash.title)
3138
3169
  );
3139
3170
  }
3140
- return send(200, "text/html; charset=utf-8", inPageShell(dash, dashboards, g.union, givensFromUrl(url5), g.tiles));
3171
+ return send(
3172
+ 200,
3173
+ "text/html; charset=utf-8",
3174
+ inPageShell(dash, dashboards, g.union, givensFromUrl(url5), urlStateFromUrl(url5), g.tiles)
3175
+ );
3141
3176
  }
3142
- return send(200, "text/html; charset=utf-8", parentShell(dash, frameBase, dashboards, givensFromUrl(url5)));
3177
+ return send(
3178
+ 200,
3179
+ "text/html; charset=utf-8",
3180
+ parentShell(dash, frameBase, dashboards, givensFromUrl(url5), urlStateFromUrl(url5))
3181
+ );
3143
3182
  }
3144
3183
  if (url5.pathname === "/inpage.js") {
3145
3184
  return send(200, "application/javascript; charset=utf-8", await inPageBundle());
@@ -3834,7 +3873,7 @@ async function launchCmd(mode, opts) {
3834
3873
  }
3835
3874
 
3836
3875
  // package.json
3837
- var version = "0.2.24";
3876
+ var version = "0.2.25";
3838
3877
 
3839
3878
  // src/index.ts
3840
3879
  function shortSha(sha) {
@@ -10,16 +10,63 @@
10
10
  // Deliberately dependency-free (no React, no node builtins) so the Node dev
11
11
  // server and the browser bundle can share the same file.
12
12
 
13
+ // Two namespaces share the query string and must never collide:
14
+ // `$NAME` a GIVEN — the governed, filter-typed query contract (declared in
15
+ // the model, drives the auto-rendered controls, MCP-visible).
16
+ // `~key` a custom component's VIEW-STATE (useUrlState): a rack string, a
17
+ // board layout, a checkbox. Not a query parameter; the runtime
18
+ // round-trips it verbatim so JS-driven components get shareable links.
19
+ export const URL_STATE_PREFIX = "~";
20
+
13
21
  /** Query string -> given values. Keys KEEP their `$` prefix — the runtime
14
- requires it — and `d` (the dashboard selector) is dropped. */
22
+ requires it — while `d` (the dashboard selector) and the `~` view-state
23
+ namespace are dropped. */
15
24
  export function givensFromSearch(search: string): Record<string, string> {
16
25
  const g: Record<string, string> = {};
17
26
  for (const [k, v] of new URLSearchParams(search)) {
18
- if (k !== "d") g[k] = v;
27
+ if (k !== "d" && k.charAt(0) !== URL_STATE_PREFIX) g[k] = v;
19
28
  }
20
29
  return g;
21
30
  }
22
31
 
32
+ /** Query string -> view-state values (`useUrlState`). Keys KEEP their `~`
33
+ prefix, mirroring givensFromSearch: the runtime strips it itself. */
34
+ export function urlStateFromSearch(search: string): Record<string, string> {
35
+ const s: Record<string, string> = {};
36
+ for (const [k, v] of new URLSearchParams(search)) {
37
+ if (k.charAt(0) === URL_STATE_PREFIX) s[k] = v;
38
+ }
39
+ return s;
40
+ }
41
+
42
+ /** View-state -> `~`-prefixed query params. Accepts keys with or without the
43
+ prefix. Unlike givens, an EMPTY STRING is kept: a component whose default is
44
+ non-empty needs `~rack=` to mean "the user cleared it" (the runtime already
45
+ drops keys that equal their default, so nothing pointless reaches here). */
46
+ export function urlStateToParams(state: Record<string, unknown>): URLSearchParams {
47
+ const p = new URLSearchParams();
48
+ for (const [k, v] of Object.entries(state ?? {})) {
49
+ if (v == null) continue;
50
+ p.set(k.charAt(0) === URL_STATE_PREFIX ? k : URL_STATE_PREFIX + k, String(v));
51
+ }
52
+ return p;
53
+ }
54
+
55
+ /** The one shareable query string: `d` (when the host uses a selector) +
56
+ `$givens` + `~view-state`, in that order. Returns "" or "?…". */
57
+ export function shareSearch(opts: {
58
+ d?: string;
59
+ givens?: Record<string, unknown>;
60
+ urlState?: Record<string, unknown>;
61
+ }): string {
62
+ const p = new URLSearchParams();
63
+ if (opts.d != null) p.set("d", opts.d);
64
+ for (const [k, v] of givensToParams(opts.givens ?? {})) p.set(k, v);
65
+ for (const [k, v] of urlStateToParams(opts.urlState ?? {})) p.set(k, v);
66
+ const s = p.toString();
67
+ return s ? "?" + s : "";
68
+ }
69
+
23
70
  /** Given values -> `$`-prefixed query params, skipping empties. Accepts keys
24
71
  with or without the prefix so callers can pass either the runtime's bare
25
72
  names or values already read out of a URL. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@malloydata/malloyyo",
3
- "version": "0.2.24",
3
+ "version": "0.2.25",
4
4
  "description": "Publish Malloy models to a Malloyyo instance",
5
5
  "license": "MIT",
6
6
  "repository": {