@malloydata/malloyyo 0.2.16 → 0.2.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -86,7 +86,7 @@ import path from "node:path";
86
86
  import url from "node:url";
87
87
  var HOST_ONLY = "host_only";
88
88
  var contentFiles = {
89
- "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, run separately and\n combined into one `# dashboard`. Use for multi-tile / cross-source; prefer the\n 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',
89
+ "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',
90
90
  "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',
91
91
  "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",
92
92
  "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",
@@ -1188,19 +1188,27 @@ function readArtifactTag(ident, q) {
1188
1188
  const title = nested?.text("title") ?? tag.text("title") ?? description?.split("\n")[0] ?? ident.defaultName;
1189
1189
  const rawTiles = nested?.textArray("tiles") ?? tag.textArray("tiles");
1190
1190
  if (rawTiles && rawTiles.length) {
1191
- const info2 = {
1192
- name,
1193
- query: "",
1194
- title,
1195
- tiles: rawTiles.map((t) => resolveTile(t, ident.source))
1196
- };
1191
+ const resolved = rawTiles.map((t) => resolveTile(t, ident.source));
1192
+ const cols = nested?.numeric("dashboard_columns") ?? tag.numeric("dashboard_columns");
1193
+ const autorunText2 = nested?.text("autorun") ?? tag.text("autorun");
1194
+ const givens2 = readGivens(tag);
1195
+ if (resolved.length === 1) {
1196
+ const info3 = { name, query: resolved[0], title };
1197
+ if (ident.source) info3.source = ident.source;
1198
+ if (description) info3.description = description;
1199
+ if (autorunText2 === "false") info3.autorun = false;
1200
+ if (givens2) info3.givens = givens2;
1201
+ if (typeof cols === "number")
1202
+ info3.warnings = [
1203
+ "dashboard_columns is ignored on a single-tile artifact (one tile renders like a single-query `# artifact`, laid out by the tile query\u2019s own `# dashboard` tag)"
1204
+ ];
1205
+ return info3;
1206
+ }
1207
+ const info2 = { name, query: "", title, tiles: resolved };
1197
1208
  if (ident.source) info2.source = ident.source;
1198
1209
  if (description) info2.description = description;
1199
- const cols = nested?.numeric("dashboard_columns") ?? tag.numeric("dashboard_columns");
1200
1210
  if (typeof cols === "number") info2.dashboard_columns = cols;
1201
- const autorunText2 = nested?.text("autorun") ?? tag.text("autorun");
1202
1211
  if (autorunText2 === "false") info2.autorun = false;
1203
- const givens2 = readGivens(tag);
1204
1212
  if (givens2) info2.givens = givens2;
1205
1213
  return info2;
1206
1214
  }
@@ -1219,7 +1227,7 @@ async function artifactQueries(runtime, entry) {
1219
1227
  const model = await runtime.loadModel(entry).getModel();
1220
1228
  const artifacts = [];
1221
1229
  const modelComposite = readArtifactTag({ runExpr: "", defaultName: "dashboard" }, model);
1222
- if (modelComposite?.tiles) artifacts.push(modelComposite);
1230
+ if (modelComposite && (modelComposite.tiles || modelComposite.query)) artifacts.push(modelComposite);
1223
1231
  for (const queryName of model.queries().named) {
1224
1232
  const pq = model.getPreparedQueryByName(queryName);
1225
1233
  const info = readArtifactTag({ runExpr: queryName, defaultName: queryName }, pq);
@@ -1230,7 +1238,7 @@ async function artifactQueries(runtime, entry) {
1230
1238
  { runExpr: "", defaultName: src.name, source: src.name },
1231
1239
  src
1232
1240
  );
1233
- if (srcComposite?.tiles) artifacts.push(srcComposite);
1241
+ if (srcComposite && (srcComposite.tiles || srcComposite.query)) artifacts.push(srcComposite);
1234
1242
  for (const field of src.allFields) {
1235
1243
  if (!field.isQueryField()) continue;
1236
1244
  const view = field;
@@ -1279,11 +1287,11 @@ async function modelArtifact(runtime, entry, defaultName) {
1279
1287
  try {
1280
1288
  const model = await runtime.loadModel(entry).getModel();
1281
1289
  const composite = readArtifactTag({ runExpr: "", defaultName }, model);
1282
- if (composite?.tiles) return { ok: true, artifact: composite };
1290
+ if (composite && (composite.tiles || composite.query)) return { ok: true, artifact: composite };
1283
1291
  for (const queryName of model.queries().named) {
1284
1292
  const pq = model.getPreparedQueryByName(queryName);
1285
1293
  const info = readArtifactTag({ runExpr: queryName, defaultName }, pq);
1286
- if (info && !info.tiles) return { ok: true, artifact: { ...info, tiles: [info.query], query: "" } };
1294
+ if (info && !info.tiles) return { ok: true, artifact: info };
1287
1295
  }
1288
1296
  for (const src of model.explores) {
1289
1297
  for (const field of src.allFields) {
@@ -1293,7 +1301,7 @@ async function modelArtifact(runtime, entry, defaultName) {
1293
1301
  { runExpr: `${src.name} -> ${view.name}`, defaultName, source: src.name, view: view.name },
1294
1302
  view
1295
1303
  );
1296
- if (info && !info.tiles) return { ok: true, artifact: { ...info, tiles: [info.query], query: "" } };
1304
+ if (info && !info.tiles) return { ok: true, artifact: info };
1297
1305
  }
1298
1306
  }
1299
1307
  return { ok: true, artifact: void 0 };
@@ -1301,61 +1309,6 @@ async function modelArtifact(runtime, entry, defaultName) {
1301
1309
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
1302
1310
  }
1303
1311
  }
1304
- function liftRenderTags(annotations) {
1305
- if (!annotations) return [];
1306
- return annotations.filter((a) => {
1307
- const v = a.value;
1308
- return v.startsWith("# ") && !v.startsWith("# artifact");
1309
- });
1310
- }
1311
- function tileAsNestField(name, res) {
1312
- const fields = (res.schema?.fields ?? []).map((f) => ({
1313
- name: f.name,
1314
- type: f.type,
1315
- annotations: f.annotations
1316
- }));
1317
- return {
1318
- kind: "dimension",
1319
- name,
1320
- type: {
1321
- kind: "array_type",
1322
- element_type: { kind: "record_type", fields }
1323
- },
1324
- annotations: liftRenderTags(res.annotations)
1325
- };
1326
- }
1327
- function uniqueName(name, taken) {
1328
- if (!taken.has(name)) {
1329
- taken.add(name);
1330
- return name;
1331
- }
1332
- let i = 2;
1333
- while (taken.has(`${name}_${i}`)) i++;
1334
- const out = `${name}_${i}`;
1335
- taken.add(out);
1336
- return out;
1337
- }
1338
- function combineTiles(tiles, opts = {}) {
1339
- const taken = /* @__PURE__ */ new Set();
1340
- const named = tiles.map((t) => ({ name: uniqueName(t.name, taken), result: t.result }));
1341
- const columns = typeof opts.columns === "number" && Number.isFinite(opts.columns) ? ` {columns=${Math.trunc(opts.columns)}}` : "";
1342
- return {
1343
- // Provenance carried from the first tile; the combined result never runs
1344
- // SQL itself, so connection_name is cosmetic but the renderer reads it.
1345
- connection_name: tiles[0]?.result.connection_name ?? "composite",
1346
- model_annotations: tiles[0]?.result.model_annotations,
1347
- annotations: [{ value: `# dashboard${columns}
1348
- ` }],
1349
- schema: { fields: named.map((t) => tileAsNestField(t.name, t.result)) },
1350
- // One dashboard row whose columns are the tiles' data cells (verbatim).
1351
- data: {
1352
- kind: "array_cell",
1353
- array_value: [
1354
- { kind: "record_cell", record_value: named.map((t) => t.result.data) }
1355
- ]
1356
- }
1357
- };
1358
- }
1359
1312
  var INSTANCE_PLACEHOLDER = "{{INSTANCE_NAME}}";
1360
1313
  function renderInstructions(text, instanceName) {
1361
1314
  return text.replaceAll(INSTANCE_PLACEHOLDER, instanceName);
@@ -1954,6 +1907,7 @@ function developSurface(host, opts = {}) {
1954
1907
 
1955
1908
  // src/host.ts
1956
1909
  var ENTRY = "index.malloy";
1910
+ var IDLE_SHUTDOWN_MS = 6e4;
1957
1911
  function tileName(runExpr) {
1958
1912
  const arrow = runExpr.lastIndexOf("->");
1959
1913
  return (arrow >= 0 ? runExpr.slice(arrow + 2) : runExpr).trim();
@@ -1998,17 +1952,33 @@ async function makeRunner(root) {
1998
1952
  let configPromise = null;
1999
1953
  const getConfig = () => configPromise ??= loadConfig(rootUrl, reader);
2000
1954
  let inFlight = 0;
1955
+ let idleTimer = null;
1956
+ const clearIdleTimer = () => {
1957
+ if (idleTimer) {
1958
+ clearTimeout(idleTimer);
1959
+ idleTimer = null;
1960
+ }
1961
+ };
1962
+ const scheduleIdleShutdown = () => {
1963
+ clearIdleTimer();
1964
+ idleTimer = setTimeout(() => {
1965
+ idleTimer = null;
1966
+ if (inFlight === 0) void getConfig().then((c) => c.shutdown("idle").catch(() => {
1967
+ }));
1968
+ }, IDLE_SHUTDOWN_MS);
1969
+ idleTimer.unref?.();
1970
+ };
2001
1971
  async function leaseIn(entryFile, fn) {
2002
1972
  const config = await getConfig();
2003
1973
  const { reader: prepared, entry } = prepareSource(reader, { url: path2.join(abs, entryFile) });
2004
1974
  const runtime = new Runtime({ config, urlReader: prepared });
2005
1975
  inFlight++;
1976
+ clearIdleTimer();
2006
1977
  try {
2007
1978
  return await fn(runtime, entry);
2008
1979
  } finally {
2009
1980
  inFlight--;
2010
- if (inFlight === 0) await config.shutdown("idle").catch(() => {
2011
- });
1981
+ if (inFlight === 0) scheduleIdleShutdown();
2012
1982
  }
2013
1983
  }
2014
1984
  const lease = (fn) => leaseIn(ENTRY, fn);
@@ -2016,6 +1986,7 @@ async function makeRunner(root) {
2016
1986
  root: abs,
2017
1987
  entryExists: () => fs.existsSync(path2.join(abs, ENTRY)),
2018
1988
  async dispose() {
1989
+ clearIdleTimer();
2019
1990
  if (!configPromise) return;
2020
1991
  const config = await configPromise.catch(() => null);
2021
1992
  configPromise = null;
@@ -2065,45 +2036,33 @@ async function makeRunner(root) {
2065
2036
  artifactForFile(entryFile, defaultName) {
2066
2037
  return leaseIn(entryFile, (runtime, entry) => modelArtifact(runtime, entry, defaultName));
2067
2038
  },
2068
- runDashboard(entryFile, tiles, opts) {
2039
+ dashboardGivens(entryFile, tiles) {
2069
2040
  return leaseIn(entryFile, async (runtime, entry) => {
2070
- const givens = opts.givens ?? {};
2071
- const ran = [];
2041
+ const byName = /* @__PURE__ */ new Map();
2072
2042
  for (const tile of tiles) {
2073
2043
  const specs = await dashboardGivenSpecs(runtime, entry, tile);
2074
- const names = specs.ok ? new Set(specs.givens.map((s) => s.name)) : null;
2075
- const tileGivens = names ? Object.fromEntries(Object.entries(givens).filter(([k]) => names.has(k))) : givens;
2076
- const result = await run(runtime, entry, {
2077
- runExpr: tile,
2078
- givens: tileGivens,
2079
- stableResult: true,
2080
- rowLimit: 5e3
2081
- });
2082
- ran.push({ name: tileName(tile), result });
2083
- }
2084
- const problems = ran.flatMap((t) => t.result.problems ?? []);
2085
- const good = ran.filter((t) => t.result.ok && t.result.stable_result);
2086
- if (good.length === 0) return { ok: false, problems };
2087
- if (good.length === 1) {
2088
- return { ok: true, stable_result: good[0].result.stable_result, problems };
2044
+ if (specs.ok) {
2045
+ for (const s of specs.givens) if (!byName.has(s.name)) byName.set(s.name, s);
2046
+ }
2089
2047
  }
2090
- const combined = combineTiles(
2091
- good.map((t) => ({ name: t.name, result: t.result.stable_result })),
2092
- { columns: opts.columns }
2093
- );
2094
- return { ok: true, stable_result: combined, problems };
2048
+ return { ok: true, givens: [...byName.values()] };
2095
2049
  });
2096
2050
  },
2097
- dashboardGivens(entryFile, tiles) {
2051
+ dashboardTiles(entryFile, tiles) {
2098
2052
  return leaseIn(entryFile, async (runtime, entry) => {
2099
2053
  const byName = /* @__PURE__ */ new Map();
2054
+ const out = [];
2100
2055
  for (const tile of tiles) {
2101
2056
  const specs = await dashboardGivenSpecs(runtime, entry, tile);
2102
- if (specs.ok) {
2103
- for (const s of specs.givens) if (!byName.has(s.name)) byName.set(s.name, s);
2104
- }
2057
+ const gvs = specs.ok ? specs.givens : [];
2058
+ for (const s of gvs) if (!byName.has(s.name)) byName.set(s.name, s);
2059
+ out.push({
2060
+ run: tile,
2061
+ name: tileName(tile),
2062
+ givens: gvs.map((s) => s.name)
2063
+ });
2105
2064
  }
2106
- return { ok: true, givens: [...byName.values()] };
2065
+ return { ok: true, tiles: out, union: [...byName.values()] };
2107
2066
  });
2108
2067
  },
2109
2068
  validate(runExpr, givens) {
@@ -2154,6 +2113,7 @@ async function gatherDashboards(dir) {
2154
2113
  const a = res.artifact;
2155
2114
  const manifest = { title: a.title, entryFile };
2156
2115
  if (a.tiles) manifest.tiles = a.tiles;
2116
+ else if (a.query) manifest.query = a.query;
2157
2117
  if (a.dashboard_columns !== void 0) manifest.dashboard_columns = a.dashboard_columns;
2158
2118
  if (a.description) manifest.description = a.description;
2159
2119
  if (a.givens) manifest.givens = a.givens;
@@ -2252,6 +2212,7 @@ async function runLint(abs, runner) {
2252
2212
  }
2253
2213
  if (!res.artifact) continue;
2254
2214
  const art = res.artifact;
2215
+ if (art.warnings) warnings.push(...art.warnings);
2255
2216
  if (seenNames.has(art.name)) {
2256
2217
  errors.push(`duplicate dashboard name "${art.name}" (also declared by ${seenNames.get(art.name)})`);
2257
2218
  } else {
@@ -2260,8 +2221,8 @@ async function runLint(abs, runner) {
2260
2221
  if (art.dashboard_columns !== void 0 && (!Number.isInteger(art.dashboard_columns) || art.dashboard_columns < 1)) {
2261
2222
  errors.push(`dashboard_columns must be a positive integer (got ${JSON.stringify(art.dashboard_columns)})`);
2262
2223
  }
2263
- const tiles = art.tiles ?? [];
2264
- if (tiles.length === 0) errors.push(`\`## artifact\` declares no tiles`);
2224
+ const tiles = art.tiles ?? (art.query ? [art.query] : []);
2225
+ if (tiles.length === 0) errors.push(`\`# artifact\` declares neither a query nor tiles`);
2265
2226
  for (const tile of tiles) {
2266
2227
  const v = await runner.validateIn(entryFile, tile, {});
2267
2228
  if (!v.ok) errors.push(`tile "${tile}": ${v.error}`);
@@ -2763,19 +2724,29 @@ for (const spec of HOST_LIBS) {
2763
2724
  function resolveRuntimeDir() {
2764
2725
  const candidates = [
2765
2726
  new URL("./frame-runtime/", import.meta.url),
2766
- // dev: src/dashboard.ts
2727
+ // src/dashboard.ts (tsx) OR dist/index.js (published)
2767
2728
  new URL("../src/frame-runtime/", import.meta.url)
2768
- // built: dist/index.js
2729
+ // built dist/ next to sibling src/ (checkout)
2769
2730
  ].map((u) => fileURLToPath(u));
2770
2731
  const found = candidates.find((c) => fs3.existsSync(c));
2771
2732
  if (!found) {
2772
2733
  throw new Error(
2773
- "frame-runtime/ not found \u2014 `dashboard dev` currently needs the CLI source checkout (looked in ./ and ../src). See docs/repo-artifacts.md packaging note."
2734
+ "frame-runtime/ not found next to the CLI (looked in ./frame-runtime and ../src/frame-runtime). A published install should ship it in dist/; reinstall the CLI, or rebuild with `npm run build`."
2774
2735
  );
2775
2736
  }
2776
2737
  return found;
2777
2738
  }
2778
2739
  var resolveFrameEntry = () => path4.join(resolveRuntimeDir(), "..", "frame-entry.tsx");
2740
+ var resolveInPageEntry = () => path4.join(resolveRuntimeDir(), "..", "frame-inpage-entry.tsx");
2741
+ var hostAliasPlugin = {
2742
+ name: "host-alias",
2743
+ setup(b) {
2744
+ b.onResolve(
2745
+ { filter: /^(react($|\/)|react-dom($|\/)|@malloydata\/(render|malloy-filter)$)/ },
2746
+ (args) => HOST_ALIAS[args.path] ? { path: HOST_ALIAS[args.path] } : void 0
2747
+ );
2748
+ }
2749
+ };
2779
2750
  async function discoverDashboards(root, runner) {
2780
2751
  const dir = path4.join(root, "dashboards");
2781
2752
  if (!fs3.existsSync(dir)) return [];
@@ -2831,12 +2802,9 @@ function makeBundler() {
2831
2802
  }));
2832
2803
  }
2833
2804
  b.onResolve({ filter: /^@malloyyo\/dashboard$/ }, () => ({ path: runtimeIndex }));
2834
- b.onResolve(
2835
- { filter: /^(react($|\/)|react-dom($|\/)|@malloydata\/(render|malloy-filter)$)/ },
2836
- (args) => HOST_ALIAS[args.path] ? { path: HOST_ALIAS[args.path] } : void 0
2837
- );
2838
2805
  }
2839
- }
2806
+ },
2807
+ hostAliasPlugin
2840
2808
  ]
2841
2809
  });
2842
2810
  const js = result.outputFiles[0].text;
@@ -2844,15 +2812,61 @@ function makeBundler() {
2844
2812
  return js;
2845
2813
  };
2846
2814
  }
2815
+ function makeInPageBundler() {
2816
+ let cached;
2817
+ const entry = resolveInPageEntry();
2818
+ const runtimeDir = resolveRuntimeDir();
2819
+ const stampOf = () => fs3.statSync(entry).mtimeMs + fs3.readdirSync(runtimeDir).map((f) => fs3.statSync(path4.join(runtimeDir, f)).mtimeMs).reduce((a, b) => a + b, 0);
2820
+ return async function bundle() {
2821
+ const stamp = stampOf();
2822
+ if (cached && cached.stamp === stamp) return cached.js;
2823
+ const result = await esbuild2.build({
2824
+ entryPoints: [entry],
2825
+ bundle: true,
2826
+ format: "iife",
2827
+ platform: "browser",
2828
+ jsx: "automatic",
2829
+ write: false,
2830
+ logLevel: "silent",
2831
+ loader: { ".css": "empty" },
2832
+ define: { "process.env.NODE_ENV": '"production"' },
2833
+ plugins: [hostAliasPlugin]
2834
+ });
2835
+ const js = result.outputFiles[0].text;
2836
+ cached = { stamp, js };
2837
+ return js;
2838
+ };
2839
+ }
2847
2840
  var html = (body, title) => `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title><meta name="viewport" content="width=device-width,initial-scale=1"></head><body style="margin:0">${body}</body></html>`;
2841
+ function navHtml(dash, all) {
2842
+ if (all.length <= 1) return "";
2843
+ return `<nav style="display:flex;gap:4px;align-items:center;padding:8px 12px;background:#f6f7f9;border-bottom:1px solid #e2e4e8;font:13px system-ui,sans-serif"><span style="color:#888;margin-right:8px">Dashboards</span>` + all.map((x) => {
2844
+ const on = x.name === dash.name;
2845
+ return `<a href="/?d=${encodeURIComponent(x.name)}" style="padding:4px 10px;border-radius:6px;text-decoration:none;${on ? "background:#1a1a1a;color:#fff" : "color:#333"}">${esc(x.title || x.name)}</a>`;
2846
+ }).join("") + `</nav>`;
2847
+ }
2848
+ function inPageShell(dash, all, givenSpecs, initialGivens, tileSpecs) {
2849
+ const info = {
2850
+ name: dash.name,
2851
+ query: dash.query,
2852
+ title: dash.title,
2853
+ description: dash.description,
2854
+ tiles: dash.tiles,
2855
+ tileSpecs,
2856
+ dashboard_columns: dash.dashboard_columns,
2857
+ givens: dash.givens,
2858
+ autorun: dash.autorun
2859
+ };
2860
+ return html(
2861
+ navHtml(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>`,
2862
+ dash.title
2863
+ );
2864
+ }
2848
2865
  function parentShell(dash, frameBase, all, initialGivens) {
2849
2866
  const givensQs = Object.entries(initialGivens).map(([k, v]) => `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("");
2850
2867
  const d = JSON.stringify(dash.name);
2851
2868
  const fb = JSON.stringify(frameBase);
2852
- const nav = all.length > 1 ? `<nav style="display:flex;gap:4px;align-items:center;padding:8px 12px;background:#f6f7f9;border-bottom:1px solid #e2e4e8;font:13px system-ui,sans-serif"><span style="color:#888;margin-right:8px">Dashboards</span>` + all.map((x) => {
2853
- const on = x.name === dash.name;
2854
- return `<a href="/?d=${encodeURIComponent(x.name)}" style="padding:4px 10px;border-radius:6px;text-decoration:none;${on ? "background:#1a1a1a;color:#fff" : "color:#333"}">${esc(x.title || x.name)}</a>`;
2855
- }).join("") + `</nav>` : "";
2869
+ const nav = navHtml(dash, all);
2856
2870
  return html(
2857
2871
  `<div style="display:flex;flex-direction:column;height:100vh">` + nav + // allow-popups(+escape-sandbox): let a # link mark open its target in a
2858
2872
  // normal new tab on click instead of being blocked by the sandbox.
@@ -2894,13 +2908,16 @@ function givensFromUrl(url4) {
2894
2908
  for (const [k, v] of url4.searchParams) if (k !== "d") g[k] = v;
2895
2909
  return g;
2896
2910
  }
2897
- function frameDoc(dash, givenSpecs, initialGivens) {
2911
+ function frameDoc(dash, givenSpecs, initialGivens, tileSpecs) {
2898
2912
  const info = {
2899
2913
  name: dash.name,
2900
2914
  query: dash.query,
2901
2915
  title: dash.title,
2902
2916
  description: dash.description,
2903
2917
  tiles: dash.tiles,
2918
+ // Per-tile run/name/given-names for the composite renderer (composite only).
2919
+ // Each tile runs with just the givens it references.
2920
+ tileSpecs,
2904
2921
  dashboard_columns: dash.dashboard_columns,
2905
2922
  givens: dash.givens,
2906
2923
  autorun: dash.autorun
@@ -2933,7 +2950,17 @@ async function serveDashboard(opts) {
2933
2950
  }
2934
2951
  let byName = new Map(dashboards.map((d) => [d.name, d]));
2935
2952
  const bundle = makeBundler();
2953
+ const inPageBundle = makeInPageBundler();
2936
2954
  const pick = (url4) => byName.get(url4.searchParams.get("d") ?? dashboards[0].name) ?? dashboards[0];
2955
+ async function resolveGivens(dash) {
2956
+ if (dash.tiles && dash.entryFile) {
2957
+ const t = await runner.dashboardTiles(dash.entryFile, dash.tiles);
2958
+ return { ok: true, union: t.union, tiles: t.tiles };
2959
+ }
2960
+ const specs = dash.entryFile ? await runner.givensForQueryIn(dash.entryFile, dash.query) : await runner.givensForQuery(dash.query);
2961
+ if (!specs.ok) return { ok: false, error: specs.error };
2962
+ return { ok: true, union: specs.givens };
2963
+ }
2937
2964
  const sseClients = /* @__PURE__ */ new Set();
2938
2965
  const notifyReload = () => {
2939
2966
  for (const c of sseClients) c.write("data: reload\n\n");
@@ -2967,15 +2994,15 @@ async function serveDashboard(opts) {
2967
2994
  if (onFramePort) {
2968
2995
  if (url4.pathname === "/frame") {
2969
2996
  const dash = pick(url4);
2970
- const specs = dash.tiles && dash.entryFile ? await runner.dashboardGivens(dash.entryFile, dash.tiles) : await runner.givensForQuery(dash.query);
2971
- if (!specs.ok) {
2997
+ const g = await resolveGivens(dash);
2998
+ if (!g.ok) {
2972
2999
  return send(
2973
3000
  200,
2974
3001
  "text/html; charset=utf-8",
2975
- html(`<pre style="color:crimson;padding:16px">model error: ${esc(specs.error)}</pre>`, dash.title)
3002
+ html(`<pre style="color:crimson;padding:16px">model error: ${esc(g.error)}</pre>`, dash.title)
2976
3003
  );
2977
3004
  }
2978
- return send(200, "text/html; charset=utf-8", frameDoc(dash, specs.givens, givensFromUrl(url4)));
3005
+ return send(200, "text/html; charset=utf-8", frameDoc(dash, g.union, givensFromUrl(url4), g.tiles));
2979
3006
  }
2980
3007
  if (url4.pathname === "/bundle.js") {
2981
3008
  return send(200, "application/javascript; charset=utf-8", await bundle(pick(url4)));
@@ -2990,14 +3017,29 @@ async function serveDashboard(opts) {
2990
3017
  return;
2991
3018
  }
2992
3019
  if (url4.pathname === "/") {
2993
- return send(200, "text/html; charset=utf-8", parentShell(pick(url4), frameBase, dashboards, givensFromUrl(url4)));
3020
+ const dash = pick(url4);
3021
+ if (!dash.tsxPath) {
3022
+ const g = await resolveGivens(dash);
3023
+ if (!g.ok) {
3024
+ return send(
3025
+ 200,
3026
+ "text/html; charset=utf-8",
3027
+ html(`<pre style="color:crimson;padding:16px">model error: ${esc(g.error)}</pre>`, dash.title)
3028
+ );
3029
+ }
3030
+ return send(200, "text/html; charset=utf-8", inPageShell(dash, dashboards, g.union, givensFromUrl(url4), g.tiles));
3031
+ }
3032
+ return send(200, "text/html; charset=utf-8", parentShell(dash, frameBase, dashboards, givensFromUrl(url4)));
3033
+ }
3034
+ if (url4.pathname === "/inpage.js") {
3035
+ return send(200, "application/javascript; charset=utf-8", await inPageBundle());
2994
3036
  }
2995
3037
  if (url4.pathname === "/api/run" && req.method === "POST") {
2996
- const { d, query, malloy, givens, dashboard } = JSON.parse(await readBody(req));
3038
+ const { d, query, malloy, givens } = JSON.parse(await readBody(req));
2997
3039
  const dash = byName.get(d);
2998
3040
  if (!dash) return send(404, "application/json", JSON.stringify({ ok: false, problems: [{ message: `no dashboard '${d}'` }] }));
2999
3041
  const entry = dash.entryFile;
3000
- const out = dashboard && dash.tiles && entry ? await runner.runDashboard(entry, dash.tiles, { columns: dash.dashboard_columns, givens: givens ?? {} }) : typeof malloy === "string" ? entry ? await runner.runTextIn(entry, malloy, givens ?? {}) : await runner.runText(malloy, givens ?? {}) : entry ? await runner.runIn(entry, String(query ?? ""), givens ?? {}) : await runner.run(String(query ?? dash.query), givens ?? {});
3042
+ const out = typeof malloy === "string" ? entry ? await runner.runTextIn(entry, malloy, givens ?? {}) : await runner.runText(malloy, givens ?? {}) : entry ? await runner.runIn(entry, String(query ?? ""), givens ?? {}) : await runner.run(String(query ?? dash.query), givens ?? {});
3001
3043
  return send(200, "application/json", JSON.stringify(out));
3002
3044
  }
3003
3045
  send(404, "text/plain", "not found");
@@ -3013,7 +3055,7 @@ async function serveDashboard(opts) {
3013
3055
  malloyyo dashboard dev \u2014 model: ${root}`);
3014
3056
  console.error(` http://localhost:${port}/ (artifact origin: ${frameBase})`);
3015
3057
  for (const d of dashboards) {
3016
- const kind = d.tsxPath ? "custom" : "default UI";
3058
+ const kind = d.tsxPath ? "custom (iframe)" : "tag-only (in-page)";
3017
3059
  console.error(` \u2022 ${d.name} (${kind}) \u2192 http://localhost:${port}/?d=${d.name}`);
3018
3060
  }
3019
3061
  console.error(` Ctrl-C to stop.
@@ -3141,7 +3183,7 @@ async function launchCmd(mode, opts) {
3141
3183
  }
3142
3184
 
3143
3185
  // package.json
3144
- var version = "0.2.16";
3186
+ var version = "0.2.18";
3145
3187
 
3146
3188
  // src/index.ts
3147
3189
  function shortSha(sha) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@malloydata/malloyyo",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
4
4
  "description": "Publish Malloy models to a Malloyyo instance",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "scripts": {
25
25
  "build:engine": "cd ../mcp-engine && npm run build",
26
- "build": "npm run build:engine && esbuild src/index.ts --bundle --platform=node --format=esm --external:commander --external:esbuild --external:@malloydata/* --external:@modelcontextprotocol/* --outfile=dist/index.js",
26
+ "build": "npm run build:engine && esbuild src/index.ts --bundle --platform=node --format=esm --external:commander --external:esbuild --external:@malloydata/* --external:@modelcontextprotocol/* --outfile=dist/index.js && node scripts/copy-frame-src.mjs",
27
27
  "dev": "tsx src/index.ts",
28
28
  "typecheck": "tsc --noEmit",
29
29
  "pretest": "npm run build",
@@ -35,13 +35,19 @@
35
35
  "@malloydata/malloy": "^0.0.423",
36
36
  "@malloydata/malloy-connections": "^0.0.423",
37
37
  "@malloydata/malloy-filter": "^0.0.423",
38
+ "@malloydata/render": "^0.0.423",
38
39
  "@modelcontextprotocol/sdk": "^1.29.0",
39
- "commander": "^12.1.0"
40
+ "commander": "^12.1.0",
41
+ "esbuild": "^0.24.0",
42
+ "react": "19.2.4",
43
+ "react-dom": "19.2.4",
44
+ "vega": "^5.33.1",
45
+ "vega-interpreter": "^2.2.1",
46
+ "vega-lite": "^5.23.0"
40
47
  },
41
48
  "devDependencies": {
42
49
  "@malloyyo/mcp-engine": "*",
43
50
  "@types/node": "^20",
44
- "esbuild": "^0.24.0",
45
51
  "tsx": "^4.21.0",
46
52
  "typescript": "^5"
47
53
  }