@malloydata/malloyyo 0.2.24 → 0.2.26

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
@@ -147,8 +147,9 @@ import path from "node:path";
147
147
  import url from "node:url";
148
148
  var HOST_ONLY = "host_only";
149
149
  var contentFiles = {
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',
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/charts` (the\n`# bar_chart`/`# line_chart` tags and their channel rules),\n`dashboards/custom-components` (a flat `<name>.jsx`), `dashboards/vega-charts`\n(`<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/charts.md": "# Charts (`# bar_chart`, `# line_chart`, \u2026)\n\nThe renderer's built-in chart tags cover most dashboard tiles. Tag a nested view\nand it draws:\n\n```malloy\nnest:\n # bar_chart\n sales_by_brand\n # line_chart\n sales_by_month\n```\n\nRelated: `yo_help dashboards/grid-layout` (where tiles sit),\n`dashboards/vega-charts` (`<VegaChart>`, for shapes these tags can't express),\n`dashboards/authoring` (the dashboard file itself).\n\n## Channels: name them, don't leave them to be guessed\n\nA chart has three channels \u2014 **x** (the category axis), **y** (the value), and\n**series** (the colour split). Set them on the tag:\n\n```malloy\n# bar_chart { x=weekday y=drunk }\n# bar_chart { x=nickname y=flight_count series=destination }\n# line_chart { x=sale_date y=['sales', 'cost'] }\n```\n\nLeft unset, the renderer infers them: x prefers a time dimension and otherwise\ntakes the first dimension, y takes the first aggregate, and **series takes any\ndimension left over**. That last rule is the one that surprises people \u2014 see\nbelow. Naming `x` and `y` explicitly costs one line and removes the guesswork.\n\nOther properties: `.stack`, `.size` (`spark`/`xs`/`sm`/`md`/`lg`/`xl`/`2xl`, or\n`size.width` / `size.height`), `.x.limit`, `.series.limit`, and `.independent`\non any channel for nested charts.\n\n## THE RULE: charts count DIMENSIONS, not columns\n\nA chart can carry **one x dimension and one series dimension**. The renderer\ncounts the *dimensions* in the result (`group_by` fields \u2014 not aggregates) and:\n\n- **two dimensions, only `x` named** \u2192 the spare one is promoted to a colour\n series, whether or not you wanted a legend\n- **three or more dimensions, none tagged `series`** \u2192 it refuses:\n *\"Too many dimensions. A bar chart can have at most 2 dimensions: 1 for the x\n axis, and 1 for the series. To use 3+ dimensions, explicitly tag multiple\n fields as series.\"*\n\nThat last sentence is the escape hatch: 3+ dimensions is legal if you say which\nones are the series. What is NOT legal is leaving it to be inferred. If you\ngenuinely want a third dimension in the picture, name it; if you don't, the\nproblem is that a field you didn't think of as a dimension is one.\n\nAggregates are exempt. A result can carry as many measures as you like \u2014 only\nthe ones named in `y` (or the first, if `y` is unset) get plotted, and the rest\nare ignored rather than turned into channels.\n\n**`# hidden` does not exempt a dimension from this count.** It hides a field in\ntables, `big_value` comparisons and `# link` targets; it has no effect on chart\nchannel assignment. A `# hidden` group_by is still a dimension and will still\nbecome a series.\n\n## Sorting a named axis (the common case this rule makes hard)\n\nYou want an axis labelled `Sunday \u2026 Saturday`, or `Jan \u2026 Dec`, in *that* order \u2014\nnot alphabetical. The label has to be a string, but the sort key is a number, and\nthe naive fixes both fail:\n\n```malloy\n// WRONG \u2014 two dimensions: weekday_num becomes a colour series.\ngroup_by:\n # hidden\n weekday_num is day_of_week(exit_date)\n weekday is pick 'Sunday' when day_of_week(exit_date) = 1 \u2026\n```\n\n```malloy\n// ALSO WRONG \u2014 a `select:` stage is a projection, so it has no measures:\n// every column becomes a dimension and a 3-column result is rejected.\n} -> {\n select: weekday is pick 'Sunday' when weekday_num = 1 \u2026 , drunk, avg_price\n}\n```\n\n**Make the sort key a MEASURE.** Measures aren't dimensions, so a sort key\nexpressed as one can never be promoted to a channel:\n\n```malloy\n# bar_chart { x=weekday y=drunk }\nview: by_weekday is {\n group_by: weekday is\n pick 'Sunday' when day_of_week(exit_date) = 1\n pick 'Monday' when day_of_week(exit_date) = 2\n \u2026\n else 'Saturday'\n aggregate:\n drunk\n weekday_sort is min(day_of_week(exit_date)) // orders rows; never a channel\n order_by: weekday_sort asc\n}\n```\n\nOne dimension reaches the chart, the rows arrive in the order you asked for, and\na bar chart preserves query row order for a categorical axis. The same shape\nworks for months (`min(month(date))`), fiscal periods, size buckets, and any\nother \"display a name, sort by a number\" axis.\n\n> `min()` because a sort key needs *some* aggregate function and every row in the\n> group shares the value; `max()` is equally fine.\n\n## Deciding which tile is a chart at all\n\n- **Ranked categories** (top brands, regions by volume) \u2014 `# bar_chart`, ordered\n by the measure. Query order is the bar order.\n- **A value over time** \u2014 `# line_chart` with the time dimension as `x`.\n- **A wide detail table** \u2014 no chart tag. Charting eight columns is worse than\n showing them; give the tile `# colspan=6` instead and label the columns.\n- **Flat data** \u2014 if every bar is within a few percent of the others, the chart\n is saying \"no pattern here\" at the cost of a card. Consider whether the tile\n earns its place.\n\n## Trends by period: drop the incomplete one\n\nA by-year or by-month trend whose final period is partial draws a cliff that\nreads as collapse. Filter to complete periods, using a cutoff that comes from\nthe DATA (e.g. a snapshot/max-date column carried on a joined dimension), not\nfrom the result set \u2014 deriving it from the visible rows will wrongly declare a\nperiod partial as soon as a filter narrows the data.\n\n## Validate\n\n`malloyyo lint` compiles each dashboard and its tiles, but it does not render\nthem \u2014 a chart that compiles can still throw \"Too many dimensions\" in the\nbrowser. See it with `malloyyo dashboard dev`, which runs the queries\nserver-side and renders the real result.\n",
152
+ "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\n**Use `--dash-*` and nothing else.** A custom component renders in its own\n**iframe**, so CSS variables defined by the surrounding page \u2014 including the\nbundled site\'s `--line` / `--card` / `--muted` \u2014 are NOT in scope inside it. A\ncomponent styled against those still renders, but every rule referencing them\nresolves to nothing: borders, dividers and panel backgrounds vanish silently\nwhile text and layout survive, so the page looks *almost* right and the cause\nisn\'t obvious. If you\'re porting CSS that has to work both inside the frame and\non a bundled page, resolve each colour once through the chain and use the alias:\n\n```css\n.my-card {\n --edge: var(--dash-border, var(--line, #e4e6eb));\n --surface: var(--dash-panel-bg, var(--card, #fff));\n border: 1px solid var(--edge);\n background: var(--surface);\n}\n```\n\nThis is a class of bug `lint` cannot see and a screenshot can \u2014 look at custom\ncomponents in `malloyyo dashboard dev` before shipping them.\n\nFor charts beyond the Malloy renderer\'s `#` tags, use `<VegaChart>` \u2014\n`yo_help dashboards/vega-charts`.\n',
152
153
  "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
154
  "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
155
  "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 +2766,21 @@ import path5 from "node:path";
2765
2766
  import * as esbuild2 from "esbuild";
2766
2767
 
2767
2768
  // src/shared/givens-url.ts
2769
+ var URL_STATE_PREFIX = "~";
2768
2770
  function givensFromSearch(search) {
2769
2771
  const g = {};
2770
2772
  for (const [k, v] of new URLSearchParams(search)) {
2771
- if (k !== "d") g[k] = v;
2773
+ if (k !== "d" && k.charAt(0) !== URL_STATE_PREFIX) g[k] = v;
2772
2774
  }
2773
2775
  return g;
2774
2776
  }
2777
+ function urlStateFromSearch(search) {
2778
+ const s = {};
2779
+ for (const [k, v] of new URLSearchParams(search)) {
2780
+ if (k.charAt(0) === URL_STATE_PREFIX) s[k] = v;
2781
+ }
2782
+ return s;
2783
+ }
2775
2784
 
2776
2785
  // src/shared/nav.ts
2777
2786
  var esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -2957,7 +2966,7 @@ var html = (body, title) => `<!doctype html><html><head><meta charset="utf-8"><t
2957
2966
  function navHtml2(dash, all) {
2958
2967
  return navHtml(dash.name, all, (n) => `/?d=${encodeURIComponent(n)}`);
2959
2968
  }
2960
- function inPageShell(dash, all, givenSpecs, initialGivens, tileSpecs) {
2969
+ function inPageShell(dash, all, givenSpecs, initialGivens, initialUrlState, tileSpecs) {
2961
2970
  const info = {
2962
2971
  name: dash.name,
2963
2972
  query: dash.query,
@@ -2970,12 +2979,12 @@ function inPageShell(dash, all, givenSpecs, initialGivens, tileSpecs) {
2970
2979
  autorun: dash.autorun
2971
2980
  };
2972
2981
  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>`,
2982
+ 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
2983
  dash.title
2975
2984
  );
2976
2985
  }
2977
- function parentShell(dash, frameBase, all, initialGivens) {
2978
- const givensQs = Object.entries(initialGivens).map(([k, v]) => `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("");
2986
+ function parentShell(dash, frameBase, all, initialGivens, initialUrlState) {
2987
+ const givensQs = Object.entries({ ...initialGivens, ...initialUrlState }).map(([k, v]) => `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("");
2979
2988
  const d = JSON.stringify(dash.name);
2980
2989
  const fb = JSON.stringify(frameBase);
2981
2990
  const nav = navHtml2(dash, all);
@@ -2985,21 +2994,37 @@ function parentShell(dash, frameBase, all, initialGivens) {
2985
2994
  `<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
2995
  const f=document.getElementById('f');
2987
2996
  try{new EventSource('/events').onmessage=()=>location.reload();}catch(e){}
2997
+ // The shareable URL has TWO namespaces the frame syncs independently:
2998
+ // '$NAME' = a given (the governed query contract), '~key' = a custom
2999
+ // component's useUrlState view-state. Each write must re-emit the other's
3000
+ // params, so both are cached and every write rebuilds the whole query string.
3001
+ let G=${JSON.stringify(Object.fromEntries(Object.entries(initialGivens).map(([k, v]) => [k.replace(/^\$/, ""), v])))};
3002
+ let U=${JSON.stringify(Object.fromEntries(Object.entries(initialUrlState).map(([k, v]) => [k.replace(/^~/, ""), v])))};
3003
+ function shareUrl(dashboard){
3004
+ const u=new URL(location.href); u.search='';
3005
+ u.searchParams.set('d',dashboard);
3006
+ for(const [k,v] of Object.entries(G)) if(v!=null&&String(v)!=='') u.searchParams.set('$'+k,String(v));
3007
+ for(const [k,v] of Object.entries(U)) if(v!=null) u.searchParams.set('~'+k,String(v));
3008
+ return u.pathname+u.search;
3009
+ }
2988
3010
  window.addEventListener('message',async(e)=>{
2989
3011
  if(e.source!==f.contentWindow||e.origin!==${fb})return;
2990
3012
  const m=e.data;
2991
3013
  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);
3014
+ G=m.givens||{};
3015
+ history.replaceState(null,'',shareUrl(${d}));
3016
+ return;
3017
+ }
3018
+ if(m&&m.type==='urlstate'){
3019
+ U=m.state||{};
3020
+ history.replaceState(null,'',shareUrl(${d}));
2996
3021
  return;
2997
3022
  }
2998
3023
  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;
3024
+ // A drill leaves this dashboard: carry the givens it seeded, but NOT this
3025
+ // component's view-state \u2014 that belongs to the component being left.
3026
+ G=m.givens||{}; U={};
3027
+ location.href=shareUrl(m.dashboard);
3003
3028
  return;
3004
3029
  }
3005
3030
  if(!m||m.type!=='run')return;
@@ -3018,7 +3043,10 @@ window.addEventListener('message',async(e)=>{
3018
3043
  function givensFromUrl(url5) {
3019
3044
  return givensFromSearch(url5.search);
3020
3045
  }
3021
- function frameDoc(dash, givenSpecs, initialGivens, tileSpecs) {
3046
+ function urlStateFromUrl(url5) {
3047
+ return urlStateFromSearch(url5.search);
3048
+ }
3049
+ function frameDoc(dash, givenSpecs, initialGivens, initialUrlState, tileSpecs) {
3022
3050
  const info = {
3023
3051
  name: dash.name,
3024
3052
  query: dash.query,
@@ -3033,7 +3061,7 @@ function frameDoc(dash, givenSpecs, initialGivens, tileSpecs) {
3033
3061
  autorun: dash.autorun
3034
3062
  };
3035
3063
  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>`,
3064
+ `<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
3065
  dash.title
3038
3066
  );
3039
3067
  }
@@ -3112,7 +3140,11 @@ async function serveDashboard(opts) {
3112
3140
  html(`<pre style="color:crimson;padding:16px">model error: ${esc2(g.error)}</pre>`, dash.title)
3113
3141
  );
3114
3142
  }
3115
- return send(200, "text/html; charset=utf-8", frameDoc(dash, g.union, givensFromUrl(url5), g.tiles));
3143
+ return send(
3144
+ 200,
3145
+ "text/html; charset=utf-8",
3146
+ frameDoc(dash, g.union, givensFromUrl(url5), urlStateFromUrl(url5), g.tiles)
3147
+ );
3116
3148
  }
3117
3149
  if (url5.pathname === "/bundle.js") {
3118
3150
  return send(200, "application/javascript; charset=utf-8", await bundle(pick(url5)));
@@ -3137,9 +3169,17 @@ async function serveDashboard(opts) {
3137
3169
  html(`<pre style="color:crimson;padding:16px">model error: ${esc2(g.error)}</pre>`, dash.title)
3138
3170
  );
3139
3171
  }
3140
- return send(200, "text/html; charset=utf-8", inPageShell(dash, dashboards, g.union, givensFromUrl(url5), g.tiles));
3172
+ return send(
3173
+ 200,
3174
+ "text/html; charset=utf-8",
3175
+ inPageShell(dash, dashboards, g.union, givensFromUrl(url5), urlStateFromUrl(url5), g.tiles)
3176
+ );
3141
3177
  }
3142
- return send(200, "text/html; charset=utf-8", parentShell(dash, frameBase, dashboards, givensFromUrl(url5)));
3178
+ return send(
3179
+ 200,
3180
+ "text/html; charset=utf-8",
3181
+ parentShell(dash, frameBase, dashboards, givensFromUrl(url5), urlStateFromUrl(url5))
3182
+ );
3143
3183
  }
3144
3184
  if (url5.pathname === "/inpage.js") {
3145
3185
  return send(200, "application/javascript; charset=utf-8", await inPageBundle());
@@ -3834,7 +3874,7 @@ async function launchCmd(mode, opts) {
3834
3874
  }
3835
3875
 
3836
3876
  // package.json
3837
- var version = "0.2.24";
3877
+ var version = "0.2.26";
3838
3878
 
3839
3879
  // src/index.ts
3840
3880
  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. */
@@ -121,17 +121,57 @@ and **givens** (the parameters that become the dashboard's filter controls).
121
121
  use. **Only what `index.malloy` exports is visible** to dashboards, `dashboard
122
122
  dev`, and the hosted app.
123
123
 
124
+ Give each exported source a `#"` doc string. It's what `list_sources` shows the
125
+ hosted app and any MCP client, and it's the only place to record the things a
126
+ consumer can't infer — which source answers which question, and any measure
127
+ whose meaning is subtle.
128
+
124
129
  For Malloy modeling and givens specifics, lean on the author MCP rather than
125
130
  guessing: the repo's `.mcp.json` wires `mcp__malloyyo_author__*`. Call
126
131
  `mcp__malloyyo_author__compile` to check files and
127
132
  `mcp__malloyyo_author__yo_help` for topics (`develop/working-with-models`).
128
133
 
134
+ Three shapes that compile, pass `lint`, and still produce a dashboard that looks
135
+ right and isn't:
136
+
137
+ - **A stage-level `where:` zeroes measures that carry their own filters.**
138
+ `count() { where: status = 'Sold' }` needs the sold rows to reach it; a
139
+ `where: in_cellar` on the stage removes them first and the measure reads 0
140
+ everywhere. Constrain the output with `having:` instead.
141
+ - **Grouping finer than the fact scatters the counts.** Group by an attribute
142
+ that only applies to some rows and the measures over the others split across
143
+ groups and read near-zero. Pick the grain the question is asked at; if you
144
+ need both, group at the coarser one and `nest:` the finer.
145
+ - **Detail tables default to raw field names and bare numbers.** `exit_date` and
146
+ `829.57`, and a null renders as `∅`. Tag them — `# label="Opened"`,
147
+ `# label="Paid" currency` — and `coalesce(field, '')` where empty is
148
+ meaningful rather than missing.
149
+
150
+ Aggregate locality is NOT one of these: Malloy's symmetric aggregates keep
151
+ measures honest through a `join_many` fan-out, and it warns (or errors) rather
152
+ than silently answering when an `avg`/`sum` crosses a join without explicit
153
+ locality. `notes.rating.avg()` already averages at the notes grain; ask for
154
+ `source.avg(notes.rating)` only when you want the fan-out-weighted number. See
155
+ `yo_help language/aggregate-locality-symmetric-aggregates`.
156
+
129
157
  ### 4. Author dashboards and preview live
130
158
 
131
- Each dashboard is a `.malloy` (the query/view) + a `.jsx` (the layout) under
132
- `dashboards/`. Author them with the `malloyyo_author` MCP and its `yo_help`
133
- topics **read these, don't guess the JSX/grid API**:
134
- `dashboards/authoring`, `dashboards/grid-layout`, `dashboards/vega-charts`.
159
+ Each dashboard is **one self-contained `dashboards/<name>.malloy`** the query,
160
+ its filtering, and the tags that lay it out. The filename is the dashboard's
161
+ name and URL slug.
162
+
163
+ **A `.jsx` is OPTIONAL and most dashboards don't need one.** The default form —
164
+ an inline query tagged `# artifact` + `# dashboard { columns=6 }` — renders KPI
165
+ tiles and a card grid with no React at all. Reach for a sibling
166
+ `dashboards/<name>.jsx` only when you want bespoke layout or presentation the
167
+ tags can't express (a hand-built card, a picker, a Vega chart).
168
+
169
+ Author with the `malloyyo_author` MCP and its `yo_help` topics — **read these,
170
+ don't guess the API**: `dashboards/authoring` (the dashboard file),
171
+ `dashboards/grid-layout` (colspans), `dashboards/charts` (chart tags and their
172
+ channel rules), `dashboards/givens-and-controls` (filters),
173
+ `dashboards/custom-components` and `dashboards/vega-charts` (only if you add a
174
+ `.jsx`).
135
175
 
136
176
  Preview in a browser with live reload:
137
177
 
@@ -139,9 +179,24 @@ Preview in a browser with live reload:
139
179
  malloyyo dashboard dev # serves at http://localhost:4173
140
180
  ```
141
181
 
142
- Iterate here until the dashboards look right. `malloyyo lint` validates the
143
- dashboards against the model; `malloyyo test` previews what the hosted claude.ai
144
- app would see.
182
+ Two things about `dashboard dev` worth knowing:
183
+
184
+ - It runs the queries **server-side**, through the same local DuckDB the CLI
185
+ uses — the browser only renders results. So it works without DuckDB-WASM or
186
+ its CDN extensions, and it is the only way to see a custom component render
187
+ against real data short of publishing.
188
+ - It holds **one long-lived connection set with a warm schema cache** (a
189
+ deliberate tradeoff — refetching schemas per compile reads like a hang on a
190
+ warehouse). The file watcher hot-reloads your `.malloy` edits and re-discovers
191
+ `# artifact` tags, but nothing invalidates that cache, so a change to the
192
+ DATA's shape — a new column in a parquet — needs a restart. The tell is
193
+ `'<column>' is not defined` in the browser while the CLI and `lint` are green.
194
+
195
+ Iterate here until the dashboards look right. **Look at every dashboard before
196
+ you call it done**: `malloyyo lint` compiles the dashboards against the model but
197
+ does not render them, so it cannot see a chart that throws at render time, a
198
+ component whose CSS resolved to nothing, or a column of raw field names.
199
+ `malloyyo test` previews what the hosted claude.ai app would see.
145
200
 
146
201
  ### 5. Build the static site
147
202
 
@@ -169,6 +224,10 @@ skill (worked example: `malloydata/malloyyo-imdb`).
169
224
  ## Done when
170
225
 
171
226
  - `malloyyo sql -e "SELECT count(*) FROM 'docs/<file>.parquet'"` returns real rows
172
- - `malloyyo dashboard dev` renders every dashboard with no errors
227
+ - `malloyyo lint` passes
228
+ - **you have LOOKED at every dashboard** in `malloyyo dashboard dev` — each one
229
+ renders with no console errors, every tile has data, and the labels read like
230
+ English rather than column names. `lint` passing is not this; a dashboard can
231
+ compile and still throw at render time.
173
232
  - `docs/` has the bundled `*.html` + `.nojekyll` alongside the parquet
174
233
  - the Pages URL loads and the dashboards populate (data fetch succeeds)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@malloydata/malloyyo",
3
- "version": "0.2.24",
3
+ "version": "0.2.26",
4
4
  "description": "Publish Malloy models to a Malloyyo instance",
5
5
  "license": "MIT",
6
6
  "repository": {