ouroboros/web/modules/masonry.js
Ouroboros 01b66a85b0 widgets: retain keep-alive, honest badge, Refresh confirm, masonry without DOM moves
Widgets lifecycle phase 3. A framed card whose effective launch policy is
`retain` keeps its frame mounted in the hidden page when the owner leaves
Widgets (`widgetsVisible` stays the mount gate, never a disposer); it starts
on the first visit like `auto`, never at app load, and still stops on the
owner's Stop, on the hard reset, when its skill leaves the live list — a
lifecycle event while the page is hidden now fetches the list and
force-stops kept frames whose skill vanished — and with the SPA by
construction. Its status reads "Keeps running" (dot + text, existing tone
rules). Refresh stays the hard reset and asks first, through the shared
confirm dialog (strict boolean), only while a kept-running card would be
stopped; Cancel changes nothing.

Masonry packs the cards in the page's explicit key order instead of the DOM
order and writes the plan back only as `--masonry-w/-x/-y` per card and
`--masonry-h` on the list, applied by static rules in style.css; the
generated per-container <style> with :nth-child rules, `ensureStyleElement`,
`nextId` and `data-masonry-id` are gone, frames coalesce, and `applyMasonry`
returns an idempotent disposer (both ResizeObservers, the MutationObserver,
the pending frame). A reorder (drag or keyboard) is a pure move in the key
order handed back to the page; neither it nor the keyed list patch moves an
<article>, so a running frame never reloads on reorder. Disclosed residual:
the Tab/focus order follows the DOM and can differ from the visible order
after a reorder until the hard reset rebuilds the cards.

The declarative chart helpers and the shared dotted-path reader move
unchanged into widget_chart.js to keep widgets.js under the size band
ceiling.

Tests: node (masonry key order / custom properties / no <style> / disposer;
moveWidgetKey; isRetainedWidget; honest badge; injectable Refresh
confirmation), static pins updated deliberately for the new contracts, and a
ui_browser retain test on chromium and webkit (same iframe and window across
pages, setInterval progress while hidden on both engines, rAF progress
asserted on webkit only and recorded per engine, bridged ticks while the
declarative poll stays silent, keyboard reorder without node move or reload,
Refresh confirm Cancel/Restart, Stop frees the frame, hidden disable
force-stops). Docs: ARCHITECTURE lifecycle doctrine, DEVELOPMENT typed
retention rule + focus-order residual + module map, CREATING_SKILLS launch
policy.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
2026-09-03 07:32:38 +03:00

194 lines
7.9 KiB
JavaScript

