Skip to content

Make Dashboard a first-class module with one-dashboard-per-current-workspace v1 UX #280

Description

@BorisTyshkevich

Summary

Make Dashboard a first-class, independently constructible product module instead of deriving it directly from favorited saved queries and global AppState.

For the v1 product UX, keep the current simple model:

The browser has one current workspace. That workspace contains one ordered query collection and zero or one editable Dashboard. Starring a panel query adds one default tile to that Dashboard; un-starring removes that default tile.

Do not add dashboard collection management, dashboard folders, dashboard CRUD screens, or multi-dashboard authoring UX in this issue.

At the architecture and format level, introduce:

  • an atomic StoredWorkspaceV1 persistence aggregate;
  • an explicit DashboardDocumentV1;
  • separate authoring and read-only viewer sessions;
  • one canonical PortableBundleV1 JSON format;
  • versioned, lazy-loadable Dashboard layouts;
  • a normative flow@1 layout contract;
  • deterministic serialization, diagnostics, resource limits, and import remapping;
  • atomic, fallible Dashboard commands and whole-workspace validation.

The portable format may contain multiple dashboards for forward compatibility and external tooling, but the v1 Workbench manages at most one Dashboard.

This work should build on the narrow service/session boundaries from #276. Dashboard model and application code must not depend on the full App controller, Workbench tabs, editors, or global AppState.


Current problem

The current Dashboard looks standalone, but its domain model, persistence, and lifecycle are implicit:

  • membership is derived from spec.favorite;
  • sequence is inherited from Library query order;
  • layout is stored as browser-global preferences;
  • filters, tile cancellation, layout controls, rendering, and execution use the full App object;
  • saved queries, Library name, layout, filters, and other state are persisted under separate keys;
  • there is no persisted aggregate connecting the query collection and Dashboard;
  • there is no explicit Dashboard identity, tile sequence, authored placement, revision, layout engine, or filter-definition contract;
  • a Dashboard cannot be loaded, validated, previewed, or tested independently of Workbench state.

This prevents reliable reorder/resize persistence, atomic migration, direct read-only viewing, safe external bundle execution, future layout engines, AI-authored changes, and eventual server persistence.


Product decisions for v1

One current workspace, one Dashboard

The browser has one current workspace containing:

  • stable workspace ID;
  • workspace name;
  • ordered saved-query collection;
  • zero or one DashboardDocumentV1.

There is no Dashboard list or selector.

The user workflow remains query-centric:

  1. Create or edit a saved query.
  2. Configure its reusable panel presentation.
  3. Star it to add it to the current Dashboard.
  4. Open Dashboard authoring preview.
  5. Reorder or resize tiles and select presentation variants.
  6. Open a full-screen read-only preview.
  7. Export one portable bundle.

Dashboard is an explicit aggregate

Do not continue recomputing Dashboard state from stars. Persist one explicit document containing tile instances, semantic order, layout, filters, and revision.

Star means Dashboard membership

For v1, the star action is:

Add to Dashboard / Remove from Dashboard

dashboard.tiles is the canonical membership source. Temporary compatibility dual-write to spec.favorite is allowed only during migration and must have a documented removal path.

The model may support several tile instances referencing one query, but the v1 star UI manages one default instance per query.

One canonical portable JSON format

All newly written importable/exportable JSON uses PortableBundleV1.

Continue reading legacy Library v1/v2 files through compatibility decoders, but do not write new Library-only JSON.

Portable format may contain several dashboards

PortableBundleV1 contains dashboards: DashboardDocumentV1[].

The v1 Workbench still imports or manages at most one Dashboard. When a bundle contains several dashboards, Open or Import asks the user to select one. This is import resolution, not multi-dashboard management UX.


Internal persistence: StoredWorkspaceV1

Portable interchange and browser persistence are separate contracts.

interface StoredWorkspaceV1 {
  storageVersion: 1;
  id: string;
  name: string;
  queries: SavedQueryV2[];
  dashboard: DashboardDocumentV1 | null;
}

WorkspaceRepository

interface WorkspaceRepository {
  loadCurrent(): Promise<StoredWorkspaceV1 | null>;
  commit(candidate: StoredWorkspaceV1): Promise<WorkspaceCommitResult>;
  clearCurrent(): Promise<void>;
}

type WorkspaceCommitResult =
  | {
      ok: true;
      workspace: StoredWorkspaceV1;
      dashboardRevision: number | null;
    }
  | {
      ok: false;
      diagnostics: WorkspaceDiagnostic[];
    };

Requirements:

  • persist the complete workspace under one aggregate key or a storage mechanism with equivalent atomic replacement semantics;
  • validate the complete candidate before writing;
  • publish committed application state only after persistence succeeds;
  • failed persistence leaves the previous stored workspace intact;
  • failed persistence leaves the authoring draft dirty;
  • failed persistence does not increment Dashboard revision;
  • never partially replace queries, Dashboard, layout, or workspace metadata.

