@malloy-publisher/skills 0.1.42 → 0.1.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/skills/eval-answer/SKILL.md +1 -1
  3. package/skills/eval-diagnose/SKILL.md +1 -1
  4. package/skills/eval-import/SKILL.md +1 -1
  5. package/skills/eval-improve/SKILL.md +1 -1
  6. package/skills/eval-judge/SKILL.md +1 -1
  7. package/skills/eval-loop/SKILL.md +1 -1
  8. package/skills/eval-report/SKILL.md +1 -1
  9. package/skills/malloy-analysis/SKILL.md +1 -1
  10. package/skills/malloy-analysis-report/SKILL.md +1 -1
  11. package/skills/malloy-charts/SKILL.md +1 -1
  12. package/skills/malloy-dashboards/SKILL.md +1 -1
  13. package/skills/malloy-define/SKILL.md +1 -1
  14. package/skills/malloy-discover/SKILL.md +1 -1
  15. package/skills/malloy-document/SKILL.md +1 -1
  16. package/skills/malloy-getting-started/SKILL.md +1 -1
  17. package/skills/malloy-gotchas-modeling/SKILL.md +1 -1
  18. package/skills/malloy-html-data-apps/SKILL.md +6 -2
  19. package/skills/malloy-html-data-apps/reference/depth-patterns.md +9 -2
  20. package/skills/malloy-html-data-apps/reference/embedding.md +9 -0
  21. package/skills/malloy-html-data-apps/reference/overlays.md +131 -0
  22. package/skills/malloy-html-data-apps/reference/performance.md +247 -0
  23. package/skills/malloy-html-data-apps/reference/runtime.md +14 -4
  24. package/skills/malloy-html-data-apps/reference/verification-harness.md +53 -0
  25. package/skills/malloy-lookml-review/SKILL.md +1 -1
  26. package/skills/malloy-materialization/SKILL.md +1 -1
  27. package/skills/malloy-model/SKILL.md +1 -1
  28. package/skills/malloy-model-as-you-go/SKILL.md +1 -1
  29. package/skills/malloy-modeling/SKILL.md +1 -1
  30. package/skills/malloy-notebooks/SKILL.md +1 -1
  31. package/skills/malloy-phrase-detection/SKILL.md +1 -1
  32. package/skills/malloy-powerbi-review/SKILL.md +1 -1
  33. package/skills/malloy-publish/SKILL.md +1 -1
  34. package/skills/malloy-queries/SKILL.md +1 -1
  35. package/skills/malloy-reports/SKILL.md +2 -2
  36. package/skills/malloy-review/SKILL.md +1 -1
  37. package/skills/malloy-run-agent/SKILL.md +1 -1
  38. package/skills/malloy-source-unreachable/SKILL.md +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/skills",
3
3
  "description": "Agent skills for Malloy and the Malloy Publisher",
4
- "version": "0.1.42",
4
+ "version": "0.1.43",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-answer
3
3
  description: 'Score one analytical answer against a verified golden, and score which of the entities the golden depends on retrieval delivered to the answerer. Run the contamination checklist, re-execute the submitted query yourself, then spawn a judge subagent per skill:eval-judge. Append attempt, tool_call, score, and retrieval_score events to the file ledger (reference/ledger-schema.md). Never explain the failure (eval-diagnose) or edit the model (eval-improve). Use when asked whether an answer was correct, to score a run, or to baseline a model.'
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
 
7
7
  # Evaluate One Answer
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-diagnose
3
3
  description: 'Diagnose why a scored answer failed and who owns the fix, then cluster the failures by shared root cause. Walk dataset, agent-call, get_context/model, get_context/retrieval, construction, then model-definition. Append issue events to the file ledger, linked by traceId, one per cluster. Use after eval-answer, when triaging a run, or before changing a model. Does not edit the model (eval-improve).'
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
 
7
7
  # Diagnose One Answer
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-import
3
3
  description: 'Turn a list of questions into an eval set, whatever shape it arrived in: a JSONL a customer sent, a CSV, a spreadsheet export, a markdown doc, an email thread, or a pull from production logs. Classify each item by what came WITH the question (a query, a number, prose criteria, or nothing), write cases.jsonl and set.json per reference/case-format.md, keep the file as it arrived, and seal each question so a later edit is detectable. Never marks a value-bearing golden verified and never invents a question. Use when questions arrive from outside and need to become a set, or before the first run against a set nobody here authored.'
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
 
7
7
  # Import questions into an eval set
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-improve
3
3
  description: 'Make the smallest safe edit that closes a diagnosed gap the model repo owns: a Malloy model edit, or a package skill under skills/. Every factual claim carries a probe receipt. Use after eval-diagnose, or when asked to fix a model so an agent can discover the right answer. Never accepts its own edit; the acceptance check belongs to eval-loop. Does not decide whether an answer was wrong (eval-answer) or why (eval-diagnose).'
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
 
7
7
  # Improve the Model
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-judge
3
3
  description: 'Decide whether ONE answer matches its golden, and say whether you believe the golden. Read this before emitting any verdict. Covers containment, column pairing, near_match, refusals, and the gold_status judgement. Use when scoring an attempt in an evaluation run; never to conduct a run (eval-loop), diagnose a failure (eval-diagnose) or edit a model (eval-improve).'
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
 
7
7
  # The judge
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-loop
3
3
  description: 'Conduct a local Publisher evaluation loop in five steps: scrape/run, eval, diagnose, improve, checkpoint. You are the conductor: import cases into the file ledger, spawn a blind answerer, then run eval-answer, eval-diagnose, and eval-improve. Persistence is plain files: the set in the model package''s evals/ directory, runs in the set''s workdir; checkpoints are git commits of the model repo. `eval.py` runs each step from the set''s eval.toml. Use to score a model, diagnose failures, improve behind an acceptance check, or roll back a bad direction.'
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
 
7
7
  # The Evaluation Loop
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-report
3
3
  description: 'Write the summary a person reads after an evaluation run: what ran, what it scored, which failures were the MODEL and which were the EVAL itself (out of turns, contaminated, unestablished golden, unreadable judge), what was learned, and what to do next. Every claim links to the artifact behind it. Use at the end of any run, before quoting a number to anyone. Never scores an answer (eval-answer) or decides who owns a failure (eval-diagnose).'
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
 
7
7
  # Report a Run
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-analysis
3
3
  description: "Read before your first get_context call on any business or data question. The required workflow, not background: which source, which rows, how to verify. Skipping it causes confident wrong answers."
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
 
7
7
  <!-- Copyright (c) Credible Data Inc. SPDX-License-Identifier: MIT -->
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-analysis-report
3
3
  description: Combine validated Malloy queries into a report (a linear report, read top to bottom). Use when the user asks to "create a report", "combine these into a report", or wants a persistent multi-query artifact.
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
 
7
7
  <!--
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-charts
3
3
  description: Read before choosing a chart or adding a chart annotation. Chart types, tag syntax and scale rules, KPI cards, dashboards, sparklines, and the renderer mistakes that fail silently.
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
  <!--
7
7
  Copyright (c) Credible Data Inc.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-dashboards
3
3
  description: "Renamed to malloy-reports. A dashboard is now a report with layout=grid: a .malloy file with an ## artifact tag, written at the package root. Load malloy-reports instead when the user asks for a dashboard, a filterable view, or drill-through between views."
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
 
7
7
  <!--
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-define
3
3
  description: Propose scope, a source plan and field definitions for a Malloy model. Which tables and questions, which sources at what grain, then renames, dimensions and measures, each backed by querying data.
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
  <!--
7
7
  Copyright (c) Credible Data Inc.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-discover
3
3
  description: Silent data discovery for Malloy modeling. Used at Step 1 of the modeling workflow. Scans tables, columns, distributions, and relationships without user interaction. The agent builds an internal picture before presenting anything.
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
  <!--
7
7
  Copyright (c) Credible Data Inc.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-document
3
3
  description: 'Add documentation with #(doc) tags to Malloy models so fields and sources are described in plain language. Use when user asks to "add documentation", "add doc tags", "document the model", or wants fields and sources described for natural-language search and discovery. For runtime filters, declare given: parameters (see the malloy-model skill); #(filter) is deprecated. Filters are a runtime/modeling construct (governance, latency, correctness), not a documentation tag.'
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
  <!--
7
7
  Copyright (c) Credible Data Inc.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-getting-started
3
3
  description: First steps for using a Malloy Publisher deployment through its MCP tools. Use when connecting to Publisher for the first time, when you do not yet know the available environments, packages, or models, or when a user asks what data they can explore. Also use for "malloy help" or which Malloy skill to read. Covers verifying the server, discovering data with get_context, and running a first grounded query.
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
  <!--
7
7
  Copyright (c) Credible Data Inc.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-gotchas-modeling
3
3
  description: Malloy modeling mistakes and compile-error fixes. Read before writing sources, dimensions, measures or joins, and when a .malloy file will not compile. Reserved words, NULLs, dates, field access.
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
  <!--
7
7
  Copyright (c) Credible Data Inc.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-html-data-apps
3
3
  description: "Build or modify an in-package HTML data app for a Malloy Publisher package (a public/ directory the package serves): design it, write its Publisher.query code, embed it. Hand-authored, no build step."
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
  <!--
7
7
  Copyright (c) Credible Data Inc.
@@ -47,7 +47,7 @@ The agent orchestrates these. Each query and chart step hands off to a focused s
47
47
  2. READ THE MODEL. Get the model's real source and view names, through your environment's context tool if it has one, or by opening the `.malloy` file directly. Never guess field or view names.
48
48
  3. SCAFFOLD the package (template below).
49
49
  4. WRITE THE QUERIES. Read `reference/runtime.md`. Validate each before pasting it into the page, using whatever query tool your environment provides or a running Publisher (see `reference/runtime.md`). Malloy syntax questions go to `skill:malloy-queries`.
50
- 5. CHOOSE CHARTS with `skill:malloy-charts` when rendering through `<malloy-render>`; otherwise it is your own chart library drawing the returned rows. Vendor any chart library into `public/` and load it locally, not from a CDN. Two reasons: embedded author JavaScript runs with the viewing user's data authority, and a blocked CDN (agent sandboxes and many corporate networks block them) is easy to miss, because the script never runs and the charts come up empty. The `storefront` example ships its chart library in `public/vendor/` and loads it from `public/index.html` as `./vendor/chart.umd.js`. Copy that, but resolve the path against the page's own directory: a page in a subdirectory (`public/reports/index.html`) needs `../vendor/chart.umd.js`. A wrong relative path 404s and leaves the charts blank, which is the failure you are trying to avoid.
50
+ 5. CHOOSE CHARTS with `skill:malloy-charts` when rendering through `<malloy-render>`; otherwise it is your own chart library drawing the returned rows. Vendor any chart library into `public/` and load it locally, not from a CDN. Two reasons: embedded author JavaScript runs with the viewing user's data authority, and a blocked CDN (agent sandboxes and many corporate networks block them) is easy to miss, because the script never runs and the charts come up empty. The `storefront` example ships its chart library in `public/vendor/` and loads it from `public/index.html` as `./vendor/chart.umd.js`. Copy its vendoring, not its loading (it uses a plain `<script>`, which the next sentences rule out), and resolve the path against the page's own directory: a page in a subdirectory (`public/reports/index.html`) needs `../vendor/chart.umd.js`. A wrong relative path 404s and leaves the charts blank, which is the failure you are trying to avoid. Load it off the critical path (`<link rel="preload">`, then injected on the first chart), not as a plain `<script>` ahead of the app: it is usually most of the page's weight and would hold every query until it has parsed (`reference/performance.md`).
51
51
  6. EMBED (optional). Read `reference/embedding.md`.
52
52
  7. PREVIEW with the local authoring loop (below).
53
53
  8. VERIFY before you call it done (see "What 'done' means" below). This step is not optional.
@@ -67,6 +67,9 @@ A data app you can defend has all of these. Build to this list, not to the scaff
67
67
  - **Visible assumptions.** When you assume something (two sources joined by month, an in-month proxy that differs from a certified definition) or a metric is incomplete, say so *in the app*: a caption, a footnote, a placeholder card with the reason. The non-technical user cannot see your reasoning; bury a caveat and you have misled them. Don't silently drop a metric you couldn't model. Show a placeholder that names what's missing and why.
68
68
  - **Built to the design brief, not to the model's shape.** The app matches the archetype, forms, and depth plan from step 1 (`reference/design.md`), carries a real token contract (surfaces, text ramp, semantic colors, a categorical chart palette) that the chart code reads at runtime rather than hardcoding, and puts visible weight on the two or three numbers that lead. A uniform grid of same-size cards, or a tile per view, means the brief was skipped.
69
69
  - **Vendored libraries.** Chart and helper libraries live in `public/`, loaded locally (step 5).
70
+ - **Light, and nothing waits on the chart library.** Pages that draw charts preload the library and load it on first use; a page that draws none never loads it. Trim it to the series types the app draws once it is big enough to matter, and verify a trimmed build by reading pixels, because a missing type draws an empty box without an error. One query text per resource, so any cache or prefetch actually hits. (`reference/performance.md`, which also has two opt-in recipes, answering from the last visit and starting a drawer's queries on intent, each with the conditions it needs.)
71
+ - **Holds still while it loads.** Script-filled blocks reserve their final size in the HTML, skeletons are sized from what is coming, settled tile heights are remembered for the next visit, and a tile re-running for a new filter keeps its last answer dimmed instead of collapsing. Target: layout shift under 0.1 on a first visit and 0 on a repeat, embedded as well as standalone. (`reference/performance.md`.)
72
+ - **Works embedded.** Inside an auto-sized frame there is no viewport: the frame never scrolls, nothing extends past the content, and anything that floats (a drawer, a tray) is placed over the slice of the page the reader can see, keeps its scroll to itself, and returns the reader exactly where they were. A drawer built the standalone way opens at the top of the document and drags the host page up to meet it. (`reference/embedding.md`, `reference/overlays.md`.)
70
73
  - **Lazy-load below the fold, once the page has more than about eight query-backed tiles.** ("Many" is not decidable; that is the threshold.) Tiles sitting below the fold is *why* the technique works, not a second trigger for it - almost every real data app scrolls, so reading it as one makes the rule "always" and contradicts the count. Under that threshold, firing every tile at once is cheaper than the machinery. Don't fire every tile's query on load. `reference/lazy-load.md` is the recipe: `IntersectionObserver` (rootMargin ~240px) + a small concurrency cap + reserve each tile's height so lazy tiles don't reflow. Includes the verification trap - on a short/tall-default viewport all tiles intersect at once and you get a false "everything deferred" pass, so test on a deliberately small viewport.
71
74
 
72
75
  ### Verify before you call it done
@@ -75,6 +78,7 @@ You are building for someone who cannot tell a correct dashboard from a broken o
75
78
 
76
79
  - **Validate every query against the model before wiring it in** (step 4): confirm it compiles and that the column names match what your render code reads.
77
80
  - **Load the finished page and confirm every tile shows real numbers**: not stuck on "Loading…", not an error, not an empty state you didn't intend. In a headless browser, wait on `load` plus a content selector, not network idle (`publisher.js` holds an SSE stream open; see `reference/runtime.md`). Don't hand-roll this each time - `reference/verification-harness.md` is a copy-adaptable recipe: a mock `sdk/publisher.js` returning canned rows keyed by `(model, query)`, a `python3 -m http.server` webroot, and Playwright assertions (KPIs non-null, no `.is-error`, no stuck `.kit-skeleton`, a chart/table present). It also documents the false-"stuck skeleton" trap (assert after the mock's async delay, never on `networkidle`).
81
+ - **Check it embedded, for stability, and through its drawers.** Load each page inside the Publisher console (`/<env>/<package>/data-apps/<path>`) as well as on its own, measure layout shift on a first and a repeat visit, confirm the app never scrolls inside its frame, and open a drawer from well down the page after scrolling the host with real wheel events: the page behind must not move, the drawer must span exactly the visible area, and its scroll must stay in it. `reference/verification-harness.md` section 4 has the checks. A tile check alone passed on a build where every one of these was broken.
78
82
  - **Unit-test any non-trivial pure logic** (a month-join, a de-cumulation, a unit conversion). Keep that logic in DOM-free helpers so `node --test` can cover it, and run it.
79
83
 
80
84
  ## Minimal scaffold
@@ -80,7 +80,7 @@ Write down that you did this and why. A reader who later sees hand-built drill q
80
80
 
81
81
  ## Drill-down: the panel side
82
82
 
83
- An entity drawer has six parts. Skipping any one produces a bug that only shows up in use, not in a screenshot.
83
+ An entity drawer has six parts, and four more decide whether it feels finished. Skipping any one produces a bug that only shows up in use, not in a screenshot.
84
84
 
85
85
  ```js
86
86
  const drill = { entity: null, charts: [], returnFocus: null };
@@ -110,7 +110,7 @@ function closeDrill() {
110
110
  document.removeEventListener("keydown", onTab);
111
111
  drill.charts.forEach((c) => c.destroy()); // 4. tear charts down
112
112
  drill.charts = [];
113
- drill.returnFocus?.focus(); // 5. give focus back
113
+ drill.returnFocus?.focus({ preventScroll: true }); // 5. give focus back, without scrolling
114
114
  }
115
115
  ```
116
116
 
@@ -123,6 +123,13 @@ What each part prevents:
123
123
  5. **Chart teardown** - most chart libraries keep a registry and attach resize listeners; reopening a drawer twenty times without destroying leaks memory and can leave ghost tooltips from the old instance.
124
124
  6. Also re-render the drawer when the global filter changes underneath it, or it silently shows numbers for a period the rest of the page has moved off.
125
125
 
126
+ The four that separate a working drawer from a polished one:
127
+
128
+ 7. **Open on the click, resolve inside.** When the drawer needs a lookup before it can query (a name resolved to an id), open it at once with the name as its title and a skeleton, and swap the content in when the id arrives, under the stale-response guard. Waiting for the lookup before opening anything makes the click look dead for as long as the query takes.
129
+ 8. **Drilling from a drawer replaces it in place.** A product opened from inside a market drawer swaps the drawer's title and body without a second slide-in, and the new subject reads from its top: reset the body's `scrollTop` to 0, or it opens wherever the reader had scrolled the previous one.
130
+ 9. **The panel body is its only scroller, and its scroll stays in it.** Wheel and touch inside the drawer scroll the drawer; nothing scrolls the page behind it, over the scrim either, and closing returns the reader to exactly where they were. On a real build the wheel inside an open drawer scrolled the page behind it by 2,000 to 3,000px, and closing left the reader that far from the row they had clicked.
131
+ 10. **It lands on the reader's screen when embedded.** Inside an auto-sized frame `position: fixed` means the top of the whole document, so a drawer built the standalone way opens far above the reader and drags the host page up to meet it. `reference/overlays.md` is the recipe for 9 and 10: finding the visible band, one clipped layer over it, scroll containment, and focus that does not scroll.
132
+
126
133
  ## Cross-filtering (shared scope)
127
134
 
128
135
  Clicking a category filters the other tiles. Express scope as a `where:` refinement on the **same named view** each tile already runs, so a filtered tile and an unfiltered one still share one definition.
@@ -30,6 +30,15 @@ Omit `height` and the frame auto-sizes. The embedded page measures its real cont
30
30
 
31
31
  Do not rely on `body { min-height: 100vh }` to drive the frame height. The runtime deliberately measures the content's bottom edge, not the viewport, to avoid a grow-forever loop.
32
32
 
33
+ ## Inside the frame there is no viewport
34
+
35
+ The host scrolls; the frame does not. Inside an auto-sized frame `window.innerHeight` is the whole document, `window.scrollY` stays 0, and `position: fixed` means the top of the document rather than the top of the reader's screen. Two consequences, both invisible when the page is opened directly:
36
+
37
+ - **An auto-sized frame must never become scrollable.** Anything whose box reaches past the content's bottom edge (a sentinel, an absolutely positioned helper, an overlay) is scrollable overflow: the document grows past the height the host sized, and the reader's first wheel turns scroll the app inside its frame before the host moves. Keep every added element within the content, and check it with the harness rather than hiding overflow: a fixed-height frame (`height` above, or `publisher:fit`) needs the app to scroll inside it.
38
+ - **Anything that floats over the page needs placing.** A drawer, a compare tray, a toast: built with `position: fixed` it opens at the top of the document, far above the reader, and focusing it scrolls the host page up to meet it. `reference/overlays.md` is the recipe: track the slice of the page the reader can see, place one clipped layer over it, contain its scroll, and focus without scrolling.
39
+
40
+ Test embedded, not just standalone: the Publisher console renders a package's app at `/<env>/<package>/data-apps/<path>`, which exercises the same resize contract a host does (`reference/verification-harness.md`).
41
+
33
42
  ## Auth
34
43
 
35
44
  - Same-origin or same-tenant: pass no token. The browser's cookies authenticate the iframe.
@@ -0,0 +1,131 @@
1
+ <!--
2
+ Copyright (c) Credible Data Inc.
3
+ SPDX-License-Identifier: MIT
4
+ -->
5
+
6
+ # Drawers, trays and toasts in an embedded app
7
+
8
+ > How to place anything that floats over the page (an entity drawer, a compare tray, a toast) so it lands on the reader's screen and the page behind it holds still, standalone and inside an auto-sized frame. Referenced from `SKILL.md` and `reference/embedding.md`. Every rule below is a bug a real build shipped and a reader found.
9
+
10
+ ## Why `position: fixed` breaks when embedded
11
+
12
+ A host that auto-sizes the frame (the Publisher console, a page using `Publisher.embed` without a `height`, any host that honours the resize messages) sizes the iframe to the app's full content height and scrolls its own container. So inside the frame:
13
+
14
+ - `window.innerHeight` is the whole document, thousands of pixels;
15
+ - `window.scrollY` is always 0, because the frame never scrolls; the host does;
16
+ - `position: fixed; top: 0` means **the top of the document**, not the top of the reader's screen.
17
+
18
+ A drawer built the standalone way therefore opens at the top of the app, far above where the reader is. Then focusing its close button scrolls the *host* up to meet it, and the page the reader was on jumps. Measured on a real build: the clicked row moved about 1,000px, the drawer was the height of the whole document so it could not scroll on its own, and closing it left the reader thousands of pixels from where they had been. None of this shows up when the page is opened directly, because standalone `fixed` is correct.
19
+
20
+ The host does not tell the frame where the reader is: the resize protocol goes one way, frame to host. The frame has to work it out.
21
+
22
+ ## Find the visible band
23
+
24
+ An `IntersectionObserver` with the implicit root reports against the **top-level** viewport, clipped by the host's scroll container, for same-origin and cross-origin frames alike, and its `intersectionRect` comes back in the frame's own coordinates. Observe a column of thin strips down the page, keep the union of their visible parts current from page load, and read it synchronously when something opens.
25
+
26
+ ```js
27
+ // view.js: which slice of our document is on the reader's screen.
28
+ export const EMBEDDED = (() => { try { return window.self !== window.top; } catch { return true; } })();
29
+ let VIEW = { top: 0, height: innerHeight, known: !EMBEDDED };
30
+ const listeners = new Set();
31
+ export const onView = (fn) => (listeners.add(fn), () => listeners.delete(fn));
32
+
33
+ if (EMBEDDED && "IntersectionObserver" in window) {
34
+ const STEP = 100, holder = document.createElement("div"), strips = [], vis = {};
35
+ holder.className = "view-strips"; // height 0: see the rules below
36
+ const io = new IntersectionObserver((entries) => {
37
+ const sy = window.scrollY; // viewport -> document coordinates
38
+ for (const e of entries) {
39
+ const r = e.intersectionRect;
40
+ if (e.isIntersecting && r.height > 0) vis[e.target.__i] = [r.top + sy, r.bottom + sy];
41
+ else delete vis[e.target.__i];
42
+ }
43
+ const spans = Object.values(vis);
44
+ if (!spans.length) return;
45
+ const top = Math.min(...spans.map((s) => s[0])), bottom = Math.max(...spans.map((s) => s[1]));
46
+ VIEW = { top: Math.round(top), height: Math.round(bottom - top), known: true };
47
+ listeners.forEach((fn) => fn(VIEW));
48
+ }, { threshold: Array.from({ length: 21 }, (_, i) => i / 20) }); // every 5% of a strip
49
+
50
+ // Cover the content and STOP AT ITS BOTTOM EDGE, measured the way the SDK
51
+ // measures the height it reports: the lowest bottom among body's children,
52
+ // plus body's bottom padding.
53
+ const contentBottom = () => Math.ceil(Math.max(0, ...[...document.body.children]
54
+ .filter((k) => k !== holder && !k.classList.contains("overlay"))
55
+ .map((k) => k.getBoundingClientRect().bottom + window.scrollY))
56
+ + (parseFloat(getComputedStyle(document.body).paddingBottom) || 0));
57
+ const cover = () => {
58
+ const end = contentBottom(), need = Math.max(1, Math.ceil(end / STEP));
59
+ while (strips.length < need) { const s = document.createElement("div"); s.__i = strips.length; strips.push(s); holder.append(s); io.observe(s); }
60
+ while (strips.length > need) { const s = strips.pop(); io.unobserve(s); s.remove(); delete vis[s.__i]; }
61
+ strips.forEach((s, i) => {
62
+ const h = Math.max(1, Math.min(STEP, end - i * STEP)) + "px";
63
+ s.style.top = i * STEP + "px";
64
+ // A strip that is fully visible before and after a resize crosses no threshold,
65
+ // so the observer would keep its old rect: observe it afresh.
66
+ if (s.style.height !== h) { s.style.height = h; io.unobserve(s); io.observe(s); }
67
+ });
68
+ };
69
+ document.body.append(holder);
70
+ cover();
71
+ new ResizeObserver(cover).observe(document.body); // tiles land; the page grows and shrinks
72
+ }
73
+
74
+ // The band an overlay should occupy, in document coordinates. The top is pinned
75
+ // to the top of what the reader sees; only the height gives way.
76
+ export function visibleBand() {
77
+ if (!EMBEDDED) return { top: window.scrollY, height: innerHeight };
78
+ if (!VIEW.known) return { top: 0, height: Math.min(innerHeight, 900) };
79
+ const room = document.documentElement.scrollHeight - VIEW.top;
80
+ return { top: VIEW.top, height: Math.min(Math.max(VIEW.height, 360), room) };
81
+ }
82
+ ```
83
+
84
+ ```css
85
+ .view-strips { position: absolute; top: 0; left: 0; width: 1px; height: 0; pointer-events: none; visibility: hidden; }
86
+ .view-strips > div { position: absolute; left: 0; width: 1px; }
87
+ ```
88
+
89
+ ## Place one layer over the band
90
+
91
+ Put the scrim and the panel in one layer. Standalone it is `position: fixed; inset: 0`. Embedded it is `position: absolute` with its `top` and `height` set from `visibleBand()`, re-set whenever `onView` fires, so it follows the host if the reader scrolls it by other means. The layer clips its contents (`overflow: hidden`), so the panel's slide-in never widens the page.
92
+
93
+ ```css
94
+ .overlay { position: fixed; inset: 0; z-index: 40; overflow: hidden; pointer-events: none; }
95
+ .overlay.is-open, .overlay.is-open > * { pointer-events: auto; }
96
+ .overlay.is-anchored { position: absolute; inset: auto 0 auto 0; } /* top/height from JS */
97
+ .overlay .scrim { position: absolute; inset: 0; background: var(--scrim); opacity: 0; transition: opacity var(--dur-base); }
98
+ .overlay.is-open .scrim { opacity: 1; }
99
+ .overlay .panel {
100
+ position: absolute; top: 0; right: 0; bottom: 0; width: min(760px, 94vw);
101
+ display: flex; flex-direction: column;
102
+ transform: translateX(calc(100% + 64px)); /* parked past its own shadow */
103
+ transition: transform var(--dur-base) var(--ease-out);
104
+ }
105
+ .overlay.is-open .panel { transform: none; }
106
+ .overlay .panel-head { flex: none; }
107
+ .overlay .panel-body { /* the panel's only scroller */
108
+ flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
109
+ display: grid; align-content: start; gap: var(--space-4);
110
+ }
111
+ ```
112
+
113
+ A tray or toast pinned to the bottom of the screen follows the same rule: embedded, give it `position: absolute` and set its `top` to `band.top + band.height - itsHeight - gap` on every `onView`.
114
+
115
+ ## The rules, and the bug each one prevents
116
+
117
+ - **Read the band synchronously; never wait on a fresh measurement to open.** An async probe with a timeout fallback raced the click, lost, and opened the drawer at the top of the document.
118
+ - **Pin the overlay's top to the top of the band; let only its height give way.** Pushing the top up to honour a minimum height puts the header above the screen when only the bottom of the app is visible.
119
+ - **Nothing you add may extend the content.** Sentinels, strips, an overlay layer: anything whose box reaches past the content's bottom edge is scrollable overflow. The document then becomes taller than the frame the host sized, the reader's first wheel turns scroll the app *inside* its frame, and everything placed by the band is offset by that much. Measured: strips rounded up to the next 100px made every drawer open 7 to 46px too high, header under the host's breadcrumb bar, bottom short of the window. Keep sentinels in a zero-height holder, end the last one at the content edge, and keep overlays inside the band.
120
+ - **An auto-sized frame never scrolls.** It cannot, as long as nothing extends the content (the rule above), and the harness asserts it. Do not force it with `overflow: hidden` on the document: the app cannot tell an auto-sized frame from a fixed-height one (`Publisher.embed({ height })`, a page with `<meta name="publisher:fit" content="viewport">`), and a fixed-height frame needs the app to scroll inside it. Hidden overflow there makes everything below the fold unreachable.
121
+ - **Work in document coordinates.** `intersectionRect` is in viewport coordinates; an absolutely positioned layer is placed in document coordinates. Add `window.scrollY` at the moment you read it. They agree only while the frame is unscrolled, which is exactly the assumption the previous bug broke.
122
+ - **Focus without scrolling.** `el.focus({ preventScroll: true })`, both when focusing into the panel and when handing focus back on close. A plain `focus()` scrolls whatever ancestor (including the host) it takes to bring the element into view.
123
+ - **Consume scroll that is not the panel's.** A non-passive `wheel` and `touchmove` listener on the layer calls `preventDefault()` unless the event is inside the panel body and the body can still scroll that way. `overscroll-behavior: contain` on the body stops chaining at its ends. Without both, the wheel inside a drawer scrolled the page behind it by 2,000 to 3,000px.
124
+ - **Scroll keys scroll the panel.** With focus on the close button, PageDown and Space go to the page behind. Handle the scroll keys on the panel and scroll the body. Escape closes; Tab stays inside.
125
+ - **Nothing behind the panel moves.** No `overflow: hidden` toggled on the page (it removes a scrollbar and reflows everything), no `backdrop-filter: blur` on the scrim (it re-rasterises the page on every frame of the slide and reads as the page itself moving).
126
+ - **Exits keep their content.** Remove the open class, let the panel slide out with what it showed, then remove the layer. Under `prefers-reduced-motion: reduce`, remove it at once.
127
+ - **Page rules leak into the panel.** A panel section built from `<section>` inherits the page's `section { margin-top }`, and in a grid that margin does not collapse: measured as a 44px hole above every panel. Reset margins on panel content.
128
+
129
+ ## Verify it embedded
130
+
131
+ None of this can be checked standalone. Load the app inside a real host (the Publisher console at `/<env>/<package>/data-apps/<path>`, or a test page that calls `Publisher.embed` inside a scrolling container), scroll the host with real wheel events, open a drawer from well down the page, and assert on screen coordinates. `reference/verification-harness.md` has the checks.
@@ -0,0 +1,247 @@
1
+ <!--
2
+ Copyright (c) Credible Data Inc.
3
+ SPDX-License-Identifier: MIT
4
+ -->
5
+
6
+ # Performance and layout stability
7
+
8
+ > What a data app downloads, what it asks the server for, and whether the page holds still while it loads. Referenced from `SKILL.md`. Every rule here came from a real build that passed its functional checks while being slow, heavy, or jumpy: a working app is not yet a polished one.
9
+
10
+ ## Measure first
11
+
12
+ Record these per page, before and after any change, with the harness in `reference/verification-harness.md`. A change you did not measure is a guess.
13
+
14
+ | Measure | How | Bar |
15
+ |---|---|---|
16
+ | Bytes downloaded, by type | sum of response sizes on a cold load | the chart library is usually most of it; see below |
17
+ | Queries issued | count of `/query` requests until the page settles | one per tile, no more |
18
+ | Identical queries | same model + same query text twice on one page | 0 |
19
+ | Layout shift (CLS) | a `layout-shift` `PerformanceObserver`, first visit and repeat visit | under 0.1 first visit, 0 on repeat |
20
+ | Time to settled | until no query has been in flight for ~1.5s | depends on the warehouse; measure, do not promise |
21
+
22
+ Measure embedded as well as standalone (`reference/embedding.md`). Inside an auto-sized frame the whole document is "the viewport", so every shift anywhere on the page counts, and numbers that looked fine standalone (0.01 to 0.07) measured 0.12 to 0.41 embedded on the same build.
23
+
24
+ ## Keep the chart library off the critical path
25
+
26
+ A vendored chart library is typically 80 to 90% of a data app's bytes, and as a plain `<script>` ahead of the app code it blocks every query until it has downloaded and parsed. It also lets the browser paint the page before the app code can size it, which is where much of the first-visit layout shift comes from.
27
+
28
+ - **Preload it, load it on the first chart.** Put `<link rel="preload" as="script" href="./vendor/echarts.min.js">` in the `<head>` of pages that draw charts, and have the chart layer inject the script the first time it mounts a chart. The download starts at once, nothing waits for it, and a tile whose rows land before the library does waits only for itself. The preload's `href` and the injected script's `src` must resolve to the same URL, or the preload is a second download that nothing uses.
29
+ - **A page that draws no chart never loads it.** An overview or landing page of numbers and links should not pay for a chart library at all. Measured: 1,464 KB to 348 KB for one such page.
30
+ - **Hand back a stand-in while it loads.** Callers usually wire a click handler on the chart instance right after mounting it. Return an object that records `.on(...)` calls and replays them on the real instance once it exists, so the calling code does not change shape.
31
+
32
+ The loader below is written for ECharts (a global `echarts`, click handlers wired with `.on()`). For another library change the file name, the global, and the stand-in to record whatever its callers call; Chart.js, for one, takes its click handler as an option rather than through `.on()`. The URL is resolved against `charts.js` itself, so it is right from a page in any directory as long as `charts.js` sits beside `vendor/` (the layout in `reference/runtime.md`).
33
+
34
+ ```js
35
+ // charts.js
36
+ let libP = null;
37
+ function chartLib() {
38
+ if (window.echarts) return Promise.resolve(window.echarts);
39
+ libP ??= new Promise((res, rej) => {
40
+ const s = document.createElement("script");
41
+ s.src = new URL("./vendor/echarts.min.js", import.meta.url).href; // same URL as the preload
42
+ s.onload = () => res(window.echarts);
43
+ s.onerror = () => { libP = null; rej(new Error("Chart library failed to load")); };
44
+ document.head.append(s);
45
+ });
46
+ return libP;
47
+ }
48
+
49
+ export function mount(host, buildOption) {
50
+ if (!window.echarts) {
51
+ const calls = [], stub = { on: (...a) => (calls.push(a), stub) };
52
+ const mine = (host.__mount = (host.__mount || 0) + 1); // a newer mount wins
53
+ chartLib().then(() => {
54
+ if (host.__mount !== mine || !host.isConnected) return;
55
+ const inst = mount(host, buildOption);
56
+ calls.forEach((a) => inst.on(...a));
57
+ }).catch((e) => showError(host, e)); // the tile's own error state
58
+ return stub;
59
+ }
60
+ host.__mount = (host.__mount || 0) + 1;
61
+ echarts.getInstanceByDom(host)?.dispose(); // a remount replaces, never merges
62
+ const inst = echarts.init(host);
63
+ inst.setOption(buildOption());
64
+ return inst;
65
+ }
66
+ ```
67
+
68
+ ### Trim the library to what the app draws
69
+
70
+ Most chart libraries publish a modular build. A bundle of only the series types and components the app actually uses is typically a third smaller (one build: 1,122 KB to 713 KB, 369 KB to 243 KB gzipped). Inventory what the code passes the library (every series `type:`, and components such as legend, tooltip, visualMap, markLine), bundle exactly those once with a bundler outside the package, and commit the bundle together with its entry file and the command that rebuilt it.
71
+
72
+ **A trimmed build fails silently.** A series type left out of the bundle still mounts its canvas, throws nothing and logs nothing, and draws an empty box. A canvas count proves nothing. Verify by reading pixels: open every page and every kind of drawer, and require each chart canvas to have drawn pixels, against the full build first and then the trimmed one. Write the list of included types next to the bundle, so the next person to add a chart type knows to rebuild.
73
+
74
+ ## Ask once
75
+
76
+ - **One query text per resource.** `Publisher.query` caches nothing: every call is a request. Any cache the app keeps, any sharing of an in-flight request, and any prefetch are keyed on the exact query string. Two pages that read the same tile with different text (one adds `order_by`, one does not) get two cache entries and two round trips for one answer. Where order does not matter to a caller, send the text the other caller sends.
77
+ - **A prefetch must match what the destination sends.** Warming the next page's queries on nav hover is worth it only if the warmed text is byte-for-byte what that page will ask for. Check it: list what each page sends and what each prefetch warms, and diff them.
78
+ - **Count identical queries on a page.** It should be 0. Two tiles or two drawer panels needing the same rows should share one in-flight request.
79
+
80
+ ## Hold still while loading
81
+
82
+ Layout shift is the most visible difference between a generated app and a polished one, and almost all of it has three causes.
83
+
84
+ **1. Script-filled blocks at the top of the page.** A KPI strip, a provenance line or a filter bar that is an empty `<div>` in the HTML and is filled by script appears *after* the first paint, and pushes everything below it down. Measured: about 180px of drop on every page of one app, from three such blocks. Give each its final size in the first paint:
85
+
86
+ - put placeholder markup in the HTML, built from the same classes the script renders, so the layout reserves the right height at every width (four `.kpi` placeholder cards in the KPI grid, replaced when the numbers arrive);
87
+ - give a one-line block a `min-height` while it is `:empty`.
88
+
89
+ **2. A tile that changes height when its rows land.** A four-line skeleton replaced by a 25-row list shifts everything below it by hundreds of pixels.
90
+
91
+ - **Size the skeleton from what is coming.** If the query carries a `limit:` or the tile slices to N rows, skeleton N rows at the real row height.
92
+ - **Remember the settled height.** Store each tile's rendered height per page, tile and width bucket in `localStorage`, and hold it as `min-height` on the next load. Data that refreshes daily keeps its height, so a returning reader sees the page at its final size from the first frame. Measured: 0 layout shift on repeat visits on every page.
93
+
94
+ ```js
95
+ const KEY = "app-size:" + location.pathname;
96
+ const sizes = (() => { try { return JSON.parse(localStorage.getItem(KEY) || "{}"); } catch { return {}; } })();
97
+ const sizeKey = (host) => host.id && `${host.id}@${Math.round(host.clientWidth / 40)}`;
98
+ function holdSize(host) { const k = sizeKey(host); if (k && sizes[k]) host.style.minHeight = sizes[k] + "px"; return k; }
99
+ function settleSize(host, k) { // call right after render, same task
100
+ host.style.minHeight = "";
101
+ if (!k) return;
102
+ const h = Math.round(host.getBoundingClientRect().height);
103
+ if (h > 0 && sizes[k] !== h) { sizes[k] = h; try { localStorage.setItem(KEY, JSON.stringify(sizes)); } catch {} }
104
+ }
105
+ ```
106
+
107
+ **3. A tile that re-runs for a new filter.** Dropping back to a skeleton on every scope change makes the page collapse and regrow. Keep the last answer on screen, dimmed and `aria-busy`, at its current height, until the new one lands. And guard the race: number each run per tile, and drop a response that is not the latest, or a slow query for the previous filter paints over the new one.
108
+
109
+ ```js
110
+ function load(host, query, render) {
111
+ const seq = (host.__seq = (host.__seq || 0) + 1);
112
+ if (host.__drawn) { host.style.minHeight = host.offsetHeight + "px"; host.classList.add("is-busy"); host.setAttribute("aria-busy", "true"); }
113
+ else showSkeleton(host);
114
+ const unbusy = () => { host.classList.remove("is-busy"); host.removeAttribute("aria-busy"); };
115
+ return Publisher.query(MODEL, query).then((rows) => {
116
+ if (seq !== host.__seq) return; // a newer run owns this tile
117
+ unbusy(); host.__drawn = true;
118
+ render(rows, host);
119
+ host.style.minHeight = ""; // released after the new rows are in
120
+ }, (e) => {
121
+ if (seq !== host.__seq) return;
122
+ unbusy(); showError(host, e); // a failed re-run must not stay dimmed and busy
123
+ host.style.minHeight = "";
124
+ });
125
+ }
126
+ ```
127
+
128
+ ## Answer from the last visit (opt-in)
129
+
130
+ A per-tab cache (`sessionStorage`, or memory) starts every new tab and every next-day visit from nothing: every query is re-sent and every tile waits on it. Measured on a real build: 1.6 to 3.8 s before the first screen filled in a new tab. Most data apps read tables built on a schedule, so the last visit's rows are usually still right. A persistent cache can draw them at once, but it is the one recipe here that can show someone rows they may not read, so it is opt-in and has conditions.
131
+
132
+ **Use it only when every reader sees the same rows.** Check the model before adding it: no `given:` declarations the app or a host sets per reader, no `#(authorize)` gates, no access filters, nothing that makes the same query text answer differently for different people. If any of those apply, do not persist results; a per-tab cache is the most you can do.
133
+
134
+ **Why the conditions.** Every `public/` app on a deployment is served from the API's own origin, so they all share one IndexedDB. The page is served without a sign-in, and it cannot tell who is viewing it. A row-level refusal is a 200 with fewer rows, not a 403, so no error marks the cached answer as wrong. A cache keyed on query text alone shows one reader's rows to the next person on the same browser profile, signed in or not, and keeps showing them to someone whose access was withdrawn.
135
+
136
+ How it stays safe:
137
+
138
+ - **Nothing paints from the store until this visit has proven it may read.** Every tile sends its query at once, as without a cache. Stored answers are held back until one query on the page has answered successfully, then painted together while the rest of the queries finish. A signed-out visitor, or someone whose access was withdrawn, gets an error before any stored row reaches the screen. The first screen then waits one round trip rather than every query.
139
+ - **Every stored answer is re-asked.** There is no window in which the page trusts the store and sends nothing. A stored answer older than the build schedule plus a margin (a day and a half for a daily build) is not painted at all.
140
+ - **The key is everything the answer depends on:** the environment and package (the page's own path), the model, the exact query text, and the query's options (`givens`, `filterParams`, a target `environment` or `package`).
141
+ - **A 401 or 403 on any query wipes the store**, and that tile shows its error.
142
+ - **Repaint only the tile that still shows that query, and only when the rows differ.** A tile whose filter changed while its query was in flight ignores the late answer (the sequence guard in "Hold still while loading"). An unchanged answer must not redraw: it would re-animate charts for no reason.
143
+ - **The store is capped.** Every filter combination is a new key; prune the oldest past a few hundred entries.
144
+ - **Show how old the number is.** A tile that can draw yesterday's answer should carry the as-of date of its data.
145
+
146
+ Measured before the session gate was added: the new-tab load drew its first screen in 87 to 93 ms. The gate adds one query's round trip to that.
147
+
148
+ ```js
149
+ // cache.js: draw the last visit's answer once this visit has proven it may read.
150
+ // Opt-in: only for models whose rows are the same for every reader (see above).
151
+ const STALE = 36 * 3600e3, CAP = 300;
152
+ const SCOPE = location.pathname.split("/").slice(0, 5).join("/"); // /environments/<env>/packages/<pkg>
153
+ let mayRead; const live = new Promise((res) => (mayRead = res)); // resolves on the first good answer
154
+ const inflight = new Map();
155
+ const db = new Promise((res) => {
156
+ try { const rq = indexedDB.open("app-cache", 1);
157
+ rq.onupgradeneeded = () => rq.result.createObjectStore("q").createIndex("t", "t");
158
+ rq.onsuccess = () => res(rq.result); rq.onerror = rq.onblocked = () => res(null);
159
+ } catch { res(null); } // private mode: no persistence
160
+ });
161
+ const store = (mode, fn) => db.then((d) => d && new Promise((res) => {
162
+ try { const tx = d.transaction("q", mode); const r = fn(tx.objectStore("q")); tx.oncomplete = () => res(r?.result ?? null); tx.onerror = () => res(null); }
163
+ catch { res(null); }
164
+ }));
165
+ const wipe = () => store("readwrite", (s) => s.clear());
166
+ const prune = () => store("readwrite", (s) => {
167
+ const c = s.count(); c.onsuccess = () => {
168
+ let extra = c.result - CAP; if (extra <= 0) return;
169
+ s.index("t").openCursor().onsuccess = (e) => { const cur = e.target.result; if (cur && extra-- > 0) { cur.delete(); cur.continue(); } };
170
+ };
171
+ });
172
+
173
+ // Resolves with rows for the tile: the stored answer once the page may read, or the
174
+ // network answer if that comes first. onFresh(rows, err) fires if the network answer
175
+ // arrives after a stored one was drawn and differs, or if the query is refused.
176
+ export function cachedQuery(model, q, opts = {}, onFresh) {
177
+ const k = JSON.stringify([SCOPE, model, q, opts]);
178
+ let net = inflight.get(k); // two tiles, one request
179
+ if (!net) {
180
+ net = Publisher.query(model, q, opts).then((rows) => {
181
+ mayRead();
182
+ store("readwrite", (s) => s.put({ t: Date.now(), r: rows }, k)).then(prune);
183
+ return rows;
184
+ }, (e) => { if (e?.status === 401 || e?.status === 403) wipe(); throw e; })
185
+ .finally(() => inflight.delete(k));
186
+ inflight.set(k, net);
187
+ }
188
+ const stored = store("readonly", (s) => s.get(k)).then((box) =>
189
+ box && Date.now() - box.t < STALE ? live.then(() => box.r) : new Promise(() => {})); // never: wait for the network
190
+ return Promise.race([net.then((r) => ({ r })), stored.then((r) => ({ r, stored: true }))]).then((first) => {
191
+ if (first.stored) net.then( // drawn from the store: follow up once
192
+ (rows) => { if (JSON.stringify(rows) !== JSON.stringify(first.r)) onFresh?.(rows, null); },
193
+ (e) => onFresh?.(null, e));
194
+ return first.r;
195
+ });
196
+ }
197
+ ```
198
+
199
+ ## Start on intent (opt-in)
200
+
201
+ Resting the pointer on something that opens a drawer, or focusing it, usually comes a few hundred milliseconds before the click. Start the drawer's queries then, and the click finds them in flight or answered. Measured on one build: all 7 queries of a market drawer and all 15 of a product drawer (a name lookup included) were in flight before the click, and the click itself sent none. Worth it on pages whose drawers are the main way in; skip it where drawers are rare.
202
+
203
+ Build each drawer in one function that both the click and the warm-up call. A hand-written list of each drawer's queries, warmed on hover, is a second copy of every drawer's query text and drifts the first time a panel changes. Do not warm by dispatching a synthetic click on the row either: capture-phase listeners still run, a row that is a link navigates, a `<summary>` toggles, and an app that wires its drawers by delegation gets no warm-up at all and no error saying so.
204
+
205
+ ```js
206
+ // drawers.js: a drill row names its drawer (data-drawer="market" data-key="42").
207
+ // DRAWERS.market(key, body, { warm }) sends the drawer's queries; warm, it stops there.
208
+ export function drawerFor(row, { warm = false } = {}) {
209
+ const build = DRAWERS[row.dataset.drawer];
210
+ if (!build) return;
211
+ if (warm) return build(row.dataset.key, null, { warm }); // queries only: nothing rendered, nothing opened
212
+ return build(row.dataset.key, openDrawer(row), { warm }); // openDrawer: the real open (reference/overlays.md)
213
+ }
214
+ document.addEventListener("click", (e) => { const row = e.target.closest?.("[data-drawer]"); if (row) drawerFor(row); });
215
+
216
+ // Intent: an 80ms rest, by pointer or keyboard, so a sweep down a list warms nothing;
217
+ // at most two drawers warming at once.
218
+ let timer = 0, warmingNow = 0;
219
+ const warmed = new WeakSet();
220
+ const intent = (e) => {
221
+ clearTimeout(timer);
222
+ const row = e.target.closest?.("[data-drawer]");
223
+ if (!row || warmed.has(row) || e.pointerType === "touch") return;
224
+ timer = setTimeout(() => {
225
+ if (warmingNow >= 2) return;
226
+ warmed.add(row); warmingNow++;
227
+ Promise.resolve(drawerFor(row, { warm: true })).catch(() => {}).finally(() => warmingNow--);
228
+ }, 80);
229
+ };
230
+ document.addEventListener("pointerover", intent);
231
+ document.addEventListener("focusin", intent);
232
+ ```
233
+
234
+ What makes it safe:
235
+
236
+ - **A warm build only asks.** It sends the drawer's queries through the same cache as the real open (see "Ask once") and returns; it renders nothing, so no chart instance is left on a detached node, and nothing opens, navigates, or writes.
237
+ - **The warm flag is an argument, not a global.** A drawer that first resolves a name to an id passes `{ warm }` on to the step that runs after the lookup, so a warm-up never opens a drawer when the lookup returns.
238
+ - **Warm what the click will ask for.** The click and the warm-up run the same `DRAWERS` function, so they send the same query text.
239
+ - **Never warm on page load.** Warm-ups wait for intent. Warming every drawer a page could open is a query storm the reader pays for.
240
+
241
+ ## Respond before the answer arrives
242
+
243
+ A click that waits on a query before anything visible happens reads as a dead click. The common case is a drill that must first resolve a name to an id: open the drawer immediately, titled with the name and a skeleton, run the lookup inside it, and replace the skeleton in place when the id arrives. Drop the late answer if the reader has closed the drawer or opened another subject meanwhile (`reference/depth-patterns.md`, stale-response guard).
244
+
245
+ ## What the page cannot fix
246
+
247
+ Compression and cache headers belong to whatever serves `public/`. Check them (`curl -sI -H 'Accept-Encoding: gzip, br' <url>`): a chart library served uncompressed is downloaded at full size on the first visit, and with `max-age=0` every later page asks the server again before using it (a conditional request answered 304, one round trip per file). Report it to whoever runs the server rather than working around it in the page.
@@ -22,20 +22,26 @@ The runtime loads from the root-relative `<script src="/sdk/publisher.js">` and
22
22
 
23
23
  ## Structure the app as modules, not one inline script
24
24
 
25
- Past a single tile, an inline `<script>` becomes unmaintainable and untestable. Split the work, and load it without a build step: put your shared libraries first as plain globals, then one ES-module entry point that `import`s your own files.
25
+ Past a single tile, an inline `<script>` becomes unmaintainable and untestable. Split the work, and load it without a build step: the runtime first as a plain global, then one ES-module entry point that `import`s your own files.
26
26
 
27
27
  ```html
28
- <!-- Globals first: the runtime, then any vendored chart library. -->
28
+ <head>
29
+ <!-- Start the chart library's download now, without making anything wait for it.
30
+ Only on pages that draw a chart. -->
31
+ <link rel="preload" as="script" href="./vendor/chart.umd.js">
32
+ </head>
33
+ ...
29
34
  <script src="/sdk/publisher.js"></script>
30
- <script src="./vendor/chart.umd.js"></script>
31
35
  <!-- One module entry; it imports the rest. ES modules resolve with no bundler. -->
32
36
  <script type="module" src="./app.js"></script>
33
37
  ```
34
38
 
39
+ **Do not load the chart library with a plain `<script>` ahead of your code.** It is usually most of the page's weight, and loaded that way it holds every query until it has downloaded and parsed. `charts.js` injects it the first time it draws, so the queries start at once. `reference/performance.md` has the loader, and the stand-in that lets callers wire chart clicks before the library has arrived.
40
+
35
41
  A separation that keeps each piece testable and changeable on its own:
36
42
 
37
43
  - **`format.js`**. Pure functions only: number/date formatting, a series-align-by-month helper, status thresholds. No DOM, no globals. This is the file `node --test` can cover directly.
38
- - **`charts.js`**. Turns a prepared data object into a drawn chart; the only file that touches the chart library.
44
+ - **`charts.js`**. Turns a prepared data object into a drawn chart; the only file that touches the chart library, and the one that loads it.
39
45
  - **`tiles.js`**. Your tiles as *data*: for each, its model/source/view (and target source, if any), plus a pure `build(rows)` that shapes query rows for the chart. This is the single source of truth for what each tile queries.
40
46
  - **`app.js`**. The thin entry point: reads `tiles.js`, runs the queries, wires results to the DOM. Adding a tile means adding a `tiles.js` entry, not editing `app.js`.
41
47
 
@@ -88,6 +94,10 @@ const [planMix, byIndustry, kpisRows] = await Promise.all([
88
94
 
89
95
  Prefer defining the views in the model (one per tile, pre-aggregated and sorted) over building long query strings in JS.
90
96
 
97
+ **Same question, same query text.** `Publisher.query` caches nothing, but a cache the app keeps, a shared in-flight request, and any prefetch are all keyed on the exact string. Two pages reading the same view with different text (one adds an `order_by`) pay for two round trips for one answer, and a prefetch that warms different text from what the destination page sends warms nothing. Build each tile's query in one place and reuse that string wherever the same rows are needed.
98
+
99
+ **Set ARIA and data attributes as attributes.** A small element helper that does `Object.assign(document.createElement(tag), props)` silently drops `aria-modal`, `aria-label`, `aria-busy` and `data-*`: they are not properties, so assigning them does nothing and no error says so. Route any `aria-*`, `data-*` and `role` key through `setAttribute`. And a clickable row is a control: give it `tabIndex = 0` and Enter/Space handling, or keyboard users cannot open what a mouse user can.
100
+
91
101
  Get the numbers right. The fastest way to ship a wrong-but-convincing dashboard is to paper over missing data:
92
102
 
93
103
  - **Missing is not zero.** When you join two series (actuals to a separately-keyed target) and a key is absent, leave it `null` so the chart skips it. Do not `|| 0`, which plots a real-looking zero and reads as "we hit nothing that month." Align on a normalized key (`"YYYY-MM"`), and let the renderer omit null points:
@@ -242,6 +242,59 @@ Three things bite on this path, and each makes a correct page look broken:
242
242
  - **Switch themes by clicking the app's own control**, not by setting an attribute on the root. Setting the attribute skips whatever re-resolves chart colours, so the charts keep their construction-time palette and you get a screenshot that looks like a real theming bug.
243
243
  - **Kill the browser when you finish** and use a port nobody else is on. A stray headless Chrome holding a profile directory is a confusing thing to debug later.
244
244
 
245
+ ## 4. Embedded, layout stability, and drawers
246
+
247
+ Sections 2 and 3 prove the tiles render. They do not prove the app is polished, and every check below caught a real bug on a build that passed them. These need a live Publisher (the layout and timing are the point), a real browser, and real wheel events.
248
+
249
+ **Load it embedded, not only standalone.** Two hosts, in order of effort:
250
+
251
+ - **The Publisher console**, at `http://localhost:4000/<env>/<package>/data-apps/<path>` (for example `.../data-apps/index.html`). Same origin, no setup, and it sizes the frame through the same resize contract a host does.
252
+ - **A test host page** that calls `Publisher.embed` inside a fixed header and a *scrolling container*, which is how real hosts lay out. Serve it from another origin (`python3 -m http.server 4100 --bind 127.0.0.1`) and start Publisher with `PUBLISHER_FRAME_ANCESTORS="'self' http://127.0.0.1:4100"`, or the browser refuses to frame the app.
253
+
254
+ Then, per page, embedded and standalone:
255
+
256
+ ```js
257
+ // Layout shift: install before navigation, applies to every frame.
258
+ await context.addInitScript(() => {
259
+ window.__cls = 0;
260
+ new PerformanceObserver((l) => l.getEntries().forEach((e) => { if (!e.hadRecentInput) window.__cls += e.value; }))
261
+ .observe({ type: "layout-shift", buffered: true });
262
+ });
263
+ // Read __cls from the APP's frame after the page settles, on a first visit and
264
+ // again after page.reload() in the same context (the repeat visit). Target:
265
+ // under 0.1 first, 0 repeat. Embedded, every shift on the page counts.
266
+
267
+ // An auto-sized frame (the console, Publisher.embed without a height) must never
268
+ // scroll: the document is exactly as tall as its frame. Skip this for a
269
+ // fixed-height embed, which is meant to scroll inside its frame.
270
+ const innerScroll = await appFrame.evaluate(() => document.documentElement.scrollHeight - innerHeight);
271
+ if (innerScroll > 0) problems.push(`app scrolls ${innerScroll}px inside its frame`);
272
+
273
+ // Ask once: identical (model, query) pairs on one page.
274
+ const seen = new Set(); let dup = 0;
275
+ page.on("request", (r) => { if (r.url().includes("/query")) { const k = r.url() + r.postData(); dup += seen.has(k); seen.add(k); } });
276
+ ```
277
+
278
+ **Drawer checks**, at two or three window sizes (1280×720, 1512×857 at 2×, 1920×1080). Scroll the host with `page.mouse.wheel` (not `scrollTo`: a real wheel is what exposes inner-frame scrolling), click a drill row that sits well down the page, and assert in **screen** coordinates (an element's rect inside the frame plus the iframe's rect in the host):
279
+
280
+ | Check | Pass |
281
+ |---|---|
282
+ | The clicked row's screen position, sampled every frame while the drawer opens | moves 0px |
283
+ | Host scroll position during the open | unchanged |
284
+ | Drawer header top | equals the top of the host's visible area (below the host's own header) |
285
+ | Drawer bottom | equals the bottom of the visible area, within a few pixels (the band moves in steps of the observer's thresholds) |
286
+ | Drawer body `scrollTop` on open, and after drilling into a second subject from inside it | 0 |
287
+ | 12 wheel turns over the drawer | the body scrolls; the host and the app move 0px |
288
+ | 4 wheel turns over the scrim | the host and the app move 0px |
289
+ | Host scrolled by other means (`scrollBy` on the host container) while open | drawer still spans the visible area |
290
+ | Escape | drawer gone, host where it was, focus back on the clicked row |
291
+
292
+ Compare screen coordinates against the host's *visible* area, not against the window: a host header or breadcrumb bar sits over the top of it. And sample the open as frames, not as one before/after pair: the page jump this catches can return to its starting point by the time a single later sample is taken.
293
+
294
+ **Charts draw, everywhere they appear.** Read pixels (section 3) on every page *and inside every kind of drawer*. Run it against the full chart library first, then again after any change to how the library is built or loaded: a series type missing from a trimmed bundle mounts its canvas and draws nothing, with no error.
295
+
296
+ **Wait for the warehouse, not a fixed time.** Locally a tile can take seconds; a fixed sleep then measures loading, not the app. Count `/query` requests in flight and treat the page as settled after about 1.5s with none, and attach those listeners *before* `goto`, or the first wave of queries is missed and the page "settles" at once.
297
+
245
298
  ## Gotchas (each cost a real debugging cycle)
246
299
 
247
300
  - **Wait out the mock's async delay before asserting.** Asserting immediately after `load` reads the skeleton, not the resolved tile, and reports a false "stuck skeleton." Wait until every tile shows its own resolved content (the per-tile `waitForFunction` above), never on `networkidle`, because `publisher.js` keeps the live-reload SSE stream open, so the page never reaches network idle.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-lookml-review
3
3
  description: Analyze LookML files as prior art for Malloy modeling. Used during Step 1 (DISCOVER) when .lkml files are present. Coordinates reference files that extract business logic, relationships, and curation decisions. Works with or without a database connection.
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
  <!--
7
7
  Copyright (c) Credible Data Inc.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-materialization
3
3
  description: Add, confirm and debug Malloy Persistence materializations in a package - persist an expensive source so queries read a pre-built table, in the source's own warehouse or in a storage destination. Read this whenever the user wants to materialize a source, add a persist annotation, speed up a slow source, build one stored rollup over another, tune what to persist, or asks why a persist source isn't building or isn't being read.
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
  <!--
7
7
  Copyright (c) Credible Data Inc.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-model
3
3
  description: Build Malloy semantic models with base source and joined source files. Use when creating or modifying .malloy files, user asks to "create a malloy model", "add dimensions", "add measures", "create a source", or any Malloy model authoring task.
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
  <!--
7
7
  Copyright (c) Credible Data Inc.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-model-as-you-go
3
3
  description: 'After answering a data question, write down what the answer assumed so the next reader can trust the number. A field with a #(doc) in the model when you can edit it, an extend in the notebook when you can only author reports, or a stated assumption plus a Malloy snippet when you can only chat. Use after every answered question that rested on a judgment call, and whenever a question is asked against tables that have no model yet.'
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
 
7
7
  <!--
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-modeling
3
3
  description: Build semantic models with Malloy for the Malloy Publisher. Read this skill whenever the user asks about modeling data or specifically mentions Malloy.
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
  <!--
7
7
  Copyright (c) Credible Data Inc.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-notebooks
3
3
  description: "Renamed to malloy-reports. A notebook is now a report with layout=linear: a .malloy file with an ## artifact tag, written at the package root. Load malloy-reports instead when the user asks for a notebook, a data story, or a report that reads top to bottom."
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
 
7
7
  <!--
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-phrase-detection
3
3
  description: How to phrase search_text on a get_context call so retrieval returns the fields you need instead of a truncated catalog. Covers target-type classification and decomposition patterns.
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
  <!-- Copyright (c) Credible Data Inc. SPDX-License-Identifier: MIT -->
7
7
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-powerbi-review
3
3
  description: Move a Power BI semantic model (TMDL, PBIP, or .pbix) onto Malloy - read their model, transpile its DAX, and prove the numbers match. Use when Power BI artifacts are present, when a user wants to migrate or switch off Power BI, and whenever a specific DAX measure has to become Malloy - CALCULATE, ALL/ALLSELECTED/ALLEXCEPT, RANKX and top-N, time intelligence, USERELATIONSHIP, calculation groups, what-if parameters, or an RLS role. Carries a worked, executed transpile per construct and states what each port costs. Works with or without a database connection.
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
  <!--
7
7
  Copyright (c) Credible Data Inc.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-publish
3
3
  description: Package Malloy models for serving by Malloy Publisher. Use when user asks to "publish", "package", "deploy", or wants to share models with others.
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
  <!--
7
7
  Copyright (c) Credible Data Inc.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-queries
3
3
  description: Read before writing or debugging a Malloy query. Dates, aggregates vs dimensions, joins, filters, strings, window functions, chart annotations, and the compile errors a SQL habit produces.
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
  <!--
7
7
  Copyright (c) Credible Data Inc.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-reports
3
3
  description: "Build or modify a Malloy Publisher report: a .malloy file with an ## artifact tag, written at the package root unless asked otherwise, laid out as a grid (a dashboard, scanned at a glance) or linear (a notebook or data story, read top to bottom), with filter controls rendered from givens and # drill click-through. Use when the user asks for a dashboard, a notebook, a report, a data story, a filterable view, or drill-through between views, and no code is wanted. Legacy .malloynb files are read, not authored."
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
 
7
7
  <!--
@@ -283,7 +283,7 @@ For a grid, write `layout=grid`, add `dashboard { columns=12 }` after the tag's
283
283
 
284
284
  ## Editing in the Console
285
285
 
286
- A person can open any report in the Console's builder (**Edit** on the report page; **+ Report** on the package page writes `<slug>.malloy` at the package root). The builder splices the file rather than regenerating it, so comments and anything it does not model survive; its layout switch rewrites `layout=` and nothing else. A file written as `run:` cells, the retired linear form, is no report and does not open (`reference/linear.md`). A save whose text declares a real `#(authorize)` or `#(access_filter)` gate outside prose is refused with a 400: gates live in a model file the report imports.
286
+ A person can open any report in the Console's builder (**Edit** on the report page; **+ Report** on the package page writes `<slug>.malloy` at the package root). The builder splices the file rather than regenerating it, so comments and anything it does not model survive; it never rewrites `layout=`, so the layout is set in the file. A file written as `run:` cells, the retired linear form, is no report and does not open (`reference/linear.md`). A save whose text declares a real `#(authorize)` or `#(access_filter)` gate outside prose is refused with a 400: gates live in a model file the report imports.
287
287
 
288
288
  ## What "done" means
289
289
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-review
3
3
  description: Malloy semantic-model code review. Invoke when the user asks to review, audit, or critique a `.malloy` file, a folder of Malloy models, or a GitHub PR that touches Malloy. Enforces project modeling standards and emits a navigable review file.
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
  <!--
7
7
  Copyright (c) Credible Data Inc.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-run-agent
3
3
  description: Adopt an agent a Malloy package declares, by name. Use only when the user names a package agent, for example "run the analyst agent on storefront" or "work as the agent in this package". Fetches the agent's definition with get_agent, works from its instructions, installs its skills, and runs one of its tasks when asked.
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
  <!--
7
7
  Copyright (c) Credible Data Inc.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-source-unreachable
3
3
  description: Work out why a source is missing from get_context, or why a query against it was refused with a 404 or a 403, and what to do next. Use when a source you expect is absent from discovery, when execute_query answers "No queryable source", when a query that used to work stops working, or when deciding whether a package's curation is hiding something you need.
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  ---
6
6
  <!-- Copyright (c) Credible Data Inc. SPDX-License-Identifier: MIT -->
7
7