Release disabled widget cells without losing surviving layout

This commit is contained in:
Ouroboros 2026-09-30 20:30:56 +05:00
parent c73904e594
commit 853072c980
11 changed files with 255 additions and 56 deletions

View file

@ -250,7 +250,7 @@ Widgets is a separate page because extension UI is an execution surface, not cat
The list and UI-preferences reads share one `WIDGET_REQUEST_TIMEOUT_MS` authority. Each reconcile owns one `AbortController` and a 25-second deadline covering response headers and body. The failure asymmetry is deliberate: a timeout or a cards-list failure aborts the preferences sibling, leaves the last good cards in place and exposes the existing Retry with an explicit error, while a preferences failure degrades to `null` (last known order kept) and card loading continues. A cards reply without an `ui_tabs` array is refused, never read as an empty list, and a cancelled body read stays a cancellation (`fetchJson` rethrows `AbortError`), so navigating away can never stop a kept-running frame. Navigation, page disposal and pagehide abort owned requests, and only the current render generation may paint or enable controls. Hidden retained frames keep their keyed lifecycle; the passive `/api/widgets` read writes nothing. After a successful preferences read, the page pins newly shown cards' default cells in the existing `widget_layout` map once, so later list removals cannot repack them; an unreadable stored preferences file gets a typed 503 rather than fake defaults, and a failed preferences read disables move/resize until Retry instead of authoring an empty replacement. A failed save leaves the card locally arranged but says plainly that it was not saved; a later successful edit can persist the arrangement.
Three render modes share one capability set (`sandbox="allow-scripts allow-pointer-lock allow-downloads"`, `allow="autoplay; fullscreen; clipboard-write"`, never `allow-same-origin`; author-facing contract: `docs/CREATING_SKILLS.md`). An extension-route iframe (`kind: iframe`) is the skill's own page with no bridge and must declare the `route` it loads; the frozen registration contract still accepts an omitted route, which paints a not-supported card rather than a widget. A declarative widget is rendered by host-owned code from a validated schema. A reviewed module widget runs in an opaque-origin `srcdoc` iframe under a document CSP that `web/modules/widget_module.js` builds from the page origin (`default-src 'none'`, no `connect-src`; scripts only inline, `blob:` and the skill's module prefix, with `'wasm-unsafe-eval'`). Framed declarations may set a bounded `height` (320–8,192 px); a module without one starts at the floor and reports its `#root` content height through the nonce-bound bridge, capped by an optional module-only `max_height`; legacy route iframes stay explicit-height-only because an opaque document cannot be measured, and geometry keys are rejected for declarative renders. Card order (`widget_order`), each card's grid cell (`widget_layout`) and the owner's per-card launch-policy override (`widget_start_mode`) are host UI state in `/api/ui/preferences`; there is no second store. The desktop list is a 12-column grid of 40px rows (`web/modules/widget_grid.js`) on which every card keeps its saved column, row, width and height on every revisit: the plan is a pure function of the saved cells, the key order and each card's declared default size and never measures content, so a frame that grows, shrinks, starts or stops moves and resizes nothing, and taller content scrolls inside its card. A card without a saved cell takes its default size from `span` (a third or two thirds of the grid) and its declared frame height, and is packed first-fit in key order below the saved arrangement. The owner arranges cards with the card's move handle and corner grip, by pointer (a live cell-by-cell preview; Escape cancels) or arrow keys (Home / End: top-left / below everything); a card in the way is pushed straight down, nothing compacts upwards. The first move or resize pins every shown card at its cell and re-derives `widget_order` from the grid's reading order; saved cells of cards that are not live are kept, so a re-enabled skill's card returns to its place. Writes go one at a time and a list read begun before a local arrangement does not overwrite it. Below a 720px list width (a 24px band keeps a scrollbar from flapping it) the list falls back to one stacked column in `widget_order`, each card at its saved height, where the handles reorder the stack and resize the height. Placement reaches the DOM only as `--widget-*` custom properties and the list's `data-widget-layout` mode, so an arrangement never moves a node or reloads a frame (disclosed residual: Tab order follows the DOM until a reload). Engineering rules: `docs/DEVELOPMENT.md` "Embedded surfaces declare geometry and refresh semantics", "UI resources carry a disposer", "Declarative widgets".
Three render modes share one capability set (`sandbox="allow-scripts allow-pointer-lock allow-downloads"`, `allow="autoplay; fullscreen; clipboard-write"`, never `allow-same-origin`; author-facing contract: `docs/CREATING_SKILLS.md`). An extension-route iframe (`kind: iframe`) is the skill's own page with no bridge and must declare the `route` it loads; the frozen registration contract still accepts an omitted route, which paints a not-supported card rather than a widget. A declarative widget is rendered by host-owned code from a validated schema. A reviewed module widget runs in an opaque-origin `srcdoc` iframe under a document CSP that `web/modules/widget_module.js` builds from the page origin (`default-src 'none'`, no `connect-src`; scripts only inline, `blob:` and the skill's module prefix, with `'wasm-unsafe-eval'`). Framed declarations may set a bounded `height` (320–8,192 px); a module without one starts at the floor and reports its `#root` content height through the nonce-bound bridge, capped by an optional module-only `max_height`; legacy route iframes stay explicit-height-only because an opaque document cannot be measured, and geometry keys are rejected for declarative renders. Card order (`widget_order`), each card's grid cell (`widget_layout`) and the owner's per-card launch-policy override (`widget_start_mode`) are host UI state in `/api/ui/preferences`; there is no second store. The desktop list is a 12-column grid of 40px rows (`web/modules/widget_grid.js`) on which every card keeps its saved column, row, width and height on every revisit: the plan is a pure function of the saved cells, the key order and each card's declared default size and never measures content, so a frame that grows, shrinks, starts or stops moves and resizes nothing, and taller content scrolls inside its card. A card without a saved cell takes its default size from `span` (a third or two thirds of the grid) and its declared frame height, and is packed first-fit in key order below the saved arrangement. The owner arranges cards with the card's move handle and corner grip, by pointer (a live cell-by-cell preview; Escape cancels) or arrow keys (Home / End: top-left / below everything); a card in the way is pushed straight down, nothing compacts upwards. After a successful list and preferences read, shown cards receive pinned default cells. When an installed-skills read confirms a missing card's skill is disabled, its saved cell and order entry are released and later cards may fill a free earlier cell without displacing another saved card; when it returns, its card receives a new default cell below the saved arrangement for the owner to position. Temporary loader absence and failed preferences or installed-skills reads never authorize removing a saved slot. An owner move or resize pins shown cards and re-derives `widget_order` from the grid's reading order. Writes go one at a time; each layout POST may carry the full layout it read as `widget_layout_if`, atomically refusing a stale second-window replacement with HTTP 409 instead of restoring a released slot. A list read begun before a local arrangement does not overwrite it. Below a 720px list width (a 24px band keeps a scrollbar from flapping it) the list falls back to one stacked column in `widget_order`, each card at its saved height, where the handles reorder the stack and resize the height. Placement reaches the DOM only as `--widget-*` custom properties and the list's `data-widget-layout` mode, so an arrangement never moves a node or reloads a frame (disclosed residual: Tab order follows the DOM until a reload). Engineering rules: `docs/DEVELOPMENT.md` "Embedded surfaces declare geometry and refresh semantics", "UI resources carry a disposer", "Declarative widgets".
Framed cards start under a launch policy — the owner's override over the author's validated `render.start` over the kind default (module and route iframe → `manual`, declarative → `auto`), a pure function in `web/modules/widget_card.js` — with one primary Start/Stop control and a policy menu (Auto / Manual / Keep running). `retain` keeps a framed card mounted while Widgets is hidden and ends it on Stop, when its skill leaves the live list, on a changed `revision` and with the window, never with the server alone. A mounted widget owns its resources through one disposer: a module widget is stopped in order with acknowledgement (`ouro-widget-disposed`, or `WIDGET_DISPOSE_ACK_TIMEOUT_MS` — one second), with one mount in flight per card key so a remount waits for the pending stop; a route iframe disposes synchronously. Poll and WebSocket writers use monotonic progress per job, so an older response cannot rewind a newer event. Chart.js is bundled locally; rendering must not depend on a third-party CDN.

View file

@ -23,7 +23,8 @@ DEFAULT_UI_PREFERENCES: dict[str, Any] = {
# {"x", "y", "w", "h"} in grid cells (column / row of the top-left cell, width
# in columns, height in rows). Out-of-range values are clamped into the grid
# (web/modules/widget_grid.js mirrors the bounds); keys are never checked
# against live widgets, so a disabled skill's card returns to its place.
# against live widgets; the Widgets client retires only confirmed-disabled
# cards, while temporary loader absence leaves their cells intact.
"widget_layout": {},
"nested_subagents_expanded": False,
# Resizable side sections (0 = use the CSS default). Clamped to sane ranges so
@ -241,15 +242,24 @@ async def api_ui_preferences_post(request: Request) -> JSONResponse:
body = await request_json_or(request, None)
if not isinstance(body, dict):
return json_error("request body must be a JSON object", 400)
unknown = sorted(set(body) - _KNOWN_KEYS)
unknown = sorted(set(body) - _KNOWN_KEYS - {"widget_layout_if"})
if unknown:
return json_error(f"unknown ui preference key: {unknown[0]}", 400)
drive_root = request_drive_root(request)
path = pathlib.Path(drive_root) / "state" / "ui_preferences.json"
try:
# An arrangement carries its observed map; a second window cannot put
# a disabled card's released slot back via a stale whole-map POST.
basis = body.get("widget_layout_if")
if "widget_layout_if" in body and "widget_layout" not in body:
return json_error("widget_layout_if requires widget_layout", 400)
if "widget_layout_if" in body:
basis = _normalize_preferences({"widget_layout": basis}, fill_defaults=False)["widget_layout"]
with _preferences_lock(path):
prefs = _stored_preferences(path)
incoming = _normalize_preferences(body, fill_defaults=False)
if "widget_layout_if" in body and prefs["widget_layout"] != basis:
return json_error("Widget layout changed in another window; reload Widgets before editing", 409)
incoming = _normalize_preferences({key: val for key, val in body.items() if key != "widget_layout_if"}, fill_defaults=False)
if "project_seen_revision" in incoming:
from ouroboros.projects_registry import get_project

View file

@ -314,7 +314,7 @@ def test_ui_preferences_widget_start_mode_override(tmp_path):
def test_ui_preferences_widget_layout_cells(tmp_path):
"""Owner Widgets grid cells: persisted per card key, whole-map replace, clamped
into the grid, stale keys kept (a disabled skill's card returns to its place),
into the grid, transiently absent keys retained until a confirmed disable,
bounded like the other per-card maps."""
from starlette.testclient import TestClient
@ -340,6 +340,27 @@ def test_ui_preferences_widget_layout_cells(tmp_path):
stored = json.loads((tmp_path / "state" / "ui_preferences.json").read_text(encoding="utf-8"))
assert stored["widget_layout"] == layout
# The Widgets client binds a whole-map replacement to the layout it read.
# A stale window cannot resurrect a disabled card's retired slot.
compacted = {"game:main": layout["game:main"], "gauge:live": layout["gauge:live"]}
assert client.post("/api/ui/preferences", json={
"widget_layout": compacted, "widget_layout_if": layout,
}).status_code == 200
stale = client.post("/api/ui/preferences", json={
"widget_layout": layout, "widget_layout_if": layout,
})
assert stale.status_code == 409
assert client.get("/api/ui/preferences").json()["widget_layout"] == compacted
# Narrow-screen order-only writes carry that same layout basis. A stale
# second window must not put the retired card back into widget_order.
assert client.post("/api/ui/preferences", json={
"widget_layout": layout, "widget_layout_if": layout,
"widget_order": ["gone_skill:old", "gauge:live", "game:main"],
}).status_code == 409
assert client.get("/api/ui/preferences").json()["widget_order"] == ["game:main", "gauge:live"]
assert client.post("/api/ui/preferences", json={"widget_layout_if": compacted}).status_code == 400
layout = compacted
# Other keys leave the layout alone; a layout write replaces the whole map.
other = client.post("/api/ui/preferences", json={"widget_start_mode": {"game:main": "retain"}})
assert other.json()["widget_layout"] == layout

View file

@ -275,7 +275,7 @@ export const apiClient = {
* @returns {Promise<import('./api_types.js').ProviderTestResponse>}
*/
providerTest: (payload) => jsonPost('/api/providers/test', payload),
extensions: () => fetchJson('/api/extensions', { cache: 'no-store' }),
extensions: (init = {}) => fetchJson('/api/extensions', { cache: 'no-store', ...init }),
/**
* Widgets page cards: live extension UI tabs projected from the loader
* snapshot (no skill discovery), each stamped with the owning skill's

View file

@ -96,6 +96,9 @@ function occupancy() {
for (let row = slot.y; row < slot.y + slot.h; row += 1) rows[row] = (rows[row] || 0) | mask(slot);
return slot;
},
free(slot) {
for (let row = slot.y; row < slot.y + slot.h; row += 1) rows[row] = (rows[row] || 0) & ~mask(slot);
},
};
}
@ -112,24 +115,24 @@ function byCell([aKey, a], [bKey, b]) {
}
/**
* Every card's cell. Saved slots first, in reading order, each at its own cell
* or pushed straight down past one it would overlap; then the cards without a
* saved slot, in key order, packed first-fit BELOW the saved arrangement, so a
* new card never lands inside the owner's composition. `cards` is
* Every card's cell. Saved slots (including temporarily absent cards) reserve
* their cells first, in reading order, each at its own cell or pushed straight
* down past an overlap; only visible cards enter the result. Unsaved cards pack
* first-fit BELOW the retained arrangement, never inside the owner's composition. `cards` is
* `[{ key, w, h }]` in key order (w/h: the default size); returns a Map
* key → { x, y, w, h } in the same order. No measured size is an input.
*/
export function planWidgetGrid(cards, layout = {}) {
const grid = occupancy();
const placements = new Map();
const saved = cards
.map((card) => [card.key, Object.prototype.hasOwnProperty.call(layout || {}, card.key) ? normalizeWidgetSlot(layout[card.key]) : null])
.filter(([, slot]) => slot)
.sort(byCell);
// Reserve retained but temporarily absent cards too. A newly appearing card
// must not be pinned across a slot whose owner is merely missing in this read.
const saved = Object.entries(normalizeWidgetLayout(layout)).sort(byCell);
const visible = new Set(cards.map((card) => card.key));
let floor = 0;
for (const [key, slot] of saved) {
const placed = settle(grid, slot);
placements.set(key, placed);
if (visible.has(key)) placements.set(key, placed);
floor = Math.max(floor, placed.y + placed.h);
}
for (const card of cards) {
@ -174,11 +177,16 @@ export function widgetReadingOrder(placements) {
return [...placements].sort(byCell).map(([key]) => key);
}
/**
* The `widget_layout` write for an arrangement: every card shown now, pinned
* at its cell, then the saved slots of cards not shown — a disabled skill
* keeps its place, the `widget_start_mode` rule — within the stored bound.
*/
/** Only an explicit installed-skill disabled fact releases an absent card. */
export function confirmedDisabledWidgetKeys(savedKeys, liveKeys, skills) {
if (!Array.isArray(skills)) return [];
const live = new Set(liveKeys);
const disabled = new Set(skills.filter((skill) => skill?.enabled === false && !skill.identity_collision)
.map((skill) => skill.name));
return savedKeys.filter((key) => !live.has(key) && disabled.has(key.split(':', 1)[0]));
}
/** Pin shown cards; retain absent, not-confirmed-disabled keys unchanged. */
export function widgetLayoutFromPlacements(placements, previous = {}) {
const entries = [...placements].map(([key, { x, y, w, h }]) => [key, { x, y, w, h }]);
for (const [key, slot] of Object.entries(normalizeWidgetLayout(previous))) {
@ -187,6 +195,37 @@ export function widgetLayoutFromPlacements(placements, previous = {}) {
return Object.fromEntries(entries.slice(0, WIDGET_LAYOUT_MAX_ITEMS));
}
/**
* Release only confirmed-disabled keys, never a card temporarily absent from
* the live list. Survivors' existing cells are reserved before any compaction:
* a card may move to a free EARLIER cell, but may not displace another card.
*/
export function compactWidgetLayout(cards, previous, disabledKeys) {
const layout = normalizeWidgetLayout(previous);
const disabled = new Set(disabledKeys);
const missing = Object.entries(layout).filter(([key]) => disabled.has(key)).sort(byCell);
if (!missing.length) return null;
const visible = new Set(cards.map((card) => card.key));
const vacancy = missing[0][1];
const grid = occupancy();
const result = new Map(Object.entries(layout).filter(([key]) => !disabled.has(key)));
for (const slot of result.values()) grid.take(slot);
for (const [key, slot] of [...result].filter(([key]) => visible.has(key)).sort(byCell)) {
if (byCell([key, slot], missing[0]) <= 0) continue;
grid.free(slot);
let placed = null;
for (let y = vacancy.y; y <= slot.y && !placed; y += 1) {
for (let x = 0; x + slot.w <= WIDGET_GRID_COLUMNS && !placed; x += 1) {
if (y === slot.y && x >= slot.x) break;
const candidate = { ...slot, x, y };
if (grid.fits(candidate)) placed = candidate;
}
}
result.set(key, grid.take(placed || slot));
}
return Object.fromEntries(result);
}
/** `grid` or `stack` for a list this wide; a zero width (a hidden page) keeps the mode. */
export function widgetGridMode(width, previous = 'grid') {
if (!(width > 0)) return previous;

View file

@ -8,6 +8,26 @@
never by moving or rebuilding nodes. */
import { WIDGET_REQUEST_TIMEOUT_MS, withWidgetRequestTimeout } from './widget_job.js';
import { confirmedDisabledWidgetKeys } from './widget_grid.js';
/** A transient loader gap must not be mistaken for an owner disabling a skill. */
export async function confirmedDisabledLayoutKeys(client, tabs, preferences) {
const live = tabs.map(widgetKey);
const saved = [...new Set([
...Object.keys(preferences.widget_layout), ...preferences.widget_order,
])];
if (saved.every((key) => live.includes(key))) return [];
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const index = await client.extensions({ signal: controller.signal });
return confirmedDisabledWidgetKeys(saved, live, index?.skills);
} catch {
return [];
} finally {
clearTimeout(timer);
}
}
export function widgetKey(tab) {
return tab.key || `${tab.skill}:${tab.tab_id}`;

View file

@ -14,6 +14,7 @@
import { widgetKey } from './widget_list.js';
import {
applyWidgetGrid,
compactWidgetLayout,
arrangeWidgetSlot,
defaultWidgetSize,
planWidgetGrid,
@ -37,8 +38,7 @@ export function normalizeWidgetOrder(value) {
});
}
// Reorder only visible slots. A disabled skill's key keeps its place in the
// owner's order and reappears there when enabled again.
// Reorder visible slots while preserving temporarily absent skills' keys.
export function mergeVisibleWidgetOrder(fullOrder, visibleOrder) {
const visible = normalizeWidgetOrder(visibleOrder);
const visibleKeys = new Set(visible);
@ -128,7 +128,8 @@ export function createWidgetArrangement(list, options) {
inFlight = new Promise((resolve) => resolve(options.save(payload)))
.catch((err) => {
console.warn('Failed to save widget arrangement', err);
announce('Layout not saved. Try moving a card again when the connection recovers.');
queued = null; // A later optimistic basis cannot authorize a write after failure.
announce('Layout not saved. Reload Widgets before editing again.');
})
.finally(() => {
inFlight = null;
@ -137,10 +138,14 @@ export function createWidgetArrangement(list, options) {
};
const persist = (next) => {
if (options.canEdit?.() === false) return;
// Order-only changes still carry the observed layout basis: another
// window may have released a disabled card since this window's read.
const basis = { widget_layout_if: queued?.widget_layout_if ?? options.prefs().widget_layout ?? {} };
const update = { widget_layout: options.prefs().widget_layout ?? {}, ...next };
revision += 1;
options.commit(next);
options.commit(update);
relayout();
queued = { ...queued, ...next };
queued = { ...queued, ...update, ...basis };
if (!inFlight) flush();
};
const commitOrder = (next) => persist({ widget_order: mergeVisibleWidgetOrder(options.prefs().widget_order, next) });
@ -324,14 +329,31 @@ export function createWidgetArrangement(list, options) {
return {
relayout,
// The first successful preferences read pins every newly shown card.
// Before this, removing a sibling would repack the default positions.
// A failed preferences read never authors an empty replacement map.
pinDefaults() {
// Called only after a successful preferences AND widget-list read.
// Retire absent keys, compact into vacated cells, then pin new cards
// below the retained arrangement in one serialized write.
pinDefaults(disabledKeys = []) {
const cards = options.tabs().map((tab) => ({ key: widgetKey(tab), ...defaultWidgetSize(tab) }));
const stored = options.prefs().widget_layout || {};
const missing = order().filter((key) => !Object.prototype.hasOwnProperty.call(stored, key));
if (!missing.length || Object.keys(stored).length + missing.length > WIDGET_LAYOUT_MAX_ITEMS) return;
persist({ widget_layout: widgetLayoutFromPlacements(plan(), stored) });
const disabled = new Set(disabledKeys);
const compacted = compactWidgetLayout(cards, stored, disabled);
const current = compacted || stored;
const missing = cards.filter((card) => !Object.prototype.hasOwnProperty.call(current, card.key));
const staleOrder = normalizeWidgetOrder(options.prefs().widget_order)
.filter((key) => disabled.has(key));
if (!compacted && !missing.length && !staleOrder.length) return;
if (Object.keys(current).length + missing.length > WIDGET_LAYOUT_MAX_ITEMS) return;
const placements = planWidgetGrid(cards, current);
const next = {
widget_layout: widgetLayoutFromPlacements(placements, current),
...(staleOrder.length ? {
widget_order: mergeVisibleWidgetOrder(
options.prefs().widget_order.filter((key) => !disabled.has(key)),
widgetReadingOrder(placements),
),
} : {}),
};
persist(next);
},
/** Bind the handles of cards added since the last call (each once). */
bind() {

View file

@ -10,10 +10,8 @@ import {
import { chartConfig, formatNumber, getPath, renderChartDataTable, renderTableCell } from './widget_chart.js';
import { applyChartTheme, onThemeChange } from './theme_palette.js';
import { mountModuleWidget, mountRouteIframeWidget } from './widget_module.js';
import {
planWidgetListPatch, requestWidgetCards, requestWidgetListPayload, widgetKey,
widgetListRequests, widgetTabsSignature,
} from './widget_list.js';
import { confirmedDisabledLayoutKeys, planWidgetListPatch, requestWidgetCards, requestWidgetListPayload,
widgetKey, widgetListRequests, widgetTabsSignature } from './widget_list.js';
import {
bindWidgetCardMenus,
effectiveStartMode,
@ -1438,9 +1436,11 @@ export function initWidgets(ctx = {}) {
}
renderShell(list, tabs);
}
const disabledKeys = prefs ? await confirmedDisabledLayoutKeys(apiClient, tabs, uiPreferences) : [];
if (!isCurrent()) return;
lastTabs = tabs;
lastSignature = signature;
if (prefs) arrangement.pinDefaults();
if (prefs) arrangement.pinDefaults(disabledKeys);
arrangement.bind();
list.querySelectorAll('[data-widget-move-handle], [data-widget-resize-handle]')
.forEach((handle) => { handle.disabled = !preferencesAvailable; });

View file

@ -9,6 +9,8 @@ import {
normalizeWidgetLayout,
normalizeWidgetSlot,
planWidgetGrid,
compactWidgetLayout,
confirmedDisabledWidgetKeys,
WIDGET_GRID_COLUMNS,
WIDGET_GRID_MAX_H,
WIDGET_GRID_MAX_Y,
@ -105,10 +107,27 @@ test('adding, removing or updating other cards never moves a saved card', () =>
// A changed declaration (span, declared height) only changes the DEFAULT size.
const updated = slots(planWidgetGrid([card('a', 12, 40), card('b', 3, 4), card('c')], layout));
assert.deepEqual(updated, base);
// A saved slot of a card that is not shown occupies nothing.
// An absent card still reserves its retained slot, without rendering it.
assert.deepEqual(slots(planWidgetGrid([card('c')], layout)), { c: base.c });
});
test('a temporarily absent card reserves its slot before a new card is pinned', () => {
const layout = {
a: { x: 0, y: 12, w: 4, h: 8 },
b: { x: 4, y: 0, w: 4, h: 8 },
};
const whileAbsent = planWidgetGrid([card('b'), card('c')], layout);
assert.deepEqual(slots(whileAbsent), {
b: layout.b,
c: { x: 0, y: 20, w: 4, h: 8 },
});
const pinned = widgetLayoutFromPlacements(whileAbsent, layout);
assert.deepEqual(pinned.a, layout.a);
assert.deepEqual(slots(planWidgetGrid([card('a'), card('b'), card('c')], pinned)), {
a: layout.a, b: layout.b, c: pinned.c,
});
});
test('a move pushes the cards in its way straight down, in cascade, and compacts nothing', () => {
const plan = planWidgetGrid([card('a'), card('b'), card('c')], {
a: { x: 0, y: 0, w: 4, h: 4 },
@ -150,16 +169,68 @@ test('reading order and the pinned layout write', () => {
c: { x: 8, y: 0, w: 4, h: 4 },
});
assert.deepEqual(widgetReadingOrder(plan), ['c', 'b', 'a']);
const previous = { gone: { x: 0, y: 0, w: 12, h: 4 }, a: { x: 0, y: 40, w: 4, h: 4 } };
const previous = { gone: { x: 0, y: 0, w: 12, h: 4 } };
const layout = widgetLayoutFromPlacements(plan, previous);
// Every shown card pinned at its cell first, then the hidden card's slot kept.
assert.deepEqual(Object.keys(layout), ['a', 'b', 'c', 'gone']);
assert.deepEqual(layout.a, { x: 6, y: 4, w: 4, h: 4 });
assert.deepEqual(layout.gone, previous.gone);
const crowded = Object.fromEntries(Array.from({ length: 250 }, (_, i) => [`old:${i}`, { x: 0, y: i, w: 4, h: 4 }]));
const bounded = widgetLayoutFromPlacements(plan, crowded);
assert.equal(Object.keys(bounded).length, WIDGET_LAYOUT_MAX_ITEMS);
assert.deepEqual(Object.keys(bounded).slice(0, 3), ['a', 'b', 'c'], 'shown cards survive the bound');
const crowded = new Map(Array.from({ length: 250 }, (_, i) => [`new:${i}`, { x: 0, y: i, w: 4, h: 4 }]));
assert.equal(Object.keys(widgetLayoutFromPlacements(crowded)).length, WIDGET_LAYOUT_MAX_ITEMS);
});
test('only a confirmed disabled skill releases a slot, not a temporarily missing tab', () => {
const saved = ['live:one', 'missing:one', 'disabled:one'];
const live = ['live:one'];
assert.deepEqual(confirmedDisabledWidgetKeys(saved, live, null), []);
assert.deepEqual(confirmedDisabledWidgetKeys(saved, live, [
{ name: 'missing', enabled: true }, { name: 'disabled', enabled: false },
]), ['disabled:one']);
assert.deepEqual(confirmedDisabledWidgetKeys(saved, live, [
{ name: 'disabled', enabled: false, identity_collision: true },
]), []);
const previous = Object.fromEntries(saved.map((key, x) => [key, { x: x * 4, y: 0, w: 4, h: 4 }]));
const compacted = compactWidgetLayout([card('live:one')], previous, ['disabled:one']);
assert.deepEqual(compacted['missing:one'], previous['missing:one']);
assert.equal(compacted['disabled:one'], undefined);
assert.equal(compactWidgetLayout([card('live:one')], previous, []), null);
});
test('disabling releases a saved slot, compacts later cards, and re-enabling starts below them', () => {
const all = [card('a'), card('b'), card('c')];
const previous = widgetLayoutFromPlacements(planWidgetGrid(all));
const remaining = all.slice(1);
const compacted = compactWidgetLayout(remaining, previous, ['a']);
assert.deepEqual(Object.keys(compacted), ['b', 'c']);
assert.deepEqual(compacted.b, previous.a);
assert.deepEqual(compacted.c, previous.b);
assert.equal(compactWidgetLayout(remaining, compacted, ['a']), null);
const reenabled = planWidgetGrid(all, compacted);
assert.equal(reenabled.get('a').y, previous.a.h, 'returning card must be placed by the owner');
assert.deepEqual(reenabled.get('b'), compacted.b);
assert.deepEqual(reenabled.get('c'), compacted.c);
});
test('compaction reserves later cells: a wide survivor never gets pushed down', () => {
const layout = {
gone: { x: 0, y: 0, w: 4, h: 4 },
wide: { x: 4, y: 0, w: 8, h: 8 },
lower: { x: 0, y: 4, w: 4, h: 4 },
};
const compacted = compactWidgetLayout([card('wide'), card('lower')], layout, ['gone']);
assert.deepEqual(compacted.wide, layout.wide);
assert.deepEqual(compacted.lower, { x: 0, y: 0, w: 4, h: 4 });
});
test('compaction leaves cards before the vacancy and their sizes intact', () => {
const layout = {
a: { x: 0, y: 0, w: 4, h: 5 },
gone: { x: 4, y: 0, w: 4, h: 5 },
b: { x: 8, y: 0, w: 4, h: 7 },
c: { x: 0, y: 7, w: 8, h: 9 },
};
const compacted = compactWidgetLayout([card('a'), card('b'), card('c')], layout, ['gone']);
assert.deepEqual(compacted.a, layout.a);
assert.deepEqual(compacted.b, { x: 4, y: 0, w: 4, h: 7 }, 'the vacancy is occupied without changing its height');
assert.equal(compacted.c.h, 9);
});
test('a reload restores the same cells from the stored JSON', () => {

View file

@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
confirmedDisabledLayoutKeys,
planWidgetListPatch,
widgetCardSignature,
widgetKey,
@ -24,6 +25,16 @@ function tab(overrides = {}) {
};
}
test('only an authoritative disabled-skill read releases a missing card', async () => {
const prefs = { widget_layout: { 'demo:main': { x: 0, y: 0, w: 4, h: 8 } }, widget_order: ['demo:main'] };
assert.deepEqual(await confirmedDisabledLayoutKeys({ extensions: async () => ({
skills: [{ name: 'demo', enabled: false }],
}) }, [], prefs), ['demo:main']);
assert.deepEqual(await confirmedDisabledLayoutKeys({ extensions: async () => { throw Error('offline'); } }, [], prefs), []);
assert.deepEqual(await confirmedDisabledLayoutKeys({ extensions: async () => ({ skills: [] }) }, [], prefs), []);
assert.deepEqual(await confirmedDisabledLayoutKeys({ extensions: async () => { throw Error('should not read'); } }, [tab()], prefs), []);
});
test('widgetKey prefers the server key and falls back to skill:tab_id', () => {
assert.equal(widgetKey(tab()), 'demo:main');
assert.equal(widgetKey({ skill: 's', tab_id: 't' }), 's:t');

View file

@ -45,7 +45,7 @@ test('normalizeWidgetOrder and sortTabsByWidgetOrder keep the phase-2 contract',
assert.deepEqual(sortTabsByWidgetOrder(tabs, ['z']).map((tab) => tab.key), ['z', 'x', 'y']);
});
test('reordering visible cards preserves disabled keys in the owner order', () => {
test('reordering visible cards retains temporarily absent keys in the owner order', () => {
assert.deepEqual(mergeVisibleWidgetOrder(['a', 'disabled', 'b'], ['b', 'a']), ['b', 'disabled', 'a']);
assert.deepEqual(mergeVisibleWidgetOrder([], ['b', 'a']), ['b', 'a']);
assert.deepEqual(mergeVisibleWidgetOrder(['a', 'gone', 'b'], ['b', 'new', 'a']), ['b', 'gone', 'new', 'a']);
@ -209,18 +209,22 @@ test('an unavailable preferences read cannot overwrite hidden saved cells', () =
assert.deepEqual(page.state.prefs.widget_layout, {});
});
test('first successful preferences read pins defaults; removing a sibling keeps saved cells', async () => {
test('a successful list refresh frees the disabled card and pins compacted neighbors', async () => {
const page = harness({ tabs: [moduleTab('demo:a'), moduleTab('demo:b'), moduleTab('demo:c')] });
page.arrangement.pinDefaults();
assert.equal(page.saves.length, 1);
assert.deepEqual(Object.keys(page.saves[0].widget_layout), ['demo:a', 'demo:b', 'demo:c']);
const original = page.arrangement.relayout;
await flushMicrotasks();
page.state.tabs = page.state.tabs.filter((tab) => tab.key !== 'demo:a');
original();
assert.deepEqual(page.cell('demo:b'), [5, 1, 4, 8]);
assert.deepEqual(page.cell('demo:c'), [9, 1, 4, 8]);
page.arrangement.pinDefaults(['demo:a']);
assert.deepEqual(Object.keys(page.state.prefs.widget_layout), ['demo:b', 'demo:c']);
assert.deepEqual(page.state.prefs.widget_order, ['demo:b', 'demo:c']);
assert.deepEqual(page.cell('demo:b'), [1, 1, 4, 8]);
assert.deepEqual(page.cell('demo:c'), [5, 1, 4, 8]);
await flushMicrotasks();
page.state.tabs.push(moduleTab('demo:a'));
page.arrangement.pinDefaults();
assert.equal(page.saves.length, 1, 'an unchanged list is not saved again');
assert.deepEqual(page.state.prefs.widget_layout['demo:a'], { x: 0, y: 8, w: 4, h: 8 });
assert.deepEqual(page.cell('demo:b'), [1, 1, 4, 8]);
await flushMicrotasks();
});
@ -236,6 +240,7 @@ test('grid keys move a card one cell, pin every shown card and re-derive the rea
'demo:c': { x: 8, y: 0, w: 4, h: 8 },
},
widget_order: ['demo:b', 'demo:c', 'demo:a'],
widget_layout_if: {},
}]);
assert.equal(page.status.textContent, 'Moved to column 1, row 2');
assert.equal(page.byKey('demo:a').scrolled, 1, 'the moved card is kept in view');
@ -280,7 +285,7 @@ test('the narrow stack reorders the key order and resizes the height only', asyn
const page = harness({ tabs: [moduleTab('demo:a'), moduleTab('demo:b'), moduleTab('demo:c')], width: 480 });
assert.equal(page.list.dataset.widgetLayout, 'stack');
page.byKey('demo:a').move.dispatch('keydown', { key: 'ArrowDown' });
assert.deepEqual(page.saves.at(-1), { widget_order: ['demo:b', 'demo:a', 'demo:c'] });
assert.deepEqual(page.saves.at(-1), { widget_layout: {}, widget_order: ['demo:b', 'demo:a', 'demo:c'], widget_layout_if: {} });
assert.deepEqual(['demo:a', 'demo:b', 'demo:c'].map((key) => page.byKey(key).props.get('--widget-order')), ['1', '0', '2']);
assert.equal(page.status.textContent, 'Moved to position 2 of 3');
await flushMicrotasks();
@ -290,7 +295,7 @@ test('the narrow stack reorders the key order and resizes the height only', asyn
assert.equal(page.byKey('demo:b').resize.dispatch('keydown', { key: 'ArrowRight' }).defaultPrevented, false);
page.byKey('demo:b').resize.dispatch('keydown', { key: 'ArrowDown' });
const saved = page.saves.at(-1);
assert.deepEqual(Object.keys(saved), ['widget_layout'], 'a stacked resize keeps the owner\'s stacked order');
assert.deepEqual(Object.keys(saved), ['widget_layout', 'widget_layout_if'], 'a stacked resize keeps the owner\'s stacked order');
assert.equal(saved.widget_layout['demo:b'].h, 9);
assert.equal(page.status.textContent, 'Resized to 9 rows');
});
@ -367,7 +372,7 @@ test('a stacked pointer drag reorders by the other cards\' midpoints', () => {
a.move.dispatch('pointermove', { pointerId: 1, clientX: 10, clientY: 600 });
assert.deepEqual(['demo:a', 'demo:b', 'demo:c'].map((key) => page.byKey(key).props.get('--widget-order')), ['1', '0', '2']);
a.move.dispatch('pointerup', { pointerId: 1 });
assert.deepEqual(page.saves, [{ widget_order: ['demo:b', 'demo:a', 'demo:c'] }]);
assert.deepEqual(page.saves, [{ widget_layout: {}, widget_order: ['demo:b', 'demo:a', 'demo:c'], widget_layout_if: {} }]);
});
test('writes go one at a time, the last arrangement wins, and a read begun before it is stale', async () => {