Workspace operation semantics

  • Rename workspace changes workspace.name; it does not automatically rename an existing Dashboard.
  • Creating a new workspace generates a new workspace ID and replaces the one current workspace after confirmation where data loss is possible.
  • Replace from bundle replaces queries and Dashboard atomically.
  • Import queries modifies the query collection only; imported favorite flags do not add Dashboard tiles.
  • Import Dashboard replaces the current Dashboard after explicit conflict resolution.
  • Browser reload loads StoredWorkspaceV1; it does not recreate Dashboard state from favorites.
  • Two files with the same name still produce distinct generated workspace IDs.
  • Export does not mutate workspace identity or Dashboard revision.

Multi-tab authoring policy

Concurrent Workbench authoring in several tabs is unsupported in v1.

  • WorkspaceRepository.commit is atomic replacement but not compare-and-swap.
  • Last successful commit wins.
  • The UI may warn when another tab appears active, but generation-based conflict rejection is out of scope.
  • This limitation must be documented and covered by a test proving that one commit never produces a partially mixed workspace.

Legacy migration marker

When no stored workspace aggregate exists:

  1. Read legacy keys.
  2. Build one candidate StoredWorkspaceV1.
  3. Create the initial Dashboard from legacy favorites.
  4. Convert legacy layout preferences to flow@1.
  5. Validate the complete candidate.
  6. Persist it atomically.
  7. Treat successful aggregate persistence as migration completion.

Do not delete or modify legacy keys until the aggregate write succeeds.


Domain ownership rules

Use this decision test:

If a value normally remains the same when a query is placed in another Dashboard, it belongs to SavedQuery Spec. If it describes how one query instance is used in this Dashboard, it belongs to the Dashboard tile or layout. If it changes while viewing, it belongs to DashboardViewerSession.

SavedQuery Spec owns reusable presentation

It owns:

  • query name and description;
  • SQL and parameter contract;
  • base panel renderer and configuration;
  • field labels, units, decimals, colors, null text;
  • chart axes, legend, grid, stacking, curve, and renderer-specific configuration;
  • Dashboard role (panel, filter, or legacy setup);
  • reusable named Dashboard presentation variants;
  • default variant;
  • layout-neutral size hints.

It must not own actual Dashboard sequence, coordinates, span, actual height, or Dashboard-specific title overrides.

Suggested shape:

interface QueryDashboardPresentationV1 {
  role?: 'panel' | 'filter' | 'setup';
  defaultVariant?: string;
  variants?: Record<string, QueryPresentationPatchV1>;
  sizeHints?: {
    preferred?: 'compact' | 'medium' | 'wide';
    minimum?: 'compact' | 'medium' | 'wide';
    aspectRatio?: number;
  };
}

Dashboard tile owns query usage in this Dashboard

interface DashboardTileV1 {
  id: string;
  queryId: string;
  title?: string;
  description?: string;
  presentation?: {
    variant?: string;
    override?: QueryPresentationPatchV1;
  };
}

A tile owns stable instance identity, query reference, selected variant, optional small local presentation override, and optional local title/description.

Dashboard layout owns actual placement and size

interface DashboardLayoutDocumentV1 {
  type: string;
  version: number;
  preset?: string;
  config?: JsonObject;
  items?: Record<string, JsonObject>;
  fallback?: DashboardLayoutFallbackV1;
}

Actual placement is layout-specific. Do not force flow spans, future grid coordinates, report sections, and custom canvas dimensions into one generic width/height schema.

Dashboard owns semantic sequence

dashboard.tiles[] is canonical for:

  • semantic order;
  • default execution planning;
  • DOM and accessibility traversal;
  • fallback rendering;
  • print/export order;
  • deterministic serialization;
  • drag and keyboard reorder.

Workspace query order remains catalog/authoring order and is independent of Dashboard tile order.


Presentation variants and tile overrides

QueryPresentationPatchV1 uses RFC 7396 JSON Merge Patch semantics against the saved query's base panel object.

type QueryPresentationPatchV1 = JsonMergePatch<PanelSpecV1>;

Resolution order

For every panel tile, resolve presentation in this exact order:

SavedQuery base panel
  -> apply selected named variant patch
  -> apply tile-local override patch
  -> validate final resolved panel

Normative patch rules

  • Variants and tile overrides are deltas, not complete snapshots.
  • Object members merge recursively according to JSON Merge Patch.
  • Arrays replace the previous array completely; arrays are never concatenated or merged by index.
  • A null member deletes an optional property.
  • Deleting a required property causes final resolved-panel validation to fail.
  • Unknown fields follow the openness rules of the underlying panel schema and are preserved only where that schema allows them.
  • In v1, neither a named variant nor a tile-local override may change the base renderer type. A patch containing a different panel.cfg.type fails validation.
  • The final resolved panel must pass the same structural and semantic validation as a normal saved-query panel, including result-column role validation when result metadata is available.
  • A selected variant name must exist. There is no silent fallback to the default variant when a persisted name is missing.
  • A missing selected variant uses defaultVariant when valid; otherwise it uses only the base panel.

A canonical resolver should be shared by Workbench preview, Dashboard viewer, import validation, tests, and future AI/MCP commands.


DashboardDocumentV1

Create canonical JSON Schema, generated types, generated validators, codec, semantic validation, diagnostics, and migration support through the repository's existing schema pipeline.

interface DashboardDocumentV1 {
  documentVersion: 1;
  id: string;
  title: string;
  description?: string;
  revision: number;
  layout: DashboardLayoutDocumentV1;
  filters: DashboardFilterDefinitionV1[];
  tiles: DashboardTileV1[];
}

Do not add ownership, ACL, public visibility, share policy, or server identifiers in this issue.

Filter contract

interface DashboardFilterDefinitionV1 {
  id: string;
  parameter: string;
  label?: string;
  sourceQueryId?: string;
  targets?: string[]; // absent = every compatible panel tile
  defaultValue?: JsonValue;
  defaultActive?: boolean;
}

Semantic validation must enforce:

  • unique filter IDs;
  • source query exists and has role filter;
  • source query does not create a tile;
  • target tile IDs exist;
  • target queries declare the named parameter;
  • parameter types are compatible across all targets;
  • absent targets resolve to every compatible panel tile;
  • runtime values and curated result caches are not persisted in the document.

Setup is unsupported in Dashboard v1

Dashboard v1 does not execute Setup queries.

  • There is no setupQueries field.
  • Setup queries are not included in Dashboard dependency closure merely because they exist.
  • Setup queries cannot be tiles or filter sources.
  • External Dashboard documents attempting Setup execution fail semantic validation.
  • Query-only bundles may preserve a SavedQuery with role setup, but Dashboard v1 never executes it.

PortableBundleV1

interface PortableBundleV1 {
  $schema?: string;
  format: 'altinity-sql-browser/portable-bundle';
  version: 1;
  exportedAt: string;
  metadata?: {
    name?: string;
    description?: string;
  };
  queries: SavedQueryV2[];
  dashboards: DashboardDocumentV1[];
}

Both arrays are required even when empty.

Schema hierarchy:

portable-bundle-v1.schema.json
├── saved-query-v2.schema.json
│   └── query-spec-v1.schema.json
└── dashboard-v1.schema.json

Cross-resource semantic validation

Validate before any state mutation or SQL execution:

  • unique query IDs;
  • unique Dashboard IDs;
  • unique tile IDs per Dashboard;
  • unique filter IDs per Dashboard;
  • every tile query resolves;
  • selected presentation variants exist;
  • every resolved panel validates;
  • layout items reference existing tiles;
  • no orphan placement entries;
  • filter sources and targets resolve;
  • query roles are compatible with their use;
  • unknown future versions fail closed;
  • unsupported layout type/version has a valid fallback or fails;
  • Setup execution references are rejected.

Diagnostics use stable application codes and exact paths.

Portable dependency closure

Exporting one Dashboard includes exactly:

  • all tile queries;
  • referenced filter-role queries;
  • no unrelated workspace queries;
  • no Setup execution dependencies.

A query referenced more than once appears once in queries[].

Opening a bundle for viewing must not import its queries into the current workspace.


Canonical output ordering

All newly encoded portable bundles and persisted workspace documents must be deterministic.

Dashboard dependency query order

For a Dashboard-only dependency closure:

  1. Traverse dashboard.tiles[] in semantic order.
  2. Emit each referenced query on first occurrence.
  3. Traverse dashboard.filters[] in filter order.
  4. Emit each sourceQueryId not already emitted.
  5. Emit every query once.
  6. Setup references are absent in v1.

Full current-workspace export

  • Emit the zero or one current Dashboard.
  • Preserve workspace query catalog order.
  • Do not reorder the complete query collection by Dashboard usage.

Multi-Dashboard bundles produced by tooling

  • Sort dashboards lexicographically by normalized Dashboard ID because v1 assigns no catalog meaning to Dashboard array order.
  • Order referenced queries by first reference across that canonical Dashboard order, using tile order followed by filter order.
  • Append unreferenced queries in their original catalog order.
  • Emit every query once.

Object key ordering

  • Arrays retain semantic order.
  • Schema-defined object fields use one documented canonical field order.
  • Map-like keys such as layout.items, variant names, and column maps sort lexicographically.
  • Open extension/config objects are recursively key-sorted.
  • JSON object order never carries domain meaning.

Use one canonical encoder for export, persistence snapshots, hashing, equality checks, and snapshot tests.

Diagnostic ordering

Before returning diagnostics, sort by:

  1. severity: error, warning, information;
  2. path using numeric-aware segment comparison;
  3. diagnostic code;
  4. resource ID;
  5. message as final tie-breaker.

Concurrent validation must not make diagnostic order nondeterministic.


Resource limits

Define concrete schema and runtime bounds. Initial v1 limits:

const PORTABLE_LIMITS = {
  maxDecodedJsonBytes: 10 * 1024 * 1024,
  maxJsonDepth: 64,

  maxQueries: 1000,
  maxDashboards: 32,
  maxTilesPerDashboard: 100,
  maxFiltersPerDashboard: 32,
  maxLayoutItemsPerDashboard: 100,
  maxVariantsPerQuery: 32,

  maxIdLength: 256,
  maxNameLength: 512,
  maxTitleLength: 512,
  maxDescriptionLength: 16 * 1024,
  maxSqlLength: 1024 * 1024,

  maxSerializedQuerySpecBytes: 1024 * 1024,
  maxSerializedLayoutConfigBytes: 256 * 1024,
  maxSerializedFilterDefaultBytes: 64 * 1024,
} as const;

Requirements:

  • JSON Schema enforces item, property, and string-length bounds where possible.
  • The codec enforces UTF-8 byte size before parsing, normalized serialized byte size, and maximum depth.
  • Layout item count must not exceed tile count.
  • Setup references per Dashboard are limited to zero.
  • Runtime checks repeat security-relevant limits after parsing.
  • Legacy Library conversion must respect the same limits and fail with precise diagnostics rather than partially importing.

Dashboard layout registry and fallback

Use compile-time registered, lazy-loadable layout modules. Do not load arbitrary remote JavaScript plugins in v1.

interface DashboardLayoutRegistration {
  id: string;
  versions: readonly number[];
  load(version: number): Promise<DashboardLayoutPlugin>;
}

Concrete fallback contract

type DashboardLayoutFallbackV1 = FlowLayoutV1;

Rules:

  • A persisted fallback is a complete valid flow@1 layout, not a second layout-neutral placement language.
  • A consumer that cannot load the primary layout must validate and render the fallback.
  • An unsupported primary layout without a valid flow@1 fallback fails before execution.
  • Fallback tile IDs and placement keys must resolve against the same dashboard.tiles[].

Normative flow@1 contract

type FlowPresetV1 =
  | 'full-width'
  | 'report'
  | 'columns-2'
  | 'columns-3';

type FlowHeightV1 = 'compact' | 'medium' | 'large';

interface FlowTilePlacementV1 {
  span?: 1 | 2 | 3;
  height?: FlowHeightV1;
}

interface FlowLayoutV1 {
  type: 'flow';
  version: 1;
  preset: FlowPresetV1;
  items: Record<string, FlowTilePlacementV1>;
}

FlowTilePlacementV1 is closed: unknown placement fields fail validation. Future extension requires flow@2 or an explicit extension namespace.

Presets

Preset Desktop columns Behavior
full-width 1 Each row uses available Dashboard width
report 1 Centered constrained-width report column
columns-2 2 Two equal columns
columns-3 3 Three equal columns

Exact pixels, gaps, and maximum report width remain renderer/theme concerns.

Missing placement defaults

const DEFAULT_FLOW_PLACEMENT: FlowTilePlacementV1 = {
  span: 1,
  height: 'medium',
};

When a query is first added, authoring may derive and persist an initial placement from SavedQuery size hints. The viewer does not continuously derive placement from later hint changes.

Preset changes

Changing presets does not rewrite stored spans.

effectiveSpan = Math.min(storedSpan ?? 1, activeColumnCount);

One-column presets render every effective span as one while preserving stored values for switching back.

Packing and collision rules

flow@1 has no persisted x/y coordinates. It uses deterministic row-major packing:

  1. Read visible tiles in dashboard.tiles[] order.
  2. Resolve effective span.
  3. Place the tile in the current row when it fits.
  4. Otherwise start the next row.
  5. Never overlap tiles.

Semantic, DOM, visual, and keyboard order

For flow@1:

dashboard.tiles[] order
  = DOM order
  = keyboard traversal order
  = visual row-major order
  = print/export order

Do not use CSS masonry or visual ordering that diverges from DOM order.

Dragging or keyboard movement updates dashboard.tiles[]; placement remains attached by tile ID.

Responsive/mobile normalization

At the mobile breakpoint:

  • effective column count is one;
  • every effective span is one;
  • tile order remains dashboard.tiles[];
  • persisted preset, span, and height are unchanged;
  • returning to desktop restores authored placement.

Do not persist separate mobile coordinates in v1.

Height semantics

compact < medium < large is normative ordering. Exact pixel heights are renderer-defined.

Height does not implicitly select a presentation variant.

Accessible authoring

Pointer drag/resize cannot be the only editing mechanism.

Provide equivalent commands and controls for:

moveTileEarlier(tileId);
moveTileLater(tileId);
setTileSpan(tileId, span);
setTileHeight(tileId, height);

Requirements:

  • keyboard-focusable move handle or actions menu;
  • visible Move earlier/Move later actions;
  • focus remains on the moved tile;
  • ARIA live announcement reports the new position;
  • span and height can be changed through standard controls;
  • keyboard traversal remains semantic tile order.

KPI bands

KPI bands are deterministic derived structures created by flow@1; they are not persisted as Dashboard entities.

Grouping algorithm:

  1. Resolve every tile's effective presentation.
  2. Identify explicitly configured KPI panels.
  3. Scan visible tiles in semantic order.
  4. Group each maximal consecutive KPI sequence into one band.
  5. End the band at a non-KPI tile.
  6. Recompute after reorder, add/remove, variant change, or query presentation change.

Rules:

  • each KPI remains an independently identified tile;
  • execution, cancellation, errors, and results remain per tile;
  • a band starts on a new flow row and occupies all active columns;
  • members retain semantic order and equal internal widths;
  • members wrap when necessary and stack on mobile;
  • ordinary tile span is ignored while rendered inside a KPI band but remains persisted;
  • adjacent KPI sequences cannot be manually separated in v1 without a non-KPI tile between them.

User-controlled groups or sections require a future layout/document version.


Dashboard open-source contract

Do not imply that arbitrary local bundles have durable direct URLs.

type DashboardOpenSource =
  | {
      kind: 'current-workspace';
      workspaceId: string;
      dashboardId: string;
    }
  | {
      kind: 'session-bundle';
      token: string;
      dashboardId: string;
    };

Current workspace source

A current-workspace route is bookmarkable. It authenticates, loads the stored workspace, verifies workspace and Dashboard IDs, and opens the Dashboard. If the current workspace was replaced, show unavailable/not-found rather than silently opening another Dashboard.

Session bundle source and cross-tab transport

Use IndexedDB one-time token records as the v1 shared origin-local transport for full-screen preview and temporary external-bundle viewing.

Requirements:

  • generate an unguessable token;
  • write the validated bundle/document and expiry to IndexedDB before opening the new tab;
  • the viewer atomically consumes the token record;
  • tokens are one-time and short-lived;
  • delete the record after successful consumption or expiry;
  • remove the token from the visible URL using history.replaceState after resolution;
  • ordinary sessionStorage alone is not sufficient for the cross-tab repository.

A later implementation may add postMessage or BroadcastChannel for readiness signaling, but IndexedDB is the durable source of truth for the handoff.

External file flow

For arbitrary external files:

  1. authenticate;
  2. choose/drop the file;
  3. parse and validate;
  4. show execution preflight;
  5. require explicit confirmation;
  6. create the viewer session and run.

Do not promise OAuth-surviving local file state unless it has been materialized into the defined one-time source repository.


External bundles are untrusted executable input

Opening without importing can still execute embedded SQL against the authenticated ClickHouse host.

Before first execution of an external bundle, show:

  • Dashboard title;
  • target ClickHouse host;
  • panel-query count;
  • filter-query count;
  • tile count;
  • parameters and defaults;
  • unsupported or rejected statements;
  • execution limits.

Require an explicit action such as:

Run Dashboard on <host>

Never auto-execute an external bundle after file selection or OAuth return.

Viewer policy must enforce:

  • existing read/row-returning gate for panel and filter queries;
  • no Setup execution;
  • complete semantic validation before any query starts;
  • row and byte caps;
  • bounded concurrency;
  • per-tile cancellation;
  • stale-result protection;
  • session destruction cancels pending and running work;
  • no partial execution of an invalid bundle.

Open, import, and replace semantics

Replace ambiguous Append language with resource-oriented operations.

Open bundle

Validate and view without mutating the current workspace.

  • one Dashboard: open its viewer after trust preflight when external;
  • queries only: show query-import preview;
  • several Dashboards: ask which one to open;
  • invalid dependency closure: show diagnostics and execute nothing.

Import queries

Import selected queries only. Do not add Dashboard tiles based on imported favorite flags.

Conflict actions:

  • use existing;
  • import as copy;
  • replace existing;
  • skip.

Import Dashboard and required queries

Import one selected Dashboard plus its dependency closure. Because v1 has one Dashboard, importing when one already exists requires explicit replacement or cancellation. Dashboard merge is out of scope.

Replace workspace

Replace current queries and Dashboard atomically after complete validation.

Transactional import planner

interface PortableBundleImportPlan {
  sourceDashboardId?: string;
  queryMappings: Record<string, {
    sourceId: string;
    targetId: string | null;
    action: 'use-existing' | 'copy' | 'replace' | 'skip';
  }>;
  candidateWorkspace: StoredWorkspaceV1 | null;
  diagnostics: ImportDiagnostic[];
}

Required order:

parse
-> schema validation
-> select Dashboard
-> calculate dependency closure
-> detect conflicts
-> collect decisions
-> build complete ID mapping
-> rewrite every reference
-> construct candidate workspace
-> validate rewritten workspace
-> commit atomically

Rules:

  • use-existing may be automatic only when canonical normalized query contents match; differing content requires explicit confirmation;
  • copy generates a new ID and rewrites tile query IDs, filter source IDs, and every schema-defined future query reference through one central rewrite operation;
  • replace keeps the source ID and replaces the query only in the candidate workspace;
  • skip is allowed for query-only import, but skipping a required Dashboard dependency invalidates Dashboard import;
  • do not silently remove dependent tiles or filters;
  • no application state changes before the fully rewritten candidate validates.

Legacy Library v1/v2 decoding normalizes to an in-memory portable bundle with dashboards: [].


Revision semantics

revision is a persistence-commit revision.

Increment once for each successfully committed Dashboard document mutation. Validation, preview, and export never increment revision.

Rules:

  • authoring commands mutate an in-memory draft and do not increment persisted revision;
  • one successful manual save increments once;
  • one successful autosave increments once;
  • several draft changes in one commit increment once;
  • failed persistence leaves revision unchanged;
  • initial legacy migration starts at revision 1;
  • import as copy generates a new Dashboard ID and starts at revision 1;
  • replace preserves imported Dashboard identity and revision;
  • the first successful local edit after replace increments from the imported revision.

Fallible, atomic authoring commands

Draft commands and persistence commits are separate atomic boundaries.

type DashboardCommandResult<T = void> =
  | {
      ok: true;
      value: T;
      document: DashboardDocumentV1;
      draftVersion: number;
    }
  | {
      ok: false;
      diagnostics: DashboardDiagnostic[];
      draftVersion: number;
    };
type DashboardCommand =
  | { type: 'add-query'; queryId: string }
  | { type: 'add-query-instance'; queryId: string; variant?: string }
  | { type: 'remove-tile'; tileId: string }
  | { type: 'move-tile'; tileId: string; toIndex: number }
  | { type: 'update-tile'; tileId: string; patch: DashboardTilePatch }
  | { type: 'update-placement'; tileId: string; placement: JsonObject }
  | { type: 'change-layout'; layout: DashboardLayoutDocumentV1 };
interface DashboardAuthoringSession {
  readonly state: ReadonlySignal<DashboardAuthoringState>;

  execute<T = void>(
    command: DashboardCommand,
    options?: { expectedDraftVersion?: number },
  ): Promise<DashboardCommandResult<T>>;

  commit(): Promise<WorkspaceCommitResult>;
  createPortableBundle(): PortableBundleV1;
  destroy(): void;
}

Atomic command algorithm

Every command must:

clone current draft
-> apply command to isolated candidate
-> normalize through active layout plugin
-> validate Dashboard structure
-> validate query references and roles
-> resolve and validate presentations
-> validate layout placement
-> validate resource limits
-> sort diagnostics
-> replace draft only when valid

On failure, the previous draft remains byte-for-byte unchanged.

Stale command protection

Async commands may include expectedDraftVersion. A mismatch returns a structured dashboard-command-stale diagnostic and does not mutate the draft.

Expected failures

add-query can fail for missing query, incompatible role, duplicate default instance, tile limit, invalid default variant, or invalid initial placement.

move-tile can fail for missing tile, out-of-range index, or stale draft version. Do not silently clamp indexes.

update-placement can fail for missing tile, unknown fields, unsupported span/height, plugin rejection, or size limits.

change-layout can fail when the plugin cannot load, the version is unsupported, fallback is invalid, or normalization produces an invalid document. The previous layout remains intact.

Persistence failure behavior

A valid draft may remain dirty after a failed repository commit. The committed workspace and persisted revision remain unchanged, and the user can retry.


Saved-query mutations must preserve workspace validity

Dashboard commands are not the only operations that can invalidate Dashboard references. Every saved-query mutation must construct and validate a complete candidate workspace.

This includes:

  • deleting a referenced query;
  • changing a tile query from panel to filter or setup;
  • changing a filter source role;
  • deleting a selected presentation variant;
  • changing parameter declarations used by Dashboard filters;
  • changing base panel type or structure so resolved tile presentation becomes invalid.

Policy:

Reject an invalidating saved-query mutation unless the user explicitly selects an atomic repair that produces a valid candidate workspace.

Examples of repair choices:

  • remove affected tiles;
  • remove affected filter definitions;
  • switch affected tiles to another valid variant;
  • remap to another query;
  • cancel the query mutation.

The repair and query mutation must be applied to one candidate workspace, validated, and committed atomically. Ordinary query editing must not bypass Dashboard invariants.


Dashboard sessions

DashboardViewerSession

Create a read-only runtime session usable by direct workspace routes, temporary session-bundle routes, full-screen preview, embedded preview, and tests.

It owns runtime-only state:

  • immutable Dashboard snapshot;
  • resolved queries and presentations;
  • filter values;
  • execution queue and bounded concurrency;
  • per-tile cancellation;
  • stale-wave protection;
  • tile results, errors, and progress;
  • loaded layout controller;
  • timers, listeners, effects, and destroy() cleanup.
interface DashboardViewerSession {
  readonly state: ReadonlySignal<DashboardViewState>;
  start(): Promise<void>;
  refresh(): Promise<void>;
  refreshTile(tileId: string): Promise<void>;
  setFilter(filterId: string, value: unknown): Promise<void>;
  cancelTile(tileId: string): void;
  destroy(): void;
}

It depends on narrow interfaces such as query execution, connection session, query resolver, presentation resolver, and layout registry. It must not depend on Workbench tabs, CodeMirror, schema browser DOM, mobile Workbench navigation, full App, or global AppState.

DashboardAuthoringSession

Owns the one editable Dashboard draft, dirty state, selected tile, atomic commands, diagnostics, preview state, and commit/export operations. Workbench and future AI/MCP integrations invoke typed commands instead of mutating signals or document objects directly.


Suggested module boundaries

src/
  workspace/
    stored-workspace.ts
    workspace-repository.ts
    workspace-validator.ts
    import-planner.ts

  dashboard/
    model/
      dashboard-document.ts
      dashboard-codec.ts
      portable-bundle-codec.ts
      presentation-resolver.ts
      diagnostics.ts
    application/
      dashboard-authoring-session.ts
      dashboard-viewer-session.ts
      dashboard-commands.ts
      dashboard-query-resolver.ts
      dashboard-open-source.ts
    layouts/
      registry.ts
      flow/
        schema.ts
        normalize.ts
        viewer.ts
        editor.ts
    ui/
      dashboard-viewer-shell.ts
      dashboard-authoring-preview.ts
      dashboard-tile-view.ts

Exact paths may differ, but dependency direction must remain:

model <- application <- UI adapters

Implementation phases

Phase 1: contracts and codecs

  • Add StoredWorkspaceV1, DashboardDocumentV1, PortableBundleV1, filter, presentation patch, flow@1, and fallback schemas.
  • Generate types and validators through the existing schema pipeline.
  • Add canonical encoder and deterministic diagnostic sorter.
  • Add resource-limit enforcement.
  • Add whole-workspace semantic validation.

Phase 2: atomic persistence and migration

  • Implement WorkspaceRepository atomic aggregate persistence.
  • Migrate legacy keys only when no aggregate exists.
  • Convert favorites and legacy layout preferences into one explicit Dashboard.
  • Preserve legacy keys until the aggregate commit succeeds.

Phase 3: authoring domain

  • Implement fallible atomic commands and draftVersion checks.
  • Implement presentation resolution using JSON Merge Patch.
  • Make all saved-query mutations validate complete candidate workspaces.
  • Implement revision-on-successful-commit semantics.

Phase 4: viewer and flow layout

  • Implement independent DashboardViewerSession.
  • Implement normative flow@1 viewer/editor behavior.
  • Preserve derived KPI-band behavior.
  • Add accessible keyboard reorder and sizing controls.

Phase 5: portable open/import/export

  • Implement dependency closure and canonical output ordering.
  • Implement transactional import planning and complete ID remapping.
  • Replace Append UX with Open, Import queries, Import Dashboard, and Replace workspace.
  • Add external bundle preflight and execution confirmation.

Phase 6: direct and full-screen viewing

  • Implement bookmarkable current-workspace route.
  • Implement IndexedDB one-time session-bundle handoff.
  • Lazy-load the selected layout module.
  • Ensure the read-only path does not require Workbench/editor construction.

Required tests

Schemas, codecs, and limits

  • valid and invalid Dashboard, bundle, filter, presentation patch, fallback, and flow documents;
  • every stated item/string/byte/depth limit at boundary and boundary + 1;
  • deterministic canonical encoding from differently ordered equivalent objects;
  • deterministic numeric-aware diagnostic ordering;
  • legacy format conversion and limit failures without partial import.

Presentation resolution

  • base-only panel;
  • valid named variant patch;
  • tile override after variant;
  • arrays replace atomically;
  • null deletes optional fields;
  • deletion of required fields fails;
  • renderer-type change fails;
  • missing selected variant fails;
  • final resolved panel validation catches structural and semantic errors.

Workspace persistence and migration

  • atomic full-workspace commit;
  • failed write preserves previous persisted workspace;
  • failed write leaves dirty draft and revision unchanged;
  • migration runs once based on aggregate existence;
  • legacy keys remain until successful migration commit;
  • last-successful-commit-wins multi-tab behavior never mixes aggregates.

Whole-workspace validity

  • deleting referenced query is rejected without repair;
  • role changes that invalidate tiles or filters are rejected;
  • deleting selected variant is rejected or atomically repaired;
  • parameter changes invalidate or repair filter targets atomically;
  • query mutation and repair commit as one candidate workspace.

Atomic commands

  • success replaces draft and increments draftVersion once;
  • failure leaves draft byte-for-byte unchanged;
  • stale expected version fails without mutation;
  • invalid move index is not clamped;
  • duplicate default instance fails;
  • invalid placement and layout changes fail atomically;
  • persistence failure after valid commands preserves draft for retry.

Flow layout

  • missing placement defaults;
  • span clamping for every preset;
  • switching presets does not mutate stored span;
  • deterministic row wrapping and no overlaps;
  • mobile one-column normalization without persistence mutation;
  • semantic, DOM, visual, keyboard, and print order match;
  • drag and keyboard reorder preserve placement by tile ID;
  • unknown placement fields fail;
  • keyboard move retains focus and announces position;
  • KPI bands form from consecutive KPI tiles, split at non-KPI tiles, and recompute after relevant changes;
  • per-tile cancellation and error state inside one KPI band;
  • valid fallback renders when primary layout cannot load.

Open, trust, import, and remapping

  • current-workspace route validates both IDs;
  • one-time IndexedDB token is unguessable, expiring, consumed once, deleted, and removed from URL;
  • external bundle never auto-executes;
  • invalid bundle executes zero queries;
  • preflight shows target host and execution summary;
  • import-as-copy rewrites tile and filter references;
  • use-existing automatic only for canonical equality;
  • skipped required dependency invalidates Dashboard import;
  • rewritten candidate validates before atomic commit.

Dependency boundaries

Add architecture tests or lint rules proving Dashboard model/application modules do not import Workbench UI, full App, global AppState, or editor modules.


Acceptance criteria

  • Browser persistence uses one atomic StoredWorkspaceV1 aggregate.
  • The v1 browser exposes one current workspace and zero or one Dashboard.
  • Dashboard membership, tile order, and layout no longer derive from favorites and global layout preferences after migration.
  • New JSON exports use PortableBundleV1; legacy Library formats remain readable.
  • SavedQuery Spec owns base panel, variants, and size hints; Dashboard owns tile instances and actual placement.
  • Presentation variants and tile overrides use the documented JSON Merge Patch resolution rules.
  • Resolved presentations validate before authoring success, import success, or execution.
  • Dashboard filters use the concrete v1 contract; Setup execution is rejected.
  • flow@1 and DashboardLayoutFallbackV1 have complete portable contracts.
  • KPI bands are deterministic derived flow@1 structures, not persisted groups.
  • Canonical bundle, map-key, dependency, and diagnostic ordering is deterministic.
  • All stated resource limits are enforced with precise diagnostics.
  • Authoring commands are fallible and atomic, with draftVersion separate from persisted revision.
  • Every saved-query mutation validates a complete candidate workspace and requires atomic repair when needed.
  • Dashboard revision increments only after a successful persistence commit; export never increments it.
  • Open/import conflict handling builds and validates a complete ID mapping before commit.
  • External bundles require explicit execution confirmation and obey viewer safety limits.
  • Full-screen preview uses one-time IndexedDB token transport rather than relying on sessionStorage sharing.
  • Direct current-workspace viewing is bookmarkable; temporary session-bundle viewing is not.
  • Dashboard viewer can be constructed and tested without Workbench or editor modules.
  • Multi-tab v1 policy is documented as last successful commit wins.

Non-goals

  • server-side Dashboard or workspace repository implementation;
  • ACLs, public sharing, anonymous links, or ownership model;
  • normalized server storage of queries and dashboards;
  • Dashboard list, folders, duplication, or multi-dashboard authoring UX;
  • Dashboard merge during import;
  • collaborative editing or compare-and-swap multi-tab concurrency;
  • Setup query execution;
  • arbitrary remote JavaScript layout plugins;
  • advanced Grafana-style coordinate editor;
  • user-authored groups or sections;
  • AI editing UI, although typed commands must support future AI/MCP callers;
  • separate persisted mobile layout;
  • custom server-backed direct Dashboard IDs.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions