Dashboard Studio 2.5.0: phone sizes Small, Medium, Large and Row with a phone design per card

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 17:30:03 +01:00
parent 0eef8593c8
commit 3b2cbbc96c
7 changed files with 503 additions and 10 deletions
+43 -7
View File
@@ -250,6 +250,25 @@
return d;
}
// ------------------------------------------------------------ phone sizes
// On a phone dashboard every card comes in four sizes, like widgets on a phone: Small (half width, square),
// Medium and Large (full width, two and four rows) and Row (full width, one line). Each has its own design.
const PHONE_SIZES = { s: [2, 2], m: [4, 2], l: [4, 4], row: [4, 1] };
const phoneSizes = (type) => Widgets[type]?.phone?.sizes || Object.keys(PHONE_SIZES);
/** A widget's phone size, from its width and height. */
const phoneSizeOf = (w) => (w.w <= 2 ? "s" : w.h <= 1 ? "row" : w.h >= 4 ? "l" : "m");
/** The size of this card nearest to a width and height. */
function snapPhone(type, w, h) {
const d = ([pw, ph]) => Math.abs(pw - w) * 2 + Math.abs(ph - h);
const k = [...phoneSizes(type)].sort((a, b) => d(PHONE_SIZES[a]) - d(PHONE_SIZES[b]))[0];
return { w: PHONE_SIZES[k][0], h: PHONE_SIZES[k][1] };
}
/** A card's size on a phone, from its size on a computer: small tiles Small, the rest by their height. */
function phoneFit(type, w, h, cols = 12) {
const k = w <= cols / 4 && h <= 3 ? "s" : h <= 1 ? "row" : h >= 4 ? "l" : "m";
return phoneSizes(type).includes(k) ? { w: PHONE_SIZES[k][0], h: PHONE_SIZES[k][1] } : snapPhone(type, ...PHONE_SIZES[k]);
}
function makeWidget(type, x = 0, y = 0) {
const def = Widgets[type];
const cfg = {};
@@ -1070,8 +1089,12 @@
for (const [prop, val] of [["--lamp", lamp], ["--w-bg", st.bg], ["--w-color", st.color], ["--w-accent", st.accent]]) {
if (val) el.style.setProperty(prop, val); else el.style.removeProperty(prop);
}
// phone dashboards: each card's design for its size (Small, Medium, Large or Row)
const size = current?.mobile && !child ? phoneSizeOf(w) : null;
for (const k of Object.keys(PHONE_SIZES)) el.classList.toggle(`ph-${k}`, size === k);
ctx.phone = size;
let html;
try { html = def.render(w.cfg, ctx); } catch (e) { console.error(e); html = `<div class="empty">${tr("This widget couldn’t be drawn.")}</div>`; }
try { html = (size && Widgets.__phoneRender(w.type, w.cfg, ctx, size)) || def.render(w.cfg, ctx); } catch (e) { console.error(e); html = `<div class="empty">${tr("This widget couldn’t be drawn.")}</div>`; }
const hiddenNow = editMode && !child && !isVisible(w);
el.classList.toggle("cond-hidden", hiddenNow);
el.innerHTML = `<div class="w-body ${def.flush ? "flush" : ""}">${html}</div>` +
@@ -1143,7 +1166,7 @@
const eid = b.dataset.e, type = SUGGEST[domainOf(eid)];
const wd = makeWidget(type);
if (Widgets[type].fields.some((f) => f.k === "entities")) wd.cfg.entities = [eid]; else wd.cfg.entity = eid;
wd.w = current.mobile ? (wd.w >= 3 ? 4 : 2) : Math.min(wd.w, current.grid.cols);
if (current.mobile) Object.assign(wd, phoneFit(type, wd.w, wd.h)); else wd.w = Math.min(wd.w, current.grid.cols);
Object.assign(wd, findSpot(wd.w, wd.h));
mutate(() => tab().widgets.push(wd));
$(".sg-list").innerHTML = draw();
@@ -1310,7 +1333,8 @@
${def.fields.filter((f) => !f.when || f.when(w.cfg)).map((f) => fieldHTML(f, w.cfg[f.k], "cfg")).join("")}
${def.container ? childrenHTML(w) : ""}
<div class="section"><div class="section-title">${tr("Size and position")}</div>
<div class="row">${[["x", "Column"], ["y", "Row"], ["w", "Width"], ["h", "Height"]].map(([k, l]) => `<div class="field"><label for="pos-${k}">${tr(l)}</label><input class="input" id="pos-${k}" type="number" min="${k === "w" || k === "h" ? 1 : 0}" data-path="pos" data-k="${k}" data-num="1" value="${w[k]}"></div>`).join("")}</div></div>
${current.mobile ? fieldHTML({ k: "size", t: "seg", l: "Size on the phone", o: phoneSizes(w.type), labels: { s: tr("Small"), m: tr("Medium"), l: tr("Large"), row: tr("Row") } }, phoneSizeOf(w), "phsize") : ""}
<div class="row">${(current.mobile ? [["x", "Column"], ["y", "Row"]] : [["x", "Column"], ["y", "Row"], ["w", "Width"], ["h", "Height"]]).map(([k, l]) => `<div class="field"><label for="pos-${k}">${tr(l)}</label><input class="input" id="pos-${k}" type="number" min="${k === "w" || k === "h" ? 1 : 0}" data-path="pos" data-k="${k}" data-num="1" value="${w[k]}"></div>`).join("")}</div></div>
${current.tabs.length > 1 ? fieldHTML({ k: "tab", t: "select", l: "Tab", o: current.tabs.map((t) => t.id), labels: Object.fromEntries(current.tabs.map((t) => [t.id, t.name])) }, tab().id, "move") : ""}
<div class="section"><div class="section-title">${tr("Style")}</div>${STYLE_FIELDS.map((f) => fieldHTML(f, w.style?.[f.k], "style")).join("")}</div>
${visibilityHTML(w)}
@@ -1542,6 +1566,13 @@
return;
}
if (path === "layout") { convertLayout(!!v); renderInspector(); return; }
if (path === "phsize") {
const w = selectedWidget(), [pw, ph] = PHONE_SIZES[v] || [];
if (!w || !pw) return;
mutate(() => { Object.assign(w, { w: pw, h: ph, x: Math.min(w.x, current.grid.cols - pw) }); resolveCollisions(w); });
renderInspector();
return;
}
if (path === "nav") {
mutate(() => { current.nav[k] = v; });
if (k === "awayTab" || k === "awayPerson") { awayState = null; if (isChange) renderInspector(); }
@@ -1696,9 +1727,10 @@
for (const t of current.tabs) {
const ws = [...t.widgets].sort((a, b) => a.y - b.y || a.x - b.x);
for (const w of ws) {
if (toMobile) { // phone: small tiles become half width, anything a third wide or more full width
w.w = w.w >= from / 3 ? 4 : 2;
w.x = w.w === 4 ? 0 : w.x * f >= 1.5 ? 2 : 0;
if (toMobile) { // phone: small tiles become Small, the rest Medium, Large or a Row by their height
const x0 = w.x;
Object.assign(w, phoneFit(w.type, w.w, w.h, from));
w.x = w.w === 4 ? 0 : x0 * f >= 1.5 ? 2 : 0;
} else {
w.w = Math.max(1, Math.min(to, Math.round(w.w * f)));
w.x = Math.min(to - w.w, Math.round(w.x * f));
@@ -1734,7 +1766,7 @@
function addWidget(type) {
const wd = makeWidget(type);
wd.w = current.mobile ? (wd.w >= 3 ? 4 : 2) : Math.min(wd.w, current.grid.cols);
if (current.mobile) Object.assign(wd, phoneFit(wd.type, wd.w, wd.h)); else wd.w = Math.min(wd.w, current.grid.cols);
Object.assign(wd, findSpot(wd.w, wd.h));
mutate(() => tab().widgets.push(wd));
select(wd.id);
@@ -1994,6 +2026,10 @@
drag.el.style.height = `${Math.max(rh, drag.r0.height + dy)}px`;
n.w = Math.max(1, Math.min(cols - o.x, o.w + Math.round(dx / (colW + gap))));
n.h = Math.max(1, o.h + Math.round(dy / (rh + gap)));
if (current.mobile) { // phone dashboards: the nearest of the card's sizes
Object.assign(n, snapPhone(drag.w.type, o.w + (dx / (colW + gap)), o.h + (dy / (rh + gap))));
n.x = Math.min(n.x, cols - n.w);
}
}
placeEl(drag.ph, n);
showGuides(n, drag.w);