Keep a hidden widget's slot when the shown cards are reordered

A reorder wrote only the cards on screen as the whole widget_order, and
the server replaces that list, so a widget whose skill was off lost its
place and came back at the end, against the DESIGN "Widgets board"
promise. mergeWidgetOrder (web/modules/widget_reorder.js) now applies
the reorder of the shown cards onto the full stored order: every slot
holding a shown key takes the next shown key, keys not on screen keep
their slots, and shown keys the stored order lacks take new slots at
its end (stored [A, H, B], shown [B, A] -> [B, H, A]). "Not on screen"
is the only signal; no catalogue call.

A card the stored order does not hold also kept the server's listing
order (sorted by key), so a widget appearing mid-session landed before
the cards already shown when its key sorted first. The list sync now
passes the window's shown sequence to sortTabsByWidgetOrder: unknown
keys keep their shown place and a new key joins the end.

DESIGN states both, and the honest edge: the order is stored when the
owner moves a card, so after a reload widgets added since the last move
follow the stored ones alphabetically. widgets.js stays at its cap
(1599 here). Review round 1 findings 2 and 3 (scope MAJOR/MINOR, Grok
and Fable MINOR, Astra MINOR).
This commit is contained in:
Ouroboros 2026-10-03 21:13:04 +03:00
parent cf8df4cc26
commit c69acaf535
5 changed files with 89 additions and 14 deletions

View file

@ -1195,9 +1195,12 @@ widget's content belongs to its author.
the order the owner set with a card's move handle; a card that does not fit
the rest of a row starts the next one. Nothing is measured to place a card,
so a card whose content grows only makes its own row taller and never sends
a neighbour to another column. A new widget joins the end of the order. A
widget whose skill is off is not drawn; its place in the order and its width
stay stored for its return, and nothing is reserved meanwhile.
a neighbour to another column. A new widget joins the end of the order; the
order is stored when the owner moves a card, so after a window reload the
widgets added since the last move follow the stored ones alphabetically. A
widget whose skill is off is not drawn; its place in the order (moving the
other cards keeps its slot) and its width stay stored for its return, and
nothing is reserved meanwhile.
- **Gaps belong to the owner.** A short card beside a tall one leaves space
under it (cards align to the top of their row), and widths that do not fill
a row leave the rest of it empty. The owner closes a gap by choosing widths;

View file

@ -600,6 +600,13 @@ def test_widgets_card_order_is_owner_ui_preference():
assert "event.key === 'ArrowUp'" in reorder
assert "apiClient.uiPreferences()" in source
assert "apiClient.saveUiPreferences({ widget_order: normalized })" in source
# A reorder of the shown cards is merged into the stored order (a card that
# is off keeps its slot); a card the order lacks keeps its shown place and a
# new one joins the end (docs/DESIGN.md "Widgets board").
assert "export function mergeWidgetOrder(stored, shown)" in reorder
assert "const normalized = mergeWidgetOrder(uiPreferences.widget_order, order);" in source
assert "export function sortTabsByWidgetOrder(tabs, order, shown = [])" in reorder
assert "uiPreferences.widget_order, currentWidgetOrder()," in source
assert "export function moveWidgetKey(order, key, toIndex)" in reorder
assert "export function bindWidgetCardReorder(list, currentOrder, onOrderChange)" in reorder
assert "bindWidgetCardReorder(list, currentWidgetOrder, persistWidgetOrder);" in source

View file

@ -1,5 +1,6 @@
/* Widgets card arrangement by the owner: the `widget_order` preference applied
to the card list, the pure key-order move behind a reorder, the drag /
to the card list, the pure key-order move behind a reorder and its merge
into the stored order (a card not on screen keeps its slot), the drag /
keyboard reorder handles, and the card widths (`createWidgetWidths`: the
card menu, the edge handle and its keys over `ui_preferences.widget_size`).
Nothing here moves an <article>: the board (web/modules/widget_grid.js)
@ -28,8 +29,15 @@ export function normalizeWidgetOrder(value) {
});
}
export function sortTabsByWidgetOrder(tabs, order) {
const rank = new Map(normalizeWidgetOrder(order).map((key, idx) => [key, idx]));
/**
* The cards in the owner's order. A card the order does not hold yet keeps the
* place this window last showed it in (`shown`), and a card new to both joins
* the end, so a widget that appears never lands at its listing place (the
* server lists by key) ahead of the cards already on screen.
*/
export function sortTabsByWidgetOrder(tabs, order, shown = []) {
const known = normalizeWidgetOrder([...normalizeWidgetOrder(order), ...normalizeWidgetOrder(shown)]);
const rank = new Map(known.map((key, idx) => [key, idx]));
return tabs.map((tab, originalIndex) => ({ tab, originalIndex })).sort((a, b) => {
const aRank = rank.has(widgetKey(a.tab)) ? rank.get(widgetKey(a.tab)) : Number.MAX_SAFE_INTEGER;
const bRank = rank.has(widgetKey(b.tab)) ? rank.get(widgetKey(b.tab)) : Number.MAX_SAFE_INTEGER;
@ -38,6 +46,23 @@ export function sortTabsByWidgetOrder(tabs, order) {
}).map((item) => item.tab);
}
/**
* The stored order after the owner rearranged the shown cards into `shown`:
* every slot that holds a shown key takes the next key of `shown`, so a key
* not on screen (its skill is off) keeps its slot for its return; shown keys
* the stored order lacks take new slots at its end. Stored `[A, H, B]` with
* `[B, A]` shown becomes `[B, H, A]`. "Not on screen" is the only signal.
*/
export function mergeWidgetOrder(stored, shown) {
const visible = normalizeWidgetOrder(shown);
const onScreen = new Set(visible);
const kept = normalizeWidgetOrder(stored);
const keptKeys = new Set(kept);
let next = 0;
return [...kept, ...visible.filter((key) => !keptKeys.has(key))]
.map((key) => (onScreen.has(key) ? visible[next++] : key));
}
/**
* Pure key-order move: `key` leaves its slot and re-enters at `toIndex`
* (clamped to the list). A drop onto another card passes that card's index,

View file

@ -25,7 +25,7 @@ import {
WIDGET_START_MODES,
withWidgetStartMode,
} from './widget_card.js';
import { bindWidgetCardReorder, createWidgetWidths, normalizeWidgetOrder, sortTabsByWidgetOrder } from './widget_reorder.js';
import { bindWidgetCardReorder, createWidgetWidths, mergeWidgetOrder, normalizeWidgetOrder, sortTabsByWidgetOrder } from './widget_reorder.js';
import {
apiClient,
apiFetch,
@ -1388,7 +1388,7 @@ export function initWidgets(ctx = {}) {
}
const tabs = sortTabsByWidgetOrder(
Array.isArray(data.ui_tabs) ? data.ui_tabs : [],
uiPreferences.widget_order,
uiPreferences.widget_order, currentWidgetOrder(),
);
const signature = widgetTabsSignature(tabs);
if (hasCards() && tabs.length) {
@ -1458,14 +1458,13 @@ export function initWidgets(ctx = {}) {
}
}
// A reorder (handle drag / keys) hands over the next key order: remember it,
// A reorder (handle drag / keys) hands over the next order of the shown cards:
// merge it into the stored order (a card not on screen keeps its slot),
// re-sort the last good list, relayout in place, persist. No node moves.
function persistWidgetOrder(order) {
const normalized = normalizeWidgetOrder(order);
const normalized = mergeWidgetOrder(uiPreferences.widget_order, order);
uiPreferences = { ...uiPreferences, widget_order: normalized };
if (lastTabs) {
lastTabs = sortTabsByWidgetOrder(lastTabs, normalized);
}
if (lastTabs) lastTabs = sortTabsByWidgetOrder(lastTabs, normalized);
relayout();
apiClient.saveUiPreferences({ widget_order: normalized }).catch((err) => {
console.warn('Failed to save widget order', err);

View file

@ -2,7 +2,9 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createWidgetWidths, moveWidgetKey, normalizeWidgetOrder, sortTabsByWidgetOrder } from '../modules/widget_reorder.js';
import {
createWidgetWidths, mergeWidgetOrder, moveWidgetKey, normalizeWidgetOrder, sortTabsByWidgetOrder,
} from '../modules/widget_reorder.js';
// Widgets lifecycle phase 3: a reorder is a pure move in the KEY order; the
// handles never move an <article> (a moved <iframe> reloads). The card widths
@ -39,6 +41,45 @@ test('normalizeWidgetOrder and sortTabsByWidgetOrder keep the phase-2 contract',
assert.deepEqual(sortTabsByWidgetOrder(tabs, ['z']).map((tab) => tab.key), ['z', 'x', 'y']);
});
const keysOf = (tabs) => tabs.map((tab) => tab.key);
const tabsOf = (...keys) => keys.map((key) => ({ key }));
test('a reorder of the shown cards keeps every stored key that is not on screen in its slot', () => {
// Stored [A, H, B]; H's skill is off; the owner moves B before A.
assert.deepEqual(mergeWidgetOrder(['a', 'h', 'b'], ['b', 'a']), ['b', 'h', 'a']);
// Stored [A, B, C]; B is off; C moves up: B keeps the middle slot, never the end.
assert.deepEqual(mergeWidgetOrder(['a', 'b', 'c'], ['c', 'a']), ['c', 'b', 'a']);
// A shown card the stored order lacks takes a new slot at its end first.
assert.deepEqual(mergeWidgetOrder(['a', 'h', 'b'], ['n', 'a', 'b']), ['n', 'h', 'a', 'b']);
// Nothing stored yet: the shown order is the order.
assert.deepEqual(mergeWidgetOrder([], ['c', 'a']), ['c', 'a']);
assert.deepEqual(mergeWidgetOrder(null, ['c', 'a']), ['c', 'a']);
// Both inputs are normalised, neither is mutated.
const stored = [' a ', 'h', 'a', 'b'];
assert.deepEqual(mergeWidgetOrder(stored, ['b', 'a', 'b']), ['b', 'h', 'a']);
assert.deepEqual(stored, [' a ', 'h', 'a', 'b']);
});
test('disable a widget, reorder the others, enable it again: it is back in its old slot', () => {
const stored = ['s:A', 's:B', 's:C', 's:D'];
// B's skill is off: the board shows A, C, D, and the owner moves D before C.
const shown = keysOf(sortTabsByWidgetOrder(tabsOf('s:A', 's:C', 's:D'), stored));
const written = mergeWidgetOrder(stored, moveWidgetKey(shown, 's:D', shown.indexOf('s:C')));
assert.deepEqual(written, ['s:A', 's:B', 's:D', 's:C']);
// B's skill is on again: it stands between A and D, where it stood.
assert.deepEqual(keysOf(sortTabsByWidgetOrder(tabsOf('s:A', 's:B', 's:C', 's:D'), written)), written);
});
test('a widget that appears joins the end, even when its key sorts before the cards on screen', () => {
// The server lists by key; nothing is arranged yet and this window shows m, z.
const listed = tabsOf('a:main', 'm:main', 'z:main');
assert.deepEqual(keysOf(sortTabsByWidgetOrder(listed, [], ['m:main', 'z:main'])), ['m:main', 'z:main', 'a:main']);
// The stored order still wins; the shown order places only the keys it lacks.
assert.deepEqual(keysOf(sortTabsByWidgetOrder(listed, ['z:main'], ['m:main', 'z:main'])), ['z:main', 'm:main', 'a:main']);
// A window's first list (nothing shown yet) keeps the listing order.
assert.deepEqual(keysOf(sortTabsByWidgetOrder(listed, [])), ['a:main', 'm:main', 'z:main']);
});
test('the reorder module moves keys only: no node insertion or move API, no masonry import', () => {
const source = readFileSync(new URL('../modules/widget_reorder.js', import.meta.url), 'utf8');
for (const forbidden of ['.before(', '.after(', '.prepend(', '.append(', 'insertBefore', 'appendChild', 'replaceWith', "from './masonry.js'"]) {