Analytics
How the dashboard is built — Mosaic's public surface and nothing under it, a spec that is read whole or refused, a recommender made of Show Me's cases, three tables over one closed set of kinds, and an editor nobody downloads until they edit — and what would reverse each rule.
The dashboard page is what Dashboard does, and
the spec page is what it stores. This page is why it is built
the way it is.
The product is discovery, not BI. A dashboard here exists to show what a relation holds that nobody had looked at: which categories dominate, how a measure spreads, what moves over time, and what a brush on one of those does to the rest. It is not a report builder. Each rule below is judged by that, and a feature that only a report builder needs is the one this page turns away.
Mosaic does the work, through its public surface
The charts are Mosaic clients on the page's coordinator, and where Mosaic already solves something the library calls it rather than repeating it:
makeClientis the client behind every chart input. It replaced aMosaicClientsubclass of our own.toDataColumnsreads an Arrow answer, sonumbers(data, field)is one line over it.DuckDBWASMConnectoris the connector. It replaced the deprecatedwasmConnector.denseStackreads a mark'schannels, the same inputmarkQueryreads, and no longer reads the query's private_groupbyand_select.
A Selection's clauses are relayed by relaySelection in @kanzo-tech/mosaic. It reads the
selection's state, never its events: a dispatch raised while another is pending is queued, and
Mosaic may coalesce it, so the only reliable account of what a selection holds is clauses once
the event arrives. A relay diffs that list by clause identity, withdraws a clause that has left
its source, and withdraws everything it put in when it is stopped. What it carries now is a chart's
own clauses from the page's selection into the selection its ChartHighlight reads.
It replaced from._relay.add(to) in MosaicProvider, a private member, and the copy of the same
loop a bridge between selections ran on the public API. Keeping _relay behind a guard exemption
was rejected: an exemption is a private member the next Mosaic release may change without notice.
What would reverse it: a test showing a clause lost or reordered on the public path. Then the fix
is a public relay upstream in mosaic-core, not an exemption, and relaySelection is deleted for it.
Held by packages/ui/src/mosaic-public-api.test.ts, "reaches no underscore member of a Mosaic
object in any shipped package", and "is not blind: it sees a relay reached, a resolved list read, a
filter group destructured and a query's grouping read"; packages/mosaic/src/relay.test.ts,
"withdraws what it relayed when stopped, even with the source's reset still queued", and "loses
nothing in a burst: the last delivery holds the latest state"; packages/ui/src/charts/dense-stack.test.ts,
"completes every (step, series) pair the filtered query answered with, at zero".
The guard sits in packages/ui rather than packages/mosaic, because it reads the type
declarations of @uwdata/* as they are installed. A member counts as private when its name begins
with _ and it is declared under @uwdata/. The guard scans every package in guard-corpus.ts's
shipped set, so it covers @kanzo-tech/graph as well as the analytics.
What a clause says is Mosaic's to answer
clauseParts(clause) splits a clause into a field, an operator and a value, and clauseLabel
joins those into the words of a chip. Both live in @kanzo-tech/mosaic, beside clauseFor, the
function that builds the clause. FilterBar's chips and a dashboard's filters both read them.
It replaced a reader inside the chip row, which the dashboard's filters had to import a chip component to reach. Reading a clause is a question about Mosaic, not about a chip.
What would reverse it: Mosaic's clause carrying a description of its own. Then the description is read straight from the clause, and these two are deleted.
Held by packages/mosaic/src/clause.test.ts, "names a semi-join by its publisher's label, and
does not count its members".
One row of filters, and a dashboard draws in it
The page has one filter bar, FilterBar, and a Dashboard draws its filters into it through a
slot — a portal from inside the dashboard's tree, so its filters keep the dashboard's relation and
publish into the page's one selection on it, crossed to every other relation by key. The bar draws every
other clause as a chip and leaves the dashboard's to the dashboard's chips. Metabase's filter bar is
one row above everything it scopes; a page with a graph and a dashboard had two — the dashboard's,
and FilterChips over the page — and the page's showed the dashboard's clauses a second time.
The bar is the same in every view. A dashboard hidden behind MosaicClients keeps its filters
and draws them: the chips that open a filter, Add a filter, and the readout of what they keep. Its
filters reach the whole page through its semi-join, so they are about the canvas as much as about
the tiles, and the reader sets them from whichever view they are in. The controls were mounted all
along, so each is exempt from its own clause; what changes is that they are drawn.
It replaced a filter row inside Dashboard, with its readout and Clear, beside a separate
FilterChips. The alternative was filters owned by the page and published as column clauses; it
was turned down because a column clause filters only the vertex types that have the column, where a
dashboard's filter reaches the whole graph through its semi-join, and because it would have taken
filters out of the saved spec.
The hidden view's controls came back by the reversal this rule named. v0.33.0 drew a hidden dashboard's clauses as plain chips and none of its controls, so a graph view did not read type: Any above a canvas. Its reversal was "a reader who sets a hidden dashboard's filter from another view", and that reader arrived: in Keasy's Graph view the bar was an empty strip, and its filters could only be set by switching to the Dashboard and back.
What would reverse it: a page with two dashboards on screen at once, whose filters in one row could no longer say which dashboard each belongs to. Then each dashboard draws its own row again, and the bar draws only what no dashboard holds. And for the hidden view: controls that mislead in a view they do not filter — a hidden dashboard whose relation the visible view does not answer. Then that view draws the clauses as chips alone, as v0.33.0 did.
Held by packages/ui/src/charts/filter-bar.test.tsx, "draws what a part puts in its slot, and
leaves that part's clauses on its relation to it", and "draws a hidden view's controls as it draws a shown one's,
querying as the bar does and mounted once"; packages/ui/src/charts/dashboard.test.tsx, "are drawn
in the page's FilterBar", and "are drawn from
a dashboard under MosaicClients that is off as when it is shown: control, remove, Add a filter".
The bar is one line, and its verbs are pinned
FilterBar is one row at any number of filters. The chips scroll sideways in a ScrollArea,
its scrollbar hidden and a fade at whichever edge hides more, and a chip added on its own is
scrolled to — by the viewport's scrollLeft, since scrollIntoView would also scroll the page up
to the bar while the reader brushes a chart below it. After a hairline the verbs are pinned as
icons, each named by a tooltip and an aria-label: what a part puts there, Add a filter
(ListFilterPlus), then Clear every filter (FunnelX), then the readout and the host's
children. Clear every filter is disabled rather than absent while nothing filters, so the first
filter moves nothing beside it; with none at all the row reads No filters at the same height.
Linear draws filters as one row of chips with the view's controls pinned at its end; Notion keeps + Add filter and Reset beside the chips and never among them. The bar wrapped instead, and the analytics example with a dashboard's filters, a brush and a pick took two lines — so the page under it jumped as filters came and went, and + Filter sat wherever the last chip left it, a verb that moved every time a filter did.
A second slot, not a part. A dashboard's Add a filter is the dashboard's: it knows the fields
left, the bar does not. So it draws in the bar as its chips do, through InFilterBarActions, a
second portal beside InFilterBar, rather than the bar growing an onAdd and a field list for a
menu it cannot fill. ScrollArea's scrollFade already masked the edges a column hides; it now
masks the sides a row hides too, gated on there being sideways overflow, since Zag reports neither
side reached when the content fits.
What would reverse it: a reader who has to see every filter at once to read the page — filters long or many enough that a fade and a sideways scroll hide the one they came to change, measured on a real page. Then the row wraps again, or folds what does not fit into a +N chip that opens the rest. And for the icons: a verb a reader does not recognise from its icon, which then gets its word back.
Held by packages/ui/src/charts/filter-bar.test.tsx, "clears every clause on the page, and keeps
the button in place, disabled, while there is none", "draws what a part puts among its actions
after the chips, before Clear every filter", "scrolls its chips in one row rather than wrapping, and
says there are none while there are none", and "scrolls a chip added on its own into view, by the
row alone".
The spec is read whole or refused
A DashboardSpec is a valibot schema, and the type is inferred from that schema, so the two cannot
drift. parseDashboards reads untrusted JSON, and either returns a value of the
type or throws not a dashboards document at <path>, naming the field. It does not coerce, it does
not fill in defaults, and it does not drop an unknown key. A measure that could never be drawn against
any relation, such as a sum of nothing or a share of no value, is refused when the spec is read,
not when it is drawn.
There is no version field, and there is no migration. Something an earlier release saved is
refused as a whole. The host deletes it, and the dashboard follows the relation's statistics again.
It replaced migrateDashboards and a hand-written type checked by hand. The migration chain was
the only reader of the version field, and the only data it ever carried forward was our own,
which is thrown away while the library is 0.x.
What would reverse it: a consumer whose saved dashboards cannot be thrown away. Then version
returns together with one migration per breaking release, each a schema of its own. Even then,
nothing would be partly read.
Held by packages/ui/src/charts/dashboard-spec.test.ts, "refuses what an earlier release saved,
whole, rather than reading part of it"; "coerces, defaults and drops nothing"; "refuses a measure
that cannot be drawn whatever the relation: a sum of nothing, a share of no value".
The recommender is Show Me's cases, in CompassQL's pipeline
The recommender behind autoDashboard, recommend(fields, intent), runs three steps. enumerate asks every rule for its candidates.
rank orders them by the intent's weights. explain turns a candidate into a tile, a sentence of
rationale and the id of the rule that chose it. That is CompassQL's split, and Draco's after it:
the set of what could be drawn, the preference among those, and the reason. The rules are not
ours. Each is one of the cases in Mackinlay, Hanrahan and Stolte's Show Me (2007), and it names
that case in its showMe field: a line for a time, bars for a category, a histogram for a measure,
a scatter plot for two measures.
Draco itself was rejected. Draco 2 is Python and clingo, and it answers in Vega-Lite. Running it would mean shipping a solver to the browser and translating its output into the spec. A table of four Show Me cases is the part of Draco this dashboard uses, which makes it a light Draco and not a new design.
What would reverse it: a rule that no published case covers, or a fifth chart that Show Me's
cases cannot rank. Then the knowledge base becomes a set of constraints and a solver, as Draco's
is, and RULES is retired, not grown.
Held by packages/ui/src/charts/recommend.test.ts, "is made of Show Me's cases, each rule naming
the one it is"; "enumerates every rule's candidates in the table's order, capped per rule, before
any weighing"; "explains a candidate with a derived id, so the same fields always give the same
answer".
A tile records which rule drew it
ChartTile.origin is { rule }. The automatic dashboard sets it, and rationale(tile, fields)
reads it back to say why the card is there. edited(before, after) drops origin as soon as the
tile reads something else, or is renamed. A card added by hand never gets one, even when a rule
would have proposed the same card.
Deriving the rationale was rejected. Running the rules again over the current tile would claim a reason for a card that somebody drew by hand, and a reader would take a choice they made for a choice the recommender made.
What would reverse it: a rationale that has to change when the rules do, for tiles that have
already been saved. Then it is derived, and origin is deleted.
Held by packages/ui/src/charts/recommend.test.ts, "is not claimed by a card added by hand, even
one the rules would propose"; "drops it once the tile reads something else or is renamed".
One closed set of kinds, and three tables over it
A tile is a stat, a chart or a table. Three tables are each keyed by that kind:
KINDSintile-kinds.ts: how to make a tile of the kind, how to title it, and how to convert one from another kind. It has no React in it.TILE_VIEWSintile-views.tsx: how to draw it.TILE_EDITORSintile-fields.tsx: how to edit it.
Each table is mapped over TileKind, so adding a kind is a type error until all three rows exist.
Making a tile is pure: the id is passed in, and it is minted once, in Dashboard's add.
Hosts cannot extend the set. A host-defined kind would be a spec this library cannot read back,
which is exactly what parseDashboards refuses to do.
What would reverse it: a second consumer that needs a kind which reads a relation in a way none of the three does. Then the kind joins the set, here, with its three rows.
Held by packages/ui/src/charts/tile-kinds.test.ts, "makes every kind with the id it is given, so
making a tile is pure", and "lets every kind become every other, keeping the id and dropping the
title, and a figure has no width"; packages/ui/src/charts/dashboard-layering.test.ts, "keeps React out of the
spec, the recommender and the kind table".
Figures are a band, not grid tiles
The figures are drawn first, as one band whose row they share equally, wrapping when they run out of
room. Charts and tables flow through the three-column grid beneath it, each as wide as its span. A
figure has no span in the spec, and the editor asks for none; its position is among the figures,
as a chart's is among the grid's tiles. A figure that becomes a chart moves to the grid, and back.
A grid row is as tall as its tallest tile, so a figure that shared one with a chart stood as tall as the chart: one number in a tall empty card. The band is where Few's Information Dashboard Design puts the key figures, and what Grafana's stat row and every KPI strip draw.
Two alternatives were rejected. A grid of fixed-height rows, where a figure takes one and a chart three, as Grafana and Metabase lay panels out, would stack figures beside a chart; it needs every chart to fill a height it is given, and a table to scroll inside one. A height stored per tile, with drag to resize, is that and an editor besides.
What would reverse it: a dashboard whose figures belong beside the chart they summarise, read together, more than together at the top. Then the fixed-row grid above, and figures leave the band.
Held by packages/ui/src/charts/tile-kinds.test.ts, "draws a figure in the band and every other
kind in the grid", and "places a tile among its peers, whatever lies between them in the spec";
packages/ui/src/charts/dashboard-spec.test.ts, "refuses a width on a figure: the band gives it its
width"; packages/ui/src/charts/dashboard.test.tsx, "changes the kind, keeping the id, and a figure
that becomes a table moves from the band to the grid".
The editor is the page's aside, and is loaded on first edit
TileEditor is drawn in the page's aside — the end edge of the app at full height, with no card —
the way draw.io's Format panel edits what is selected, and the way Grafana's panel options, Power
BI's visualizations pane and Metabase's sidebar edit a tile: none of them covers the board or stops
it scrolling. The tile's slot keeps its own view mounted and draws the draft in it, outlined, so a
change is seen where the tile is. The panel fills the aside: a header with the title, the kind's
hint and Close, one scrolling list in the order of Grafana's panel options — the kind as a row of
cards like a chart's mark, its fields, then the title, the width as a SegmentGroup and the
position — and the actions in a footer. Not a Sheet (Ark's dialog, modal, the scroll lock again)
and not a FloatingPanel, which floats over a canvas rather than beside a grid. A tile being added
gets a slot of its own at the end of the grid, brought into view as the panel opens, and Cancel
removes the slot again.
The aside is the host's, and the dashboard draws in it through a slot, the mechanism a
dashboard's filters already use to draw in the page's FilterBar: the host places
TileEditorAside in its own ShellAside, and the dashboard portals its editor there, so the editor
keeps the dashboard's tree — its provider, its crossfilter, its draft. The slot sits beside the
bar's, on the outermost MosaicProvider, so a dashboard whose provider is nested in the page's
still reaches it. A page without one offers no editing, and there is no fallback: a dashboard that
drew the editor somewhere of its own when the page had no aside would be the docked panel below,
kept as a second path.
It replaced a panel docked beside the board inside Dashboard: a ShellAside styled as a card,
rounded, bordered, shadowed and sticky, in a row with the board, which docked along the bottom of
the view under a narrow container. The dashboard was placing a page region. Its row decided the
editor's width and height, a page with an inspector had two end edges, and the card, the sticky
offset and the narrow dock re-derived what the shell already lays out. That panel had replaced a
modal Popover anchored to the tile, which locked the page — Ark's modal popover sets
overflow: hidden and pointer-events: none on <body> — while it covered the tiles beside the
one it edited, and the popover had replaced a Sheet that covered the board.
What would reverse it: a host that draws a dashboard where it owns no aside, such as an embed or a card on somebody else's page, and still has to edit it. Then the editor needs a home of its own again, and the question is reopened between the docked panel and Grafana's full-screen editor with the tile drawn in it.
The host learns an edit is open from useTileEditorOpen(), a flag on the slot's own context.
draw.io swaps the Format panel in when something is selected, so the host has to know when to show
its aside. The other way was the part drawing nothing while no edit is open, and the host checking
whether it has content. That is a question to the DOM — :empty, or a :has() on a portal target
React fills after the commit — so the host's own state cannot follow it: which inspector the dock
holds, whether it collapses. An aside shown by a selector also draws its border and its width
around nothing for a frame. One boolean beside the element the slot already holds is one field and
one hook. The part stays mounted while the aside is hidden, which is how the dashboard knows editing
is offered, and the editor is drawn only once the flag is out, so it opens on screen and takes
focus.
What would reverse it: a host that needs more than whether — which tile is edited, or a say in
whether it opens. Then the flag becomes the draft's kind, or the part takes Ark's open and
onOpenChange pair.
The part is TileEditorAside: what draws there, then where, as FilterBar is the filters and
the bar and ShellAside the shell's side region. The other side of a slot is spelled In* —
InFilterBar is what a dashboard draws through — and it stays internal, as that one does. Not
TileEditorSlot: on Names and slots a slot is data-slot and the slot
prop, and Radix's Slot is asChild's merge, so the name would read as either. Not
TileEditorPanel: the panel is TileEditor, and the part is where it goes.
What would reverse it: a second thing a dashboard draws in the aside, such as a filter's settings or the spec as JSON. Then the part is named for the aside rather than the editor.
The editor draws no tile of its own. It holds the draft as a controlled value and reaches the slot
through anchor, because a view the editor drew would be a new mount: a chart that remounts builds
new Mosaic clients and queries again, which on opening reads as the tile reloading. For the same
reason a scroll lock is not a theme change: useThemeTick ignores the inline style a modal overlay
elsewhere on the page writes on <html>, which used to rebuild every chart on the page.
Dashboard imports the editor through React.lazy. A dashboard without onChange, or on a page
without a TileEditorAside, is read-only:
it draws no edit control and never fetches the kind picker, the field pickers, Select or
RadioGroup.
Held by packages/ui/src/charts/dashboard-layering.test.ts, "loads no editor for a read-only
dashboard", and "is not blind: it sees the editor once the import is static";
packages/ui/src/charts/dashboard.test.tsx, "draws no edit control and never fetches the editor";
"opens from the pencil beside the tile it edits, which stays mounted and draws the draft"; "adds a
tile in a slot of its own at the end, brought into view, which Cancel takes away again"; "draws in
the host's aside, shown while a tile is edited, and locks nothing"; "offers no editing on a page
without a TileEditorAside";
packages/ui/src/lib/theme-tick.test.tsx, "stays put for an inline style on the root, and still
moves for a theme attribute".
Going back to automatic is undefined
Reset calls onChange(undefined). The host deletes what it stored, and the dashboard follows the
relation's statistics again.
Handing the host the automatic spec was rejected. A stored copy of it would freeze the cards at the moment of the reset, and would not follow the relation the way the automatic dashboard is meant to.
What would reverse it: a host that has to tell "never edited" apart from "reset". Neither one carries any data, so this case has no evidence that could reverse it, and none is expected.
Held by packages/ui/src/charts/dashboard.test.tsx, "hands the host undefined, and is disabled
while nothing is stored".
A hop reads as a sentence, and says what it costs
RelationPicker offers the hops that leave the last type as sentences, not as schema. An out-edge
reads The {to} it {label} — The place it is part of — and an in-edge reads as the type it
brings in and the edge, {Source} · {label} this {destination} — Comment · located in this
place. The predicate is the edge's label in words: isPartOf is is part of, isLocatedIn
located in where a lone is drops out, hasCreator whose creator is. A host's
phrase(edge, direction) overrides any sentence (a self-edge's parent and children wording), and
both patterns, like every other word the picker draws, are entries of its translations.
No noun inflects (decided 13 October 2026). The in-edge read Comments located in this place,
and the plural was the library's guess: an +s rule, and plural(type) for every noun it got wrong
(People). The rule that titles name no plural (Tasks)
holds here too, for its reason: no labelling vocabulary carries a plural, so a plural is always
derived, and derived English is wrong outside English and for every irregular noun. The sentence is
written so it needs none — the type as its label, singular, beside the edge's words — and the +s
rule and plural are gone. What would reverse it: a labelling vocabulary that carries a plural;
then it is read, not derived, as on Tasks.
The menu has two groups, Follow from each {Root} for out-edges and Bring in what points at it
for in-edges, each sorted by fan-out ascending. Each item says its fan-out — 1 per Place,
~103 per Place, ≤1 per Place — and warns above ten, because one choice keeps the row count and
the next multiplies it by a hundred. The fan-out is the edge's rows over the rows of the type it
leaves: JoinType.rows and JoinEdge.rows, which joinGraphOf carries from the record_count
readStructure already read. No query is added, and the counts are as fresh as the last load.
The path is chips — Place › Comment · located in this place — and any chip can be removed, which
cuts the path there. Nothing is written under them: the fan-out on each item already says what a
hop does to the rows before it is taken, which is where every reference below says it, and a line
restating the grain sat on every dashboard as chrome nobody read (the owner, 2026-10-11). The joins
stay inner, as relationQuery has always built them (mosaic-sql's join() defaults to
INNER).
The references, verified 2026-10-10: Looker's join relationship (one_to_one, many_to_one,
one_to_many, many_to_many) exists so measures survive fan-out; Metabase's notebook joins
default to a left outer join; Tableau's relationships keep each table at its own level of detail,
with cardinality and referential integrity deciding the join; Power BI marks cardinality 1/*
on the line; Neo4j Bloom and Linkurious expand by a chosen relationship type and direction, and
Linkurious badges a supernode with its count. What every one of them shows is the cardinality of
the step before it is taken, which is what the fan-out is.
Rejected: keeping the arrows (isPartOf → Place, ← isLocatedIn · Comment) and adding
headings. Two notations for one idea, and nothing says what the arrow is relative to.
Not yet: the skew behind an average (~103 per Place is 111 Places holding every Comment), a left join, and an aggregate column in place of a fan-out (Count of Comments per Place, the grain Tableau and Looker protect). Each needs a query or a new relation shape.
What would reverse it: a reader misled by an average on a skewed edge — then the hint shows the
"has any" share, one count(distinct …) per edge at load; or a host that needs every root kept —
then the join becomes left; or a reader surprised that a hop dropped roots — then the item that
takes it says so, beside its fan-out.
Held by packages/ui/src/charts/relation-picker.test.tsx, "reads each hop as a sentence, grouped
by direction and sorted by fan-out", "removes any chip, cutting the path there" and "lets a host
phrase an edge, and words every sentence through its translations"; packages/mosaic/src/relation.test.ts, "gives each hop its fan-out when
the graph carries counts".
A tile carries its own question
A tile holds its own conditions and its own ranking — where and top — beside what it shows,
and is drawn under them and under the page's clauses. A condition is a path and a predicate — in,
between, contains — and a path off the tile's relation is crossed in by JoinResolver.crossing,
as Tasks says, so a tile's
condition and a page's chip are the same predicate. top keeps a bar chart's first bars by its
measure, ties broken by the bar, or a table's first rows; on any other chart it is refused when the
spec is read, because a line's or a histogram's steps are not a ranking.
It is what makes an answer a tile without losing anything: Physics prizes by university was saved as prizes by university, because a tile had no place for Physics. Looker's dashboard tile is a saved query with its own filters beside the dashboard's, and a Metabase card is a saved question with its own; both draw the dashboard's filters on top.
One compiler turns a tile into its query, and every reader of a tile is fed by it: tileQuery
gives the relation narrowed by the tile's own question — what a chart's marks, a figure, a detail
table and Filter the page read — the measure on it, and the tile's rows, which Ask reads. Ask had a
second, answerQuery, which shared measureExpr and bucketExpr and not the grouping, the order,
top or the cap, so the rows a reply described and the chart beside it could differ. A tile naming a
field or a path its relation cannot read is drawn as what it lacks, as a chart's missing field was.
What would reverse it: a tile whose own condition must be edited by the page's filters — a dashboard where a reader expects the page's chip on Category to replace the tile's Physics rather than narrow it. Then the tile's condition is a default the page overrides, as Looker's listens to does, and it is still the tile's.
Held by packages/ui/src/charts/dashboard-spec.test.ts, "narrows by a condition on the relation as
its clause, and crosses one off it in at the step it leaves"; "keeps a bar chart's top bars by its
measure, ties by the bar, and refuses a top on any other chart"; "says what the relation cannot read:
a field it lacks, a path the joins do not have, a step it does not reach".
count counts the measured type
{ op: "count" } is a count(DISTINCT …) of the relation's root key, on a tile and on an
answer. On a type alone it is the row count, as before — its keys are its rows, so it is written
count(*), which Mosaic can pre-aggregate; across a hop it is how many of the measured type the
group holds, not how many joined rows. Prizes by university counts a prize once per university,
however many paths lead there. The reference is Malloy's count(), which counts the source's distinct
rows whatever the query joined.
The other measures stay what measureExpr makes them, over the relation's rows: a sum or an
avg across a hop that fans out sees a measured row once per path. Malloy computes those
symmetrically too, and the cost of doing so here — no mosaic-sql node, the fanned rows held in
DuckDB-WASM's memory — is on Tasks.
A path has its count too: Malloy's path.count(). A count may name a path —
{ op: "count", path: "affiliation" } — and then counts the distinct rows at that path's end: a
count(DISTINCT …) of the key of the step the path reaches, which JoinResolver.steps names, so the
measure goes through the same door as every other path. The path must lie on the shown relation, as
every shown path must; a path off it is a join/branches miss, said before anything runs. count
with no path keeps its meaning, the measured type's.
What would reverse it: readers reading a bare count across a hop as the joined rows, often
enough in the evaluation set or in keasy that the measured type's count misleads more than it
helps — then a bare count says which type it counts in its title, as Malloy's count() does by
its source. A path's count would be reversed only by a reader who needs the joined rows themselves,
duplicates and all, which no reference offers as a count.
Held by packages/ui/src/charts/dashboard-spec.test.ts, "counts what is measured once per group
across a hop, a path's step when it names one, and rows on a type alone"; packages/ai/src/data/answer.test.ts,
"counts a path's step when the count names one: the people the women know".
A count's axis is whole, and a bar's label fits before it is cut
A count or a distinct count is ticked at whole numbers, from zero. Plot's ticks for a domain of
[0, 2] are 0.0, 0.5 … 2.0, which says a laureate can win half a prize. ChartCard hands Plot a
tick interval of its own for those two measures — at most seven ticks, stepping 1, 2, 5 by a power of
ten, never below 1 — and formats them as integers. Every other measure keeps Plot's ticks: a mean is
fractional.
A bar's label band is as wide as its longest label, up to 28 characters, before any label is cut. It was 18 characters inside a 140px band, so Univ. of California was cut with half the card empty. The width is estimated from the characters, about 6.5px each at the axis's size, because the band is a margin set before Plot draws, and Plot's own measuring is after. The gesture under a chart's title is a sentence: Click a bar to filter.
What would reverse it: a card so narrow that a 194px band leaves the bars no room — a phone beside a sidebar — which sizes the band from the card's measured width instead; or Plot's automatic margins, which would measure the labels and retire the estimate.
Held by packages/ui/src/charts/chart-card.test.ts, "ticks whole numbers only, from 0: no 0.5 on a
count of 2"; "fits a label before it cuts one".
Mosaic is reached through @kanzo-tech/mosaic, and a name through one spelling
@kanzo-tech/mosaic is the one door to Mosaic: /analytics re-exports none of it, and
@kanzo-tech/ui's charts import Mosaic through it, not from @uwdata/mosaic-core or
@uwdata/mosaic-sql. /analytics re-exported about forty-five of its names, keasy imported
JoinGraph from both, and the SQL vocabulary arrived from three modules — the aggregates from vgplot,
asc and desc from mosaic, Query from mosaic-sql through a chart module. graph reached
Mosaic only through the door, and ai but for one type it now takes from it; the charts are the third. Every SQL builder a host writes — the
aggregates, sql, verbatim, asc, desc, Query — is imported from @kanzo-tech/mosaic, which
holds the aggregates /analytics stopped re-exporting. /analytics keeps its own parts and the
plotting half — bin and the six axis marks its descriptors withhold — and vgplot's coordinator
setter, which only MosaicProvider calls, is on neither. mosaic-core and mosaic-sql are
@kanzo-tech/mosaic's peers, declared once there; @kanzo-tech/ui no longer names them, as
@kanzo-tech/graph and @kanzo-tech/ai never did.
It is Conventions' rule for a re-export most callers bypass — two ways to write
one import — and the reason @kanzo-tech/mosaic exists: one copy of Mosaic, so one coordinator.
The six interactor aliases are gone. ChartPickX, ChartPickY and ChartPickColor were
ChartToggleX, ChartToggleY and ChartToggleColor, and ChartBrushX, ChartBrushY and
ChartBrushXY were ChartIntervalX, ChartIntervalY and ChartIntervalXY, kept as names that
describe the gesture. vgplot's names are the reference's, and an alias is the one thing the
repository forbids by name.
One facet item, passed as items. FacetFilter, DataTableFacetFilter and ChartFilter each
take items of FacetFilterItem — a value, a label, an icon, a count — and DataTableFacetOption
and ChartFilterOption, two re-declarations of it, are gone. items is Ark's word for a
collection's members. A chart filter's values are the column's, a number as often as a string; the
item's value is their text, and ChartFilter maps it back to the column's value where it has read
it, and otherwise publishes the text, which DuckDB casts to the column's type as it casts any string
literal it compares with one.
What would reverse it: a Mosaic name a host needs that @kanzo-tech/mosaic does not export —
then mosaic exports it, and /analytics still does not. A host that writes an open expression
builder the door does not hold (gt, regexp_matches) imports it from mosaic-sql, as the docs'
standing-orders showcase does; the second host to need one is the case for the door.
valibot is an optional peer of @kanzo-tech/ui/analytics. It was a direct dependency of
@kanzo-tech/ui, and only /analytics' dashboard spec imports it, so every consumer of { Button }
installed a validator it never ran. It joins /analytics' other optional peers by
the engine rule, the rule /editor and /table follow.
What would reverse it: the root barrel reading a spec — then it is a dependency again.
Held by packages/ui/src/mosaic-public-api.test.ts, "imports mosaic-core and mosaic-sql through
@kanzo-tech/mosaic in every shipped package but that one", "passes none of Mosaic through /analytics,
value or type"; packages/ui/src/charts/chart-inputs.test.tsx, "takes its items with their labels
and skips the lookup", "publishes a ticked item's text, which it read no column value for"; packages/ui/src/table/data-table-toolbar.test.tsx, "honours its items and
their labels".
A gauge per entry, measured as a consumer pays it
The gauges this subpath had — the whole subpath in isolation, and one Dashboard — were replaced on
13 October 2026 by the rule on How a rule lives here:
the subpath is measured over the root, and { Dashboard } stays as the tree-shaken cost of one
dashboard. What stays from this section is the reason every package is left out: the analytics gauge
measured valibot and every @uwdata/* package until they were externalised, and that is somebody
else's code. And one thing neither figure can say: esbuild bundles without splitting, so the lazy
editor is inside both, while a consumer's bundler splits it out.
What would reverse it: a bundler in size-limit that splits a dynamic import. Then the
{ Dashboard } gauge measures the entry chunk alone, and a separate gauge measures the editor.
The graph view
How @kanzo-tech/graph is built — one Mosaic client on the page's coordinator over a corpus fossil attached, uploaded to cosmos.gl once and greyed out by the crossfilter, an Ark-shaped root over flat parts — and what would reverse each rule.
Timeline
Why a time filter is a histogram whose brush owns its clause and sticks to its bars, why play runs inside the brushed range, accumulating, through the brush's own clause and at the page's pace, why its window reaches the page as identity and greys what has no time, why time is read from the schema and never guessed, and why a date on a relation is a vertex — and what would reverse each rule.