/* Absolute-position masonry for the Widgets list. `layout()` measures the
container and its items, plans the columns (`planMasonryLayout`, pure) and
writes the plan back ONLY as narrow custom properties — `--masonry-w/-x/-y`
on each item, `--masonry-h` on the container — which one static rule set in
web/style.css turns into width / transform / height. The visual order is the
caller's explicit key order (`options.order`), never the DOM order: a reorder
relayouts without moving a node, so a running <iframe> in a card is never
reloaded by it. `applyMasonry` returns an idempotent disposer. */
const bound = new WeakMap();
function shortestColumn(columns) {
let index = 0;
for (let i = 1; i < columns.length; i += 1) {
if (columns[i] < columns[index]) index = i;
}
return index;
}
function bestPair(columns) {
if (columns.length < 2) return 0;
let index = 0;
let best = Math.max(columns[0], columns[1]);
for (let i = 1; i < columns.length - 1; i += 1) {
const candidate = Math.max(columns[i], columns[i + 1]);
if (candidate < best) {
best = candidate;
index = i;
}
}
return index;
}
export function planMasonryLayout(width, itemSpecs, options = {}) {
const gap = Number(options.gap ?? 14);
const minColumnWidth = Number(options.minColumnWidth ?? 280);
const denseMinColumnWidth = Number(options.denseMinColumnWidth ?? 240);
const spans = itemSpecs.map((item) => Number(item.span) >= 2 ? 2 : 1);
const desiredColumns = spans.reduce((total, span) => total + span, 0);
const availableColumns = Math.max(1, Math.floor((width + gap) / (minColumnWidth + gap)));
let count = Math.min(desiredColumns, availableColumns);
// Multiple wide cards cannot pack usefully into three tracks: every pair
// overlaps an occupied track and leaves a tall visual void. When four
// still-legible tracks fit, let wide cards sit side by side. One-wide and
// narrow layouts retain the ordinary minimum width.
const wideCount = spans.filter((span) => span === 2).length;
const denseAvailableColumns = Math.max(
1,
Math.floor((width + gap) / (denseMinColumnWidth + gap)),
);
if (wideCount >= 2 && denseAvailableColumns >= 4) {
count = Math.min(desiredColumns, denseAvailableColumns);
}
// On the common four-track desktop layout, one narrow card between
// multiple wide cards otherwise occupies only half of the right lane.
// That leaves a persistent visual hole and needlessly squeezes long
// readouts. Treat the lone narrow span as a responsive width hint and
// give it the same readable lane width as its wide neighbours.
const effectiveSpans = [...spans];
const narrowIndexes = spans
.map((span, index) => span === 1 ? index : -1)
.filter((index) => index >= 0);
if (count === 4 && wideCount >= 2 && narrowIndexes.length === 1) {
effectiveSpans[narrowIndexes[0]] = 2;
}
const columnWidth = Math.floor((width - gap * (count - 1)) / count);
const heights = Array(count).fill(0);
const placements = itemSpecs.map((item, index) => {
const span = effectiveSpans[index] === 2 && count > 1 ? 2 : 1;
const column = span === 2 ? bestPair(heights) : shortestColumn(heights);
const top = span === 2
? Math.max(heights[column], heights[column + 1])
: heights[column];
const left = column * (columnWidth + gap);
const itemWidth = span * columnWidth + (span - 1) * gap;
const bottom = top + Math.max(0, Number(item.height) || 0) + gap;
for (let i = column; i < column + span; i += 1) heights[i] = bottom;
return { span, column, top, left, width: itemWidth };
});
return {
columnCount: count,
columnWidth,
height: Math.max(0, Math.max(...heights, 0) - gap),
placements,
};
}
// Items in the caller's key order; keys the order does not name keep their
// relative DOM order after the named ones (the `sortTabsByWidgetOrder` rule).
function orderedItems(container, config) {
const rank = new Map(config.order.map((key, index) => [key, index]));
return Array.from(container.querySelectorAll(config.itemSelector))
.map((item, index) => {
const key = config.keyOf(item);
return { item, index, rank: rank.has(key) ? rank.get(key) : Number.MAX_SAFE_INTEGER };
})
.sort((a, b) => a.rank - b.rank || a.index - b.index)
.map((entry) => entry.item);
}
function layout(container, config) {
const items = orderedItems(container, config);
if (!items.length) {
container.style.removeProperty('--masonry-h');
return;
}
const width = container.clientWidth;
if (!width) return;
const spanClass = config.spanClass || 'widgets-card-span-2';
const itemSpecs = items.map((item) => ({
span: item.classList.contains(spanClass) ? 2 : 1,
height: item.offsetHeight,
}));
const plan = planMasonryLayout(width, itemSpecs, config);
items.forEach((item, idx) => {
const placement = plan.placements[idx];
item.style.setProperty('--masonry-w', `${placement.width}px`);
item.style.setProperty('--masonry-x', `${placement.left}px`);
item.style.setProperty('--masonry-y', `${placement.top}px`);
});
container.style.setProperty('--masonry-h', `${plan.height}px`);
}
/**
* Bind (once per container) and schedule a layout. A later call with
* `options.order` replaces the key order and relayouts; every call returns the
* same idempotent disposer, which disconnects the three observers, cancels a
* pending frame and forgets the container.
*/
export function applyMasonry(container, options = {}) {
if (!container) return () => {};
const existing = bound.get(container);
if (existing) {
if (Array.isArray(options.order)) existing.config.order = options.order.slice();
existing.run();
return existing.dispose;
}
const config = {
itemSelector: options.itemSelector || '.widgets-card',
gap: options.gap ?? 14,
minColumnWidth: options.minColumnWidth ?? 280,
spanClass: options.spanClass || 'widgets-card-span-2',
keyOf: options.keyOf || ((item) => item.dataset.widgetKey || ''),
order: Array.isArray(options.order) ? options.order.slice() : [],
};
// One layout per frame however many triggers land before it.
let frame = 0;
const run = () => {
if (frame) cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
frame = 0;
layout(container, config);
});
};
const observedItems = new Set();
const itemResizeObserver = new ResizeObserver(run);
const observeItems = () => {
Array.from(observedItems).forEach((item) => {
if (container.contains(item)) return;
itemResizeObserver.unobserve(item);
observedItems.delete(item);
});
container.querySelectorAll(config.itemSelector).forEach((item) => {
if (observedItems.has(item)) return;
observedItems.add(item);
itemResizeObserver.observe(item);
});
};
const resizeObserver = new ResizeObserver(run);
resizeObserver.observe(container);
const mutationObserver = new MutationObserver(() => {
observeItems();
run();
});
mutationObserver.observe(container, { childList: true, subtree: true });
const entry = { config, run };
entry.dispose = () => {
if (bound.get(container) !== entry) return;
bound.delete(container);
if (frame) cancelAnimationFrame(frame);
frame = 0;
resizeObserver.disconnect();
itemResizeObserver.disconnect();
mutationObserver.disconnect();
observedItems.clear();
};
bound.set(container, entry);
observeItems();
run();
return entry.dispose;
}