Sense 13: The Canvas
Sense 13 · In bloom · Last touched 2026-08-20
- last_verified: 2026-06-24
Synced from
docs/design/sense_13_the_canvas.mdin the engine repo — that’s the source; this page is a build-time mirror.
Status note (2026-06-24). The “folded_in” marker captured that Sense 13’s room metaphors (Studio / Salon / Lab) were absorbed by Sense 13.1. The tile substrate — the wedge — was never folded; it just hadn’t been picked up. V1 spec for the tile substrate now lives below (“V1 Implementation Spec — the tile substrate”); code follows after review.
A page is a composition, not a program.
The Canvas idiom was folded into the two-room grammar (Sense 13.1). The Studio / Salon / Lab vocabulary that lived on top has been retired; the text below remains for historical context.
What pulled us in
Section titled “What pulled us in”Every new analytical perspective requires a new JinDesk page:
price-lab/+page.svelte(622 lines)consumption-lab/+page.svelte(380 lines)map/+page.svelte(310 lines)dimensions/[entity]/+page.svelte(560 lines)
Each duplicates the same patterns: data loading, toolbar, chart rendering, drilldown panel, entity list, Price Points. Adding a new perspective means writing a new page. Changing the layout of an existing one means editing Svelte code.
The Vision
Section titled “The Vision”A page is a YAML declaration, not a Svelte file.
page_id: material_workbenchtitle: en: Material Workbench de: Material-Werkbank fr: Atelier matériauxentity: materialsicon: "◆"
layout: - row: - panel: filter_bar width: full - row: - panel: map_view width: 2/3 preset: anomaly_heatmap - panel: entity_list width: 1/3 mode: compact - row: - panel: price_lab width: 1/2 chart: scatter - panel: consumption_chart width: 1/2 chart: monthly
interactions: # Clicking a map cell filters the entity list map_view.select → entity_list.filter # Clicking an entity in the list opens its timeline entity_list.click → price_lab.timeline # Filter bar filters all panels filter_bar.change → [map_view, entity_list, price_lab, consumption_chart]One YAML file replaces 1,500 lines of Svelte across 4 pages.
The Building Blocks
Section titled “The Building Blocks”Panels
Section titled “Panels”Each panel is a self-contained, reusable component:
| Panel | What it does | Current implementation |
|---|---|---|
entity_list | Filtered, sorted, paginated table | DataTable in dimensions page |
map_view | MapCanvas with toolbar | Map page |
price_lab | Scatter / histogram / timeline | Price Lab page |
consumption_chart | Monthly / departments / top items | Consumption Lab page |
filter_bar | Search + field filters + taxonomy tree | SearchWithHints + FilterBar |
detail_panel | Entity detail card | Finding detail, entity detail |
provenance | Data lineage panel | ProvenancePanel |
probe_summary | Severity cards + treemap | Findings overview |
Layout engine
Section titled “Layout engine”Panels are arranged in a responsive grid:
width: full— spans entire rowwidth: 1/2,2/3,1/3— fractional widths- Panels can be collapsed, resized, reordered by the user
- Layout saved to SIS
Interactions (wiring)
Section titled “Interactions (wiring)”Panels communicate through a shared selection bus:
panel.select— emits a selection (entity ID, filter, time range)panel.filter— receives a filter and narrows its view- Wiring is declared in YAML, not coded
filter_bar.change → [map_view, entity_list]map_view.select → entity_list.filterentity_list.click → price_lab.timelineThis replaces the current pattern of custom goto() calls and URL-encoded state.
Data context
Section titled “Data context”All panels in a page share a data context:
entity: which gold entity is the focusfilter: current active filter (search + field filters)selection: currently selected item(s)time_range: active time window (if applicable)
The context flows top-down (filter bar → all panels) and can be narrowed by interactions (map click → list filter).
What This Enables
Section titled “What This Enables”For the user
Section titled “For the user”- One workbench per concern: “Material Workbench” has everything about materials — map, prices, consumption, findings — on one screen
- Drag to rearrange: move panels, resize, collapse
- Saved layouts: persist your preferred arrangement per entity
For the pack developer
Section titled “For the pack developer”- New perspectives without code: add a YAML page definition, get a full workbench
- Mix and match: combine any panels in any layout
- Tenant customization: tenant overrides pack page layout (config engine L2 > L1)
For the engine
Section titled “For the engine”- One component per concern: EntityList, MapCanvas, PriceLab chart — each maintained once
- Consistent behavior: Price Points, pagination, formatting locale — inherited from the panel framework, not reimplemented per page
- Testable in isolation: each panel has its own props/events contract
Architecture
Section titled “Architecture”pages/*.yaml → Page definitions (declarative) ↓PageRenderer.svelte → Layout engine (reads YAML, arranges panels) ↓Panel components → EntityList, MapCanvas, PriceChart, ConsumptionChart, ... ↓Selection bus → Cross-panel communication (filter, select, navigate) ↓Data context → Shared entity, filter, selection stateURL contract
Section titled “URL contract”The page URL encodes:
- Page ID:
/{tenant}/workbench/{page_id} - Active filters:
?q=...&f.material_group=CONSUMABLE - Panel states:
?map.view=heatmap&price.tab=scatter
Full URL = full reproducible state. Bookmarkable, shareable.
Worked Example: KPI Tiles (the smallest panel)
Section titled “Worked Example: KPI Tiles (the smallest panel)”The overview’s KPI grid is the simplest possible Canvas exercise — a row of metric cells, each one a single number with a label and a click destination. Today every tile type is hardcoded in [tenant]/+page.svelte with a long {:else if metric === 'X'} chain. Adding a new metric costs five touchpoints:
- A query function in
lib/server/queries/<topic>.ts - A conditional load in
[tenant]/+page.server.ts - A new
{:else if}branch in the Svelte template - Four i18n keys (en/de/fr/it) for the label
- An entry in the pack/tenant
config.ymloverview.metricslist
That’s the symptom The Canvas exists to cure. Every tile is structurally identical — value, label, format, click target — yet the abstraction sits in code instead of YAML.
Target shape
Section titled “Target shape”One file per tile in the AFS, mirroring the existing entity / signal / thesis convention:
tile_id: active_peoplelabel: en: "Active people" de: "Aktive Personen" fr: "Personnes actives" it: "Persone attive"value: query: "SELECT count(*) FROM gold_person_view WHERE is_employee OR is_student"format: numberclick: to: /dimensions/persons search: "!is_employee:true | !is_student:true"A generic tile renderer reads tiles/*.yaml, runs the value.query against the tenant schema, formats the number per format, and produces a click-through anchor from click.to + click.search. Adding “Active researchers” becomes one new YAML file. Zero Svelte changes. The renderer is the panel; the YAML is the panel’s content.
Convergence with kind:report notes
Section titled “Convergence with kind:report notes”The same shape already powers metric cells inside note-kind reports (Succession Risk Brief, Workforce Quarterly):
# inside nb_vai_succession_risk_brief.yaml- id: critical_roles type: metric label: en: "Single-holder critical roles" de: "Kritische Einzelinhaber-Rollen" fr: "Rôles à détenteur unique" it: "Ruoli a detentore unico" query: "SELECT count(*) FROM ..." format: integerThe shape is identical. The Canvas unification: extract one shared metric-cell renderer → the overview tile row and the report metric cells are the same panel, instantiated in two compositions. The overview becomes a Canvas with one row of metric cells declared per-tenant. The note-based report is a Canvas with mixed metric / table / chart / narrative cells declared per-document.
Why this example matters
Section titled “Why this example matters”KPI tiles are the easiest possible panel — number, label, link. If we cannot Sense-13-ify even these, the abstraction is too heavy to ship. Conversely: once the tile YAML lands, every more-complex panel (chart, table, narrative) is a straight generalization of the same loop. The KPI grid is the wedge.
Triggers to bring this forward
Section titled “Triggers to bring this forward”- Any pack that wants a metric outside today’s hardcoded nine (
theses_confirmed,money_at_risk,findings_count,qty_at_risk,persons_flagged,critical_roles_at_risk,active_people,active_employees,active_students). - The first tenant that wants a different KPI mix than its pack default.
- Anyone asking “I want to bring my own metric just for this engagement” — which the AFS model handles in seconds while the current shape needs an engine deploy.
Migration Path
Section titled “Migration Path”The composition hierarchy (settled 2026-06-09)
Section titled “The composition hierarchy (settled 2026-06-09)”Tile atomic, reusable — one number, label, format, click ↓Panel composition of tiles — typically a grid or row ↓Page composition of panels (or a single tile — "poor man's breakfast")A tile is an instance of a Svelte renderer. The YAML
declares the content; the renderer (<MetricTile>,
<ChartTile>, …) is code. Same renderer + N tiles = N
variations without a single Svelte edit. Same renderer + N
contexts (notebook cell, overview tile row, dedicated tile route)
= one unification job, many payoffs.
V1 ships the smallest of these — a tile substrate plus one renderer plus one route. V2 makes panels out of them. V3 makes pages out of panels. V4+ lets packs ship new tile types (i.e. new renderers).
Phase 0: Current state (today)
Section titled “Phase 0: Current state (today)”- Hardcoded pages work fine.
- Metric cell + provenance (Sense 14.2 Phase 5 thread 2, shipped
2026-06-09 commit
aec6851b) is the first Canvas-ready renderer in disguise — it lives inside the notebook page, but the render logic (value query, format, polarity colouring, signal-bound provenance line) is exactly what a tile needs.
Phase 1: Tile substrate + one tile on one route (V1 — spec below)
Section titled “Phase 1: Tile substrate + one tile on one route (V1 — spec below)”The wedge. If we can’t Sense-13-ify a number with a label and a click, the whole abstraction is too heavy to ship. So V1 is exactly that — and nothing more.
Phase 2: Panel (composition of tiles)
Section titled “Phase 2: Panel (composition of tiles)”panels/*.yaml declares an ordered list of tile IDs plus a
layout hint (grid, row, stack). <Panel> arranges them. First
real use: the overview KPI grid stops being a long
{:else if metric === 'X'} chain and becomes
panel:overview_kpis.
Phase 3: Page (composition of panels)
Section titled “Phase 3: Page (composition of panels)”pages/*.yaml declares panels + URL route + page-level layout.
<PageRenderer> handles the rest. Selection bus appears here if
panels need to interact; if they don’t, no bus.
Phase 4: Panel marketplace (deferred)
Section titled “Phase 4: Panel marketplace (deferred)”Packs ship new tile types (new Svelte renderers, not just new YAMLs). This is the WASM/iframe/plugin-API decision and stays parked until a real driver asks for it. Custom content (new tile instances) works from Phase 1 onward.
V1 Implementation Spec — the tile substrate (2026-06-09)
Section titled “V1 Implementation Spec — the tile substrate (2026-06-09)”One file per tile, one renderer in Svelte, one route that reads from URL and renders. The smallest thing that proves the loop closes.
Code follows after this spec is reviewed (CLAUDE.md spec-before-code rule).
Scope discipline
Section titled “Scope discipline”V1 ships exactly this and no more:
- The
tiles/*.yamlschema and a validator that catches typos. - A compiler that bakes
_{tenant}.tile_registryinto the KLS. - One Svelte renderer:
<MetricTile>— extracted from the notebook page’s existing metric-cell branch. - One route:
/{tenant}/tiles/{tile_id}— reads a single tile ID from the URL, renders it. - Pack→tenant cascade: a tenant’s
tiles/active_people.yamlshadows the pack’s same-named file.
Out of scope for V1: panels, page composition, layout YAML, multi-tile routes, new tile types beyond metric, hot-reload, the overview-KPI-grid swap, the workbench route. Every one of those has its own phase.
Tile YAML schema
Section titled “Tile YAML schema”One file per tile. tile_id matches filename without .yaml.
tile_id: active_peopleversion: "1.0"created_at: "2026-06-09"label: en: "Active people" de: "Aktive Personen" fr: "Personnes actives" it: "Persone attive"query: > SELECT count(*) FROM gold_person_view WHERE is_employee OR is_studentformat: integer # currency | number | percent | integerpolarity: positive # positive | negative | neutral (optional)click: # optional — turns the tile into a link to: /dimensions/persons search: "!is_employee:true | !is_student:true"signal_id: signal_workforce_size # optional — see "Provenance" belowField semantics — every one mirrors the existing metric cell in the notebook (Phase 5 thread 2) deliberately, so the renderer is shared, not similar:
| Field | Required | Meaning |
|---|---|---|
tile_id | yes | unique slug; matches filename |
version | yes | semver string, baked alongside |
label | yes | i18n object (en/de/fr/it minimum) |
query | yes | SQL expression producing a single scalar value |
format | yes | one of currency, number, percent, integer |
polarity | no | colours the value (positive=green, negative=red, neutral=ink); default neutral |
click.to | no | path the tile links to; absent → tile is read-only |
click.search | no | search-grammar fragment passed as ?q=... |
signal_id | no | binds the tile to a signal; brings the provenance line |
Provenance — Phase 5 thread 2 inherits for free
Section titled “Provenance — Phase 5 thread 2 inherits for free”When signal_id: resolves to a real signal, the compiler
attaches the same _provenance sidecar already invented for
metric cells ({signal_id, version, unit, window}), and the
renderer surfaces the same provenance line beneath the value:
12 months · signal_workforce_size v1.0 · at reporting dateIncluding the cascade-resolved currency-anchor suffix when the
unit is ISO 4217. The cascade JOIN (Sense 43) lands the same
way the notebook page does it: page-level pre-fetch via
getAllCascade(tenant), passed down to the renderer.
No new render code — the line composer
($lib/notes/provenance.ts → formatProvenanceLine) is already
right. Phase 5 thread 2 was the rehearsal; V1 of Sense 13 is the
performance.
Validator (scripts/tilecheck.py)
Section titled “Validator (scripts/tilecheck.py)”Mirrors notecheck.py / signalcheck.py shape. Errors:
- Missing required field (
tile_id,version,label,query,format). tile_iddoesn’t match filename.labelmissing any required locale.formatnot in the allowed set.polaritynot in{positive, negative, neutral}when set.signal_idset but doesn’t resolve to a real signal in the AFS.click.toset but malformed (must start with/).
Warnings:
queryreturns multiple columns or rows (validator runs an EXPLAIN against a sample KLS if available; soft warn otherwise).signal_idabsent on currency-formatted tiles — the provenance line is the whole point of binding a tile to a signal.
Compiler (scripts/tilecompile.py)
Section titled “Compiler (scripts/tilecompile.py)”Mirrors notecompile.py. Reads all tiles/*.yaml, resolves
signal_id against the loaded signal registry (re-using the
exact code from notecompile.py — pull it into a shared helper
in _compiler_base.py so both compilers consume it), bakes
_{tenant}.tile_registry:
| Column | Description |
|---|---|
tile_id | slug |
version | from YAML |
label_en / label_de / label_fr / label_it | localised labels |
query | SQL string |
format | format enum |
polarity | enum or empty |
click_to / click_search | nullable |
signal_id | nullable |
provenance_json | _provenance sidecar as JSON string, or empty |
source | pack or tenant (cascade provenance) |
compiled_at | timestamp |
Cascade
Section titled “Cascade”Pack-first scan, tenant second. Tenant wins by tile_id match.
source column on the bake records which level won, so the
JinDesk (and a future “What did the tenant override?” view) can
surface it.
This mirrors signal-registry cascade: nothing new to invent.
Renderer extraction
Section titled “Renderer extraction”The notebook page’s metric-cell branch
(explorer/src/routes/[tenant]/notebook/+page.svelte:1017-1045,
post-Phase-5-thread-2) becomes <MetricTile> in
$lib/components/tiles/MetricTile.svelte. Props:
interface Props { label: string; query: string; format: 'currency' | 'number' | 'percent' | 'integer'; polarity?: 'positive' | 'negative' | 'neutral'; provenance?: MetricProvenance; analyticalDefaults: Record<string, string>; locale: string; // Tile-only props (notebook caller passes undefined): click?: { to: string; search?: string };}The notebook metric-cell branch then becomes a one-line invocation
that passes undefined for click (notebook cells don’t link).
The tile route passes the click info from the YAML.
Acceptance condition for the extraction: the existing notebook test fixtures must still render identically. No visual regression on metric cells inside notes.
Routes
Section titled “Routes”/{tenant}/tiles/{tile_id} — single-tile page (V1)Load: read tile from _{tenant}.tile_registry by tile_id,
fall through to a generic “tile not found” if absent. Pre-fetch
the cascade (same call as the notebook page). Render
<MetricTile> centred on the page with the tile’s click target
active.
Out of scope: tile index page, tile grid page. Those are V2 (panel).
File touch summary
Section titled “File touch summary”| Path | Change | Why |
|---|---|---|
scripts/tilecheck.py (new) | Validator | One-file-per-tile schema gate |
scripts/tilecompile.py (new) | Compiler | Bakes tile_registry |
scripts/_compiler_base.py | Add load_signal_registry(afs) helper | Shared by note + tile compile |
tests/test_tilecheck.py (new) | Validator cases | Mirrors test_notecheck_surfaces |
tests/test_tilecompile.py (new) | Bake cases | Provenance sidecar carry-through |
explorer/src/lib/server/queries/tiles.ts (new) | Read tile_registry | Mirrors notes.ts |
explorer/src/lib/components/tiles/MetricTile.svelte (new) | Extracted renderer | One Svelte file, two callers |
explorer/src/routes/[tenant]/notebook/+page.svelte | Replace inline metric branch with <MetricTile> | Unification — the whole point |
explorer/src/routes/[tenant]/tiles/[tile_id]/+page.server.ts (new) | Tile-route loader | Single-tile page |
explorer/src/routes/[tenant]/tiles/[tile_id]/+page.svelte (new) | Tile-route render | Wraps <MetricTile> with optional <a> for click |
explorer/src/lib/components/tiles/MetricTile.test.ts (new) | Vitest | Render shape, click wiring, provenance line |
Acceptance test (the smallest end-to-end)
Section titled “Acceptance test (the smallest end-to-end)”- Author drops
tiles/test_active_people.yamlin the pack’s tiles dir with asignal_idpointing at an existing signal. jin makeruns without errors; warnings empty.- Open
localhost:<port>/{tenant}/tiles/test_active_people— the tile renders, the number matches the SQL, the provenance line appears beneath, the link wraps the value whenclick.tois set. - Open a notebook with a metric cell — looks identical to before the extraction. No visual regression.
If both 3 and 4 hold, V1 is done. Anything richer (multiple tiles per page, layout, drag-resize) is V2+.
Trigger to bring V2 forward
Section titled “Trigger to bring V2 forward”The moment the user authors a second tile and asks “can I put them next to each other?”, V2’s panel spec is the answer.
V2 Implementation Spec — the panel substrate (2026-06-16)
Section titled “V2 Implementation Spec — the panel substrate (2026-06-16)”A panel is a composition of tiles plus a layout hint. One file per panel, one Svelte renderer that arranges tiles, one route that shows a single panel. Same wedge logic as V1, one level up the hierarchy.
Code follows after this spec is reviewed.
Scope discipline
Section titled “Scope discipline”V2 ships exactly this and no more:
- The
panels/*.yamlschema and a validator (panelcheck.py). - A compiler (
panelcompile.py) that bakes_{tenant}.panel_registryinto the KLS. - One Svelte renderer:
<Panel>— takes a panel + the tiles it references and arranges them per the layout hint. - One route:
/{tenant}/panels/{panel_id}— single-panel page. - The same pack→tenant cascade as tiles (filename match).
Out of scope for V2: pages, page-level layout YAML, panel
composition into pages, drag-resize, per-tile widths within a
panel (deferred to V3), the overview-KPI-grid swap (deferred to
V3 — needs page-level routing to make sense), new tile types
beyond metric, panel index page, multi-panel routes.
Panel YAML schema
Section titled “Panel YAML schema”One file per panel. panel_id matches filename without .yaml.
panel_id: workforce_overviewversion: "1.0"created_at: "2026-06-16"title: # optional i18n title above the panel en: "Workforce overview" de: "Belegschafts-Übersicht" fr: "Aperçu de l'effectif" it: "Panoramica del personale"layout: type: grid # row | grid | stack columns: 4 # required when type == gridtiles: - tile_id: active_people - tile_id: active_employees - tile_id: active_students - tile_id: critical_roles_at_riskField semantics:
| Field | Required | Meaning |
|---|---|---|
panel_id | yes | unique slug; matches filename |
version | yes | semver string, baked alongside |
title | no | i18n object (en/de/fr/it) — rendered above the panel; omit for unlabelled panels |
layout.type | yes | one of row, grid, stack |
layout.columns | yes when type=grid | positive integer column count; ignored otherwise |
tiles | yes | non-empty ordered list of {tile_id: <slug>} entries |
Layout vocabulary (V2 — minimal):
| Type | Visual | Use case |
|---|---|---|
row | All tiles side-by-side, wrapping on overflow | 2-4 tiles you want to read across |
grid | Fixed N columns, tiles wrap to subsequent rows | The KPI-tile grid pattern (V3 will swap the hardcoded overview into this) |
stack | Tiles top-to-bottom, full width each | Mobile-friendly default; long single-column dashboards |
Per-tile widths within a panel are V3 territory — the trigger is “I have two tiles in the same panel and I want one to be twice as wide”. V2 keeps every tile in a panel equal-sized for the chosen layout.
Validator (scripts/panelcheck.py)
Section titled “Validator (scripts/panelcheck.py)”Mirrors tilecheck.py shape. Errors:
- Missing required field (
panel_id,version,layout,tiles). panel_iddoesn’t match filename.titlepresent but missing required locales.layout.typenot in{row, grid, stack}.layout.columnsmissing or not a positive integer whenlayout.type == grid.tilesis empty.- Any tile entry missing
tile_id. - Any referenced
tile_iddoesn’t resolve to a tile in<afs_root>/tiles/(loaded the same waytilecompileloads them).
Warnings:
gridlayout withcolumns > 6— visually unreadable; nudge.- More than 12 tiles in one panel — also a nudge; the user probably wants two panels.
Compiler (scripts/panelcompile.py)
Section titled “Compiler (scripts/panelcompile.py)”Mirrors tilecompile.py. Reads all panels/*.yaml, validates,
bakes _{tenant}.panel_registry:
| Column | Description |
|---|---|
panel_id | slug |
version | from YAML |
title_en / title_de / title_fr / title_it | localised titles (empty when absent) |
layout_type | row / grid / stack |
layout_columns | grid column count (0 when N/A) |
tile_ids | comma-separated ordered list — same shape as tags / surfaces in note_registry for SQL-trivial LIKE filtering |
compiled_at | timestamp |
Why comma-separated tile_ids instead of a JSON array column or
a separate panel_tiles join table:
- Mirrors the existing
tags/surfacespatterns JinDesk already reads — no new SQL convention to learn. - Panels are small (3-12 tiles typical); a string fits.
- The renderer parses the list and resolves each tile by id
against
tile_registry— same shape as note cells resolving signal references.
A separate panel_tiles(panel_id, tile_id, position) table
becomes worth it in V3 when per-tile widths land — at that point
the per-row carries width and an ordered list isn’t enough.
Renderer (<Panel> Svelte component)
Section titled “Renderer (<Panel> Svelte component)”$lib/components/panels/Panel.svelte. Props:
interface PanelTile { // The full tile row baked into tile_registry plus // the parsed provenance sidecar, ready for <MetricTile>. // Same shape the tile route currently consumes.}
interface Props { panelId: string; version: string; title: string | null; layout: { type: 'row' | 'grid' | 'stack'; columns: number }; tiles: PanelTile[]; // ordered, fully-resolved analyticalDefaults: Record<string, string>; locale: string; fetchValue: (query: string) => Promise<number | null>;}Layout implementation — straight CSS, no JS arithmetic:
| Layout type | CSS |
|---|---|
row | flex flex-wrap gap-4 — each tile gets flex-1 min-w-[200px] |
grid | grid gap-4, dynamic grid-template-columns: repeat({columns}, minmax(0, 1fr)) |
stack | flex flex-col gap-4 |
<MetricTile> from V1 is the cell renderer — Panel never reaches
inside a tile, it only arranges them. That’s the whole point of
the hierarchy: tile renderer + panel renderer compose, neither
knows about the other’s concerns.
Server-side resolution
Section titled “Server-side resolution”The panel route loader does one query for the panel row, then a
second query for all referenced tiles in a single IN (...) —
two round-trips total, not 1+N.
// $lib/server/queries/panels.tsexport async function getKlsPanel(tenant: string, panelId: string) : Promise<KlsPanel | null>;export async function resolvePanelTiles(tenant: string, tileIds: string[]) : Promise<KlsTile[]>; // single IN-list queryresolvePanelTiles preserves the YAML order of tile_ids by
sorting client-side after the round-trip — DuckDB can’t
guarantee IN(...) ordering across versions.
Routes
Section titled “Routes”/{tenant}/panels/{panel_id} — single-panel page (V2)The page wraps <Panel> in a max-width container plus the
optional title. V3 will compose multiple panels into pages with
a panel-grid layout above the panel layout — that’s where the
overview KPI grid finally swaps.
File touch summary
Section titled “File touch summary”| Path | Change |
|---|---|
scripts/panelcheck.py (new) | Validator |
scripts/panelcompile.py (new) | Compiler — bakes panel_registry |
scripts/_compiler_base.py | No changes — tile-registry loader is local to panelcompile for now (panel compiler is the only caller); promote if a third one appears |
tests/test_panelcheck.py (new) | Validator cases |
tests/test_panelcompile.py (new) | Bake cases, tile-resolution behaviour |
explorer/src/lib/server/queries/panels.ts (new) | Reader + tile-resolver |
explorer/src/lib/components/panels/Panel.svelte (new) | Layout renderer |
explorer/src/routes/[tenant]/panels/[panel_id]/+page.server.ts (new) | Single-panel loader |
explorer/src/routes/[tenant]/panels/[panel_id]/+page.svelte (new) | Single-panel page |
jinflow/cli/commands/make.py | Add “Panel compile” to COMPILERS + tag:panel |
Acceptance test (the smallest end-to-end)
Section titled “Acceptance test (the smallest end-to-end)”- Author
panels/workforce_overview.yamlreferencing 2-4 existing tiles, inlive/numetrix/rmc/afs/panels/. jin make numetrix.rmcruns without errors. The panel compile step reportsWrote panel_registry.sql (1 panel(s)).- Open
localhost:<port>/numetrix.rmc/panels/workforce_overview— the title renders, the tiles render in the declared layout (row / grid / stack), each tile carries its own provenance line if signal-bound. - The tile route from V1 still works unchanged — the panel doesn’t disturb the single-tile contract.
If 1-4 hold, V2 is done. Anything richer (per-tile widths, panel composition into pages, mobile-aware layouts) is V3.
Trigger to bring V3 forward
Section titled “Trigger to bring V3 forward”The moment the user authors two panels and asks “can I put them on the same page?”, V3’s page spec is the answer.
V3 Implementation Spec — the page substrate (2026-06-24)
Section titled “V3 Implementation Spec — the page substrate (2026-06-24)”A page is a composition of panels plus a layout hint. Same wedge logic as V1 and V2, one level up the hierarchy. The tile→panel→page hierarchy from June 16 lands its third tier.
Code follows after this spec is reviewed.
Scope discipline
Section titled “Scope discipline”V3 ships exactly this and no more:
- The
pages/*.yamlschema and a validator (pagecheck.py). - A compiler (
pagecompile.py) that bakes_{tenant}.page_registry. - One Svelte renderer:
<PageRenderer>— takes a page + the panels it references (with each panel’s tiles already resolved) and arranges the panels per the layout hint. - One route:
/{tenant}/pages/{page_id}— single-page page. - Pack→tenant cascade by filename, same as tiles and panels.
Out of scope for V3: per-panel widths within a page (deferred to
V3.1+ — trigger: “I want one panel twice as wide as the others”),
the overview-KPI-grid swap (separate follow-up — requires touching
[tenant]/+page.svelte and the page-vs-overview routing rules),
selection bus / cross-panel interaction (waits for a real driver),
drag-resize, mobile-aware layouts, new panel types, multi-page
routes (e.g. tabbed pages), the pages/ index route.
Page YAML schema
Section titled “Page YAML schema”One file per page. page_id matches filename without .yaml.
page_id: material_workbenchversion: "1.0"created_at: "2026-06-24"title: # OPTIONAL i18n title above the page en: "Material workbench" de: "Material-Werkbank" fr: "Atelier matériaux" it: "Banco materiali"layout: type: stack # stack | row | grid columns: 2 # required when type == gridpanels: - panel_id: panel_material_pulse - panel_id: panel_material_quality - panel_id: panel_material_pricingField semantics — deliberately identical to the panel YAML for familiarity:
| Field | Required | Meaning |
|---|---|---|
page_id | yes | unique slug; matches filename |
version | yes | semver string, baked alongside |
title | no | i18n object — rendered above the page |
layout.type | yes | one of stack, row, grid |
layout.columns | yes when type=grid | positive integer |
panels | yes | non-empty ordered list of {panel_id: <slug>} entries |
Layout vocabulary — V3 starts with stack as the default
mental model. Pages typically stack panels vertically because
panels are usually wide. row and grid exist for the small
panels case (e.g. a top metrics strip across panels).
| Type | Visual | Use case |
|---|---|---|
stack | Panels top-to-bottom, full width each | Default reading-friendly layout |
row | Panels side-by-side, wrapping on overflow | Short panels (each ≤ ¼ screen) |
grid | Fixed N columns, panels wrap | Symmetric multi-panel arrangements |
Per-panel widths within a page are V3.1+ territory — same discipline as V2 deferring per-tile widths to V3.
Validator (scripts/pagecheck.py)
Section titled “Validator (scripts/pagecheck.py)”Mirrors panelcheck.py shape. Errors:
- Missing required field.
page_iddoesn’t match filename.titlepresent but missing required locales.layout.typenot in{stack, row, grid}.layout.columnsmissing/invalid whentype == grid.panelsempty or not a list.- Any entry missing
panel_id. - Any referenced
panel_iddoesn’t resolve to a panel in<afs_root>/panels/. - Duplicate
panel_idin the panel list (rendering same panel twice is almost always a mistake).
Warnings:
gridlayout withcolumns > 4for pages — pages aren’t tile grids, so the threshold is tighter than panel-level.- More than 8 panels in one page — readers usually want navigation, not infinite scroll.
Compiler (scripts/pagecompile.py)
Section titled “Compiler (scripts/pagecompile.py)”Mirrors panelcompile.py. Reads all pages/*.yaml, validates,
bakes _{tenant}.page_registry:
| Column | Description |
|---|---|
page_id | slug |
version | from YAML |
title_en / title_de / title_fr / title_it | localised titles |
layout_type | stack / row / grid |
layout_columns | grid column count (0 when N/A) |
panel_ids | comma-separated ordered list — same pattern as panel’s tile_ids |
compiled_at | timestamp |
Renderer (<PageRenderer> Svelte component)
Section titled “Renderer (<PageRenderer> Svelte component)”$lib/components/pages/PageRenderer.svelte. Props:
interface ResolvedPanel { panel: KlsPanel; // panel_registry row tiles: KlsTile[]; // already-resolved, ordered}
interface Props { pageId: string; version: string; title: string | null; layout: { type: 'stack' | 'row' | 'grid'; columns: number }; panels: ResolvedPanel[]; // ordered per panel_ids analyticalDefaults: Record<string, string>; locale: string; fetchValue: (query: string) => Promise<number | null>;}Layout — straight CSS, same pattern as <Panel>:
| Layout type | CSS |
|---|---|
stack | flex flex-col gap-8 (larger gap than panel — pages breathe more) |
row | flex flex-wrap gap-6 — each panel flex-1 min-w-[400px] |
grid | inline grid-template-columns: repeat({columns}, …) |
<Panel> from V2 is the panel renderer — PageRenderer never
reaches inside a panel, it only arranges them. Three-tier
composition; each tier owns its concern.
Server-side resolution
Section titled “Server-side resolution”Three round-trips total, no 1+N (one per tier):
getKlsPage(tenant, pageId)→ page row withpanel_ids.resolvePagePanels(tenant, panelIds)→ all referenced panel rows in oneIN(...).- Aggregate every panel’s
tile_ids, dedupe, and one finalresolvePanelTiles(tenant, allTileIds)for every tile across every panel.
Then the loader walks panels in order, attaching the right tiles
to each by tile_id lookup. The renderer receives a fully
hydrated ResolvedPanel[] and doesn’t fetch anything.
Routes
Section titled “Routes”/{tenant}/pages/{page_id} — single-page page (V3)Out of scope: page index, page-as-overview (i.e. mounting a page
at /{tenant} to replace the hardcoded landing), page-with-tabs.
File touch summary
Section titled “File touch summary”| Path | Change |
|---|---|
scripts/pagecheck.py (new) | Validator |
scripts/pagecompile.py (new) | Compiler — bakes page_registry |
tests/test_pagecheck.py (new) | Validator cases |
tests/test_pagecompile.py (new) | Bake cases |
explorer/src/lib/server/queries/pages.ts (new) | Reader + multi-tier resolver |
explorer/src/lib/components/pages/PageRenderer.svelte (new) | Layout renderer for panels |
explorer/src/routes/[tenant]/pages/[page_id]/+page.server.ts (new) | Single-page loader |
explorer/src/routes/[tenant]/pages/[page_id]/+page.svelte (new) | Single-page page |
jinflow/cli/commands/make.py | Add “Page compile” to COMPILERS + tag:page |
Acceptance test (the smallest end-to-end)
Section titled “Acceptance test (the smallest end-to-end)”- Author
pages/<page_id>.yamlreferencing 2-4 existing panels in a tenant’s AFS. jin make <pack>.<tenant>runs without errors. The page compile step reportsWrote page_registry.sql (1 page(s)).- Open
/{tenant}/pages/<page_id>— title renders (if set), each panel renders in the declared page layout, each panel uses its own layout for its tiles, each tile carries its own provenance line if signal-bound. - The panel route from V2 and the tile route from V1 still work unchanged — three independent surfaces, three independent contracts.
If 1-4 hold, V3 is done. Anything richer (per-panel widths, selection bus, overview swap) is V3.1+.
Trigger to bring V3.1 forward
Section titled “Trigger to bring V3.1 forward”The moment the user authors a page and asks “can I make this panel twice as wide as that one?”, V3.1 (per-panel widths) is the answer.
Relationship to Other Senses
Section titled “Relationship to Other Senses”| Sense | Relationship |
|---|---|
| Sense 6 (Signal Playground — pre-Sense-14 name was “Probe Playground”) | Becomes a panel: signal_builder |
| Sense 7 (Price Lab) | Becomes a panel: price_chart |
| Sense 8 (DAG Viewer) | Becomes a panel: pipeline_dag |
| Sense 10 (Computed Aggregates) | Powers the entity_list panel’s aggregation |
| Sense 11 (The Veil) | Controls which panels are visible per identity |
| Sense 12 (The Mirror) | User preferences for panel layout |
Open Questions
Section titled “Open Questions”-
How much YAML is too much? The page definition should be concise — if it grows beyond 30 lines, the abstraction is leaking.
-
Panel state isolation: Should panels share state (selection bus) or be fully independent with explicit wiring? Bus is simpler but risks tight coupling.
-
Performance: Rendering 4 panels simultaneously, each querying the DB — need lazy loading (render only visible panels) and shared query caching.
-
Mobile: Grid layout doesn’t work on phone. Panels should stack vertically with tabs on mobile.
Numerical neighbors: Sense 14: The Signal →