feat(ui): 2.0.0-alpha.13 — finer per-card width scaling (12-col grid + -/+ stepper)

clock/weather etc. default to 1/6 width; sizes store an integer span 1-12
(legacy s/m/l still accepted by /api/dashboard/layout).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-04 18:23:37 +10:00
parent ae3a45251d
commit f780043f2d
7 changed files with 51 additions and 16 deletions

View File

@@ -3,6 +3,10 @@
All notable changes to Void 2.0 are documented here.
Format: [Keep a Changelog](https://keepachangelog.com).
## 2.0.0-alpha.13 — Finer Sacred Valley tile scaling
- Cards now sit on a 12-column grid with a per-card width **/+ stepper** (span 112) in edit mode, replacing the coarse S/M/L. "Small" defaults to 1/6 width (half its previous size) so clock/weather aren't oversized.
- Layout `sizes` now store an integer column span (legacy 's'/'m'/'l' still accepted).
## 2.0.0-alpha.12 — Editable Sacred Valley layout
- "Edit layout" mode on the dashboard: per-card **resize** (S/M/L column span), **show/hide** (with a hidden-cards tray to re-add), clearer **drag-to-reorder** via a dedicated grip handle, and a **Reset** to defaults.
- All changes persist through the existing `/api/dashboard/layout` (order/sizes/hidden) — no backend changes.

View File

@@ -11,7 +11,8 @@ router.use(requireOwner);
const layoutSchema = z.object({
card_order: z.array(z.string()).default([]),
hidden: z.array(z.string()).default([]),
sizes: z.record(z.enum(['s', 'm', 'l'])).default({})
// Per-card width: an integer column span 112 (legacy 's'|'m'|'l' still accepted).
sizes: z.record(z.union([z.number().int().min(1).max(12), z.enum(['s', 'm', 'l'])])).default({})
});
router.get('/layout', asyncWrap(async (_req, res) => {

View File

@@ -1,6 +1,6 @@
{
"name": "void-server",
"version": "2.0.0-alpha.12",
"version": "2.0.0-alpha.13",
"type": "module",
"private": true,
"scripts": {

View File

@@ -4,7 +4,10 @@ import { el } from '../dom.js';
// fills `body` in its mount(); start()/stop() own its refresh timer.
export function svCard(def) {
const body = el('div', { class: 'sv-card-body' });
const root = el('div', { class: 'sv-card', dataset: { size: def.size || 'm', cardId: def.id } },
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),
body
);

View File

@@ -174,12 +174,14 @@ 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(6, 1fr); gap: 16px; align-items: start; }
.sv-card { grid-column: span 2; } /* s / fallback (factory always sets data-size) */
.sv-card[data-size="s"] { grid-column: span 2; }
.sv-card[data-size="m"] { grid-column: span 3; }
.sv-card[data-size="l"] { grid-column: span 6; }
@media (max-width: 900px) { #sv-cards { grid-template-columns: 1fr; } .sv-card { grid-column: 1 / -1 !important; } }
#sv-cards { display: grid; grid-template-columns: repeat(12, 1fr); gap: 16px; align-items: start; }
.sv-card { grid-column: span 6; } /* fallback; svCard sets an inline span (112) */
@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: 13px; 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: 11px; color: var(--text); min-width: 14px; text-align: center; }
.sv-card {
position: relative; border: 1px solid #2c242a; border-radius: 10px; padding: 16px 18px;

View File

@@ -29,18 +29,43 @@ async function saveLayout() {
}
// ---- 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;
}
saveLayout();
}
function editOverlay(def) {
const grip = el('span', { class: 'sv-grip', draggable: true, title: 'Drag to reorder' }, '⠿');
const sizes = el('span', { class: 'sv-ed-sizes' },
...['s', 'm', 'l'].map(s =>
el('button', { class: 'sv-ed-size', dataset: { s }, onclick: () => setSize(def.id, s) }, s.toUpperCase())));
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, sizes, hide);
return el('div', { class: 'sv-card-edit' }, grip, stepper, hide);
}
function mountOne(def) {
const size = layout.sizes?.[def.id] || def.size;
const { root, body } = svCard({ ...def, size });
const span = spanOf(def);
const { root, body } = svCard({ ...def, span });
root.appendChild(editOverlay(def));
grid().appendChild(root);
try { def.mount(body); def.start && def.start(); active.push(def); }

View File

@@ -10,7 +10,7 @@ import { router as iconsRouter } from './lib/api/routes/icons.js';
import { startCron } from './lib/cron/index.js';
import { seedFromConfig } from './lib/health/registry.js';
const VERSION = '2.0.0-alpha.12';
const VERSION = '2.0.0-alpha.13';
export function createApp() {
const app = express();