Wave-Terminal-inspired movable+resizable blocks for the web/ PWA. Lives in
web/dashboard.js (framework) and
web/dashboard-init.js (v1 block registry).
The dashboard is a 12-column CSS grid laid over <main class="dashboard">.
Each block has an integer cell rect {col, row, w, h}, persisted to
localStorage["familiar_dashboard_layout"] keyed by block id.
- Columns: 12
- Row height anchor: 48 px
- Gap: 8 px
- Min block size: 2 cols wide × 3 rows tall
Drag and resize happen in pointer-pixel space, then snap to cell coords on release. A ghost preview shows the snapped target.
Mobile (< 768px) collapses to a single-column flex stack; drag/resize are
disabled, settings still work.
import { dashboard } from "/dashboard.js";
dashboard.registerBlockType({
id: "gpu", // unique block-type id
name: "gpu", // shown in header bar + settings drawer title
defaultRect: { col: 8, row: 0, w: 4, h: 4 },
render(el) {
// el is the block's `<div class="block-content">` — populate it.
el.classList.add("block-content-gpu");
el.append(renderGpuWidget()); // your DOM
},
renderSettings(el, state, save) {
// Optional. el is the per-block section inside the settings drawer.
// state is { rect, visible, settings } — write to state.settings.*
// and call save() to persist + apply.
const row = document.createElement("label");
row.textContent = "show temperature";
const input = document.createElement("input");
input.type = "checkbox";
input.checked = !!state.settings.showTemp;
input.addEventListener("change", () => {
state.settings.showTemp = input.checked;
save();
});
row.appendChild(input);
el.appendChild(row);
},
});Then mount once on the root:
const root = document.getElementById("dashboard");
dashboard.mount(root);mount() instantiates one block per registered type, restoring rect+settings
from localStorage when present.
The framework's settings drawer always includes:
- visible — toggle the block on/off (preserves rect+settings while hidden)
- tint —
none|warm|cool(subtle accent overlay) - font scale —
85%|100%|115%(sets--block-font-scaleon the block element; content usingfont-size: 1emwill scale)
Block-type opt-in goes in renderSettings(el, state, save).
- Use the existing CSS custom properties from
style.css(--bg,--fg,--fg-muted,--accent,--user-bg,--assistant-bg,--border,--sidebar-bg,--color-warn,--color-error). - Fonts via
--sans/--serif/--mono. - Match the parchment + sigil-gold aesthetic of the existing UI.
- Respect
prefers-reduced-motion: reduce— kill transitions, keep state changes instant.
The dashboard has a small "vibe layer" of magical feedback:
| Cue | Trigger | Where |
|---|---|---|
| Gold ripple on send | submit any chat turn | .send-ripple overlay on #send-btn |
| Block enter/exit | add via "+" picker; hide via X | .block-entering / .block-leaving |
| Slot-picker patch wash | 200 from PATCH /api/familiar/admin/slots/:slot |
.slot-row--success gold sweep |
| Synthesized chime | same trigger, when sound is enabled | sound.chime() |
| Synthesized thunk | 503 / 409 / network error on slot PATCH | sound.thunk() |
| Synthesized tick | send button submit, when sound is enabled | sound.tick() |
Sound is OFF by default. Enable it via the "sound fx" toggle in the
sidebar voice section. Persisted to localStorage["familiar_sound"]
("on" / "off"). The toggle's first click also unlocks the
AudioContext (browsers gate audio behind a user gesture).
Both prefers-reduced-motion: reduce and the familiar_sound opt-out
disable all synthesized cues — independently. Animations gated only by
reduced-motion still respect that media query via CSS.
The realm word (e.g. "Twilight Jewel") is mirrored into the connection status pill's tooltip so you can hover the pill from anywhere to see which build is loaded.
type BlockRect = { col: number; row: number; w: number; h: number };
type BlockType = {
id: string;
name: string;
defaultRect?: BlockRect;
render(el: HTMLElement): void;
renderSettings?(
el: HTMLElement,
state: { rect: BlockRect; visible: boolean; settings: Record<string, unknown> },
save: () => void,
): void;
};
interface Dashboard {
registerBlockType(type: BlockType): void;
mount(root: HTMLElement): void;
resetLayout(): void; // wipes localStorage + reloads
applyPreset(id: string): void; // rebuilds from a named preset
presets: string[]; // available preset ids
showBlock(id: string): void; // un-hide a hidden block
hideBlock(id: string): void; // hide (preserves rect+settings)
listTypes(): Array<{ id: string; name: string; visible: boolean; registered: true }>;
listBlocks(): Array<{ id: string; typeId: string; visible: boolean }>;
}+(add block) — opens a popover listing every registered block type with its current visibility. Click a row to toggle show/hide. Keyboard nav (#65, commit0551e5b): arrow Up/Down cycles rows; first row auto-focused on open; Esc returns focus to the+button.- layout — opens a popover with named presets (
default,compact,data-dense) plus a destructivereset allrow. Selecting a preset rebuilds the layout map fromPRESETS[id]and reloads; reset wipes localStorage.
| Key | Action |
|---|---|
Ctrl+K (or Cmd+K) |
Open the command palette — fuzzy-search every action |
/ |
Focus the chat input from anywhere |
Ctrl+L |
Focus the chat input (shell muscle memory) |
Ctrl+Shift+N |
New session |
Ctrl+Shift+S |
Toggle sidebar |
Ctrl+Home / Ctrl+End |
Scroll chat log to start / end |
Esc |
Abort in-flight assistant stream (also closes the dashboard picker, returning focus to its trigger) |
Tab (in settings drawer) |
Cycles within the drawer (focus trap) |
Typed into the chat input — intercepted before the LLM sees them.
| Command | Action |
|---|---|
/help or /? |
Toast with the full shortcut list |
/clear |
Soft-clear the visible log (session intact; switching sessions restores) |
/abort or /stop |
Cancel the in-flight assistant stream |
/version |
Show the build's realm word, hash, branch, built timestamp |
Unknown /-prefixed input falls through to the assistant normally —
typing /path/to/file in a question still works.
Below 768px viewport, the grid collapses to a single-column stack. To reorder blocks on a touch device:
- Long-press the block header for 500ms → "edit mode" engages, with reorder chevrons visible
- Tap the up/down chevrons on each block to move it through the source order; layout persists per-browser
Hit areas auto-bump to 44×44px minimum on coarse-pointer devices.
- Sound effects — synthesized via Web Audio API (
web/widgets/sound.js). OFF by default; enable via the "sound fx" toggle in the sidebar. - Welcome flourish — first visit per deploy (keyed on git hash):
realm word fades in over the dashboard for ~1.5s, plays
sound.flourish()at peak. Each new deploy gets its own flourish. Respectsprefers-reduced-motion.
Preset definitions live in PRESETS in dashboard.js — add a key per
block-type id (rect map), or false to hide a type in that preset.
Also exposed as window.familiarDashboard for debugging.
| id | type | default rect (col,row,w,h) | notes |
|---|---|---|---|
chat |
chat | 0,0,8,12 | wraps the existing #log + #form markup |
palace |
palace | 0,12,12,14 | wraps the existing #palace-view markup |
model |
model | 8,0,4,6 | placeholder; real picker lands via slot-picker |
Future block authors (Echo: /api/familiar/stats endpoint, Luna: stat
widgets) just call dashboard.registerBlockType({...}) before
dashboard.mount() is invoked. Add the registration to
dashboard-init.js (or a new init file loaded ahead of app.js).