From 600057582e6dff120b74c77a451acddfb49aebb1 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 9 Jun 2026 22:33:45 +1000 Subject: [PATCH] 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 --- docs/mockups/blackflame-card.html | 150 +++++++++++ lib/api/routes/dashboard.js | 14 +- lib/db/migrations/027_dashboard_geom.sql | 7 + lib/db/repos/dashboard_layout.js | 16 +- package-lock.json | 4 +- package.json | 2 +- public/components/sv_card.js | 11 +- public/style.css | 58 +++-- public/views/cards/blackflame.js | 114 +++++++++ public/views/cards/blank.js | 16 ++ public/views/sacred_valley.js | 304 +++++++++++++++-------- 11 files changed, 552 insertions(+), 144 deletions(-) create mode 100644 docs/mockups/blackflame-card.html create mode 100644 lib/db/migrations/027_dashboard_geom.sql create mode 100644 public/views/cards/blackflame.js create mode 100644 public/views/cards/blank.js diff --git a/docs/mockups/blackflame-card.html b/docs/mockups/blackflame-card.html new file mode 100644 index 0000000..4dc7a90 --- /dev/null +++ b/docs/mockups/blackflame-card.html @@ -0,0 +1,150 @@ + + + + +Blackflame card — mockup + + + + + +
+ +
+
The Void
+
Sacred Valley · core
+
+
Blackflame · canvas particle flame (dark heart, crimson licks, rising embers)
+ + + + diff --git a/lib/api/routes/dashboard.js b/lib/api/routes/dashboard.js index 4e42436..f388940 100644 --- a/lib/api/routes/dashboard.js +++ b/lib/api/routes/dashboard.js @@ -8,11 +8,23 @@ import * as repo from '../../db/repos/dashboard_layout.js'; export const router = Router(); router.use(requireOwner); +const geomCell = z.object({ + x: z.number(), y: z.number(), + w: z.number().positive(), h: z.number().positive(), + free: z.boolean().optional() +}); const layoutSchema = z.object({ card_order: z.array(z.string()).default([]), hidden: z.array(z.string()).default([]), // Per-card width: an integer column span 1–12 (legacy 's'|'m'|'l' still accepted). - sizes: z.record(z.union([z.number().int().min(1).max(12), z.enum(['s', 'm', 'l'])])).default({}) + sizes: z.record(z.union([z.number().int().min(1).max(12), z.enum(['s', 'm', 'l'])])).default({}), + // Hybrid-canvas geometry, keyed by card id → {x,y,w,h,free} in 12-col grid units. + geom: z.record(geomCell).default({}), + // User-added decorative card instances that must survive reloads. + extras: z.array(z.object({ + id: z.string().min(1).max(64), + type: z.enum(['blank', 'blackflame']) + })).default([]) }); router.get('/layout', asyncWrap(async (_req, res) => { diff --git a/lib/db/migrations/027_dashboard_geom.sql b/lib/db/migrations/027_dashboard_geom.sql new file mode 100644 index 0000000..14b8621 --- /dev/null +++ b/lib/db/migrations/027_dashboard_geom.sql @@ -0,0 +1,7 @@ +-- 027_dashboard_geom.sql +-- Sacred Valley hybrid canvas: free/snap geometry per card + decorative card +-- instances (blank spacers, blackflame). geom is keyed by card id → +-- {x,y,w,h,free} in (fractional) 12-col grid units; extras lists the +-- user-added decorative cards so they survive reloads. +ALTER TABLE dashboard_layout ADD COLUMN IF NOT EXISTS geom jsonb NOT NULL DEFAULT '{}'::jsonb; +ALTER TABLE dashboard_layout ADD COLUMN IF NOT EXISTS extras jsonb NOT NULL DEFAULT '[]'::jsonb; diff --git a/lib/db/repos/dashboard_layout.js b/lib/db/repos/dashboard_layout.js index 11a6996..6063cfa 100644 --- a/lib/db/repos/dashboard_layout.js +++ b/lib/db/repos/dashboard_layout.js @@ -1,24 +1,28 @@ import { pool } from '../pool.js'; -const DEFAULTS = { card_order: [], hidden: [], sizes: {} }; +const DEFAULTS = { card_order: [], hidden: [], sizes: {}, geom: {}, extras: [] }; export async function get() { const { rows } = await pool.query( - `SELECT card_order, hidden, sizes FROM dashboard_layout WHERE owner_key = 'owner'` + `SELECT card_order, hidden, sizes, geom, extras + FROM dashboard_layout WHERE owner_key = 'owner'` ); return rows[0] || { ...DEFAULTS }; } -export async function put({ card_order = [], hidden = [], sizes = {} }) { +export async function put({ card_order = [], hidden = [], sizes = {}, geom = {}, extras = [] }) { await pool.query( - `INSERT INTO dashboard_layout (owner_key, card_order, hidden, sizes, updated_at) - VALUES ('owner', $1::jsonb, $2::jsonb, $3::jsonb, now()) + `INSERT INTO dashboard_layout (owner_key, card_order, hidden, sizes, geom, extras, updated_at) + VALUES ('owner', $1::jsonb, $2::jsonb, $3::jsonb, $4::jsonb, $5::jsonb, now()) ON CONFLICT (owner_key) DO UPDATE SET card_order = EXCLUDED.card_order, hidden = EXCLUDED.hidden, sizes = EXCLUDED.sizes, + geom = EXCLUDED.geom, + extras = EXCLUDED.extras, updated_at = now()`, - [JSON.stringify(card_order), JSON.stringify(hidden), JSON.stringify(sizes)] + [JSON.stringify(card_order), JSON.stringify(hidden), JSON.stringify(sizes), + JSON.stringify(geom), JSON.stringify(extras)] ); return get(); } diff --git a/package-lock.json b/package-lock.json index 970faba..73b0357 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "void-server", - "version": "2.7.0", + "version": "2.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "void-server", - "version": "2.7.0", + "version": "2.8.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@mozilla/readability": "^0.6.0", diff --git a/package.json b/package.json index e169aaf..2dde837 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "void-server", - "version": "2.7.0", + "version": "2.8.0", "type": "module", "private": true, "scripts": { diff --git a/public/components/sv_card.js b/public/components/sv_card.js index bc71854..e4db4a4 100644 --- a/public/components/sv_card.js +++ b/public/components/sv_card.js @@ -1,14 +1,13 @@ import { el } from '../dom.js'; // Builds the refined-B chrome shell and returns { root, body }. The card module -// fills `body` in its mount(); start()/stop() own its refresh timer. +// fills `body` in its mount(); start()/stop() own its refresh timer. Position + +// size are set by the Sacred Valley canvas (absolute geometry), not here. +// Decorative cards (blank / blackflame) carry no title bar. export function svCard(def) { const body = el('div', { class: 'sv-card-body' }); - const root = el('div', { - class: 'sv-card', dataset: { cardId: def.id }, - style: { gridColumn: 'span ' + (def.span || 6) } // 12-col grid; per-card width - }, - el('div', { class: 'sv-card-title' }, def.title), + const root = el('div', { class: 'sv-card', dataset: { cardId: def.id } }, + def.title ? el('div', { class: 'sv-card-title' }, def.title) : null, body ); return { root, body }; diff --git a/public/style.css b/public/style.css index 7c12ae8..cec8b12 100644 --- a/public/style.css +++ b/public/style.css @@ -382,14 +382,9 @@ ul.plain li:last-child { border-bottom: none; } /* reserved for a future agent-output phase — unused now: --hue-dross: #ff4f2e; --hue-yerin: #c45a4a; --hue-orthos: #6fa86a; */ } -#sv-cards { display: grid; grid-template-columns: repeat(12, 1fr); gap: 16px; align-items: start; grid-auto-rows: 8px; grid-auto-flow: row dense; } -.sv-card { grid-column: span 6; } /* fallback; svCard sets an inline span (1–12) */ -@media (max-width: 700px) { #sv-cards { grid-template-columns: 1fr; } .sv-card { grid-column: 1 / -1 !important; } } -.sv-ed-span { display: inline-flex; align-items: center; gap: 3px; } -.sv-ed-step { width: 18px; height: 20px; border: 1px solid var(--border); background: transparent; color: var(--muted); - border-radius: 3px; font-size: 14px; line-height: 1; cursor: pointer; padding: 0; } -.sv-ed-step:hover { color: var(--accent); border-color: var(--accent-dim); } -.sv-span-val { font-family: var(--font-mono); font-size: 12px; color: var(--text); min-width: 14px; text-align: center; } +/* Hybrid canvas: cards are absolutely placed (JS sets left/top/width/height in + 12-col grid units); the board grows to fit its content. See sacred_valley.js. */ +#sv-cards { position: relative; width: 100%; min-height: 200px; } .sv-card { position: relative; border: 1px solid #2c242a; border-radius: 10px; padding: 16px 18px; @@ -407,8 +402,11 @@ ul.plain li:last-child { border-bottom: none; } border-color: #4a2c28; transform: translateY(-2px); box-shadow: 0 8px 28px rgba(0,0,0,.45), inset 0 0 46px rgba(255,79,46,.06), 0 0 0 1px rgba(255,79,46,.10); } -.sv-card.dragging { opacity: .5; } -.sv-card.drag-over { border-color: var(--accent); } +.sv-card.dragging { transition: none; box-shadow: 0 16px 44px -12px #000, 0 0 0 1px var(--accent-dim); } +.sv-card.free { box-shadow: 0 0 0 1px var(--accent-dim), 0 10px 30px -12px #000; } +#sv-cards.editing .sv-card { transition: none; } +#sv-cards.editing .sv-card:hover { transform: none; } +.sv-card-body { height: 100%; overflow: auto; } .sv-card-title { font-family: var(--font-display); font-size: 13px; letter-spacing: .16em; text-transform: uppercase; color: var(--text); padding-bottom: 7px; margin-bottom: 12px; @@ -626,18 +624,38 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; } background: rgba(10,10,14,.88); border: 1px solid var(--border); border-radius: 6px; padding: 3px 5px; } #sv-cards.editing .sv-card-edit { display: flex; } #sv-cards.editing .sv-card { outline: 1px dashed var(--accent-dim); } -.sv-grip { cursor: grab; color: var(--muted); font-size: 14px; padding: 0 2px; user-select: none; } +.sv-grip { cursor: grab; color: var(--muted); font-size: 14px; padding: 0 2px; user-select: none; touch-action: none; } .sv-grip:active { cursor: grabbing; } -.sv-ed-sizes { display: flex; gap: 2px; } -.sv-ed-size, .sv-ed-hide { width: 20px; height: 20px; border: 1px solid var(--border); background: transparent; - border-radius: 3px; font-size: 11px; cursor: pointer; padding: 0; line-height: 1; } -.sv-ed-size { color: var(--muted); } -.sv-ed-size:hover { color: var(--accent); border-color: var(--accent-dim); } -.sv-card[data-size="s"] .sv-ed-size[data-s="s"], -.sv-card[data-size="m"] .sv-ed-size[data-s="m"], -.sv-card[data-size="l"] .sv-ed-size[data-s="l"] { background: var(--accent-dim); color: var(--text); border-color: var(--accent); } -.sv-ed-hide { color: var(--bad); font-size: 12px; } +.sv-ed-free, .sv-ed-hide { width: 20px; height: 20px; border: 1px solid var(--border); background: transparent; + border-radius: 3px; font-size: 12px; cursor: pointer; padding: 0; line-height: 1; } +.sv-ed-free { color: var(--muted); } +.sv-ed-free:hover { color: var(--accent); border-color: var(--accent-dim); } +.sv-card.free .sv-ed-free { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); } +.sv-ed-hide { color: var(--bad); } .sv-ed-hide:hover { background: var(--bad); color: var(--bg); } +/* resize handle — bottom-right corner, edit mode only */ +.sv-resize { display: none; position: absolute; right: 3px; bottom: 3px; width: 16px; height: 16px; z-index: 4; + cursor: nwse-resize; touch-action: none; border-radius: 0 0 9px 0; + background: linear-gradient(135deg, transparent 45%, var(--muted) 45% 55%, transparent 55%, + transparent 62%, var(--muted) 62% 72%, transparent 72%); opacity: .6; } +.sv-resize:hover { opacity: 1; } +#sv-cards.editing .sv-resize { display: block; } +.sv-tray-hint { font-family: var(--font-ui); font-size: 11px; margin-left: 6px; } + +/* decorative cards (blank spacer / blackflame) — no chrome padding, full-bleed body */ +.sv-card-decor { padding: 0; overflow: hidden; } +.sv-card-decor .sv-card-body { position: absolute; inset: 0; padding: 0; overflow: hidden; } +.sv-card-decor:hover { transform: none; } +.sv-blank { width: 100%; height: 100%; + background-image: repeating-linear-gradient(45deg, rgba(255,255,255,.025) 0 9px, transparent 9px 18px); } +.sv-flame-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; pointer-events: none; } +.sv-flame-crest { position: absolute; top: 46%; left: 50%; width: 22%; aspect-ratio: 1; transform: translate(-50%,-50%); + border-radius: 50%; pointer-events: none; + background: radial-gradient(circle at 50% 45%, #000 38%, #0a0a10 60%, transparent 72%); + box-shadow: 0 0 26px 10px #000, 0 0 60px 18px rgba(255,79,46,.13); } +.sv-flame-label { position: absolute; left: 0; right: 0; bottom: 14px; text-align: center; pointer-events: none; + font-family: var(--font-display); letter-spacing: .42em; text-indent: .42em; text-transform: uppercase; + font-size: 14px; color: #f4ece9; text-shadow: 0 0 18px rgba(255,79,46,.55), 0 0 4px #000; } #sv-tray { flex-wrap: wrap; align-items: center; gap: 8px; margin: 2px 0 18px; padding: 10px 12px; border: 1px dashed var(--border); border-radius: 8px; } diff --git a/public/views/cards/blackflame.js b/public/views/cards/blackflame.js new file mode 100644 index 0000000..47f503d --- /dev/null +++ b/public/views/cards/blackflame.js @@ -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; } + }; +} diff --git a/public/views/cards/blank.js b/public/views/cards/blank.js new file mode 100644 index 0000000..9f07254 --- /dev/null +++ b/public/views/cards/blank.js @@ -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() {} + }; +} diff --git a/public/views/sacred_valley.js b/public/views/sacred_valley.js index 17b80c9..c0a7c99 100644 --- a/public/views/sacred_valley.js +++ b/public/views/sacred_valley.js @@ -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')); }