Kanzo UI
The design of Kanzo UI

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:

  • makeClient is the client behind every chart input. It replaced a MosaicClient subclass of our own.
  • toDataColumns reads an Arrow answer, so numbers(data, field) is one line over it.
  • DuckDBWASMConnector is the connector. It replaced the deprecated wasmConnector.
  • denseStack reads a mark's channels, the same input markQuery reads, and no longer reads the query's private _groupby and _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:

  • KINDS in tile-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_VIEWS in tile-views.tsx: how to draw it.
  • TILE_EDITORS in tile-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.

On this page