Skip to content

The About page, a reader-first dataset route, and ltool follow-ons - #164

Merged
lloydtabb merged 13 commits into
mainfrom
dashboard-landing-page-lint
Aug 30, 2026
Merged

The About page, a reader-first dataset route, and ltool follow-ons#164
lloydtabb merged 13 commits into
mainfrom
dashboard-landing-page-lint

Conversation

@lloydtabb

@lloydtabb lloydtabb commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Nine commits, one thread: the page an author writes to introduce their data should be the first thing a reader sees, and every route and label should agree with that.

1–2. The About page becomes a first-class dashboard

dashboards/index.jsx is the static bundle's written landing page (bundle.ts:465) — plain React, no Malloy, no query, by design. Two problems:

It broke publish. lint applied the orphaned-component rule to every .jsx, and no fix was available to an author, so a repo that bundled perfectly could not be published. (The server was never involvedgatherDashboards and github-refresh walk .malloy files; the publish died at the CLI's own lint gate.) Exempted now, but still parsed with the same esbuild.transform the bundler uses.

It existed on one surface out of three. Now discover/gather find it, the GitHub refresh fetches it, and it leads every listing.

Three things a queryless page needed: given introspection is skipped, not attempted (compiling to look for a query that doesn't exist printed a model error over the author's prose); the static site's CSS variables are aliased onto the frame theme (otherwise transparent cards, invisible borders); and dashboards is now a runtime prop (only the bundler injected the sibling list).

Sibling links route through the navigate bridge — a relative href inside the sandboxed frame navigates the frame to its own origin and 404s. The listener installs once (mount() re-runs on every client-side navigation) and reads the list per click, since injection order differs between hosts.

3–4. /datasets/<ref> lands on the dataset, not its config

Config moved to /datasets/<ref>/config; the root redirects down a chain:

Goes to Because
1 first dashboard which is About when the repo ships one
2 AI Q&A questions have been asked
3 ltool + first source the dataset is empty, not broken

Tier 3 made a latent dead end reachable, so it's fixed too: ltool needed a source to give you an editable query, and without one you got "Select a query from the sidebar" with no query to select. A dataset deep link now seeds a writable scratch either way. Nothing is minted client-side — /api/run mints on run.

5–7. Getting into the data

  • A dataset's name is now a link on the home page, to the chain above. The switcher follows it too, which deleted a client-side copy of the same decision that had already drifted (it lacked tier 3, so an empty dataset landed on an empty Q&A page).
  • <> Query on the dashboard nav and on home-page sources, one shared icon.
  • ltool's source list is grouped by dataset. It wasn't random — it was API order — but nothing marked the boundaries. It also deduped on the bare source name, so two datasets each defining orders lost one. Now keyed on dataset+source.
  • Switching source starts a query, always. That's where the editor and sidebar disagreed: pick a new source and the list swapped while the editor still held the previous source's query.
  • The picker opens centred on where you are — 2600px of list in a 320px box meant scrolling to find yourself before picking a sibling source.

8–9. Naming and ids

/api/sources returned the dataset's name in a field called model — since the file's first commit, when a dataset and "a Malloy model" were the same idea. They aren't: malloy_models is a different table holding a dataset's versioned model rows. Renamed to dataset, with its four consumers.

Not renamed: MCP's model_ref is the same misnomer but on the wire (list_sources keys, describe_source/query params) — a breaking protocol change that needs its own deprecation. MCP's model parameter is unrelated and correctly named: it's the calling LLM, for run attribution.

Dataset ids stay out of URLs. All ltool and dataset links carry the name; that required the two web-query paths to resolve by ref rather than by id, which every other route already did. ltool's drill resolved the recorded uuid to a name rather than putting it in the address bar.

Deliberate exception: the GitHub webhook URL keeps its id — it's registered with GitHub and has to keep resolving, so it must not depend on a mutable field.

Bugs found along the way

  • A tag can claim the name index — artifact names come from ## artifact { name= }, not filenames, and malloy_artifacts has no unique (model_id, name) while getDashboard uses an unordered .limit(1). Guard is on resolved names.
  • The "view source" link pointed at the wrong file — basename matching sent a dashboard named index to the repo-root index.malloy.

Verification

bash scripts/preflight.sh — 11/11 green at every commit. 24 new cases across about-page.test.ts, about.test.ts and dataset-landing.test.ts.

Exercised on the real malloyyo-babynames repo across bundle / dev / hosted, and the routing against six real datasets in a production fork:

babynames        → /datasets/babynames/dashboard/index   (About)
ecommerce        → /datasets/ecommerce/dashboard/…       (first dashboard)
worldcup         → /datasets/worldcup/questions          (has questions)
flight_test      → /ltool?dataset=flight_test            (empty)

The ltool changes are UI and have no test harness in this repo; they were verified in the browser — grouped picker, centred open on stadiums, new-query on every source switch, and a run posted with "datasetId":"babynames" returning a fresh slug.

Not included

POST /api/datasets/<ref>/model/github requires a UUID and 500s on a dataset name — now the last route that insists on one. Pre-existing, hit while testing, out of scope.

🤖 Generated with Claude Code

lloydtabb and others added 2 commits August 28, 2026 16:53
`dashboards/index.jsx` is the static bundle's written landing page — the
bundler looks for it by name and compiles it in its own pass, deliberately
with no Malloy and no DuckDB. Lint knew nothing about that and applied the
orphaned-component rule to every .jsx it found:

    ✗ index.jsx
        component "index.jsx" has no matching "index.malloy" dashboard

There is no fix an author can make. The file has no query BY DESIGN, so an
`index.malloy` beside it would mean inventing a dashboard nobody wants — and
publish gates on lint, so a repo that bundled perfectly could not be published
at all. malloyyo-babynames is one.

Nothing else disagreed: gatherDashboards and the server's GitHub ingestion both
walk .malloy files only, so index.jsx was never sent, never stored, and never
reached the server. The failure was entirely in the CLI, before the request.

Exempt it, but do not leave it unchecked — `bundle` compiles it, so a syntax
error should surface at lint time with the file named rather than later. An
index.malloy that DOES exist is unaffected: that is a dashboard named "index"
whose component is this file, and the existing pair lints normally.

Test fixtures leave eslint's scope in the same change. v2-landing-broken holds
an intentionally unparseable landing page so the parse error can be asserted;
linting fixtures as source turns a fixture's whole purpose into a build failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: lloyd tabb <lloyd@malloydata.org>
`dashboards/index.jsx` was a written introduction that only `bundle` could see.
`publish` never uploaded it, the GitHub refresh never fetched it, and the dev
server ignored it — so the page an author wrote to explain their data's scope
and origins was invisible on the two surfaces where a reader arrives cold.

Make it a real artifact, first in the list, everywhere:

  - discover/gather find it (name "index", title "About", no query), so the dev
    server serves it at `/` and publish uploads the component as the source.
  - the GitHub refresh fetches it, so a pulled repo gets it too.
  - it leads every listing. That ordering IS the front door: the switcher and
    the home page both link to a dataset's FIRST dashboard, so putting About at
    the head is what makes "switch datasets, land on the introduction" true —
    no new route, and /datasets/<ref> stays the config page it already was.

A page with no query needed three things to work. Given introspection is
skipped rather than attempted, because compiling the model to look for a query
that does not exist reported its absence as a "model error" printed over the
author's prose. The static site's CSS variable names are aliased onto the frame
theme, because the same component is authored against the bundle's site.css and
rendered with invisible borders anywhere else. And `dashboards` is now a runtime
prop, so a page can link to its siblings on all three surfaces instead of only
where the bundler injected the list.

Those sibling links go through the navigate bridge. A relative href inside the
sandboxed frame navigates the FRAME to its own origin and 404s, and a top-level
href would need allow-top-navigation, which the sandbox deliberately withholds
— so the click is intercepted and the parent is handed a name, exactly as drill
already does. The listener installs once (mount() runs again on every
client-side navigation) and reads the sibling list per click, not at mount,
because the host's injection order is not guaranteed.

Also fixed, both found while building this: an artifact's name comes from its
`## artifact { name= }` tag, not its filename, so guarding the About page on
"is there an index.malloy" would let a tag that names another file "index"
publish two artifacts sharing one name — there is no unique (model_id, name),
and getDashboard's unordered `.limit(1)` would pick between them arbitrarily.
The guard is on resolved names, and the real dashboard wins. And the "view
source" link matched on basename alone, so a dashboard named "index" linked to
the repo-root index.malloy, a different file that happens to share the name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: lloyd tabb <lloyd@malloydata.org>
@lloydtabb lloydtabb changed the title Stop lint calling the landing page an orphaned component Make the About page a first-class dashboard, and the dataset's front door Aug 29, 2026
lloydtabb and others added 2 commits August 29, 2026 15:16
`/datasets/<ref>` was the config page — model version, files, GitHub settings.
That is the operator's view, and it was the first thing anyone following a
dataset link saw. Config moves to `/datasets/<ref>/config`, and the root becomes
a redirect to whatever the dataset actually offers:

  1. its first dashboard — which is the About page when the repo ships one,
     since that already leads the list;
  2. AI Q&A, when questions have been asked;
  3. ltool with the first source selected, when there is neither.

The third tier is the one worth stating: a dataset with no dashboards and no
questions is EMPTY, not broken, and the useful thing to hand someone then is a
query surface pointed at its data. ltool with no source is a blank picker — the
same dead end the config page was — so the source is what makes it land
somewhere.

The chain is a pure function so it can be tested without a database; the page
only does the I/O. It is a server component, so the redirect happens before
anything renders, and it stays DuckDB-free like every page.

Also add a "Query" item to the dashboard nav, beside AI Q&A — the same kind of
thing, a way into the data that isn't a dashboard someone built in advance. It
passes the dataset ID (the route param may be a name) and the first source, so
ltool opens on a `run: <source> ->` starter.

Three links moved with the route: the nav's config item, the home page's gear,
and the home page's "+N more" under a question list — that one now goes to the
Q&A page, which is what it always meant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: lloyd tabb <lloyd@malloydata.org>
Opening ltool on a dataset required a `source` to get an editable query. Without
one the sidebar was empty, nothing was selected, and the editor rendered "Select
a query from the sidebar" — with no query to select and no way to write one.

That is exactly a freshly loaded dataset, and it is now reachable by design: the
last tier of the /datasets/<ref> landing chain sends a dataset with no dashboards
and no questions here, and a model that declares no sources arrives with no
`source` at all.

Seed the scratch query on a `dataset` deep link too, with or without a source.
With one it keeps the `run: <source> ->` starter and expands the schema; without,
it opens blank and titled "New query", schema collapsed because there is no
source to show one for.

Nothing is minted client-side: the scratch carries id/slug null, and /api/run
mints the slug when the query is actually run — so the editor is writable
immediately and the query becomes a real, shareable thing only once it has
produced a result. Verified: a run with no baseSlug returns a fresh slug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: lloyd tabb <lloyd@malloydata.org>
@lloydtabb lloydtabb changed the title Make the About page a first-class dashboard, and the dataset's front door Dashboards: a first-class About page, a reader-first dataset route, and a writable ltool Aug 29, 2026
lloydtabb and others added 5 commits August 29, 2026 15:47
On the home page the dataset name was plain text, so the only ways in were a
dashboard chip, a question, or the config gear. It is now a link to
`/datasets/<name>`, which is not a page but the landing redirect — About, else
the first dashboard, else Q&A, else ltool on the first source. One link is
correct for every dataset, and the home page needs to know nothing about which
tier applies.

The dataset switcher goes the same way. It had reimplemented the first two tiers
client-side from /api/dashboards, which was already a second copy of the
decision and had drifted the moment ltool became the third tier: a dataset with
no dashboards and no questions landed on an empty Q&A page. Pointing it at
/datasets/<name> deletes the duplicate, the /api/dashboards fetch it existed to
feed, and the drift — for one redirect hop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: lloyd tabb <lloyd@malloydata.org>
Three things that all came down to the same gap: a source is only meaningful
alongside the dataset that defines it.

The source filter listed every dataset's sources as one flat run. It was in API
order — dataset order — but with nothing marking where one dataset ended and the
next began there was no way to see that, so it read as arbitrary. It also deduped
on the bare source name, which silently dropped a source when two datasets both
defined one called "orders" and left the survivor attributed to whichever the API
listed first. Sources now carry their dataset, dedupe on dataset+source, and
render grouped under the dataset name.

Picking a source nobody has asked anything of filtered the sidebar down to
nothing and left the editor on "Select a query from the sidebar" — the same dead
end an empty dataset had. It now opens the new-query workflow, the same scratch
a deep link gets, so the answer to "no questions here yet" is the thing you came
to do rather than an empty list.

The home page's source affordances become the same `<> Query` action as the
dashboard nav's, sharing one icon rather than three hand-drawn copies.

And ltool links carry the dataset NAME, not its uuid — `?dataset=babynames`.
That needed the two web-query paths to resolve by ref rather than by id, which
they should have done anyway: every other route takes either, and resolving only
by id made a readable link fail in the ugliest way available, with Postgres
refusing to cast the name to uuid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: lloyd tabb <lloyd@malloydata.org>
Switching source opened a fresh query only when that source had no history. But
picking a source is stating what you want to ask about, and the previous
source's query left sitting in the editor answers a different question than the
sidebar is now showing — so the "has questions" case was the one where the
editor and the list disagreed.

Now any source pick opens its starter. The filtered history is still right there
to click when the answer already exists.

"All sources" stays the exception: that clears the filter rather than choosing a
subject, so it leaves the editor alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: lloyd tabb <lloyd@malloydata.org>
With one group per dataset the list is long — 2600px in a 320px box on a server
with eight datasets — and it opened at the top every time. Picking a sibling
source in your own dataset, the common move, meant scrolling to find yourself
first.

It now opens centred on the current source, so its dataset's other sources are
the ones on screen. Scrolls the list box only, never the page.

That needed the picker to know WHICH source is current, which it could not work
out alone: the filter value is a bare source name and names are not unique
across datasets — two can each define "orders" — so it would have marked both
and scrolled to whichever came first. It now takes the current dataset as well.

That ref may be an id or a name and both genuinely occur — a replayed history
item carries the recorded uuid, a deep link carries the readable name — so the
match accepts either. Comparing against the id alone matched nothing at all once
links started carrying names, which is how this turned up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: lloyd tabb <lloyd@malloydata.org>
/api/sources returned the dataset's name in a field called `model`. It has been
that way since the file's first commit, when a dataset and "a Malloy model" were
the same idea. They are not any more — `malloy_models` is a different table
holding the versioned model rows FOR a dataset — so the name pointed at the
wrong thing, and reading `s.model` to build a dataset link looked like a bug
every time. It is `dataset` now, and its four consumers with it.

A dataset id must not reach a URL or the screen. Two places still leaked one:

  - ltool's drill built `/datasets/<id>/dashboard/<name>` from the selected
    item's recorded dataset_id, so drilling out of a replayed history row put a
    uuid in the address bar. It resolves the name through the source list first;
    MalloyResultView takes a `datasetRef` and says in its type that an id is not
    something to show anyone.
  - SourceOption's `datasetId` is now documented as internal-only, beside the
    `dataset` name that links are built from.

The GitHub webhook URL on the config page keeps its id, deliberately. That URL
is registered with GitHub and has to keep resolving; a name would tie a stored
external registration to a mutable field. It is an integration endpoint someone
copies once, not a link anyone navigates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: lloyd tabb <lloyd@malloydata.org>
@lloydtabb lloydtabb changed the title Dashboards: a first-class About page, a reader-first dataset route, and a writable ltool The About page, a reader-first dataset route, and ltool follow-ons Aug 29, 2026
lloydtabb and others added 4 commits August 29, 2026 16:34
/api/sources returned a flat list of sources with the dataset's five fields
copied onto every row — 44 rows for eight datasets — and both callers began by
regrouping it back into datasets. The endpoint returned the inverse of what
anyone wanted, and that duplication was the only reason a dataset id had to ride
along at all: it was the join key holding the flattened rows together.

It is dataset-first now: each dataset once, with the sources it offers. 44 rows
became 8, the home page's groupByDataset is gone, and the switcher no longer
dedupes a list that no longer repeats.

With the shape fixed, the id had nothing left to do, so it is gone from the wire
entirely — along with the joins it existed for. A dataset is identified by NAME
everywhere a browser can see: /api/sources, /api/dashboards,
/api/favorited-queries, /api/history and the ltool share endpoint all say
`dataset`, and the front page joins the three of them on it. Names are unique
per server (findByDatasetRef), so nothing is lost.

/api/run's body field follows: it takes `dataset`, a ref, which is what it has
actually accepted since the web-query paths moved to findByDatasetRef. Calling
the field datasetId while passing it a name was the misnomer one layer down.

ownerEmail goes too. It was admin-gated, so this is not a leak being closed —
it was simply carried through two types and the grouping and never rendered.

Still by id, deliberately: the GitHub webhook URL on the config page. That is
registered with GitHub and has to keep resolving, so it must not depend on a
mutable field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: lloyd tabb <lloyd@malloydata.org>
**The About page's "view source" linked to the wrong file.** An earlier fix
preferred a `dashboards/`-prefixed match but still fell back to the first match
anywhere — and the About page exists precisely BECAUSE there is no
`dashboards/index.malloy`, so it landed on the repo-root `index.malloy` every
time: the model's MCP entry point, a different file that happens to share the
basename. That root file is now excluded outright, so the link is correctly
absent rather than wrong. Its real source is a .jsx and is not a model file at
all. Pinned by a test, this being the second attempt.

**Duplicate React keys in the schema source picker.** Dropping the dedupe-by-
source-name (so two datasets can each declare "orders") left SchemaPanel's
picker keyed on the bare name while ltool's was updated. It now keys on
dataset+source like the other one.

**A GitHub refresh could invent an About page the CLI never would.** The guard
moved from "is there a dashboards/index.malloy" to "did anything resolve to the
name index", and each misses what the other catches: a `dashboards/index.malloy`
tagged `name="overview"` publishes as `overview`, so no row is called `index`
and the old guard passed — inserting a second, queryless artifact from the same
component, which then sorted first and became the dataset's front door. Both
guards now apply, which is what the CLI has always effectively done.

**The landing chain could redirect to an empty Q&A page.** `hasQuestions`
accepted any history row carrying a question; the Q&A page lists only executed
runs with no error and a slug. A failed MCP query records exactly such a row, so
a dataset whose only activity was one would land on "no questions" — the dead
end the chain exists to avoid — and never reach ltool. The predicate now mirrors
what the page lists.

**Two datasets could merge under one name.** `datasets_name_ready_unique` is
partial on `status = 'ready'` while the catalogue lists everything not-failed,
so a creation stuck in `modeling` can share a name with the live dataset. Now
that the name is the key every caller joins and renders on, that merged a card,
duplicated a React key, and hid one dataset. The catalogue keeps the ready one,
and findByDatasetRef's name branch orders ready-first rather than taking an
arbitrary row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: lloyd tabb <lloyd@malloydata.org>
The previous commit added a ready-first `orderBy` to findByDatasetRef's name
branch, to stop a stuck `modeling` namesake being resolved instead of the live
dataset. That row can't reach the ordering: visibleDatasetWhere already
restricts to `status = 'ready'`, and the unique index on `name` is partial on
exactly that status, so at most one row matches. The comment asserted otherwise,
which was the worse half of it.

The collision is real on the catalogue endpoint, which builds its own WHERE
(everything not-failed) — that dedupe stays. The comment here now says why the
two differ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: lloyd tabb <lloyd@malloydata.org>
SourceOption's doc said the schema panel shows one dataset's sources and so had
no use for the dataset name. It hasn't for a while: ltool hands the same
cross-dataset array to its filter AND to the schema panel, which is exactly why
both had to start keying rows on dataset+source. Comment-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: lloyd tabb <lloyd@malloydata.org>
@lloydtabb
lloydtabb merged commit d80c715 into main Aug 30, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant