mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
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>
194 lines
7.9 KiB
JavaScript
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;
|
|
}
|