feat(dashboard): widgets fit their content; wide screens get more grid columns (#5249)
CI / lint (push) Successful in 4s
CI / integration (push) Successful in 55s
CI / unit (push) Successful in 1m18s
CI / publish (push) Successful in 1m28s

Each widget is as tall as its content, never taller than its saved height
(where it scrolls). The dashboard is capped at 2400px instead of 1400px.
Past 12 columns' worth of width it adds columns (one per 110px, up to 24)
and places widgets again in reading order at their saved widths, so more sit
side by side instead of stretching. Edit mode shows and saves the
12-column layout, so a layout doesn't depend on the screen it was edited on.

The share view uses the same layout code (static/dash-layout.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 13:10:42 -04:00
co-authored by Claude Opus 5.5
parent 3737d08fd9
commit 4dcf958022
8 changed files with 225 additions and 12 deletions
+1 -1
View File
@@ -113,7 +113,7 @@ document.addEventListener('keydown', function (e) {
});
</script>
<main>
<main{% block main_class %}{% endblock %}>
{% if plugin_failures and session.user_role == 'admin' %}
<div style="background:color-mix(in srgb,var(--yellow) 12%,var(--bg-elevated));
border:1px solid color-mix(in srgb,var(--yellow) 35%,var(--border));
+5 -1
View File
@@ -1,9 +1,13 @@
{# One dashboard widget as a Gridstack item — shared by index.html (initial render)
and the add-widget endpoint (single-item insert). Edit chrome (drag handle +
remove) is in the DOM only for editors and shown via body.dash-editing CSS. #}
{# data-x/y/w/h are the saved 12-column layout; static/dash-layout.js fits
each widget to its content and adds columns on wide screens from them. #}
<div class="grid-stack-item" gs-id="{{ item.db.id }}"
gs-x="{{ item.db.grid_x }}" gs-y="{{ item.db.grid_y }}"
gs-w="{{ item.db.grid_w }}" gs-h="{{ item.db.grid_h }}">
gs-w="{{ item.db.grid_w }}" gs-h="{{ item.db.grid_h }}"
data-x="{{ item.db.grid_x }}" data-y="{{ item.db.grid_y }}"
data-w="{{ item.db.grid_w }}" data-h="{{ item.db.grid_h }}">
<div class="grid-stack-item-content">
<div class="widget-header">
<span style="display:flex;align-items:center;gap:0.4rem;min-width:0;">
+61 -4
View File
@@ -1,5 +1,6 @@
{% extends "base.html" %}
{% block title %}{{ dashboard.name }} — Steward{% endblock %}
{% block main_class %} class="main-wide"{% endblock %}
{% block head %}
{# Gridstack renders the grid for everyone (static for viewers); editors also get
the in-place drag-resize + bottom drawer. #}
@@ -56,22 +57,63 @@
{% block extra_scripts %}
<script src="{{ url_for('static', filename='vendor/gridstack-12.6.0-all.min.js') }}"></script>
<script src="{{ url_for('static', filename='dash-cells.js') }}"></script>
<script src="{{ url_for('static', filename='dash-layout.js') }}"></script>
<script>
(function () {
var gridEl = document.querySelector('.grid-stack');
if (!gridEl || typeof GridStack === 'undefined') return;
var dashId = gridEl.dataset.dashId;
var canEdit = gridEl.dataset.canEdit === '1';
var editing = false;
var editing = false; // edit mode: the saved layout, draggable
var L = window.StewardDashLayout;
var CELL_PX = 70, MARGIN_PX = 8;
var grid = GridStack.init({
column: 12, cellHeight: 70, margin: 8, float: false,
column: 12, cellHeight: CELL_PX, margin: MARGIN_PX, float: false,
staticGrid: true, // view mode: positioned but inert
handle: '.panel-drag',
resizable: { handles: 'e, se, s' },
columnOpts: { breakpointForWindow: true, breakpoints: [{ w: 700, c: 1 }] },
}, gridEl);
function items() { return Array.prototype.slice.call(gridEl.querySelectorAll('.grid-stack-item')); }
// View mode: each widget at its content height (never above its saved one),
// placed for this screen's column count (static/dash-layout.js).
function relayout() {
if (editing) return;
var cols = L.columnsFor(gridEl.clientWidth);
var els = items();
var pos = L.pack(els.map(function (el) {
var s = L.saved(el);
s.id = el.getAttribute('gs-id');
s.h = el._fitH || s.h;
return s;
}), cols);
grid.batchUpdate();
if (grid.getColumn() !== cols) grid.column(cols, 'none');
els.forEach(function (el) { grid.update(el, pos[el.getAttribute('gs-id')]); });
grid.batchUpdate(false);
}
var pending = null;
function relayoutSoon() { clearTimeout(pending); pending = setTimeout(relayout, 60); }
function fit(el) {
var box = el.querySelector('.grid-stack-item-content');
if (!box) return;
var rows = L.fitRows(box, CELL_PX, 2 * MARGIN_PX, L.saved(el).h);
if (rows !== el._fitH) { el._fitH = rows; relayoutSoon(); }
}
document.body.addEventListener('htmx:afterSettle', function (e) {
var el = e.detail.target && e.detail.target.closest && e.detail.target.closest('.grid-stack-item');
if (el && !editing) fit(el);
});
// A new column count changes widths, so text wraps differently: fit again.
window.addEventListener('resize', function () {
clearTimeout(window._dashResize);
window._dashResize = setTimeout(function () { relayout(); items().forEach(fit); }, 150);
});
relayout();
if (!canEdit) return; // viewers get the static grid only
var csrf = document.querySelector('meta[name="csrf-token"]').content;
@@ -79,6 +121,10 @@
var items = grid.save(false).map(function (n) {
return { id: parseInt(n.id, 10), x: n.x, y: n.y, w: n.w, h: n.h };
});
items.forEach(function (n) { // what's saved is the new cap
var el = gridEl.querySelector('.grid-stack-item[gs-id="' + n.id + '"]');
if (el) { el.dataset.x = n.x; el.dataset.y = n.y; el.dataset.w = n.w; el.dataset.h = n.h; }
});
fetch('/d/' + dashId + '/edit/layout', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ items: items }),
@@ -86,12 +132,23 @@
}
grid.on('change', function () { if (editing) saveLayout(); });
// Edit mode shows the saved 12-column layout at saved heights, so a layout
// edited on a wide screen is the same one a narrow screen starts from.
function showSaved() {
grid.batchUpdate();
if (grid.getColumn() !== L.MIN_COLS) grid.column(L.MIN_COLS, 'none');
items().forEach(function (el) { grid.update(el, L.saved(el)); });
grid.batchUpdate(false);
}
function setEditing(on) {
if (on) showSaved(); // before the flag: restoring isn't an edit to save
editing = on;
grid.setStatic(!on);
document.body.classList.toggle('dash-editing', on);
var btn = document.getElementById('dash-edit-toggle');
if (btn) btn.textContent = on ? 'Done' : 'Edit';
if (!on) { items().forEach(fit); relayout(); }
}
var toggle = document.getElementById('dash-edit-toggle');
if (toggle) toggle.addEventListener('click', function () { setEditing(!editing); });
@@ -129,7 +186,7 @@
var item = rm.closest('.grid-stack-item');
fetch('/d/' + dashId + '/edit/remove/' + rm.dataset.remove,
{ method: 'POST', headers: { 'X-CSRF-Token': csrf } })
.then(function () { if (item) grid.removeWidget(item); });
.then(function () { if (item) grid.removeWidget(item); relayoutSoon(); });
});
// Deep link: /d/<id>?edit=1 opens straight into edit mode (new dashboards do this).
+42 -6
View File
@@ -12,20 +12,19 @@
<link rel="stylesheet" href="{{ url_for('static', filename='steward.css') }}">
<script src="{{ url_for('static', filename='chart-colors.js') }}"></script>
<script defer src="{{ url_for('static', filename='dash-cells.js') }}"></script>
<script defer src="{{ url_for('static', filename='dash-layout.js') }}"></script>
{# The palette, type and widget classes come from steward.css like every
other page; only the read-only page's own frame and its grid live here. #}
<style>
.share-header { background: var(--bg-card); padding: 0 1.5rem; display: flex; align-items: center; justify-content: space-between; gap: 1rem; border-bottom: 1px solid var(--border-mid); min-height: 48px; }
.share-name { font-family: var(--fs-font-display); font-weight: 500; font-size: 1.15rem; color: var(--gold); overflow-wrap: anywhere; }
.share-main { max-width: 1600px; }
/* Same grid as the authenticated view, rendered from x/y/w/h. */
.share-main { max-width: 2400px; }
/* The same layout as the signed-in dashboard: rows 70px, gaps 1rem, and
static/dash-layout.js sets the columns and each cell's place and height. */
.dash-grid { display: grid; grid-template-columns: repeat(12, 1fr); grid-auto-rows: 70px; gap: 1rem; }
.dash-grid > .dash-cell { margin-bottom: 0; min-width: 0; overflow: hidden; display: flex; flex-direction: column; }
.dash-cell .dash-cell-body { flex: 1; min-height: 0; overflow: auto; }
@media (max-width: 820px) {
.dash-grid { grid-template-columns: 1fr; grid-auto-rows: auto; }
.dash-grid > .dash-cell { grid-column: 1 / -1 !important; grid-row: auto !important; }
.dash-cell .dash-cell-body { overflow: visible; }
.share-main { padding: 1rem; }
}
</style>
@@ -42,7 +41,8 @@
<div class="dash-grid">
{% for item in widgets %}
{% set gw = item.db %}
<div class="card ping-card dash-cell"
<div class="card ping-card dash-cell" data-id="{{ gw.id }}"
data-x="{{ gw.grid_x }}" data-y="{{ gw.grid_y }}" data-w="{{ gw.grid_w }}" data-h="{{ gw.grid_h }}"
style="grid-column:{{ gw.grid_x + 1 }} / span {{ gw.grid_w }};grid-row:{{ gw.grid_y + 1 }} / span {{ gw.grid_h }};">
<div class="widget-header">
<span class="widget-title" title="{{ item.title }}">{{ item.title }}</span>
@@ -63,5 +63,41 @@
{% endif %}
</main>
<script>
/* Fit each cell to its content and place it for this screen's column count,
as on the signed-in dashboard (static/dash-layout.js). */
document.addEventListener('DOMContentLoaded', function () {
var L = window.StewardDashLayout, grid = document.querySelector('.dash-grid');
if (!L || !grid) return;
var ROW_PX = 70, GAP_PX = 16;
function cells() { return Array.prototype.slice.call(grid.querySelectorAll('.dash-cell')); }
function relayout() {
var cols = L.columnsFor(grid.clientWidth);
var pos = L.pack(cells().map(function (el) {
var s = L.saved(el); s.id = el.dataset.id; s.h = el._fitH || s.h; return s;
}), cols);
grid.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
cells().forEach(function (el) {
var p = pos[el.dataset.id];
el.style.gridColumn = (p.x + 1) + ' / span ' + p.w;
el.style.gridRow = (p.y + 1) + ' / span ' + p.h;
});
}
var pending = null;
function fit(el) {
var rows = L.fitRows(el, ROW_PX + GAP_PX, GAP_PX, L.saved(el).h);
if (rows !== el._fitH) { el._fitH = rows; clearTimeout(pending); pending = setTimeout(relayout, 60); }
}
document.body.addEventListener('htmx:afterSettle', function (e) {
var el = e.detail.target && e.detail.target.closest && e.detail.target.closest('.dash-cell');
if (el) fit(el);
});
window.addEventListener('resize', function () {
clearTimeout(window._dashResize);
window._dashResize = setTimeout(function () { relayout(); cells().forEach(fit); }, 150);
});
relayout();
});
</script>
</body>
</html>