diff --git a/skills/sidemantic-webapp-builder/SKILL.md b/skills/sidemantic-webapp-builder/SKILL.md
new file mode 100644
index 00000000..c314f304
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/SKILL.md
@@ -0,0 +1,241 @@
+---
+name: sidemantic-webapp-builder
+description: Build interactive analytics webapps, demos, dashboards, or embedded app surfaces from Sidemantic semantic models using copyable component primitives and deterministic query inspection. Use when asked to create a web UI around Sidemantic models, generate a metric explorer, copy reusable analytics components into a project, connect a frontend to Sidemantic query APIs, build a Pyodide/DuckDB-WASM demo, expose Sidemantic through an app server, or adapt the Sidemantic widget/UI patterns into a product webapp.
+---
+
+# Sidemantic Webapp Builder
+
+Build webapps around a validated Sidemantic semantic layer. Default to project-owned source components copied from this skill, then adapt them to the target app and wire them to inspected Sidemantic query contracts.
+
+## Component-First Pattern
+
+Treat `assets/components/` like a small shadcn-style source library for analytics primitives. Copy components into the target project, then edit those copied files as normal app code. Do not retype component source into the answer or keep it as a hidden runtime dependency.
+
+Copy React + Tailwind components for product apps:
+
+```bash
+uv run skills/sidemantic-webapp-builder/scripts/copy_components.py \
+ --kind react-tailwind \
+ --target src/components/sidemantic
+```
+
+Copy the static kit for plain HTML demos or generated scaffolds:
+
+```bash
+uv run skills/sidemantic-webapp-builder/scripts/copy_components.py \
+ --kind static \
+ --target public/sidemantic-components
+```
+
+Use `--component metric-card --component leaderboard` to copy a narrower React subset. Use `--list` before copying when you need the available names. Existing target files are never overwritten unless `--force` is passed.
+
+Available primitives:
+
+- `DashboardShell`: dense analytics page frame with status and toolbar slots.
+- `MetricCard`: metric label, value, delta, loading, selected state.
+- `Leaderboard`: ranked dimension rows with bars, selection, and stable data attributes.
+- `FilterPill`: active filter display and removal.
+- `Sparkline`: small SVG trend line.
+- `ColumnChart`: compact categorical bars for comparisons.
+- `QueryDebugPanel`: generated SQL/debug surface.
+- `DataPreviewTable`: stable sample row preview.
+- `LoadingState`, `EmptyState`, `ErrorState`: fixed-height status surfaces.
+
+State primitives are conditional UI branches. Do not render loading, empty, and error examples as permanent app content unless the user explicitly asks for a component gallery.
+
+## Core Workflow
+
+1. Load-check the semantic layer before building UI. Use `info` and the inspector in noninteractive agent work. Use `validate` only when the current CLI exits cleanly in your environment:
+
+```bash
+uv run sidemantic info path/to/models
+uv run skills/sidemantic-webapp-builder/scripts/inspect_layer.py path/to/models \
+ --db path/to/data.duckdb \
+ --require-execute
+```
+
+2. Generate an app inventory:
+
+```bash
+uv run skills/sidemantic-webapp-builder/scripts/inspect_layer.py path/to/models \
+ --db path/to/data.duckdb \
+ --require-execute \
+ --output docs/sidemantic-app-spec.json
+```
+
+Use `--leaderboard-dimension field_name` when domain judgment says one dimension should drive the first leaderboard. Without it, the inspector prefers common categorical dimensions over identifiers and booleans.
+
+3. Copy component source into the project before building UI:
+
+```bash
+uv run skills/sidemantic-webapp-builder/scripts/copy_components.py \
+ --kind react-tailwind \
+ --target src/components/sidemantic
+```
+
+Adapt imports, class names, and styling conventions after copying. Preserve the data contract conventions: `data-metric`, `data-dimension`, `data-value`, and `data-testid` hooks for metric totals, dimension leaderboards, and query debug surfaces.
+
+For a minimal static app scaffold from the executed spec:
+
+```bash
+uv run skills/sidemantic-webapp-builder/scripts/scaffold_static_app.py \
+ docs/sidemantic-app-spec.json \
+ --output dist/sidemantic-dashboard \
+ --title "Metrics Dashboard"
+```
+
+The scaffold copies readable source from `assets/templates/static-dashboard/` and the static component kit. If you need a richer generated app, edit those copied source files in the target project; do not bury application JavaScript in Python strings or generated HTML fragments.
+
+4. Choose the app shape:
+
+- Existing app: follow its framework, routing, styling, and data-fetch patterns.
+- New product webapp: use Bun, React Router v7 as framework, Tailwind v3, and Hono only when a TypeScript API/proxy is needed.
+- Python-backed analytics app: use `sidemantic.api_server.create_app()` or `start_api_server()` when a FastAPI API is acceptable.
+- Browser-only demo: use Pyodide + DuckDB-WASM only for static demos or docs pages that must run without a backend.
+- Notebook or Python embedded view: use `sidemantic.widget.MetricsExplorer` instead of rebuilding the widget.
+- MCP app surface: use `sidemantic mcp-serve --apps --http --port 4100` and existing chart resources when the target is an MCP Apps-compatible host.
+
+5. Implement a narrow query contract. Prefer structured query payloads over ad hoc SQL strings:
+
+```json
+{
+ "metrics": ["orders.revenue"],
+ "dimensions": ["orders.order_date__day"],
+ "filters": ["orders.status = 'completed'"],
+ "order_by": ["orders.order_date__day"],
+ "limit": 500
+}
+```
+
+6. Build the UI around the copied components and query contract:
+
+- Metric cards: aggregate value, compact sparkline, selected state.
+- Dimension leaderboards: top values for the selected metric, horizontal bars, click-to-filter.
+- Filter pills: active dimension filters plus brush/date-range filters, removable.
+- Time controls: date range, grain select, brushable sparklines when a time dimension exists.
+- Optional debug surfaces: generated SQL, raw rows preview, query timing. Use these in demos and internal tools, not as default product chrome.
+
+If a control is visible, it must change the app state or data. Do not satisfy interaction requirements by only changing a status label. Removing a filter must recompute metric cards, leaderboards, charts, and preview rows. Clicking a leaderboard row must add or toggle a filter. Selecting a metric must change the leaderboard ranking metric.
+
+7. Verify end to end:
+
+```bash
+uv run sidemantic info path/to/models
+uv run skills/sidemantic-webapp-builder/scripts/inspect_layer.py path/to/models --db path/to/data.duckdb --require-execute
+uv run sidemantic query "SELECT metric_name FROM model_name LIMIT 5" --models path/to/models --db path/to/data.duckdb
+uv run skills/sidemantic-webapp-builder/scripts/verify_static_app.py dist/sidemantic-dashboard
+bunx --bun -p playwright node skills/sidemantic-webapp-builder/scripts/verify_static_interactions.mjs --url http://127.0.0.1:5174/
+bun run build
+```
+
+For frontend changes, run the app on a 4xxx-5xxx port and verify with browser screenshots at desktop and mobile widths. If browser tooling is unavailable, run `verify_static_app.py` or another deterministic DOM/data check and state that real browser visual verification was not run.
+
+For interactive dashboards, verify behavior, not just render counts:
+
+- Remove a filter pill and confirm at least one metric value changes.
+- Click a leaderboard row and confirm metrics, chart bars, selected row state, and preview rows reflect that value.
+- Click the same active leaderboard row or remove its pill and confirm the broader result set returns.
+- Select a different metric card and confirm the leaderboard ranking metric changes.
+- Confirm visible sparklines and column charts stay clipped inside their cards at desktop and mobile widths.
+
+For static dashboards that use the bundled component contracts, use the smoke-test script after starting a local server:
+
+```bash
+bunx --bun -p playwright playwright install chromium # first run only, if Playwright reports a missing browser
+bunx --bun -p playwright node skills/sidemantic-webapp-builder/scripts/verify_static_interactions.mjs \
+ --url http://127.0.0.1:4519/
+```
+
+The script clicks filter pills, leaderboard rows, metric cards, and reset controls, and fails if visible data does not change.
+
+## Query Patterns
+
+Use the generated app spec first. For deeper implementation details, read `references/webapp-patterns.md`.
+
+Default query set:
+
+- Time series: selected metrics grouped by `model.time_dimension__grain`, ordered by time, capped around 500 points.
+- Totals: selected metrics with no dimensions.
+- Dimension leaderboard: selected metric grouped by one dimension, ordered descending, capped to 5-10 rows.
+- Preview table: ungrouped/raw rows only when the app needs a data inspector.
+
+Use `inspect_layer.py --require-execute` when a database is available. This adds `result.columns`, `result.sample_rows`, and `sample_row_count` to each compiled query and exits nonzero if execution is missing or fails. Use plain `--execute` only when a warning is acceptable.
+
+For crossfilter leaderboards, exclude the dimension's own filter while querying that same dimension. If `device_os = iOS` is active, the device OS card should still show peer OS values while other cards show values within iOS.
+
+Use explicit aliases at API boundaries so UI column names stay stable. The inspector emits `output_aliases` and, with `--execute`, actual result columns. Do not make display components depend on database-specific column casing or quoted identifiers.
+
+Run DuckDB validation serially against a file database. Do not run the inspector and `sidemantic query` concurrently against the same `.duckdb` path; DuckDB file locks can make valid workflows fail.
+
+## API Modes
+
+When the app can call Python directly, prefer the existing HTTP API:
+
+- `GET /health`
+- `GET /models`
+- `GET /graph`
+- `POST /compile`
+- `POST /query?format=json`
+- `POST /query?format=arrow`
+- `POST /sql`
+
+When building a TypeScript frontend with a separate backend, keep Sidemantic execution in Python unless the project already has a stable Python service. A Hono server can proxy to the Sidemantic API, add auth/session context, and normalize responses.
+
+Never concatenate user-entered filter values into SQL in the frontend. Pass structured filter values to a server-side query builder or quote them with the same rules as Sidemantic/widget code.
+
+The CLI `sidemantic query` auto-adds default time dimensions for metrics when a model has `default_time_dimension`. For exact app query shapes like true totals, prefer the inspector-generated SQL/result samples or the Python/API structured query path using `skip_default_time_dimensions=True` internally.
+
+## Bundled Scripts
+
+- `scripts/inspect_layer.py`: inspect models, compile app query shapes, execute samples with `--execute`, or require execution with `--require-execute`.
+- `scripts/copy_components.py`: copy React + Tailwind or static component source from `assets/components/` into a project.
+- `scripts/scaffold_static_app.py`: create a small static dashboard from an executed app spec by copying templates and components. It writes `index.html`, `styles.css`, `sidemantic-components.js`, `app.js`, and `data/app-spec.json`.
+- `scripts/verify_static_app.py`: dependency-free fallback verifier for static dashboards. It checks files, executed result samples, true totals, non-id leaderboard dimensions, and expected DOM/data bindings.
+- `scripts/verify_static_interactions.mjs`: Playwright smoke test for standard static component contracts. It verifies real data changes for filter, leaderboard, metric, reset, and chart-bounds behavior.
+
+## Bundled Assets
+
+- `assets/components/react-tailwind/`: copyable React source for analytics apps using Tailwind v3.
+- `assets/components/static/`: copyable plain JS/CSS kit for generated demos and no-build static pages.
+- `assets/templates/static-dashboard/`: readable static app templates used by `scaffold_static_app.py`.
+
+After copying assets into a project, treat them as that project's code. Modify them to match local component APIs, naming, tests, and design system constraints.
+
+## Browser-Only Demos
+
+Use browser-only Pyodide + DuckDB-WASM for static demos, docs, and shareable examples. Preserve these constraints:
+
+- Install Sidemantic into Pyodide with dependency constraints that match the repo's Pyodide rules.
+- Keep large data files out of git. Download or cache Parquet at build/runtime.
+- Generate SQL in Pyodide; execute data queries in DuckDB-WASM.
+- Show loading progress and skeletons because Pyodide and DuckDB-WASM initialization is visible to users.
+- Test in a real browser. Static HTML that imports WASM/CDN modules often cannot be trusted from file-only inspection.
+
+## Design Rules
+
+Analytics webapps should feel work-focused:
+
+- Dense, scannable layouts beat marketing sections.
+- Do not add hero pages unless the requested artifact is a public landing page.
+- Avoid nested cards. Use full-width tool surfaces, tables, panels, and repeated item cards only where they represent actual data units.
+- Use stable dimensions for cards, grids, sparklines, toolbar controls, and result panes to avoid layout shift during loading.
+- Keep text small and container-appropriate inside dashboards.
+- Use existing app colors/components first. For net-new Sidemantic demos, use a restrained neutral UI with a single accent and clear positive/negative colors.
+
+## Common Failures
+
+- Building UI before the model validates. Validate first.
+- Running interactive validation in automation. If `sidemantic validate` requires `textual` or opens a TUI, use `sidemantic info` plus `inspect_layer.py` as the noninteractive check.
+- Trusting compiled SQL alone. Use `inspect_layer.py --require-execute` when possible so result columns and sample rows are checked and failures are nonzero.
+- Running parallel DuckDB checks against the same database file. Run them serially or use separate database copies.
+- Treating Python API examples as the default user path. Sidemantic is CLI-first; use API calls as app internals.
+- Missing a time dimension. Fall back to totals and dimension leaderboards, and omit brush/grain controls.
+- Letting high-cardinality dimensions dominate the UI. Cap rows, rank by selected metric, and let users search only if needed.
+- Filtering a dimension leaderboard by its own active value. Use self-filter exclusion.
+- Fake interactivity. A control that only updates a status label is not done; it must change filters, selected metric, query payload, or rendered rows.
+- Calling a component gallery an app. A kitchen-sink example can show primitives, but if it has dashboard controls, they must drive real local or server-backed state.
+- Rendering loading, empty, and error states as persistent content in a dashboard. Show state components only for their actual branch, or label the surface as a component gallery.
+- Letting SVG charts paint outside cards. Use bounded `viewBox`, padding, and `overflow: hidden`; verify with screenshots.
+- Pulling optional dependencies into core imports. Keep web/API/widget dependencies lazy and optional.
+- Using ports `3000` or `8000` in worktrees. Prefer `4100`, `4400`, `5174`, or another available 4xxx-5xxx port.
+- Opening static HTML with `file://` when it fetches JSON/CSV. Serve it locally instead, because browser file-scheme fetch behavior differs from a real app.
diff --git a/skills/sidemantic-webapp-builder/agents/openai.yaml b/skills/sidemantic-webapp-builder/agents/openai.yaml
new file mode 100644
index 00000000..9a887c70
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/agents/openai.yaml
@@ -0,0 +1,4 @@
+interface:
+ display_name: "Sidemantic Webapp Builder"
+ short_description: "Build Sidemantic webapps with copyable components"
+ default_prompt: "Use $sidemantic-webapp-builder to copy analytics components into my project and build an interactive webapp from my Sidemantic models."
diff --git a/skills/sidemantic-webapp-builder/assets/components/react-tailwind/column-chart.tsx b/skills/sidemantic-webapp-builder/assets/components/react-tailwind/column-chart.tsx
new file mode 100644
index 00000000..af669621
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/assets/components/react-tailwind/column-chart.tsx
@@ -0,0 +1,47 @@
+type ColumnChartDatum = {
+ label: string;
+ value: number;
+};
+
+type ColumnChartProps = {
+ data: ColumnChartDatum[];
+ width?: number;
+ height?: number;
+};
+
+export function ColumnChart({ data, width = 320, height = 160 }: ColumnChartProps) {
+ const max = Math.max(...data.map((item) => item.value), 1);
+ const padX = 16;
+ const padTop = 10;
+ const padBottom = 28;
+ const slot = (width - padX * 2) / Math.max(data.length, 1);
+ const barWidth = Math.max(10, Math.min(42, slot * 0.56));
+
+ return (
+
+ {data.map((item, index) => {
+ const barHeight = ((height - padTop - padBottom) * item.value) / max;
+ const x = padX + slot * index + (slot - barWidth) / 2;
+ const y = height - padBottom - barHeight;
+
+ return (
+
+
+
+ {item.label.slice(0, 8)}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/skills/sidemantic-webapp-builder/assets/components/react-tailwind/dashboard-shell.tsx b/skills/sidemantic-webapp-builder/assets/components/react-tailwind/dashboard-shell.tsx
new file mode 100644
index 00000000..152f1cee
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/assets/components/react-tailwind/dashboard-shell.tsx
@@ -0,0 +1,25 @@
+import type { ReactNode } from "react";
+
+type DashboardShellProps = {
+ title: string;
+ eyebrow?: string;
+ status?: ReactNode;
+ toolbar?: ReactNode;
+ children: ReactNode;
+};
+
+export function DashboardShell({ title, eyebrow = "Sidemantic", status, toolbar, children }: DashboardShellProps) {
+ return (
+
+
+
+ {status ? {status}
: null}
+
+ {toolbar ? : null}
+
+
+ );
+}
diff --git a/skills/sidemantic-webapp-builder/assets/components/react-tailwind/data-preview-table.tsx b/skills/sidemantic-webapp-builder/assets/components/react-tailwind/data-preview-table.tsx
new file mode 100644
index 00000000..ffc06ecb
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/assets/components/react-tailwind/data-preview-table.tsx
@@ -0,0 +1,37 @@
+import { formatValue, labelize, type SidemanticQueryResult } from "./types";
+
+type DataPreviewTableProps = {
+ result?: SidemanticQueryResult;
+};
+
+export function DataPreviewTable({ result }: DataPreviewTableProps) {
+ const columns = result?.columns || [];
+ const rows = result?.sample_rows || [];
+
+ return (
+
+
+
+
+ {columns.map((column) => (
+
+ {labelize(column)}
+
+ ))}
+
+
+
+ {rows.map((row, rowIndex) => (
+
+ {columns.map((column) => (
+
+ {formatValue(row[column])}
+
+ ))}
+
+ ))}
+
+
+
+ );
+}
diff --git a/skills/sidemantic-webapp-builder/assets/components/react-tailwind/filter-pill.tsx b/skills/sidemantic-webapp-builder/assets/components/react-tailwind/filter-pill.tsx
new file mode 100644
index 00000000..0738ce5d
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/assets/components/react-tailwind/filter-pill.tsx
@@ -0,0 +1,31 @@
+import { labelize } from "./types";
+
+type FilterPillProps = {
+ dimension: string;
+ value: string;
+ onRemove?: (filter: { dimension: string; value: string }) => void;
+};
+
+export function FilterPill({ dimension, value, onRemove }: FilterPillProps) {
+ return (
+
+
+ {labelize(dimension)}: {value}
+
+ {onRemove ? (
+ onRemove({ dimension, value })}
+ className="grid size-4 place-items-center rounded-full bg-slate-100 text-slate-500 hover:bg-slate-200"
+ >
+ ×
+
+ ) : null}
+
+ );
+}
diff --git a/skills/sidemantic-webapp-builder/assets/components/react-tailwind/index.ts b/skills/sidemantic-webapp-builder/assets/components/react-tailwind/index.ts
new file mode 100644
index 00000000..8525f21e
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/assets/components/react-tailwind/index.ts
@@ -0,0 +1,10 @@
+export * from "./column-chart";
+export * from "./dashboard-shell";
+export * from "./data-preview-table";
+export * from "./filter-pill";
+export * from "./leaderboard";
+export * from "./metric-card";
+export * from "./query-debug-panel";
+export * from "./sparkline";
+export * from "./states";
+export * from "./types";
diff --git a/skills/sidemantic-webapp-builder/assets/components/react-tailwind/leaderboard.tsx b/skills/sidemantic-webapp-builder/assets/components/react-tailwind/leaderboard.tsx
new file mode 100644
index 00000000..cd21b16f
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/assets/components/react-tailwind/leaderboard.tsx
@@ -0,0 +1,72 @@
+import { aliasFor, formatValue, labelize, type SidemanticQuerySpec } from "./types";
+
+type LeaderboardProps = {
+ query: SidemanticQuerySpec;
+ selectedValue?: string;
+ onSelect?: (selection: { dimension: string; value: string; row: Record }) => void;
+};
+
+export function Leaderboard({ query, selectedValue, onSelect }: LeaderboardProps) {
+ const dimensionRef = query.dimensions?.[0] || "";
+ const metricRef = query.metrics?.[0] || "";
+ const dimensionKey = aliasFor(query, dimensionRef);
+ const metricKey = aliasFor(query, metricRef);
+ const rows = query.result?.sample_rows || [];
+ const max = Math.max(...rows.map((row) => Number(row[metricKey]) || 0), 1);
+
+ return (
+
+
+
{labelize(dimensionKey)}
+
Ranked by {labelize(metricKey)}
+
+
+ {rows.map((row) => {
+ const rawValue = row[dimensionKey];
+ const value = String(rawValue ?? "");
+ const metricValue = Number(row[metricKey]) || 0;
+ const selected = selectedValue !== undefined && selectedValue === value;
+ const content = (
+ <>
+
+
{value || "—"}
+
{formatValue(metricValue)}
+ >
+ );
+
+ if (onSelect) {
+ return (
+
onSelect({ dimension: dimensionRef, value, row })}
+ className="relative grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 overflow-hidden border-t border-slate-100 px-2 py-2 text-left text-sm first:border-t-0 data-[selected=true]:bg-indigo-50"
+ >
+ {content}
+
+ );
+ }
+
+ return (
+
+ {content}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/skills/sidemantic-webapp-builder/assets/components/react-tailwind/metric-card.tsx b/skills/sidemantic-webapp-builder/assets/components/react-tailwind/metric-card.tsx
new file mode 100644
index 00000000..2343f967
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/assets/components/react-tailwind/metric-card.tsx
@@ -0,0 +1,54 @@
+import { formatValue, type MetricTone } from "./types";
+
+type MetricCardProps = {
+ metric: string;
+ label: string;
+ value: unknown;
+ delta?: { label: string; tone?: MetricTone };
+ selected?: boolean;
+ loading?: boolean;
+ onSelect?: (metric: string) => void;
+};
+
+export function MetricCard({ metric, label, value, delta, selected, loading, onSelect }: MetricCardProps) {
+ const content = (
+ <>
+ {label}
+
+ {loading ? : formatValue(value)}
+
+ {delta ? (
+
+ {delta.label}
+
+ ) : null}
+ >
+ );
+
+ if (onSelect) {
+ return (
+ onSelect(metric)}
+ className="min-h-[98px] rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm transition hover:border-slate-300 data-[selected=true]:border-indigo-500 data-[selected=true]:ring-1 data-[selected=true]:ring-indigo-500"
+ >
+ {content}
+
+ );
+ }
+
+ return (
+
+ {content}
+
+ );
+}
diff --git a/skills/sidemantic-webapp-builder/assets/components/react-tailwind/query-debug-panel.tsx b/skills/sidemantic-webapp-builder/assets/components/react-tailwind/query-debug-panel.tsx
new file mode 100644
index 00000000..df5435b6
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/assets/components/react-tailwind/query-debug-panel.tsx
@@ -0,0 +1,20 @@
+import type { SidemanticQuerySpec } from "./types";
+
+type QueryDebugPanelProps = {
+ queries: Record;
+};
+
+export function QueryDebugPanel({ queries }: QueryDebugPanelProps) {
+ const text = Object.entries(queries)
+ .map(([name, query]) => `-- ${name}\n${query?.sql || ""}`)
+ .join("\n\n");
+
+ return (
+
+ Generated SQL
+
+ {text}
+
+
+ );
+}
diff --git a/skills/sidemantic-webapp-builder/assets/components/react-tailwind/sparkline.tsx b/skills/sidemantic-webapp-builder/assets/components/react-tailwind/sparkline.tsx
new file mode 100644
index 00000000..717e16f7
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/assets/components/react-tailwind/sparkline.tsx
@@ -0,0 +1,27 @@
+type SparklineProps = {
+ values: number[];
+ width?: number;
+ height?: number;
+};
+
+export function Sparkline({ values, width = 160, height = 56 }: SparklineProps) {
+ const numbers = values.filter(Number.isFinite);
+ if (numbers.length < 2) {
+ return ;
+ }
+
+ const min = Math.min(...numbers);
+ const max = Math.max(...numbers);
+ const span = max - min || 1;
+ const points = numbers.map((value, index) => {
+ const x = (index / (numbers.length - 1)) * width;
+ const y = height - ((value - min) / span) * height;
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ });
+
+ return (
+
+
+
+ );
+}
diff --git a/skills/sidemantic-webapp-builder/assets/components/react-tailwind/states.tsx b/skills/sidemantic-webapp-builder/assets/components/react-tailwind/states.tsx
new file mode 100644
index 00000000..4556529d
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/assets/components/react-tailwind/states.tsx
@@ -0,0 +1,26 @@
+type StateBoxProps = {
+ title?: string;
+ message: string;
+};
+
+export function LoadingState({ message = "Loading metrics..." }: Partial) {
+ return {message}
;
+}
+
+export function EmptyState({ title = "No results", message }: StateBoxProps) {
+ return (
+
+ );
+}
+
+export function ErrorState({ title = "Query failed", message }: StateBoxProps) {
+ return (
+
+ );
+}
diff --git a/skills/sidemantic-webapp-builder/assets/components/react-tailwind/types.ts b/skills/sidemantic-webapp-builder/assets/components/react-tailwind/types.ts
new file mode 100644
index 00000000..2ab4bfda
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/assets/components/react-tailwind/types.ts
@@ -0,0 +1,43 @@
+export type SidemanticResultRow = Record;
+
+export type SidemanticQueryResult = {
+ columns: string[];
+ sample_rows: SidemanticResultRow[];
+ sample_row_count?: number;
+};
+
+export type SidemanticQuerySpec = {
+ metrics?: string[];
+ dimensions?: string[];
+ filters?: string[];
+ order_by?: string[];
+ limit?: number;
+ sql?: string;
+ output_aliases?: Record;
+ result?: SidemanticQueryResult;
+};
+
+export type ExplorerFilterState = Record;
+
+export type MetricTone = "positive" | "negative" | "neutral";
+
+export function labelize(value: string | undefined | null) {
+ return String(value || "")
+ .replaceAll("_", " ")
+ .replaceAll(".", " ")
+ .replace(/\b\w/g, (char) => char.toUpperCase());
+}
+
+export function formatValue(value: unknown, maximumFractionDigits = 2) {
+ if (value === null || value === undefined || value === "") return "—";
+ const numeric = Number(value);
+ if (Number.isFinite(numeric)) {
+ return numeric.toLocaleString(undefined, { maximumFractionDigits });
+ }
+ return String(value);
+}
+
+export function aliasFor(query: SidemanticQuerySpec, ref: string | undefined) {
+ if (!ref) return "";
+ return query.output_aliases?.[ref] || ref.split(".").at(-1) || ref;
+}
diff --git a/skills/sidemantic-webapp-builder/assets/components/static/sidemantic-components.css b/skills/sidemantic-webapp-builder/assets/components/static/sidemantic-components.css
new file mode 100644
index 00000000..5d8a0df3
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/assets/components/static/sidemantic-components.css
@@ -0,0 +1,289 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ color-scheme: light;
+ --sdm-background: #f7f8fb;
+ --sdm-surface: #ffffff;
+ --sdm-border: #e5e7eb;
+ --sdm-border-soft: #eef2f7;
+ --sdm-text: #111827;
+ --sdm-muted: #6b7280;
+ --sdm-accent: #4f46e5;
+ --sdm-positive: #15803d;
+ --sdm-negative: #b91c1c;
+}
+
+body {
+ margin: 0;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ background: var(--sdm-background);
+ color: var(--sdm-text);
+}
+
+.sdm-shell {
+ max-width: 1120px;
+ margin: 0 auto;
+ padding: 24px;
+}
+
+.sdm-shell__header {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 16px;
+ padding-bottom: 18px;
+ border-bottom: 1px solid var(--sdm-border);
+}
+
+.sdm-eyebrow,
+.sdm-status,
+.sdm-section-heading p,
+.sdm-filter-pill,
+.sdm-empty-state,
+.sdm-error-state {
+ margin: 0;
+ color: var(--sdm-muted);
+ font-size: 12px;
+}
+
+.sdm-shell h1,
+.sdm-shell h2,
+.sdm-shell h3 {
+ margin: 0;
+}
+
+.sdm-shell h1 {
+ font-size: 24px;
+ line-height: 1.15;
+}
+
+.sdm-metric-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 12px;
+ margin: 18px 0;
+}
+
+.sdm-metric-card,
+.sdm-leaderboard,
+.sdm-debug-panel,
+.sdm-data-table-wrap,
+.sdm-state-box {
+ min-width: 0;
+ background: var(--sdm-surface);
+ border: 1px solid var(--sdm-border);
+ border-radius: 8px;
+}
+
+.sdm-metric-card {
+ display: block;
+ width: 100%;
+ min-height: 98px;
+ padding: 14px;
+ text-align: left;
+}
+
+button.sdm-metric-card {
+ color: inherit;
+ cursor: pointer;
+ font: inherit;
+}
+
+.sdm-metric-card[data-selected="true"] {
+ border-color: var(--sdm-accent);
+ box-shadow: 0 0 0 1px var(--sdm-accent);
+}
+
+.sdm-metric-card h3 {
+ color: #4b5563;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.sdm-metric-card__value {
+ margin-top: 8px;
+ font-size: 28px;
+ font-weight: 700;
+ letter-spacing: 0;
+}
+
+.sdm-metric-card__delta {
+ margin-top: 4px;
+ color: var(--sdm-muted);
+ font-size: 12px;
+}
+
+.sdm-metric-card__delta[data-tone="positive"] {
+ color: var(--sdm-positive);
+}
+
+.sdm-metric-card__delta[data-tone="negative"] {
+ color: var(--sdm-negative);
+}
+
+.sdm-leaderboard {
+ padding: 16px;
+}
+
+.sdm-section-heading {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.sdm-leaderboard-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 12px;
+ position: relative;
+ padding: 8px 10px;
+ border-top: 1px solid var(--sdm-border-soft);
+ overflow: hidden;
+}
+
+.sdm-leaderboard-row:first-child {
+ border-top: 0;
+}
+
+button.sdm-leaderboard-row {
+ width: 100%;
+ border-right: 0;
+ border-bottom: 0;
+ border-left: 0;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+ font: inherit;
+ text-align: left;
+}
+
+.sdm-leaderboard-row::before {
+ content: "";
+ position: absolute;
+ inset: 4px auto 4px 0;
+ width: var(--bar-width, 0%);
+ background: rgba(79, 70, 229, 0.1);
+}
+
+.sdm-leaderboard-row span,
+.sdm-leaderboard-row strong {
+ position: relative;
+ z-index: 1;
+ min-width: 0;
+}
+
+.sdm-leaderboard-row[data-selected="true"] {
+ outline: 1px solid var(--sdm-accent);
+ outline-offset: -1px;
+}
+
+.sdm-filter-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 12px;
+}
+
+.sdm-filter-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ max-width: 100%;
+ padding: 5px 8px;
+ border: 1px solid var(--sdm-border);
+ border-radius: 999px;
+ background: var(--sdm-surface);
+ overflow-wrap: anywhere;
+}
+
+.sdm-filter-pill button {
+ width: 18px;
+ height: 18px;
+ padding: 0;
+ border: 0;
+ border-radius: 999px;
+ background: #f3f4f6;
+ color: var(--sdm-muted);
+ cursor: pointer;
+}
+
+.sdm-sparkline {
+ display: block;
+ width: 100%;
+ height: 56px;
+ max-width: 100%;
+ overflow: hidden;
+}
+
+.sdm-sparkline path {
+ fill: none;
+ stroke: var(--sdm-accent);
+ stroke-width: 2;
+ vector-effect: non-scaling-stroke;
+}
+
+.sdm-column-chart {
+ display: block;
+ width: 100%;
+ height: 160px;
+ max-width: 100%;
+ overflow: hidden;
+}
+
+.sdm-column-chart rect {
+ fill: var(--sdm-accent);
+}
+
+.sdm-column-chart text {
+ fill: var(--sdm-muted);
+ font-size: 10px;
+}
+
+.sdm-debug-panel {
+ margin-top: 18px;
+ padding: 12px;
+}
+
+.sdm-debug-panel pre {
+ overflow: auto;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+ font-size: 12px;
+}
+
+.sdm-data-table-wrap {
+ margin-top: 18px;
+ overflow: auto;
+}
+
+.sdm-data-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+}
+
+.sdm-data-table th,
+.sdm-data-table td {
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--sdm-border-soft);
+ text-align: left;
+ white-space: nowrap;
+}
+
+.sdm-data-table th {
+ color: #4b5563;
+ font-weight: 600;
+}
+
+.sdm-state-box {
+ padding: 14px;
+ overflow-wrap: anywhere;
+}
+
+.sdm-error-state {
+ color: var(--sdm-negative);
+}
diff --git a/skills/sidemantic-webapp-builder/assets/components/static/sidemantic-components.js b/skills/sidemantic-webapp-builder/assets/components/static/sidemantic-components.js
new file mode 100644
index 00000000..c9573e5f
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/assets/components/static/sidemantic-components.js
@@ -0,0 +1,232 @@
+export function labelize(value) {
+ return String(value || "")
+ .replaceAll("_", " ")
+ .replaceAll(".", " ")
+ .replace(/\b\w/g, (char) => char.toUpperCase());
+}
+
+export function formatValue(value, options = {}) {
+ if (value === null || value === undefined || value === "") return "—";
+ const numeric = Number(value);
+ if (Number.isFinite(numeric)) {
+ return numeric.toLocaleString(undefined, {
+ maximumFractionDigits: options.maximumFractionDigits ?? 2,
+ });
+ }
+ return String(value);
+}
+
+export function requireResult(queryName, query) {
+ if (!query?.result?.columns || !query?.result?.sample_rows) {
+ throw new Error(`${queryName} has no executed result. Re-run inspect_layer.py with --require-execute.`);
+ }
+ return query.result;
+}
+
+export function renderMetricCards(container, query, options = {}) {
+ const result = requireResult("metric_totals", query);
+ const row = result.sample_rows[0] || {};
+ container.replaceChildren();
+
+ for (const metric of query.metrics || []) {
+ const key = query.output_aliases?.[metric] || metric.split(".").at(-1);
+ const card = document.createElement(options.onSelect ? "button" : "article");
+ card.className = "sdm-metric-card";
+ card.dataset.metric = metric;
+ if (options.onSelect) {
+ card.type = "button";
+ card.addEventListener("click", () => options.onSelect({ metric, key, value: row[key] }));
+ }
+ if (options.selectedMetric === metric) {
+ card.dataset.selected = "true";
+ }
+
+ const title = document.createElement("h3");
+ title.textContent = options.labels?.[metric] || labelize(key);
+
+ const value = document.createElement("div");
+ value.className = "sdm-metric-card__value";
+ value.textContent = formatValue(row[key], options.valueFormat);
+
+ card.append(title, value);
+
+ const delta = options.deltas?.[metric];
+ if (delta) {
+ const deltaEl = document.createElement("p");
+ deltaEl.className = "sdm-metric-card__delta";
+ deltaEl.dataset.tone = delta.tone || "neutral";
+ deltaEl.textContent = delta.label;
+ card.appendChild(deltaEl);
+ }
+
+ container.appendChild(card);
+ }
+}
+
+export function renderLeaderboard(container, query, options = {}) {
+ const result = requireResult("dimension_leaderboard", query);
+ const dimensionRef = query.dimensions?.[0];
+ const metricRef = query.metrics?.[0];
+ const dimensionKey = query.output_aliases?.[dimensionRef] || dimensionRef?.split(".").at(-1);
+ const metricKey = query.output_aliases?.[metricRef] || metricRef?.split(".").at(-1);
+ const rows = result.sample_rows || [];
+ const max = Math.max(...rows.map((row) => Number(row[metricKey]) || 0), 1);
+
+ if (options.titleEl) options.titleEl.textContent = options.dimensionLabel || labelize(dimensionKey);
+ if (options.subtitleEl) {
+ options.subtitleEl.textContent = options.metricLabel || `Ranked by ${labelize(metricKey)}`;
+ }
+
+ container.replaceChildren();
+ for (const row of rows) {
+ const value = Number(row[metricKey]) || 0;
+ const item = document.createElement(options.interactive ? "button" : "div");
+ item.className = "sdm-leaderboard-row";
+ item.dataset.dimension = dimensionRef;
+ item.dataset.value = row[dimensionKey] ?? "";
+ item.style.setProperty("--bar-width", `${Math.round((value / max) * 100)}%`);
+ if (options.selectedValue !== undefined && String(options.selectedValue) === String(row[dimensionKey])) {
+ item.dataset.selected = "true";
+ }
+
+ const label = document.createElement("span");
+ label.textContent = row[dimensionKey] ?? "—";
+ const strong = document.createElement("strong");
+ strong.textContent = formatValue(value, options.valueFormat);
+ item.append(label, strong);
+
+ if (options.onSelect) {
+ item.addEventListener("click", () => options.onSelect({ dimension: dimensionRef, value: row[dimensionKey], row }));
+ }
+
+ container.appendChild(item);
+ }
+}
+
+export function renderFilterPills(container, filters, onRemove) {
+ container.replaceChildren();
+ for (const [dimension, values] of Object.entries(filters || {})) {
+ for (const value of values || []) {
+ const pill = document.createElement("span");
+ pill.className = "sdm-filter-pill";
+ pill.dataset.dimension = dimension;
+ pill.dataset.value = value;
+ pill.textContent = `${labelize(dimension)}: ${value}`;
+ if (onRemove) {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.ariaLabel = `Remove ${value}`;
+ button.textContent = "×";
+ button.addEventListener("click", () => onRemove({ dimension, value }));
+ pill.appendChild(button);
+ }
+ container.appendChild(pill);
+ }
+ }
+}
+
+export function renderSparkline(svg, values, options = {}) {
+ const numbers = (values || []).map(Number).filter(Number.isFinite);
+ svg.replaceChildren();
+ if (numbers.length < 2) return;
+
+ const width = Number(svg.getAttribute("width") || 160);
+ const height = Number(svg.getAttribute("height") || 56);
+ const pad = options.padding ?? 4;
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
+ svg.setAttribute("preserveAspectRatio", "none");
+
+ const min = Math.min(...numbers);
+ const max = Math.max(...numbers);
+ const span = max - min || 1;
+ const points = numbers.map((value, index) => {
+ const x = pad + (index / (numbers.length - 1)) * (width - pad * 2);
+ const y = pad + (1 - (value - min) / span) * (height - pad * 2);
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ });
+
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute("d", `M ${points.join(" L ")}`);
+ svg.appendChild(path);
+}
+
+export function renderColumnChart(svg, rows, options = {}) {
+ const data = rows || [];
+ const width = Number(svg.getAttribute("width") || 320);
+ const height = Number(svg.getAttribute("height") || 160);
+ const padX = options.paddingX ?? 16;
+ const padTop = options.paddingTop ?? 10;
+ const padBottom = options.paddingBottom ?? 28;
+ const labelKey = options.labelKey || "label";
+ const valueKey = options.valueKey || "value";
+ const max = Math.max(...data.map((row) => Number(row[valueKey]) || 0), 1);
+ const slot = (width - padX * 2) / Math.max(data.length, 1);
+ const barWidth = Math.max(10, Math.min(42, slot * 0.56));
+
+ svg.replaceChildren();
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
+ svg.setAttribute("preserveAspectRatio", "none");
+
+ data.forEach((row, index) => {
+ const value = Number(row[valueKey]) || 0;
+ const barHeight = ((height - padTop - padBottom) * value) / max;
+ const x = padX + slot * index + (slot - barWidth) / 2;
+ const y = height - padBottom - barHeight;
+
+ const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
+ rect.setAttribute("x", x.toFixed(1));
+ rect.setAttribute("y", y.toFixed(1));
+ rect.setAttribute("width", barWidth.toFixed(1));
+ rect.setAttribute("height", barHeight.toFixed(1));
+ rect.setAttribute("rx", "3");
+ rect.dataset.label = row[labelKey] ?? "";
+ rect.dataset.value = String(value);
+ svg.appendChild(rect);
+
+ const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
+ label.setAttribute("x", (x + barWidth / 2).toFixed(1));
+ label.setAttribute("y", String(height - 8));
+ label.setAttribute("text-anchor", "middle");
+ label.textContent = String(row[labelKey] ?? "").slice(0, 8);
+ svg.appendChild(label);
+ });
+}
+
+export function renderQueryDebug(container, queries) {
+ container.textContent = Object.entries(queries || {})
+ .map(([name, query]) => `-- ${name}\n${query?.sql || ""}`)
+ .join("\n\n");
+}
+
+export function renderDataPreview(table, result) {
+ const columns = result?.columns || [];
+ const rows = result?.sample_rows || [];
+ table.replaceChildren();
+
+ const thead = document.createElement("thead");
+ const headerRow = document.createElement("tr");
+ for (const column of columns) {
+ const th = document.createElement("th");
+ th.textContent = labelize(column);
+ headerRow.appendChild(th);
+ }
+ thead.appendChild(headerRow);
+
+ const tbody = document.createElement("tbody");
+ for (const row of rows) {
+ const tr = document.createElement("tr");
+ for (const column of columns) {
+ const td = document.createElement("td");
+ td.textContent = formatValue(row[column]);
+ tr.appendChild(td);
+ }
+ tbody.appendChild(tr);
+ }
+
+ table.append(thead, tbody);
+}
+
+export function renderState(container, state) {
+ container.className = state.kind === "error" ? "sdm-state-box sdm-error-state" : "sdm-state-box sdm-empty-state";
+ container.textContent = state.message;
+}
diff --git a/skills/sidemantic-webapp-builder/assets/templates/static-dashboard/app.js b/skills/sidemantic-webapp-builder/assets/templates/static-dashboard/app.js
new file mode 100644
index 00000000..21634755
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/assets/templates/static-dashboard/app.js
@@ -0,0 +1,33 @@
+import { renderLeaderboard, renderMetricCards, renderQueryDebug } from "./sidemantic-components.js";
+
+const statusEl = document.querySelector('[data-testid="app-status"]');
+const totalsEl = document.querySelector('[data-testid="metric-totals"]');
+const leaderboardEl = document.querySelector('[data-testid="leaderboard-rows"]');
+const leaderboardTitleEl = document.querySelector('[data-testid="leaderboard-title"]');
+const leaderboardSubtitleEl = document.querySelector('[data-testid="leaderboard-subtitle"]');
+const debugEl = document.querySelector('[data-testid="query-debug"]');
+
+async function main() {
+ const response = await fetch("data/app-spec.json");
+ if (!response.ok) throw new Error(`Failed to load app spec: ${response.status}`);
+ const spec = await response.json();
+ const candidate = spec.app_candidates?.[0];
+ if (!candidate) throw new Error("App spec has no app candidates");
+ const queries = candidate.queries || {};
+
+ renderMetricCards(totalsEl, queries.metric_totals);
+ renderLeaderboard(leaderboardEl, queries.dimension_leaderboard, {
+ titleEl: leaderboardTitleEl,
+ subtitleEl: leaderboardSubtitleEl,
+ });
+ renderQueryDebug(debugEl, {
+ metric_totals: queries.metric_totals,
+ dimension_leaderboard: queries.dimension_leaderboard,
+ });
+ statusEl.textContent = `${candidate.model} ready`;
+}
+
+main().catch((error) => {
+ statusEl.textContent = error.message;
+ statusEl.dataset.error = "true";
+});
diff --git a/skills/sidemantic-webapp-builder/assets/templates/static-dashboard/index.html b/skills/sidemantic-webapp-builder/assets/templates/static-dashboard/index.html
new file mode 100644
index 00000000..a8a76eae
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/assets/templates/static-dashboard/index.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+ {{TITLE}}
+
+
+
+
+
+
+
+
+ Generated SQL
+
+
+
+
+
+
diff --git a/skills/sidemantic-webapp-builder/references/webapp-patterns.md b/skills/sidemantic-webapp-builder/references/webapp-patterns.md
new file mode 100644
index 00000000..400df80b
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/references/webapp-patterns.md
@@ -0,0 +1,242 @@
+# Sidemantic Webapp Patterns
+
+Use this reference when implementing the concrete UI/data layer after the skill workflow has selected an app shape.
+
+## Copyable Components
+
+Default to copied component source instead of regenerating dashboard primitives. Copy from the skill, then edit the copied files inside the target project:
+
+```bash
+uv run skills/sidemantic-webapp-builder/scripts/copy_components.py \
+ --kind react-tailwind \
+ --target src/components/sidemantic
+```
+
+For static or no-build demos:
+
+```bash
+uv run skills/sidemantic-webapp-builder/scripts/copy_components.py \
+ --kind static \
+ --target dist/sidemantic-dashboard
+```
+
+The React kit is intentionally style-light and contract-heavy. Keep these contracts unless the target project already has stronger equivalents:
+
+- `data-metric` on metric cards.
+- `data-dimension` and `data-value` on leaderboard rows and filter pills.
+- `data-testid="metric-totals"` around aggregate cards.
+- `data-testid="dimension-leaderboard"` and `data-testid="leaderboard-rows"` around ranked dimensions.
+- `data-testid="query-debug"` for generated SQL/debug output.
+- Use `Sparkline` for compact time trends and `ColumnChart` for categorical metric comparisons.
+
+Prefer copying all primitives first for a new dashboard, then deleting unused files after the app shape is clear. Copy a subset only when fitting into an established component system.
+
+## Proven App Shapes
+
+Prior Sidemantic webapps converged on three useful forms:
+
+1. Product webapp with backend API: frontend sends structured query payloads; Python/Sidemantic compiles and executes SQL; frontend renders JSON or Arrow results.
+2. Static browser demo: Pyodide generates SQL from Sidemantic models; DuckDB-WASM executes local Parquet queries; vanilla JS or a small frontend renders the explorer.
+3. Python widget/notebook surface: Python executes Sidemantic queries; JS receives Arrow IPC and syncs state with traitlets.
+
+Default to app shape 1 for real apps. Use shape 2 only when backendless distribution is the point. Use shape 3 inside notebooks.
+
+## Metric Explorer UI
+
+Core sections:
+
+- Header/filter row: date range, active filter pills, selected metric, time grain, refresh/debug controls only when needed.
+- Metrics column: metric cards with label, aggregate value, optional comparison, and a 60px sparkline.
+- Dimensions grid: repeated leaderboard cards with top values for the selected metric.
+- Status area: loading/error/empty states that do not resize the layout.
+
+Useful interactions:
+
+- Click a metric card to select the ranking metric for leaderboards.
+- Click a dimension row to toggle that value as a filter.
+- Click a filter pill to remove that filter.
+- Drag on a sparkline to set a brush date range; double-click to clear it.
+- Change grain to refresh only time-series queries unless dimensions depend on grain.
+
+## State Contract
+
+Keep state small and serializable:
+
+```ts
+type ExplorerState = {
+ selectedMetric: string
+ filters: Record
+ dateRange?: [string, string]
+ brushSelection?: [string, string]
+ timeGrain?: "day" | "week" | "month" | "quarter" | "year"
+}
+```
+
+Avoid storing result rows in URL state. URL state should include only selections and filters.
+
+## Query Contract
+
+Use structured queries:
+
+```ts
+type SidemanticQuery = {
+ metrics: string[]
+ dimensions: string[]
+ filters?: string[]
+ segments?: string[]
+ order_by?: string[]
+ limit?: number
+ offset?: number
+ ungrouped?: boolean
+}
+```
+
+Generate these query types:
+
+- `metricSeries`: metrics + time grain dimension, order by time, limit 500.
+- `metricTotals`: metrics only.
+- `dimensionLeaderboard`: selected metric + one dimension, order by selected metric descending, limit 6.
+- `previewRows`: ungrouped, limit 50, only for inspect/debug views.
+
+Example:
+
+```json
+{
+ "metrics": ["auctions.bid_request_cnt"],
+ "dimensions": ["auctions.__time__day"],
+ "filters": [
+ "auctions.__time >= cast('2025-01-01' as date)",
+ "auctions.device_os = 'iOS'"
+ ],
+ "order_by": ["auctions.__time__day"],
+ "limit": 500
+}
+```
+
+## Crossfilter Rules
+
+Always enumerate filter cases before coding:
+
+- No filters: all metric cards and all leaderboards query the full active time range.
+- One dimension value: all metrics and other dimensions use that value.
+- Multiple values for one dimension: values are ORed inside that dimension.
+- Multiple dimensions: dimensions are ANDed together.
+- Leaderboard for the active dimension: exclude that dimension's own filter, keep all other filters.
+- Brush date range: overrides the base date range until cleared.
+- No time dimension: omit time filters, brush selection, and grain controls.
+
+The UI must prove those rules through state changes. For any visible filter or selectable row:
+
+- Removing a filter pill recomputes totals, leaderboards, charts, and preview rows.
+- Clicking a dimension row adds/toggles the corresponding filter and updates selected row state.
+- Clicking a selected dimension row again clears that dimension filter when the local UX supports toggle behavior.
+- Selecting a metric card changes the metric used by leaderboards and categorical charts.
+- Reset controls restore both filters and selected metric.
+
+Do not ship "fake" interactions that only change status text. Status text is secondary evidence; rendered data changes are the primary evidence.
+
+## Column Naming
+
+Use explicit aliases or normalize response keys at the API boundary. `inspect_layer.py` emits `output_aliases` for each generated query, and `--execute` adds actual result columns:
+
+- `orders.revenue` -> `revenue`
+- `orders.order_date__month` -> `order_date__month` unless a custom alias layer changes it
+- `customers.region` -> `region`
+
+The UI should never depend on quoted SQL output names, adapter-specific casing, or generated expression text.
+
+## Result Transport
+
+JSON is simplest and enough for most app pages. Arrow is better when the result is wide, large, or feeding canvas/WebGL/chart libraries.
+
+For Arrow:
+
+- Python can return Arrow stream bytes from `/query?format=arrow`.
+- Widget-style transports can use raw bytes or base64 Arrow IPC.
+- JavaScript should decode into plain row objects at component boundaries unless downstream code can consume Arrow tables directly.
+
+## Backend Integration
+
+FastAPI path:
+
+```python
+from sidemantic import SemanticLayer, load_from_directory
+from sidemantic.api_server import create_app
+
+layer = SemanticLayer(connection="duckdb:///data.duckdb")
+load_from_directory(layer, "models")
+app = create_app(layer, cors_origins=["http://localhost:5174"])
+```
+
+CLI path:
+
+```bash
+uv run sidemantic info models
+uv run skills/sidemantic-webapp-builder/scripts/inspect_layer.py models --db data.duckdb --require-execute
+uv run sidemantic query "SELECT revenue, status FROM orders" --models models --db data.duckdb
+```
+
+Run these checks serially against DuckDB file databases. Concurrent readers through separate processes can hit file locks.
+
+For exact totals or custom app query shapes, prefer the inspector's generated SQL or API/Python structured query path. CLI semantic SQL may auto-add a model `default_time_dimension`, which turns a totals-looking query into a time series.
+
+## Verification Fallbacks
+
+Preferred verification:
+
+- Run the app locally on a 4xxx-5xxx port.
+- Verify desktop and mobile in a real browser with screenshots.
+- Check that metrics, leaderboards, charts, loading/error branches, and filter interactions render.
+- Click through filters, leaderboard rows, metric cards, and reset controls. Assert concrete text/value changes after each click.
+- Confirm state components are conditional. Loading, empty, and error boxes should not all appear as normal dashboard content unless the artifact is explicitly a component gallery.
+- Confirm SVG charts are clipped to their cards and do not paint into neighboring tiles.
+- Serve static artifacts when they use `fetch()` for JSON/CSV. Do not rely on `file://` behavior.
+
+For static apps that follow the bundled component contracts, run:
+
+```bash
+bunx --bun -p playwright playwright install chromium # first run only, if needed
+bunx --bun -p playwright node skills/sidemantic-webapp-builder/scripts/verify_static_interactions.mjs \
+ --url http://127.0.0.1:4519/
+```
+
+This is the minimum proof that the app is not a fake gallery. It must report successful data changes after filter removal, leaderboard selection, metric selection, and reset.
+
+Fallback when browser tooling is unavailable:
+
+- Use `inspect_layer.py --require-execute` and assert `result.columns` and `sample_rows` match the UI contract.
+- Use `scripts/verify_static_app.py ` for static scaffold checks when the app follows the bundled script shape.
+- Use `bun` with a DOM library already available in the project, or a simple static parser, to confirm expected selectors and data bindings exist.
+- State clearly that real browser visual verification was not run.
+
+## Static Scaffold
+
+For a quick working artifact from an executed spec:
+
+```bash
+uv run skills/sidemantic-webapp-builder/scripts/scaffold_static_app.py docs/sidemantic-app-spec.json \
+ --output dist/sidemantic-dashboard \
+ --title "Metrics Dashboard"
+uv run skills/sidemantic-webapp-builder/scripts/verify_static_app.py dist/sidemantic-dashboard
+```
+
+The scaffold intentionally stays plain HTML/CSS/JS and consumes copied files from `assets/templates/static-dashboard/` plus the static component kit. Use it as a proof point, demo baseline, or fixture before adapting the same query contract into an existing product app.
+
+Browser-only path:
+
+- Pyodide owns Sidemantic model loading and SQL generation.
+- DuckDB-WASM owns data registration and execution.
+- Keep the model YAML and query builder visible in code so generated SQL can be debugged.
+
+## Visual Defaults
+
+For net-new Sidemantic analytics demos:
+
+- Metric column width: 300-360px on desktop.
+- Sparkline height: 56-72px.
+- Dimension cards: `minmax(220px, 1fr)`, top 6 rows.
+- Filter row: sticky at top of the tool surface.
+- Loading: skeleton cards with stable height.
+- Positive delta: green. Negative delta: red. Neutral/missing: muted.
+
+Avoid oversized hero text, decorative gradients, nested cards, and one-hue dashboards.
diff --git a/skills/sidemantic-webapp-builder/scripts/copy_components.py b/skills/sidemantic-webapp-builder/scripts/copy_components.py
new file mode 100644
index 00000000..23b392b1
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/scripts/copy_components.py
@@ -0,0 +1,119 @@
+#!/usr/bin/env python3
+"""Copy Sidemantic webapp components into a target project."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import shutil
+import sys
+from pathlib import Path
+
+SKILL_ROOT = Path(__file__).resolve().parents[1]
+COMPONENT_ROOT = SKILL_ROOT / "assets" / "components"
+
+REACT_COMPONENTS: dict[str, list[str]] = {
+ "column-chart": ["column-chart.tsx"],
+ "dashboard-shell": ["dashboard-shell.tsx", "types.ts"],
+ "data-preview-table": ["data-preview-table.tsx", "types.ts"],
+ "filter-pill": ["filter-pill.tsx", "types.ts"],
+ "leaderboard": ["leaderboard.tsx", "types.ts"],
+ "metric-card": ["metric-card.tsx", "types.ts"],
+ "query-debug-panel": ["query-debug-panel.tsx", "types.ts"],
+ "sparkline": ["sparkline.tsx"],
+ "states": ["states.tsx"],
+}
+
+STATIC_COMPONENTS: dict[str, list[str]] = {
+ "kit": ["sidemantic-components.js", "sidemantic-components.css"],
+}
+
+KINDS = {
+ "react-tailwind": REACT_COMPONENTS,
+ "static": STATIC_COMPONENTS,
+}
+
+
+def _files_for(kind: str, components: list[str]) -> list[Path]:
+ manifest = KINDS[kind]
+ source_dir = COMPONENT_ROOT / kind
+ copy_all = "all" in components
+ requested = list(manifest) if copy_all else components
+ unknown = sorted(set(requested) - set(manifest))
+ if unknown:
+ raise ValueError(f"Unknown {kind} component(s): {', '.join(unknown)}")
+
+ filenames: list[str] = []
+ for component in requested:
+ filenames.extend(manifest[component])
+ if kind == "react-tailwind" and copy_all:
+ filenames.append("index.ts")
+
+ return [source_dir / filename for filename in sorted(set(filenames))]
+
+
+def _list_components() -> None:
+ payload = {
+ kind: {
+ "components": sorted(manifest),
+ "default": "all",
+ "files": sorted(path.name for path in (COMPONENT_ROOT / kind).iterdir() if path.is_file()),
+ }
+ for kind, manifest in KINDS.items()
+ }
+ print(json.dumps(payload, indent=2, sort_keys=True))
+
+
+def copy_components(args: argparse.Namespace) -> list[Path]:
+ target = args.target.resolve()
+ files = _files_for(args.kind, args.components)
+ copied: list[Path] = []
+
+ for source in files:
+ destination = target / source.name
+ if destination.exists() and not args.force:
+ raise FileExistsError(f"{destination} already exists. Use --force to overwrite.")
+ if not args.dry_run:
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(source, destination)
+ copied.append(destination)
+
+ return copied
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--kind", choices=sorted(KINDS), default="react-tailwind")
+ parser.add_argument("--target", type=Path, help="Directory that should receive copied component source")
+ parser.add_argument(
+ "--component",
+ action="append",
+ dest="components",
+ help="Component to copy. Repeat for several. Defaults to all.",
+ )
+ parser.add_argument("--force", action="store_true", help="Overwrite existing target files")
+ parser.add_argument("--dry-run", action="store_true", help="Print target paths without writing files")
+ parser.add_argument("--list", action="store_true", help="List available component kits and exit")
+ args = parser.parse_args()
+
+ if args.list:
+ _list_components()
+ return 0
+ if args.target is None:
+ parser.error("--target is required unless --list is used")
+
+ args.components = args.components or ["all"]
+ try:
+ copied = copy_components(args)
+ except (FileExistsError, ValueError) as error:
+ print(f"copy_components.py: {error}", file=sys.stderr)
+ return 1
+
+ action = "Would copy" if args.dry_run else "Copied"
+ for path in copied:
+ print(f"{action} {path}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/skills/sidemantic-webapp-builder/scripts/inspect_layer.py b/skills/sidemantic-webapp-builder/scripts/inspect_layer.py
new file mode 100755
index 00000000..f20ca118
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/scripts/inspect_layer.py
@@ -0,0 +1,421 @@
+#!/usr/bin/env python3
+"""Inspect a Sidemantic model directory and emit a webapp-oriented JSON spec."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from datetime import date, datetime
+from decimal import Decimal
+from pathlib import Path
+from typing import Any
+
+
+def _ref(model_name: str, field_name: str) -> str:
+ return f"{model_name}.{field_name}"
+
+
+def _field_summary(field: Any, *, model_name: str) -> dict[str, Any]:
+ payload: dict[str, Any] = {
+ "name": field.name,
+ "ref": _ref(model_name, field.name),
+ }
+ for attr in (
+ "type",
+ "agg",
+ "sql",
+ "granularity",
+ "supported_granularities",
+ "description",
+ "format",
+ "filters",
+ "label",
+ "value_format_name",
+ ):
+ value = getattr(field, attr, None)
+ if value not in (None, "", []):
+ payload[attr] = value
+ return payload
+
+
+def _relationship_summary(rel: Any) -> dict[str, Any]:
+ payload: dict[str, Any] = {}
+ for attr in ("name", "type", "model", "foreign_key", "primary_key", "through"):
+ value = getattr(rel, attr, None)
+ if value not in (None, "", []):
+ payload[attr] = value
+ return payload
+
+
+def _is_identifier_dimension(model: Any, dim: Any) -> bool:
+ name = getattr(dim, "name", "")
+ sql = (getattr(dim, "sql", None) or "").strip()
+ primary_key = getattr(model, "primary_key", None)
+ identifier_names = {"id", "uuid", "guid", "key"}
+ if primary_key and name == primary_key:
+ return True
+ if name in identifier_names or name.endswith("_id") or name.endswith("_uuid") or name.endswith("_key"):
+ return True
+ if sql and (sql == primary_key or sql in identifier_names or sql.endswith("_id")):
+ return True
+ return False
+
+
+def _output_name(ref: str) -> str:
+ return ref.split(".", 1)[1] if "." in ref else ref
+
+
+def _output_aliases(metrics: list[str], dimensions: list[str]) -> dict[str, str]:
+ return {ref: _output_name(ref) for ref in [*dimensions, *metrics]}
+
+
+def _json_value(value: Any) -> Any:
+ if isinstance(value, Decimal):
+ return float(value)
+ if isinstance(value, datetime):
+ return value.isoformat(sep=" ")
+ if isinstance(value, date):
+ return value.isoformat()
+ if isinstance(value, bytes):
+ return value.decode("utf-8", errors="replace")
+ return value
+
+
+def _execute_sample(layer: Any, sql: str, *, sample_rows: int) -> dict[str, Any]:
+ result = layer.adapter.execute(sql)
+ columns = [desc[0] for desc in result.description]
+ rows = result.fetchmany(sample_rows)
+ return {
+ "columns": columns,
+ "sample_rows": [
+ {column: _json_value(value) for column, value in zip(columns, row, strict=False)} for row in rows
+ ],
+ "sample_row_count": len(rows),
+ }
+
+
+def _try_compile(
+ generator: Any,
+ *,
+ layer: Any | None = None,
+ execute: bool = False,
+ sample_rows: int = 5,
+ metrics: list[str],
+ dimensions: list[str],
+ filters: list[str] | None = None,
+ order_by: list[str] | None = None,
+ limit: int | None = None,
+ ungrouped: bool = False,
+) -> dict[str, Any]:
+ payload: dict[str, Any] = {
+ "metrics": metrics,
+ "dimensions": dimensions,
+ "filters": filters or [],
+ "order_by": order_by or [],
+ "limit": limit,
+ "ungrouped": ungrouped,
+ "output_aliases": _output_aliases(metrics, dimensions),
+ }
+ try:
+ payload["sql"] = generator.generate(
+ metrics=metrics,
+ dimensions=dimensions,
+ filters=filters,
+ order_by=order_by,
+ limit=limit,
+ ungrouped=ungrouped,
+ skip_default_time_dimensions=True,
+ )
+ if execute and layer is not None:
+ payload["result"] = _execute_sample(layer, payload["sql"], sample_rows=sample_rows)
+ except Exception as exc: # noqa: BLE001 - tool output should report model-specific failures.
+ if "sql" in payload:
+ payload["execution_error"] = str(exc)
+ else:
+ payload["error"] = str(exc)
+ return payload
+
+
+def _grain_for(model: Any, time_dim: Any) -> str:
+ default = getattr(model, "default_grain", None)
+ if default:
+ return default
+ return getattr(time_dim, "granularity", None) or "day"
+
+
+def _matches_dimension(value: str, *, model_name: str, dim: Any) -> bool:
+ return value in {getattr(dim, "name", ""), _ref(model_name, getattr(dim, "name", ""))}
+
+
+def _sort_leaderboard_dimensions(dimensions: list[Any]) -> list[Any]:
+ preferred_names = (
+ "status",
+ "state",
+ "category",
+ "product_area",
+ "region",
+ "country",
+ "channel",
+ "source",
+ "type",
+ "segment",
+ )
+ rank = {name: index for index, name in enumerate(preferred_names)}
+
+ def score(dim: Any) -> tuple[int, int, str]:
+ name = getattr(dim, "name", "")
+ type_penalty = 0 if getattr(dim, "type", None) == "categorical" else 1
+ return (rank.get(name, len(rank)), type_penalty, name)
+
+ return sorted(dimensions, key=score)
+
+
+def _leaderboard_dimension_summary(dim: Any, *, model: Any, model_name: str) -> dict[str, Any]:
+ payload = {
+ "name": getattr(dim, "name", ""),
+ "ref": _ref(model_name, getattr(dim, "name", "")),
+ "type": getattr(dim, "type", None),
+ }
+ if _is_identifier_dimension(model, dim):
+ payload["identifier_like"] = True
+ return payload
+
+
+def _candidate_for_model(
+ generator: Any,
+ layer: Any,
+ model_name: str,
+ model: Any,
+ *,
+ max_metrics: int,
+ max_dimensions: int,
+ execute: bool,
+ sample_rows: int,
+ leaderboard_dimension: str | None,
+) -> dict[str, Any]:
+ metrics = list(getattr(model, "metrics", None) or [])
+ dimensions = list(getattr(model, "dimensions", None) or [])
+ time_dimensions = [dim for dim in dimensions if getattr(dim, "type", None) == "time"]
+ all_leaderboard_dimensions = [dim for dim in dimensions if getattr(dim, "type", None) in ("categorical", "boolean")]
+ preferred_leaderboard_dimensions = [
+ dim for dim in all_leaderboard_dimensions if not _is_identifier_dimension(model, dim)
+ ]
+ candidate_leaderboard_dimensions = _sort_leaderboard_dimensions(
+ preferred_leaderboard_dimensions or all_leaderboard_dimensions
+ )
+ if leaderboard_dimension:
+ requested = [
+ dim
+ for dim in candidate_leaderboard_dimensions
+ if _matches_dimension(leaderboard_dimension, model_name=model_name, dim=dim)
+ ]
+ if requested:
+ candidate_leaderboard_dimensions = [
+ *requested,
+ *[dim for dim in candidate_leaderboard_dimensions if dim not in requested],
+ ]
+ leaderboard_dimensions = candidate_leaderboard_dimensions[:max_dimensions]
+
+ metric_refs = [_ref(model_name, metric.name) for metric in metrics[:max_metrics]]
+ selected_metric = metric_refs[0] if metric_refs else None
+
+ time_dim_name = getattr(model, "default_time_dimension", None)
+ time_dim = next((dim for dim in time_dimensions if dim.name == time_dim_name), None)
+ if time_dim is None and time_dimensions:
+ time_dim = time_dimensions[0]
+ grain = _grain_for(model, time_dim) if time_dim is not None else None
+ time_ref = f"{model_name}.{time_dim.name}__{grain}" if time_dim is not None else None
+
+ queries: dict[str, Any] = {}
+ if metric_refs and time_ref:
+ queries["metric_series"] = _try_compile(
+ generator,
+ layer=layer,
+ execute=execute,
+ sample_rows=sample_rows,
+ metrics=metric_refs,
+ dimensions=[time_ref],
+ order_by=[time_ref],
+ limit=500,
+ )
+ if metric_refs:
+ queries["metric_totals"] = _try_compile(
+ generator,
+ layer=layer,
+ execute=execute,
+ sample_rows=sample_rows,
+ metrics=metric_refs,
+ dimensions=[],
+ )
+ if selected_metric and leaderboard_dimensions:
+ dim_ref = _ref(model_name, leaderboard_dimensions[0].name)
+ queries["dimension_leaderboard"] = _try_compile(
+ generator,
+ layer=layer,
+ execute=execute,
+ sample_rows=sample_rows,
+ metrics=[selected_metric],
+ dimensions=[dim_ref],
+ order_by=[f"{selected_metric} DESC"],
+ limit=6,
+ )
+ if dimensions:
+ preview_dims = [_ref(model_name, dim.name) for dim in dimensions[: min(max_dimensions, 8)]]
+ queries["preview_rows"] = _try_compile(
+ generator,
+ layer=layer,
+ execute=execute,
+ sample_rows=sample_rows,
+ metrics=[],
+ dimensions=preview_dims,
+ limit=50,
+ ungrouped=True,
+ )
+
+ return {
+ "model": model_name,
+ "table": getattr(model, "table", None),
+ "recommended_metrics": metric_refs,
+ "recommended_dimensions": [_ref(model_name, dim.name) for dim in leaderboard_dimensions],
+ "available_leaderboard_dimensions": [
+ _leaderboard_dimension_summary(dim, model=model, model_name=model_name)
+ for dim in candidate_leaderboard_dimensions
+ ],
+ "default_leaderboard_dimension": _ref(model_name, leaderboard_dimensions[0].name)
+ if leaderboard_dimensions
+ else None,
+ "time_dimension": _ref(model_name, time_dim.name) if time_dim is not None else None,
+ "time_grain": grain,
+ "queries": queries,
+ }
+
+
+def inspect_layer(args: argparse.Namespace) -> dict[str, Any]:
+ from sidemantic import SemanticLayer, load_from_directory
+ from sidemantic.sql.generator import SQLGenerator
+
+ connection = args.connection
+ if args.db:
+ connection = f"duckdb:///{Path(args.db).resolve()}"
+
+ layer = SemanticLayer(connection=connection) if connection else SemanticLayer()
+ load_from_directory(layer, str(args.models))
+ generator = SQLGenerator(
+ layer.graph,
+ dialect=layer.dialect,
+ preagg_database=getattr(layer, "preagg_database", None),
+ preagg_schema=getattr(layer, "preagg_schema", None),
+ )
+
+ models = []
+ candidates = []
+ warnings = []
+ execute = bool(args.execute or args.require_execute)
+ if args.execute and not connection:
+ execute = False
+ warnings.append("--execute was requested without --db or --connection, so sample query execution was skipped.")
+ if args.require_execute and not connection:
+ execute = False
+ warnings.append(
+ "--require-execute was requested without --db or --connection, so sample query execution could not run."
+ )
+ for model_name, model in sorted(layer.graph.models.items()):
+ dimensions = list(getattr(model, "dimensions", None) or [])
+ metrics = list(getattr(model, "metrics", None) or [])
+ models.append(
+ {
+ "name": model_name,
+ "table": getattr(model, "table", None),
+ "primary_key": getattr(model, "primary_key", None),
+ "default_time_dimension": getattr(model, "default_time_dimension", None),
+ "default_grain": getattr(model, "default_grain", None),
+ "dimensions": [_field_summary(dim, model_name=model_name) for dim in dimensions],
+ "metrics": [_field_summary(metric, model_name=model_name) for metric in metrics],
+ "relationships": [_relationship_summary(rel) for rel in (getattr(model, "relationships", None) or [])],
+ }
+ )
+ candidates.append(
+ _candidate_for_model(
+ generator,
+ layer,
+ model_name,
+ model,
+ max_metrics=args.max_metrics,
+ max_dimensions=args.max_dimensions,
+ execute=execute,
+ sample_rows=args.sample_rows,
+ leaderboard_dimension=args.leaderboard_dimension,
+ )
+ )
+
+ graph_metrics = []
+ for metric_name, metric in sorted(layer.graph.metrics.items()):
+ graph_metrics.append(_field_summary(metric, model_name="metrics") | {"name": metric_name, "ref": metric_name})
+
+ return {
+ "models_path": str(args.models.resolve()),
+ "connection": connection,
+ "dialect": layer.dialect,
+ "model_count": len(models),
+ "models": models,
+ "graph_metrics": graph_metrics,
+ "app_candidates": candidates,
+ "warnings": warnings,
+ }
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("models", type=Path, help="Directory containing Sidemantic model files")
+ parser.add_argument("--connection", help="Sidemantic connection string")
+ parser.add_argument("--db", type=Path, help="DuckDB database path, shorthand for duckdb:///...")
+ parser.add_argument("--output", "-o", type=Path, help="Write JSON spec to this path")
+ parser.add_argument("--max-metrics", type=int, default=6, help="Max metrics per app candidate")
+ parser.add_argument("--max-dimensions", type=int, default=12, help="Max dimensions per app candidate")
+ parser.add_argument(
+ "--leaderboard-dimension",
+ help="Preferred default leaderboard dimension name or model.dimension reference when present",
+ )
+ parser.add_argument("--execute", action="store_true", help="Execute compiled app queries and include sample rows")
+ parser.add_argument(
+ "--require-execute",
+ action="store_true",
+ help="Require all generated app queries to execute successfully; exits nonzero on missing execution",
+ )
+ parser.add_argument("--sample-rows", type=int, default=5, help="Sample rows to include per executed query")
+ args = parser.parse_args()
+
+ if not args.models.exists():
+ print(f"Models directory not found: {args.models}", file=sys.stderr)
+ return 2
+
+ payload = inspect_layer(args)
+ text = json.dumps(payload, indent=2, sort_keys=True)
+ if args.output:
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_text(text + "\n", encoding="utf-8")
+ print(f"Wrote {args.output}", file=sys.stderr)
+ else:
+ print(text)
+ if args.require_execute:
+ failures: list[str] = []
+ for candidate in payload["app_candidates"]:
+ for query_name, query in candidate["queries"].items():
+ if "result" not in query:
+ failures.append(f"{candidate['model']}.{query_name}: missing executed result")
+ if query.get("execution_error"):
+ failures.append(f"{candidate['model']}.{query_name}: {query['execution_error']}")
+ if query.get("error"):
+ failures.append(f"{candidate['model']}.{query_name}: {query['error']}")
+ if failures:
+ print("Execution validation failed:", file=sys.stderr)
+ for failure in failures:
+ print(f"- {failure}", file=sys.stderr)
+ return 1
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/skills/sidemantic-webapp-builder/scripts/scaffold_static_app.py b/skills/sidemantic-webapp-builder/scripts/scaffold_static_app.py
new file mode 100644
index 00000000..d2c96c19
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/scripts/scaffold_static_app.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+"""Scaffold a minimal static Sidemantic dashboard from copyable components."""
+
+from __future__ import annotations
+
+import argparse
+import html
+import json
+import shutil
+from pathlib import Path
+from typing import Any
+
+SKILL_ROOT = Path(__file__).resolve().parents[1]
+STATIC_COMPONENT_ROOT = SKILL_ROOT / "assets" / "components" / "static"
+STATIC_TEMPLATE_ROOT = SKILL_ROOT / "assets" / "templates" / "static-dashboard"
+
+
+def _select_candidate(spec: dict[str, Any], model: str | None) -> dict[str, Any]:
+ candidates = spec.get("app_candidates") or []
+ if not candidates:
+ raise ValueError("App spec has no app_candidates")
+ if model is None:
+ return candidates[0]
+ for candidate in candidates:
+ if candidate.get("model") == model:
+ return candidate
+ raise ValueError(f"Model {model!r} not found in app_candidates")
+
+
+def _require_query(candidate: dict[str, Any], name: str) -> dict[str, Any]:
+ query = (candidate.get("queries") or {}).get(name)
+ if not query:
+ raise ValueError(f"Candidate {candidate.get('model')} has no {name} query")
+ result = query.get("result")
+ if not result or "columns" not in result or "sample_rows" not in result:
+ raise ValueError(f"{name} query has no executed result. Re-run inspect_layer.py with --require-execute.")
+ return query
+
+
+def _render_template(template_name: str, replacements: dict[str, str]) -> str:
+ template_path = STATIC_TEMPLATE_ROOT / template_name
+ content = template_path.read_text(encoding="utf-8")
+ for token, value in replacements.items():
+ content = content.replace("{{" + token + "}}", value)
+ return content
+
+
+def _write_index(path: Path, title: str) -> None:
+ path.write_text(_render_template("index.html", {"TITLE": html.escape(title)}), encoding="utf-8")
+
+
+def _write_app(path: Path) -> None:
+ path.write_text(_render_template("app.js", {}), encoding="utf-8")
+
+
+def scaffold(args: argparse.Namespace) -> None:
+ spec_path = args.app_spec.resolve()
+ spec = json.loads(spec_path.read_text(encoding="utf-8"))
+ candidate = _select_candidate(spec, args.model)
+ _require_query(candidate, "metric_totals")
+ _require_query(candidate, "dimension_leaderboard")
+
+ output_dir = args.output.resolve()
+ data_dir = output_dir / "data"
+ data_dir.mkdir(parents=True, exist_ok=True)
+
+ shutil.copyfile(spec_path, data_dir / "app-spec.json")
+ _write_index(output_dir / "index.html", args.title or f"{candidate['model']} Dashboard")
+ shutil.copyfile(STATIC_COMPONENT_ROOT / "sidemantic-components.css", output_dir / "styles.css")
+ shutil.copyfile(STATIC_COMPONENT_ROOT / "sidemantic-components.js", output_dir / "sidemantic-components.js")
+ _write_app(output_dir / "app.js")
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("app_spec", type=Path, help="Executed app spec JSON from inspect_layer.py --execute")
+ parser.add_argument("--output", "-o", type=Path, required=True, help="Output directory for the static app")
+ parser.add_argument("--model", help="Model candidate to scaffold; defaults to the first app candidate")
+ parser.add_argument("--title", help="Dashboard title")
+ args = parser.parse_args()
+
+ scaffold(args)
+ print(f"Wrote static app to {args.output}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/skills/sidemantic-webapp-builder/scripts/verify_static_app.py b/skills/sidemantic-webapp-builder/scripts/verify_static_app.py
new file mode 100644
index 00000000..e8f46dcb
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/scripts/verify_static_app.py
@@ -0,0 +1,141 @@
+#!/usr/bin/env python3
+"""Verify a static Sidemantic dashboard scaffold without browser dependencies."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+from typing import Any
+
+
+def _load_json(path: Path) -> dict[str, Any]:
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+def _first_candidate(spec: dict[str, Any]) -> dict[str, Any] | None:
+ candidates = spec.get("app_candidates") or []
+ return candidates[0] if candidates else None
+
+
+def _query(candidate: dict[str, Any], name: str) -> dict[str, Any]:
+ return (candidate.get("queries") or {}).get(name) or {}
+
+
+def _dimension_type(spec: dict[str, Any], model_name: str, dimension_ref: str) -> str | None:
+ if "." not in dimension_ref:
+ return None
+ _, dimension_name = dimension_ref.split(".", 1)
+ for model in spec.get("models") or []:
+ if model.get("name") != model_name:
+ continue
+ for dimension in model.get("dimensions") or []:
+ if dimension.get("name") == dimension_name:
+ return dimension.get("type")
+ return None
+
+
+def _is_non_id_dimension(spec: dict[str, Any], model_name: str, dimension_ref: str) -> bool:
+ if "." not in dimension_ref:
+ return False
+ _, dimension_name = dimension_ref.split(".", 1)
+ model = next((item for item in spec.get("models") or [] if item.get("name") == model_name), {})
+ primary_key = model.get("primary_key")
+ return bool(
+ dimension_name
+ and dimension_name != primary_key
+ and dimension_name != "id"
+ and not dimension_name.endswith("_id")
+ and not dimension_name.endswith("_key")
+ and not dimension_name.endswith("_uuid")
+ )
+
+
+def verify(args: argparse.Namespace) -> dict[str, Any]:
+ app_dir = args.app_dir.resolve()
+ spec_path = args.app_spec.resolve() if args.app_spec else app_dir / "data" / "app-spec.json"
+ index_path = app_dir / "index.html"
+ app_js_path = app_dir / "app.js"
+ component_js_path = app_dir / "sidemantic-components.js"
+ styles_path = app_dir / "styles.css"
+
+ report: dict[str, Any] = {"checks": {}, "app_dir": str(app_dir), "app_spec": str(spec_path)}
+ checks = report["checks"]
+
+ checks["files_exist"] = all(
+ path.exists() for path in (spec_path, index_path, app_js_path, component_js_path, styles_path)
+ )
+ if not checks["files_exist"]:
+ return report
+
+ spec = _load_json(spec_path)
+ candidate = _first_candidate(spec)
+ checks["has_app_candidate"] = candidate is not None
+ if candidate is None:
+ return report
+
+ model_name = candidate.get("model")
+ totals = _query(candidate, "metric_totals")
+ leaderboard = _query(candidate, "dimension_leaderboard")
+
+ checks["totals_executed"] = bool(totals.get("result", {}).get("columns")) and bool(
+ totals.get("result", {}).get("sample_rows")
+ )
+ checks["leaderboard_executed"] = bool(leaderboard.get("result", {}).get("columns")) and bool(
+ leaderboard.get("result", {}).get("sample_rows")
+ )
+ checks["totals_true_total"] = (
+ totals.get("result", {}).get("sample_row_count") == 1 and "group by" not in (totals.get("sql") or "").lower()
+ )
+
+ leaderboard_dimension = (leaderboard.get("dimensions") or [""])[0]
+ dimension_type = _dimension_type(spec, model_name, leaderboard_dimension)
+ checks["leaderboard_non_id"] = _is_non_id_dimension(spec, model_name, leaderboard_dimension)
+ checks["leaderboard_categorical_or_boolean"] = dimension_type in ("categorical", "boolean")
+ report["leaderboard_dimension"] = leaderboard_dimension
+ report["leaderboard_dimension_type"] = dimension_type
+
+ source = "\n".join(
+ path.read_text(encoding="utf-8") for path in (index_path, app_js_path, component_js_path, styles_path)
+ )
+ checks["references_app_spec"] = "data/app-spec.json" in source
+ checks["uses_copyable_components"] = "sidemantic-components.js" in source and ".sdm-metric-card" in source
+ checks["has_metric_totals_selector"] = 'data-testid="metric-totals"' in source
+ checks["has_leaderboard_selector"] = 'data-testid="dimension-leaderboard"' in source
+ checks["has_leaderboard_rows_selector"] = 'data-testid="leaderboard-rows"' in source
+ checks["has_metric_data_binding"] = "dataset.metric" in source or "data-metric" in source
+ checks["has_dimension_data_binding"] = "dataset.dimension" in source or "data-dimension" in source
+ checks["avoids_inner_html"] = "innerHTML" not in source
+ checks["sparkline_bounded_if_present"] = ".sdm-sparkline" not in source or (
+ "overflow: hidden" in source and 'setAttribute("viewBox"' in source
+ )
+ checks["no_persistent_state_gallery"] = not all(
+ text in source
+ for text in (
+ "Loading: metrics are refreshing",
+ "Empty: no rows for the current filter set",
+ "Error: query failed",
+ )
+ )
+
+ return report
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("app_dir", type=Path, help="Static app directory to verify")
+ parser.add_argument("--app-spec", type=Path, help="App spec JSON; defaults to app_dir/data/app-spec.json")
+ args = parser.parse_args()
+
+ report = verify(args)
+ print(json.dumps(report, indent=2, sort_keys=True))
+ failed = [name for name, passed in report.get("checks", {}).items() if not passed]
+ if failed:
+ print("Verification failed: " + ", ".join(failed), file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/skills/sidemantic-webapp-builder/scripts/verify_static_interactions.mjs b/skills/sidemantic-webapp-builder/scripts/verify_static_interactions.mjs
new file mode 100644
index 00000000..da5ba972
--- /dev/null
+++ b/skills/sidemantic-webapp-builder/scripts/verify_static_interactions.mjs
@@ -0,0 +1,230 @@
+#!/usr/bin/env node
+// Browser smoke tests for Sidemantic static dashboards.
+//
+// Run with Playwright available, for example:
+// bunx --bun -p playwright node skills/sidemantic-webapp-builder/scripts/verify_static_interactions.mjs --url http://127.0.0.1:4519/
+
+import process from "node:process";
+
+function parseArgs(argv) {
+ const args = {
+ headless: true,
+ timeout: 10000,
+ };
+ for (let index = 2; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === "--url") {
+ args.url = argv[++index];
+ } else if (arg === "--headed") {
+ args.headless = false;
+ } else if (arg === "--timeout") {
+ args.timeout = Number(argv[++index]);
+ } else if (arg === "--help" || arg === "-h") {
+ args.help = true;
+ } else {
+ throw new Error(`Unknown argument: ${arg}`);
+ }
+ }
+ return args;
+}
+
+function usage() {
+ return `Usage:
+ bunx --bun -p playwright node skills/sidemantic-webapp-builder/scripts/verify_static_interactions.mjs --url http://127.0.0.1:4519/
+
+Options:
+ --url App URL to test.
+ --headed Run a headed browser.
+ --timeout Per-action timeout. Default: 10000.
+`;
+}
+
+async function safeText(locator) {
+ if ((await locator.count()) === 0) return "";
+ return locator.first().innerText();
+}
+
+async function snapshot(page) {
+ return {
+ metricText: await safeText(page.locator('[data-testid="metric-totals"]')),
+ leaderboardText: await safeText(page.locator('[data-testid="dimension-leaderboard"]')),
+ previewText: await safeText(page.locator('[data-testid="data-preview"]')),
+ filterCount: await page.locator('[data-testid="filter-pills"] [data-dimension]').count(),
+ selectedMetricCount: await page.locator('[data-testid="metric-totals"] [data-selected="true"]').count(),
+ selectedRowCount: await page.locator('[data-testid="leaderboard-rows"] [data-selected="true"]').count(),
+ columnChartCount: await page.locator("svg.sdm-column-chart").count(),
+ sparklineCount: await page.locator("svg.sdm-sparkline").count(),
+ };
+}
+
+function changed(before, after, fields) {
+ return fields.some((field) => before[field] !== after[field]);
+}
+
+async function expectChange(name, before, after, fields) {
+ if (!changed(before, after, fields)) {
+ throw new Error(`${name} did not change any of: ${fields.join(", ")}`);
+ }
+}
+
+async function assertNoPersistentStateGallery(page) {
+ const stateTexts = [
+ "Loading: metrics are refreshing with stable layout.",
+ "Empty: no rows for the current filter set.",
+ "Error: query failed or returned an invalid result shape.",
+ ];
+ const counts = await Promise.all(stateTexts.map((text) => page.getByText(text, { exact: true }).count()));
+ if (counts.every((count) => count > 0)) {
+ throw new Error("Loading, empty, and error states are all rendered as persistent dashboard content.");
+ }
+}
+
+async function assertChartsBounded(page) {
+ const chartState = await page.locator("svg.sdm-sparkline, svg.sdm-column-chart").evaluateAll((charts) =>
+ charts.map((chart) => {
+ const style = globalThis.getComputedStyle(chart);
+ const rect = chart.getBoundingClientRect();
+ return {
+ className: chart.getAttribute("class") || "",
+ overflow: style.overflow,
+ width: rect.width,
+ height: rect.height,
+ hasViewBox: chart.hasAttribute("viewBox"),
+ };
+ }),
+ );
+ for (const chart of chartState) {
+ if (!chart.hasViewBox || chart.overflow !== "hidden" || chart.width <= 0 || chart.height <= 0) {
+ throw new Error(`Chart is not bounded: ${JSON.stringify(chart)}`);
+ }
+ }
+}
+
+async function clickFirstFilterRemove(page, timeout) {
+ const removeButtons = page.locator('[data-testid="filter-pills"] button');
+ if ((await removeButtons.count()) === 0) return { skipped: true, reason: "no removable filter pills" };
+ const before = await snapshot(page);
+ await removeButtons.first().click({ timeout });
+ const after = await snapshot(page);
+ await expectChange("Removing a filter pill", before, after, [
+ "metricText",
+ "leaderboardText",
+ "previewText",
+ "filterCount",
+ ]);
+ return { skipped: false, before, after };
+}
+
+async function clickLeaderboardRow(page, timeout) {
+ const rows = page.locator('[data-testid="leaderboard-rows"] button[data-dimension]');
+ if ((await rows.count()) === 0) return { skipped: true, reason: "no interactive leaderboard rows" };
+ const before = await snapshot(page);
+ await rows.first().click({ timeout });
+ const after = await snapshot(page);
+ if (after.selectedRowCount < 1) {
+ throw new Error("Clicking a leaderboard row did not mark any row selected.");
+ }
+ await expectChange("Clicking a leaderboard row", before, after, [
+ "metricText",
+ "previewText",
+ "filterCount",
+ "selectedRowCount",
+ ]);
+ return { skipped: false, before, after };
+}
+
+async function clickMetricCard(page, timeout) {
+ const metrics = page.locator('[data-testid="metric-totals"] button[data-metric]');
+ if ((await metrics.count()) < 2) return { skipped: true, reason: "fewer than two interactive metric cards" };
+ const before = await snapshot(page);
+ await metrics.nth(1).click({ timeout });
+ const after = await snapshot(page);
+ if (after.selectedMetricCount < 1) {
+ throw new Error("Clicking a metric card did not mark any metric selected.");
+ }
+ await expectChange("Clicking a metric card", before, after, ["leaderboardText", "selectedMetricCount"]);
+ return { skipped: false, before, after };
+}
+
+async function clickReset(page, timeout) {
+ const reset = page.locator('[data-action="reset"], button:has-text("Reset filters")');
+ if ((await reset.count()) === 0) return { skipped: true, reason: "no reset control" };
+ const before = await snapshot(page);
+ await reset.first().click({ timeout });
+ const after = await snapshot(page);
+ await expectChange("Clicking reset", before, after, ["metricText", "previewText", "filterCount", "selectedMetricCount"]);
+ return { skipped: false, before, after };
+}
+
+async function main() {
+ const args = parseArgs(process.argv);
+ if (args.help) {
+ console.log(usage());
+ return 0;
+ }
+ if (!args.url) {
+ throw new Error("--url is required.\n" + usage());
+ }
+
+ const { chromium } = await import("playwright");
+ const browser = await chromium.launch({ headless: args.headless });
+ const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
+ const consoleErrors = [];
+ page.on("console", (message) => {
+ if (message.type() === "error") consoleErrors.push(message.text());
+ });
+ page.on("pageerror", (error) => {
+ consoleErrors.push(error.message);
+ });
+
+ try {
+ await page.goto(args.url, { waitUntil: "load", timeout: args.timeout });
+ await page.locator('[data-testid="metric-totals"]').waitFor({ timeout: args.timeout });
+
+ const initial = await snapshot(page);
+ await assertNoPersistentStateGallery(page);
+ await assertChartsBounded(page);
+ const filter = await clickFirstFilterRemove(page, args.timeout);
+ const leaderboard = await clickLeaderboardRow(page, args.timeout);
+ const metric = await clickMetricCard(page, args.timeout);
+ const reset = await clickReset(page, args.timeout);
+ const final = await snapshot(page);
+
+ if (consoleErrors.length > 0) {
+ throw new Error(`Console errors: ${consoleErrors.join(" | ")}`);
+ }
+
+ console.log(
+ JSON.stringify(
+ {
+ ok: true,
+ url: args.url,
+ initial,
+ checks: {
+ boundedCharts: true,
+ noPersistentStateGallery: true,
+ filter,
+ leaderboard,
+ metric,
+ reset,
+ },
+ final,
+ },
+ null,
+ 2,
+ ),
+ );
+ return 0;
+ } finally {
+ await browser.close();
+ }
+}
+
+main()
+ .then((code) => {
+ process.exitCode = code;
+ })
+ .catch((error) => {
+ console.error(error.message);
+ process.exitCode = 1;
+ });