ouroboros/web/modules/widget_reorder.js
Anton Razzhigaev 32df3ef3f3 widgets: remove the Refresh control (owner decision Q20)
The Widgets page had a Refresh button that was a hard reset: it disposed
every mounted widget, forgot every owner Stop, rebuilt every card and
remounted everything, asking for confirmation only while a card kept
running in the background would be stopped. With the launch policy, the
passive card list and the keyed patch in place, its only remaining
unique job was picking up an edited module source without leaving the
page, and one misclick could stop every long-running program. The owner
chose to remove it.

The window reload is now the only hard reset. It ends every frame with
its window and forgets the page-session Stops, which is what the
documentation already promised for a reload. On a surface without a
reload affordance, such as the packaged desktop shell or the Telegram
mini app, the equivalent boundary is closing Ouroboros, which the
documentation already lists beside the reload.

Removed: the button and its page-header action slot, the click handler,
the `force` flag of `render()` (with it the blanket `stoppedByOwner`
clear and the forced dispose), the button's loading state in the list
sync, `confirmWidgetsRestart` and the now unused confirm-dialog import
in widget_card.js, and the button's CSS rule. Net 82 lines lighter.

Tests follow the behaviour rather than the button. Two tests of the
removed feature are deleted, because the code they covered is gone: the
Node test of the confirmation dialog and the static test of the button's
loading state. Everything else is rewritten, not dropped: the browser
suite now proves that an owner override applies on the next Widgets
entry, that a window reload kills a kept-running frame and starts a
fresh one, and that a reload forgets an owner Stop, with a negative pin
asserting the page carries no Refresh control; the static suite pins the
absence of the button, the handler, the helper and the force flag.

Docs updated in ARCHITECTURE, DEVELOPMENT and CREATING_SKILLS: the
force-destroy boundaries of a kept card are now Stop, the skill leaving
the live list, a window reload and closing Ouroboros. The Tab/focus-order
residual now names the window reload in all four places it appears.

Verified: Node 881 passed (rc 0; one case fewer than before this commit,
the dialog test that went with the dialog), tests/test_widgets_ui_static.py
25 passed (rc 0; one case fewer, the loading-state test), the full suite
in an isolated data root (rc 0), size ratchet --check (rc 0), and the
Widgets lifecycle browser suite on Chromium and WebKit, 6 passed (rc 0).

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
2026-09-03 07:32:39 +03:00

127 lines
5.6 KiB
JavaScript

/* Widgets card order: the owner's `widget_order` preference applied to the card
list, the pure key-order move behind a reorder, and the drag / keyboard
reorder handles on the cards. A reorder never moves an <article>: the visual
order is the masonry key order (web/modules/masonry.js), so a running frame —
retained or not — is never reloaded by it. widgets.js owns persisting the
order through `/api/ui/preferences` and relayouting with it. Disclosed
residual: the Tab / focus order follows the DOM, so after a visual reorder it
can differ from the visible order until a window reload rebuilds the cards;
keyboard reorder through the handle follows the key order. */
import { widgetKey } from './widget_list.js';
export function normalizeWidgetOrder(value) {
if (!Array.isArray(value)) return [];
const seen = new Set();
return value
.map((item) => String(item || '').trim())
.filter((item) => {
if (!item || seen.has(item)) return false;
seen.add(item);
return true;
});
}
export function sortTabsByWidgetOrder(tabs, order) {
const rank = new Map(normalizeWidgetOrder(order).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;
if (aRank !== bRank) return aRank - bRank;
return a.originalIndex - b.originalIndex;
}).map((item) => item.tab);
}
/**
* 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,
* which lands the dragged key after a target it was before and before a target
* it was after. Returns the SAME array when nothing changes, so callers test
* identity for "moved".
*/
export function moveWidgetKey(order, key, toIndex) {
const from = order.indexOf(key);
if (from < 0 || !order.length) return order;
const target = Math.max(0, Math.min(order.length - 1, Math.trunc(Number(toIndex) || 0)));
if (target === from) return order;
const next = order.slice();
next.splice(from, 1);
next.splice(target, 0, key);
return next;
}
// Cards keep their DOM node across list patches, so binding is per card, once;
// the drag source is shared by every binding pass over the one Widgets list.
const reorderBoundCards = new WeakSet();
let draggedKey = '';
/**
* Drag and keyboard reorder on the card handles. `currentOrder()` returns the
* complete visible key order; a move hands the next order to `onOrderChange`
* and touches no node.
*/
export function bindWidgetCardReorder(list, currentOrder, onOrderChange) {
if (!list) return;
const clearDragState = () => {
list.querySelectorAll('.widgets-card.dragging, .widgets-card.drag-over').forEach((card) => {
card.classList.remove('dragging', 'drag-over');
});
draggedKey = '';
};
const move = (key, toIndex) => {
const order = currentOrder();
const next = moveWidgetKey(order, key, toIndex);
if (next === order) return false;
onOrderChange(next);
return true;
};
list.querySelectorAll('[data-widget-reorder-handle]').forEach((handle) => {
const card = handle.closest('[data-widget-key]');
if (!card || reorderBoundCards.has(card)) return;
handle.setAttribute('draggable', 'true');
handle.addEventListener('dragstart', (event) => {
draggedKey = card.dataset.widgetKey || '';
if (!draggedKey) return;
card.classList.add('dragging');
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', draggedKey);
}
});
handle.addEventListener('dragend', clearDragState);
handle.addEventListener('keydown', (event) => {
const key = card.dataset.widgetKey || '';
const from = currentOrder().indexOf(key);
if (from < 0) return;
let toIndex = from;
if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') toIndex = from - 1;
else if (event.key === 'ArrowDown' || event.key === 'ArrowRight') toIndex = from + 1;
else if (event.key === 'Home') toIndex = 0;
else if (event.key === 'End') toIndex = Number.MAX_SAFE_INTEGER;
else return;
if (!move(key, toIndex)) return;
event.preventDefault();
clearDragState();
handle.focus();
});
});
list.querySelectorAll('.widgets-card').forEach((card) => {
if (reorderBoundCards.has(card)) return;
reorderBoundCards.add(card);
card.addEventListener('dragover', (event) => {
if (!draggedKey || card.dataset.widgetKey === draggedKey) return;
event.preventDefault();
card.classList.add('drag-over');
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
});
card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
card.addEventListener('drop', (event) => {
if (!draggedKey || card.dataset.widgetKey === draggedKey) return;
event.preventDefault();
const key = draggedKey;
const targetIndex = currentOrder().indexOf(card.dataset.widgetKey || '');
clearDragState();
if (targetIndex >= 0) move(key, targetIndex);
});
});
}