mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
Release disabled widget cells without losing surviving layout
This commit is contained in:
parent
c73904e594
commit
853072c980
11 changed files with 255 additions and 56 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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; });
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue