mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-19 10:04:16 +00:00
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:
parent
cf8df4cc26
commit
c69acaf535
5 changed files with 89 additions and 14 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'"]) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue