feat(sacred-valley): hybrid free/snap canvas + blank & blackflame cards (2.8.0)

Replace masonry grid with an absolute-positioned 12-col canvas: drag to
move, corner to resize, per-card free/overlap toggle (Alt = no-snap).
Geometry persisted (migration 027: dashboard_layout.geom + extras).
Two new addable decorative cards: blank spacer + animated blackflame
(canvas particle flame). Old layout auto-migrates by flow-placement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-09 22:33:45 +10:00
parent e8f655ed27
commit 600057582e
11 changed files with 552 additions and 144 deletions

View File

@@ -0,0 +1,114 @@
// Decorative blackflame card — the animated centrepiece (dark heart, crimson
// licks, rising embers). Canvas particle flame ported from the approved mock.
// Instanceable via the factory; stop() tears down the rAF loop + observer.
import { el } from '../../dom.js';
const TAU = Math.PI * 2;
function startFlame(canvas) {
const ctx = canvas.getContext('2d');
let W = 0, H = 0, DPR = 1, raf = 0, t = 0;
function resize() {
DPR = Math.min(2, window.devicePixelRatio || 1);
const r = canvas.getBoundingClientRect();
W = r.width; H = r.height;
canvas.width = Math.max(1, Math.round(W * DPR));
canvas.height = Math.max(1, Math.round(H * DPR));
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
const tongues = Array.from({ length: 5 }, (_, i) => ({
phase: Math.random() * TAU, speed: 0.6 + Math.random() * 0.5,
x: 0.5 + (i - 2) * 0.085, sway: 0.03 + Math.random() * 0.04, w: 0.34 - Math.abs(i - 2) * 0.05
}));
const spawn = () => ({
x: 0.5 + (Math.random() - 0.5) * 0.5, y: 1 + Math.random() * 0.2,
vy: 0.0016 + Math.random() * 0.0030, vx: (Math.random() - 0.5) * 0.0012,
r: 0.6 + Math.random() * 1.8, life: 0, max: 120 + Math.random() * 120, hot: Math.random() < 0.5
});
const embers = Array.from({ length: 46 }, spawn);
function blob(x, y, r, c0, c1) {
if (r <= 0) return;
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, c0); g.addColorStop(1, c1);
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.fill();
}
function frame() {
t += 0.016;
ctx.globalCompositeOperation = 'source-over';
ctx.clearRect(0, 0, W, H);
ctx.globalCompositeOperation = 'lighter';
const baseY = H * 0.92;
for (const tg of tongues) {
const cx = W * (tg.x + Math.sin(t * tg.speed + tg.phase) * tg.sway);
const height = H * (0.62 + 0.08 * Math.sin(t * 1.7 + tg.phase));
const steps = 26;
for (let s = 0; s < steps; s++) {
const f = s / steps;
const y = baseY - f * height;
const flick = Math.sin(t * 3.2 + tg.phase + f * 7) * W * 0.012 * (0.4 + f);
const x = cx + flick + Math.sin(t * tg.speed * 1.3 + f * 4) * W * tg.sway * 0.6;
const r = W * tg.w * (1 - f * 0.78) * (0.9 + 0.1 * Math.sin(t * 5 + f * 9));
const a = (1 - f) * 0.5;
if (f < 0.18) blob(x, y, r * 1.1, `rgba(60,12,6,${a * 0.7})`, 'rgba(60,12,6,0)');
else if (f < 0.62) blob(x, y, r, `rgba(255,79,46,${a * 0.5})`, 'rgba(122,39,22,0)');
else blob(x, y, r * 0.8, `rgba(255,150,90,${a * 0.5})`, 'rgba(255,79,46,0)');
}
}
for (const e of embers) {
e.life++; e.y -= e.vy; e.x += e.vx + Math.sin(t * 2 + e.y * 8) * 0.0004;
if (e.y < 0.18 || e.life > e.max) Object.assign(e, spawn());
const px = e.x * W, py = e.y * H, fade = 1 - e.life / e.max;
const col = e.hot ? `rgba(255,170,110,${fade * 0.9})` : `rgba(255,79,46,${fade * 0.8})`;
ctx.shadowBlur = 8; ctx.shadowColor = '#ff4f2e';
blob(px, py, e.r * 1.6, col, 'rgba(255,79,46,0)');
}
ctx.shadowBlur = 0;
// carve the dark heart back in (the "black" of black-flame)
ctx.globalCompositeOperation = 'source-over';
const cx = W * 0.5, cy = H * 0.46, cr = W * 0.20;
const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, cr);
core.addColorStop(0, 'rgba(4,4,8,0.96)');
core.addColorStop(0.55, 'rgba(6,5,10,0.7)');
core.addColorStop(1, 'rgba(8,7,12,0)');
ctx.fillStyle = core; ctx.beginPath(); ctx.arc(cx, cy, cr, 0, TAU); ctx.fill();
ctx.globalCompositeOperation = 'lighter';
blob(W * 0.5, H * 0.95, W * 0.4, 'rgba(255,79,46,0.10)', 'rgba(255,79,46,0)');
raf = requestAnimationFrame(frame);
}
resize();
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
ro?.observe(canvas);
raf = requestAnimationFrame(frame);
return () => { cancelAnimationFrame(raf); ro?.disconnect(); };
}
export function blackflameCard(id) {
let teardown = null;
return {
id,
type: 'blackflame',
title: '',
decorative: true,
size: 'l',
mount(body) {
const canvas = el('canvas', { class: 'sv-flame-canvas' });
const crest = el('div', { class: 'sv-flame-crest' });
const label = el('div', { class: 'sv-flame-label' }, 'The Void');
body.append(canvas, crest, label);
// defer one frame so the canvas has a measured size
requestAnimationFrame(() => { teardown = startFlame(canvas); });
},
start() {},
stop() { teardown && teardown(); teardown = null; }
};
}

View File

@@ -0,0 +1,16 @@
// A decorative blank spacer card — deliberate empty space / grouping on the
// Sacred Valley canvas. Instanceable: each gets a unique id via the factory.
import { el } from '../../dom.js';
export function blankCard(id) {
return {
id,
type: 'blank',
title: '',
decorative: true,
size: 'm',
mount(body) { body.appendChild(el('div', { class: 'sv-blank' })); },
start() {},
stop() {}
};
}

View File

@@ -3,8 +3,6 @@ import { api } from '../api.js';
import { renderHealthBand, stopHealthBand } from './health_band.js';
import { renderDevicesBand, stopDevicesBand } from './devices_band.js';
import { svCard } from '../components/sv_card.js';
import { moveId } from '../components/sv_reorder.js';
import { orderCards } from './cards/registry.js';
import clock from './cards/clock.js';
import weather from './cards/weather.js';
import hostPerf from './cards/host_perf.js';
@@ -16,146 +14,228 @@ import aiUsage from './cards/ai_usage.js';
import cluster from './cards/cluster.js';
import storage from './cards/storage.js';
import backups from './cards/backups.js';
import { blankCard } from './cards/blank.js';
import { blackflameCard } from './cards/blackflame.js';
const CARD_MODULES = [clock, weather, hostPerf, cluster, storage, backups, jobs, inbox, search, speedtest, aiUsage];
const BY_ID = new Map(CARD_MODULES.map(d => [d.id, d]));
const BUILTIN_BY_ID = new Map(CARD_MODULES.map(d => [d.id, d]));
// ---- hybrid canvas geometry ----
// Cards are absolutely placed on a 12-column grid. {x,y,w,h} are in grid units
// (x,w in columns; y,h in rows of ROW_H px). Snap mode keeps them integer; a
// per-card `free` flag (or holding Alt while dragging) allows fractional
// placement + overlap. Everything scales with board width, so x/w stay relative.
const COLS = 12;
const ROW_H = 28; // px per grid row
const GUTTER = 12; // visual gap baked into each card's rendered size
const SIZE_W = { s: 3, m: 4, l: 6 };
const SIZE_H = { s: 6, m: 8, l: 10 };
let active = []; // mounted cards needing stop()
let renderGen = 0; // guards overlapping async renders
let editing = false;
let layout = { card_order: [], hidden: [], sizes: {} };
let mainEl;
let layout = { hidden: [], geom: {}, extras: [] };
const grid = () => document.getElementById('sv-cards');
// ---- masonry packing: cards keep their column span (width) but pack vertically by
// content height (via grid-row span over small auto-rows), so mismatched heights no
// longer leave gaps / rigid rows. ResizeObserver re-packs as async cards fill in.
const ROW_UNIT = 8, GRID_GAP = 16;
function packCard(node) {
if (!node || !node.isConnected) return;
const h = node.getBoundingClientRect().height;
if (h) node.style.gridRowEnd = 'span ' + Math.max(1, Math.ceil((h + GRID_GAP) / (ROW_UNIT + GRID_GAP)));
function defFor(extra) {
if (extra.type === 'blank') return blankCard(extra.id);
if (extra.type === 'blackflame') return blackflameCard(extra.id);
return null;
}
const ro = typeof ResizeObserver !== 'undefined'
? new ResizeObserver(entries => entries.forEach(e => packCard(e.target))) : null;
let repackRaf;
function repackAll() {
cancelAnimationFrame(repackRaf);
repackRaf = requestAnimationFrame(() => grid()?.querySelectorAll('.sv-card').forEach(packCard));
function visibleDefs() {
const hidden = new Set(layout.hidden || []);
const builtins = CARD_MODULES.filter(d => !hidden.has(d.id));
const extras = (layout.extras || []).map(defFor).filter(Boolean);
return [...builtins, ...extras];
}
function defaultSize(def) {
if (def.type === 'blackflame') return { w: 6, h: 10 };
if (def.type === 'blank') return { w: 3, h: 4 };
return { w: SIZE_W[def.size] || 4, h: SIZE_H[def.size] || 8 };
}
function geomOf(def) {
const g = (layout.geom || {})[def.id];
if (g) return g;
const { w, h } = defaultSize(def);
return { x: 0, y: 0, w, h };
}
if (typeof window !== 'undefined') window.addEventListener('resize', repackAll);
async function saveLayout() {
try { await api.put('/api/dashboard/layout', layout); }
catch (e) { console.error('save layout', e); }
try {
await api.put('/api/dashboard/layout', {
card_order: [], sizes: {},
hidden: layout.hidden || [], geom: layout.geom || {}, extras: layout.extras || []
});
} catch (e) { console.error('save layout', e); }
}
// ---- per-card edit controls (drag grip + size + hide), shown only in edit mode via CSS
const STR_SPAN = { s: 2, m: 6, l: 12 }; // legacy size → 12-col span (s = 1/6, m = 1/2, l = full)
function spanOf(def) {
const v = layout.sizes?.[def.id];
if (typeof v === 'number') return Math.max(1, Math.min(12, v));
if (typeof v === 'string') return STR_SPAN[v] || 6;
return STR_SPAN[def.size] || 6;
}
function curSpan(id) {
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
const m = node && (node.style.gridColumn || '').match(/span (\d+)/);
return m ? +m[1] : spanOf(BY_ID.get(id) || {});
}
function setSpan(id, delta) {
const span = Math.max(1, Math.min(12, curSpan(id) + delta));
layout.sizes = { ...layout.sizes, [id]: span };
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
if (node) {
node.style.gridColumn = 'span ' + span;
const lbl = node.querySelector('.sv-span-val');
if (lbl) lbl.textContent = span;
// Backfill geometry for any visible card lacking it (first load / migration /
// a newly-shipped built-in). New cards flow below whatever already has a spot.
function autoPlaceMissing(defs) {
const geom = { ...(layout.geom || {}) };
let baseY = 0;
for (const id in geom) baseY = Math.max(baseY, geom[id].y + geom[id].h);
let cx = 0, cy = baseY, rowH = 0;
for (const d of defs) {
if (geom[d.id]) continue;
const { w, h } = defaultSize(d);
if (cx + w > COLS) { cx = 0; cy += rowH; rowH = 0; }
geom[d.id] = { x: cx, y: cy, w, h };
cx += w; rowH = Math.max(rowH, h);
}
layout.geom = geom;
}
function cellW() { return grid().clientWidth / COLS; }
function applyGeom(node, g) {
const cw = cellW();
node.style.position = 'absolute';
node.style.left = (g.x * cw) + 'px';
node.style.top = (g.y * ROW_H) + 'px';
node.style.width = Math.max(40, g.w * cw - GUTTER) + 'px';
node.style.height = Math.max(40, g.h * ROW_H - GUTTER) + 'px';
node.style.zIndex = g.free ? 5 : 1;
node.classList.toggle('free', !!g.free);
}
function fitBoard() {
let max = 0;
grid().querySelectorAll('.sv-card').forEach(n => { max = Math.max(max, n.offsetTop + n.offsetHeight); });
grid().style.height = (max + GUTTER) + 'px';
}
function relayout() {
active.forEach(def => {
const n = grid().querySelector(`.sv-card[data-card-id="${def.id}"]`);
if (n) applyGeom(n, geomOf(def));
});
fitBoard();
}
// ---- drag / resize (pointer-based; snap unless free or Alt held) ----
function beginDrag(ev, def, mode) {
if (!editing) return;
ev.preventDefault(); ev.stopPropagation();
const node = grid().querySelector(`.sv-card[data-card-id="${def.id}"]`);
if (!node) return;
const g = { ...geomOf(def) };
const start = { px: ev.clientX, py: ev.clientY, x: g.x, y: g.y, w: g.w, h: g.h };
node.classList.add('dragging');
const cw = cellW();
function moveTo(e) {
const dxc = (e.clientX - start.px) / cw;
const dyc = (e.clientY - start.py) / ROW_H;
const freeNow = g.free || e.altKey;
if (mode === 'move') {
let nx = start.x + dxc, ny = start.y + dyc;
if (!freeNow) { nx = Math.round(nx); ny = Math.round(ny); }
g.x = Math.max(0, Math.min(COLS - g.w, nx));
g.y = Math.max(0, ny);
} else {
let nw = start.w + dxc, nh = start.h + dyc;
if (!freeNow) { nw = Math.round(nw); nh = Math.round(nh); }
g.w = Math.max(2, Math.min(COLS - g.x, nw));
g.h = Math.max(2, nh);
}
applyGeom(node, g); fitBoard();
}
function end() {
document.removeEventListener('pointermove', moveTo);
document.removeEventListener('pointerup', end);
node.classList.remove('dragging');
layout.geom = { ...layout.geom, [def.id]: g };
applyGeom(node, g);
saveLayout();
}
document.addEventListener('pointermove', moveTo);
document.addEventListener('pointerup', end);
}
function toggleFree(id) {
const g = { ...(layout.geom[id] || geomOf({ id })) };
g.free = !g.free;
layout.geom = { ...layout.geom, [id]: g };
const n = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
if (n) applyGeom(n, g);
saveLayout();
}
function editOverlay(def) {
const grip = el('span', { class: 'sv-grip', draggable: true, title: 'Drag to reorder' }, '⠿');
const stepper = el('span', { class: 'sv-ed-span' },
el('button', { class: 'sv-ed-step', title: 'Narrower', onclick: () => setSpan(def.id, -1) }, ''),
el('span', { class: 'sv-span-val', title: 'Width (of 12)' }, String(spanOf(def))),
el('button', { class: 'sv-ed-step', title: 'Wider', onclick: () => setSpan(def.id, +1) }, '+'));
const hide = el('button', { class: 'sv-ed-hide', title: 'Hide card', onclick: () => hideCard(def.id) }, '✕');
return el('div', { class: 'sv-card-edit' }, grip, stepper, hide);
const grip = el('span', { class: 'sv-grip', title: 'Drag to move' }, '⠿');
grip.addEventListener('pointerdown', e => beginDrag(e, def, 'move'));
const free = el('button', { class: 'sv-ed-free', title: 'Free / snap placement', onclick: () => toggleFree(def.id) }, '');
const hide = el('button', { class: 'sv-ed-hide', title: def.decorative ? 'Delete card' : 'Hide card', onclick: () => removeCard(def) }, '✕');
const resize = el('span', { class: 'sv-resize', title: 'Drag to resize' });
resize.addEventListener('pointerdown', e => beginDrag(e, def, 'resize'));
const frag = document.createDocumentFragment();
frag.append(el('div', { class: 'sv-card-edit' }, grip, free, hide), resize);
return frag;
}
function mountOne(def) {
const span = spanOf(def);
const { root, body } = svCard({ ...def, span });
const { root, body } = svCard(def);
if (def.decorative) root.classList.add('sv-card-decor');
root.appendChild(editOverlay(def));
applyGeom(root, geomOf(def));
grid().appendChild(root);
ro?.observe(root); packCard(root);
try { def.mount(body); def.start && def.start(); active.push(def); }
catch (e) { body.appendChild(el('span', { class: 'muted' }, 'card failed')); console.error(def.id, e); }
}
function setSize(id, s) {
layout.sizes = { ...layout.sizes, [id]: s };
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
if (node) node.dataset.size = s;
saveLayout();
function placeNew(def) {
let maxBottom = 0;
for (const id in layout.geom) { const g = layout.geom[id]; maxBottom = Math.max(maxBottom, g.y + g.h); }
const { w, h } = defaultSize(def);
layout.geom = { ...layout.geom, [def.id]: { x: 0, y: maxBottom, w, h } };
}
function hideCard(id) {
if (!layout.hidden.includes(id)) layout.hidden = [...layout.hidden, id];
const def = BY_ID.get(id);
if (def?.stop) def.stop();
active = active.filter(d => d.id !== id);
grid().querySelector(`.sv-card[data-card-id="${id}"]`)?.remove();
renderTray();
saveLayout();
function addBuiltin(id) {
layout.hidden = (layout.hidden || []).filter(x => x !== id);
const def = BUILTIN_BY_ID.get(id);
if (!def) return;
placeNew(def); mountOne(def); relayout(); renderTray(); saveLayout();
}
function showCard(id) {
layout.hidden = layout.hidden.filter(x => x !== id);
const def = BY_ID.get(id);
if (def) { mountOne(def); wireDrag(); }
renderTray();
saveLayout();
function addDecor(type) {
const id = `${type}-${Date.now().toString(36)}`;
const def = defFor({ id, type });
if (!def) return;
layout.extras = [...(layout.extras || []), { id, type }];
placeNew(def); mountOne(def); relayout(); renderTray(); saveLayout();
}
function onReorder(newOrder) {
const frag = document.createDocumentFragment();
newOrder.forEach(id => { const n = grid().querySelector(`.sv-card[data-card-id="${id}"]`); if (n) frag.appendChild(n); });
grid().appendChild(frag);
layout.card_order = newOrder;
saveLayout();
}
// Drag from the grip only; the rest of the card is inert so the search box etc. work.
function wireDrag() {
let dragId = null;
grid().querySelectorAll('.sv-card').forEach(card => {
if (card._wired) return; card._wired = true;
const g = card.querySelector('.sv-grip');
if (g) {
g.addEventListener('dragstart', (e) => { dragId = card.dataset.cardId; card.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; });
g.addEventListener('dragend', () => { card.classList.remove('dragging'); dragId = null; });
}
card.addEventListener('dragover', (e) => { if (dragId) { e.preventDefault(); card.classList.add('drag-over'); } });
card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
card.addEventListener('drop', (e) => {
e.preventDefault(); card.classList.remove('drag-over');
if (!dragId || dragId === card.dataset.cardId) return;
const ids = [...grid().querySelectorAll('.sv-card')].map(c => c.dataset.cardId);
onReorder(moveId(ids, dragId, card.dataset.cardId));
});
});
function removeCard(def) {
const d = active.find(a => a.id === def.id);
if (d && d.stop) d.stop();
active = active.filter(a => a.id !== def.id);
grid().querySelector(`.sv-card[data-card-id="${def.id}"]`)?.remove();
if (def.decorative) {
layout.extras = (layout.extras || []).filter(e => e.id !== def.id);
const g = { ...layout.geom }; delete g[def.id]; layout.geom = g;
} else if (!(layout.hidden || []).includes(def.id)) {
layout.hidden = [...(layout.hidden || []), def.id];
}
renderTray(); fitBoard(); saveLayout();
}
function renderTray() {
const tray = document.getElementById('sv-tray');
if (!tray) return;
const hidden = layout.hidden.map(id => BY_ID.get(id)).filter(Boolean);
const hidden = (layout.hidden || []).map(id => BUILTIN_BY_ID.get(id)).filter(Boolean);
mount(tray,
hidden.length ? el('span', { class: 'sv-tray-label' }, 'Hidden:') : el('span', { class: 'muted' }, 'No hidden cards'),
...hidden.map(def => el('button', { class: 'sv-tray-chip', onclick: () => showCard(def.id) }, '+ ' + def.title)));
el('span', { class: 'sv-tray-label' }, 'Add card:'),
el('button', { class: 'sv-tray-chip', onclick: () => addDecor('blank') }, '+ Blank'),
el('button', { class: 'sv-tray-chip', onclick: () => addDecor('blackflame') }, '+ Blackflame'),
hidden.length ? el('span', { class: 'sv-tray-label', style: { marginLeft: '8px' } }, 'Restore:') : null,
...hidden.map(def => el('button', { class: 'sv-tray-chip', onclick: () => addBuiltin(def.id) }, '+ ' + def.title)),
el('span', { class: 'sv-tray-hint muted' }, 'drag ⠿ to move · corner to resize · ⤢ = free/overlap · Alt = no-snap'));
tray.style.display = editing ? 'flex' : 'none';
}
@@ -169,9 +249,8 @@ function toggleEdit() {
renderTray();
}
let mainEl;
async function resetLayout() {
layout = { card_order: [], hidden: [], sizes: {} };
layout = { hidden: [], geom: {}, extras: [] };
await saveLayout();
render(mainEl);
}
@@ -179,7 +258,7 @@ async function resetLayout() {
export async function render(main) {
mainEl = main;
const myGen = ++renderGen;
active.forEach(c => c.stop && c.stop()); active = []; ro?.disconnect(); stopHealthBand(); stopDevicesBand();
active.forEach(c => c.stop && c.stop()); active = []; stopHealthBand(); stopDevicesBand();
editing = false;
mount(main,
el('h1', { class: 'view-h1' }, 'Sacred Valley'),
@@ -194,12 +273,21 @@ export async function render(main) {
el('div', { id: 'sv-devices' })
);
layout = { card_order: [], hidden: [], sizes: {} };
try { layout = await api.get('/api/dashboard/layout'); } catch { /* defaults */ }
layout = { hidden: [], geom: {}, extras: [] };
try {
const l = await api.get('/api/dashboard/layout');
layout = { hidden: l.hidden || [], geom: l.geom || {}, extras: l.extras || [] };
} catch { /* defaults */ }
if (myGen !== renderGen) return;
for (const def of orderCards(CARD_MODULES, layout)) mountOne(def);
wireDrag();
const defs = visibleDefs();
autoPlaceMissing(defs);
for (const def of defs) mountOne(def);
relayout();
renderTray();
window.removeEventListener('resize', relayout);
window.addEventListener('resize', relayout);
renderHealthBand(document.getElementById('sv-health'));
renderDevicesBand(document.getElementById('sv-devices'));
}