Aller au contenu

Sense 13: The Canvas

Ce contenu n’est pas encore disponible dans votre langue.

Sense 13 · In bloom · Last touched 2026-08-20

  • last_verified: 2026-06-24

Synced from docs/design/sense_13_the_canvas.md in 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.


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.

A page is a YAML declaration, not a Svelte file.

pages/material_workbench.yaml
page_id: material_workbench
title:
en: Material Workbench
de: Material-Werkbank
fr: Atelier matériaux
entity: materials
icon: ""
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.

Each panel is a self-contained, reusable component:

PanelWhat it doesCurrent implementation
entity_listFiltered, sorted, paginated tableDataTable in dimensions page
map_viewMapCanvas with toolbarMap page
price_labScatter / histogram / timelinePrice Lab page
consumption_chartMonthly / departments / top itemsConsumption Lab page
filter_barSearch + field filters + taxonomy treeSearchWithHints + FilterBar
detail_panelEntity detail cardFinding detail, entity detail
provenanceData lineage panelProvenancePanel
probe_summarySeverity cards + treemapFindings overview

Panels are arranged in a responsive grid:

  • width: full — spans entire row
  • width: 1/2, 2/3, 1/3 — fractional widths
  • Panels can be collapsed, resized, reordered by the user
  • Layout saved to SIS

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.filter
entity_list.click → price_lab.timeline

This replaces the current pattern of custom goto() calls and URL-encoded state.

All panels in a page share a data context:

  • entity: which gold entity is the focus
  • filter: 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).

  • 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
  • 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)
  • 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
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 state

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:

  1. A query function in lib/server/queries/<topic>.ts
  2. A conditional load in [tenant]/+page.server.ts
  3. A new {:else if} branch in the Svelte template
  4. Four i18n keys (en/de/fr/it) for the label
  5. An entry in the pack/tenant config.yml overview.metrics list

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.

One file per tile in the AFS, mirroring the existing entity / signal / thesis convention:

tiles/active_people.yaml
tile_id: active_people
label:
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: number
click:
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.

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: integer

The 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.

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.

  • 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.

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).

  • 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.

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.

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.

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).

V1 ships exactly this and no more:

  • The tiles/*.yaml schema and a validator that catches typos.
  • A compiler that bakes _{tenant}.tile_registry into 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.yaml shadows 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.

One file per tile. tile_id matches filename without .yaml.

tiles/active_people.yaml
tile_id: active_people
version: "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_student
format: integer # currency | number | percent | integer
polarity: 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" below

Field semantics — every one mirrors the existing metric cell in the notebook (Phase 5 thread 2) deliberately, so the renderer is shared, not similar:

FieldRequiredMeaning
tile_idyesunique slug; matches filename
versionyessemver string, baked alongside
labelyesi18n object (en/de/fr/it minimum)
queryyesSQL expression producing a single scalar value
formatyesone of currency, number, percent, integer
polaritynocolours the value (positive=green, negative=red, neutral=ink); default neutral
click.tonopath the tile links to; absent → tile is read-only
click.searchnosearch-grammar fragment passed as ?q=...
signal_idnobinds 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 date

Including 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.

Mirrors notecheck.py / signalcheck.py shape. Errors:

  • Missing required field (tile_id, version, label, query, format).
  • tile_id doesn’t match filename.
  • label missing any required locale.
  • format not in the allowed set.
  • polarity not in {positive, negative, neutral} when set.
  • signal_id set but doesn’t resolve to a real signal in the AFS.
  • click.to set but malformed (must start with /).

Warnings:

  • query returns multiple columns or rows (validator runs an EXPLAIN against a sample KLS if available; soft warn otherwise).
  • signal_id absent on currency-formatted tiles — the provenance line is the whole point of binding a tile to a signal.

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:

ColumnDescription
tile_idslug
versionfrom YAML
label_en / label_de / label_fr / label_itlocalised labels
querySQL string
formatformat enum
polarityenum or empty
click_to / click_searchnullable
signal_idnullable
provenance_json_provenance sidecar as JSON string, or empty
sourcepack or tenant (cascade provenance)
compiled_attimestamp

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.

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.

/{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).

PathChangeWhy
scripts/tilecheck.py (new)ValidatorOne-file-per-tile schema gate
scripts/tilecompile.py (new)CompilerBakes tile_registry
scripts/_compiler_base.pyAdd load_signal_registry(afs) helperShared by note + tile compile
tests/test_tilecheck.py (new)Validator casesMirrors test_notecheck_surfaces
tests/test_tilecompile.py (new)Bake casesProvenance sidecar carry-through
explorer/src/lib/server/queries/tiles.ts (new)Read tile_registryMirrors notes.ts
explorer/src/lib/components/tiles/MetricTile.svelte (new)Extracted rendererOne Svelte file, two callers
explorer/src/routes/[tenant]/notebook/+page.svelteReplace inline metric branch with <MetricTile>Unification — the whole point
explorer/src/routes/[tenant]/tiles/[tile_id]/+page.server.ts (new)Tile-route loaderSingle-tile page
explorer/src/routes/[tenant]/tiles/[tile_id]/+page.svelte (new)Tile-route renderWraps <MetricTile> with optional <a> for click
explorer/src/lib/components/tiles/MetricTile.test.ts (new)VitestRender shape, click wiring, provenance line
  1. Author drops tiles/test_active_people.yaml in the pack’s tiles dir with a signal_id pointing at an existing signal.
  2. jin make runs without errors; warnings empty.
  3. 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 when click.to is set.
  4. 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+.

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.

V2 ships exactly this and no more:

  • The panels/*.yaml schema and a validator (panelcheck.py).
  • A compiler (panelcompile.py) that bakes _{tenant}.panel_registry into 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.

One file per panel. panel_id matches filename without .yaml.

panels/workforce_overview.yaml
panel_id: workforce_overview
version: "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 == grid
tiles:
- tile_id: active_people
- tile_id: active_employees
- tile_id: active_students
- tile_id: critical_roles_at_risk

Field semantics:

FieldRequiredMeaning
panel_idyesunique slug; matches filename
versionyessemver string, baked alongside
titlenoi18n object (en/de/fr/it) — rendered above the panel; omit for unlabelled panels
layout.typeyesone of row, grid, stack
layout.columnsyes when type=gridpositive integer column count; ignored otherwise
tilesyesnon-empty ordered list of {tile_id: <slug>} entries

Layout vocabulary (V2 — minimal):

TypeVisualUse case
rowAll tiles side-by-side, wrapping on overflow2-4 tiles you want to read across
gridFixed N columns, tiles wrap to subsequent rowsThe KPI-tile grid pattern (V3 will swap the hardcoded overview into this)
stackTiles top-to-bottom, full width eachMobile-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.

Mirrors tilecheck.py shape. Errors:

  • Missing required field (panel_id, version, layout, tiles).
  • panel_id doesn’t match filename.
  • title present but missing required locales.
  • layout.type not in {row, grid, stack}.
  • layout.columns missing or not a positive integer when layout.type == grid.
  • tiles is empty.
  • Any tile entry missing tile_id.
  • Any referenced tile_id doesn’t resolve to a tile in <afs_root>/tiles/ (loaded the same way tilecompile loads them).

Warnings:

  • grid layout with columns > 6 — visually unreadable; nudge.
  • More than 12 tiles in one panel — also a nudge; the user probably wants two panels.

Mirrors tilecompile.py. Reads all panels/*.yaml, validates, bakes _{tenant}.panel_registry:

ColumnDescription
panel_idslug
versionfrom YAML
title_en / title_de / title_fr / title_itlocalised titles (empty when absent)
layout_typerow / grid / stack
layout_columnsgrid column count (0 when N/A)
tile_idscomma-separated ordered list — same shape as tags / surfaces in note_registry for SQL-trivial LIKE filtering
compiled_attimestamp

Why comma-separated tile_ids instead of a JSON array column or a separate panel_tiles join table:

  • Mirrors the existing tags / surfaces patterns 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.

$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 typeCSS
rowflex flex-wrap gap-4 — each tile gets flex-1 min-w-[200px]
gridgrid gap-4, dynamic grid-template-columns: repeat({columns}, minmax(0, 1fr))
stackflex 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.

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.ts
export async function getKlsPanel(tenant: string, panelId: string)
: Promise<KlsPanel | null>;
export async function resolvePanelTiles(tenant: string, tileIds: string[])
: Promise<KlsTile[]>; // single IN-list query

resolvePanelTiles preserves the YAML order of tile_ids by sorting client-side after the round-trip — DuckDB can’t guarantee IN(...) ordering across versions.

/{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.

PathChange
scripts/panelcheck.py (new)Validator
scripts/panelcompile.py (new)Compiler — bakes panel_registry
scripts/_compiler_base.pyNo 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.pyAdd “Panel compile” to COMPILERS + tag:panel
  1. Author panels/workforce_overview.yaml referencing 2-4 existing tiles, in live/numetrix/rmc/afs/panels/.
  2. jin make numetrix.rmc runs without errors. The panel compile step reports Wrote panel_registry.sql (1 panel(s)).
  3. 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.
  4. 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.

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.

V3 ships exactly this and no more:

  • The pages/*.yaml schema 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.

One file per page. page_id matches filename without .yaml.

pages/material_workbench.yaml
page_id: material_workbench
version: "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 == grid
panels:
- panel_id: panel_material_pulse
- panel_id: panel_material_quality
- panel_id: panel_material_pricing

Field semantics — deliberately identical to the panel YAML for familiarity:

FieldRequiredMeaning
page_idyesunique slug; matches filename
versionyessemver string, baked alongside
titlenoi18n object — rendered above the page
layout.typeyesone of stack, row, grid
layout.columnsyes when type=gridpositive integer
panelsyesnon-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).

TypeVisualUse case
stackPanels top-to-bottom, full width eachDefault reading-friendly layout
rowPanels side-by-side, wrapping on overflowShort panels (each ≤ ¼ screen)
gridFixed N columns, panels wrapSymmetric multi-panel arrangements

Per-panel widths within a page are V3.1+ territory — same discipline as V2 deferring per-tile widths to V3.

Mirrors panelcheck.py shape. Errors:

  • Missing required field.
  • page_id doesn’t match filename.
  • title present but missing required locales.
  • layout.type not in {stack, row, grid}.
  • layout.columns missing/invalid when type == grid.
  • panels empty or not a list.
  • Any entry missing panel_id.
  • Any referenced panel_id doesn’t resolve to a panel in <afs_root>/panels/.
  • Duplicate panel_id in the panel list (rendering same panel twice is almost always a mistake).

Warnings:

  • grid layout with columns > 4 for 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.

Mirrors panelcompile.py. Reads all pages/*.yaml, validates, bakes _{tenant}.page_registry:

ColumnDescription
page_idslug
versionfrom YAML
title_en / title_de / title_fr / title_itlocalised titles
layout_typestack / row / grid
layout_columnsgrid column count (0 when N/A)
panel_idscomma-separated ordered list — same pattern as panel’s tile_ids
compiled_attimestamp

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 typeCSS
stackflex flex-col gap-8 (larger gap than panel — pages breathe more)
rowflex flex-wrap gap-6 — each panel flex-1 min-w-[400px]
gridinline 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.

Three round-trips total, no 1+N (one per tier):

  1. getKlsPage(tenant, pageId) → page row with panel_ids.
  2. resolvePagePanels(tenant, panelIds) → all referenced panel rows in one IN(...).
  3. Aggregate every panel’s tile_ids, dedupe, and one final resolvePanelTiles(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.

/{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.

PathChange
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.pyAdd “Page compile” to COMPILERS + tag:page
  1. Author pages/<page_id>.yaml referencing 2-4 existing panels in a tenant’s AFS.
  2. jin make <pack>.<tenant> runs without errors. The page compile step reports Wrote page_registry.sql (1 page(s)).
  3. 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.
  4. 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+.

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.


SenseRelationship
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
  1. How much YAML is too much? The page definition should be concise — if it grows beyond 30 lines, the abstraction is leaking.

  2. Panel state isolation: Should panels share state (selection bus) or be fully independent with explicit wiring? Bus is simpler but risks tight coupling.

  3. Performance: Rendering 4 panels simultaneously, each querying the DB — need lazy loading (render only visible panels) and shared query caching.

  4. Mobile: Grid layout doesn’t work on phone. Panels should stack vertically with tabs on mobile.


Numerical neighbors: Sense 14: The Signal

jazzisnow jinflow is a jazzisnow product
v0.64.7 · built 2026-09-20 19:48 UTC