Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/i18n-inventory.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# i18n inventory (egdev6/quorb#66)

Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `129601dcd1c39752bf3a0d5e2a484ed622db7138` and `src-tauri/src/` = `fa2fd75f1124453a881a835169cde8f4ffb08839`.
Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `0cda9fef36e559dcffcd3bf53611c07f89c49a2b` and `src-tauri/src/` = `fa2fd75f1124453a881a835169cde8f4ffb08839`.
Any commit whose `git rev-parse <commit>:src` and `<commit>:src-tauri/src` match has these exact texts.
Do not edit by hand: regenerate, or verify with `node scripts/i18n-inventory.mjs --check`.

Expand Down
56 changes: 56 additions & 0 deletions odd/tasks/241-reorder-hold-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# 241-reorder-hold-tests

> Issue: https://github.com/egdev6/quorb/issues/241 — `chore(widget)`, asignada a `DonatoSilva`.
> Rama: `chore/241-reorder-hold-tests` (reconstruida sobre `upstream/main` f08203b tras el review del mantenedor). Fork: `DonatoSilva/quorb`.
> Alcance aprobado por el mantenedor en la propia issue (`status:approved`, `type:chore`); no cambia la regla de #222.

## Specs

- S1: "**Funciones sin lógica propia.** `holdAfterReorderDrop`, `releaseOnStripLeave` y `canPointerOpen` (`src/lib/anchor-panel.ts`) envuelven un booleano (`x => x`, `_ => false`, `h => !h`), así que sus tests no pueden fallar. Los que comprueban la aridad (`canPointerOpen.length`, `hoverPanel.length`, `clickPanel.length`) y «never changes the panel state» (`src/lib/anchor-panel.test.ts`) no fijan ningún comportamiento."
- S2: "**La lógica con bordes reales no tiene test.** La comprobación de «dentro de la ventana» al soltar un reordenado está en línea en `src/Widget.tsx:1219`:"

```ts
e.clientX >= 0 && e.clientY >= 0 && e.clientX < window.innerWidth && e.clientY < window.innerHeight
```

- S3: "Un error de `<` frente a `<=` dejaría la retención activa sin que nada la libere, y el hover no volvería a abrir el panel hasta salir de la tira."
- S4: "Dejar la retención como un `useRef<boolean>` en `Widget.tsx` y quitar las tres funciones envoltorio y sus tests."
- S5: "Extraer `pointerInsideViewport(x, y, width, height)` como función pura y cubrir los bordes en ambos ejes: `0`, `-1`, `width - 1` y `width` (y lo mismo con `height`)."
- S6: "Mantener el test de `ProviderGauge` sobre el enrutado de `mouseenter` a `onPointerEnter`, que sí aporta."
- S7: "Sin cambio de comportamiento: mismas condiciones de retención y liberación que #240."
- S8: "`npm test`, `npm run typecheck` y `npm run build` en verde."
- S9: "Fuera de alcance: cambiar la regla de #222, el temporizador de 400 ms (`REORDER_GUARD_MS`) o el cableado de foco y clic."

## Mecanismo (verificado en el código)

- La retención ya es un `useRef` (`Widget.tsx:255`); `ReorderHold` es un alias de `boolean` (`anchor-panel.ts:151`) y `NO_REORDER_HOLD` es `false` (`:154`).
- Las tres envoltorio son identidades: `holdAfterReorderDrop` devuelve su argumento, `releaseOnStripLeave` devuelve `false` ignorando el argumento y `canPointerOpen` devuelve `!hold`.
- Único consumidor en producción: `Widget.tsx:1220` (escribe al soltar), `:1256` (libera al salir de la tira) y `:1320` (consulta en `onPointerEnter`). El reinicio al ocultar el widget está en `:789`.

## Tasks

- [x] T1 — S5 — Test en rojo de los bordes de `pointerInsideViewport` y la función pura en `src/lib/pointer-viewport.ts` (módulo nuevo: el viewport no comparte dominio con el estado del panel de `anchor-panel.ts`). Route: inline. Commit: `8532d1c`.
- [x] T2 — S2, S3, S7 — Cablear la función pura en el `onPointerUp` de `Widget.tsx`: misma comparación, con `window.innerWidth`/`innerHeight` leídos al soltar. Route: inline. Commit: `8532d1c`.
- [x] T3 — S1, S4 — Borrar `holdAfterReorderDrop`, `releaseOnStripLeave`, `canPointerOpen`, `ReorderHold` y `NO_REORDER_HOLD` de `src/lib/anchor-panel.ts`, y el bloque «the reorder hold» de su test. Route: inline. Commit: `8532d1c`.
- [x] T4 — S4, S7 — Aplanar la retención a `useRef<boolean>` en `Widget.tsx`: `= inside` al soltar, `= false` al salir de la tira, `= false` al ocultar el widget y `if (!reorderHold.current)` en `onPointerEnter`. Route: inline. Commit: `8532d1c`.
- [x] T5 — S8 — `npm test`, `npm run typecheck`, `npm run build` y regenerar `docs/i18n-inventory.md` (el inventario fija el hash del árbol `src/` de HEAD). Route: verify. Commit: `8532d1c` (código) y `5be4521` (inventario i18n).
- [x] T6 — S6, S9 — Commits en work units, push al fork y PR a `egdev6/quorb` con `Closes #241`, más el comentario de aviso en la issue. Route: inline. Commit: PR https://github.com/egdev6/quorb/pull/242 (rama `chore/241-reorder-hold-tests`).

## Riesgo

Risk: item 3 (se eliminan exports de `src/lib/anchor-panel.ts` que `Widget.tsx` consumía; los únicos consumidores son `Widget.tsx` y su test, ambos actualizados en el mismo cambio, y `tsc` detecta imports sobrantes).

## Estrategia de entrega

Tres commits: (1) el refactor con sus tests, el motivo del borde en el JSDoc y el ODD (`8532d1c`), (2) el inventario i18n regenerado (`5be4521`; solo cambia la línea de hashes del árbol de `src/`), (3) este registro de la entrega y del review. Push: `git push -u origin chore/241-reorder-hold-tests`, porque la rama rastrea `upstream/main`, donde no hay permiso de escritura.

## Log

- L1: "perfecto ya sabes realiza la nueva rama nacida del main de upstream/main y push al fork que tengo y pr al proyecto con mencion de comentario en el issuo par aque lo revise... recuerdas como lo hicimos con el issue 222 ??"
- L2: Decisión de diseño (parent): `pointerInsideViewport` va en un módulo nuevo `src/lib/pointer-viewport.ts` y no en `src/lib/anchor-panel.ts`. Motivo: el header de `anchor-panel.ts` declara «El estado del panel de detalle (#170)» y el consumidor conceptual del predicado (el hold puro) desaparece en este mismo cambio; el repo agrupa por dominio y los otros predicados geométricos (`dock.ts:80 contains`, `detail-presence.ts:84 pointInBounds`) viven en el módulo de su dominio. Alternativa descartada: `src/lib/anchor-focus.ts` («El seam de foco (#170)»).
- L3: Convención de estilo verificada: `export function` para el predicado (como `dock.ts:80` y `detail-presence.ts:84`; `anchor-panel.ts` usa arrow-const, que es la excepción local), TSDoc en español y breve (familia anchor/reorder), títulos de test en inglés sin acentos (96/96 describes de nivel superior en inglés), módulo DOM-free (vitest corre en entorno `node`, sin jsdom), `tsconfig` con `strict`, `noUnusedLocals` y `noUnusedParameters`.
- L4: El inventario i18n fija el hash del árbol `src/` de HEAD; cualquier cambio en `src/` lo invalida y `node scripts/i18n-inventory.mjs --check` (CI, `ci.yml:49`) falla. El script lee de un commit (`--ref`, por defecto HEAD), no del working tree: hay que commitear `src/` primero y regenerar después.
- L5: Entrega: rama `chore/241-reorder-hold-tests` desde `upstream/main` 29c9188, push al fork `DonatoSilva/quorb` y PR https://github.com/egdev6/quorb/pull/242 (`Closes #241`). Comprobaciones: `npm test` (vitest 69 archivos / 910 tests; `node --test` 298; 0 fallos), `npm run typecheck`, `npm run build` y `node scripts/i18n-inventory.mjs --check`, todo en verde. `cargo test` no aplica: no hay cambios en `src-tauri/`. Sin prueba manual en GUI porque el comportamiento no cambia: las funciones borradas eran identidades y el predicado extraído reproduce la misma comparación. La etiqueta `type:chore` y la asignación de la PR las aplica el mantenedor.
- L6: Review del mantenedor en la PR #242 (`CHANGES_REQUESTED`, 2026-11-06T06:52:01Z). Punto 1, textual: «El JSDoc que se borra de `holdAfterReorderDrop` explicaba por qué no se retiene al soltar fuera de la ventana: el `mouseleave` ya ocurrió y nada liberaría la retención después. Es justo la razón por la que `<` frente a `<=` importa, y hoy ya no aparece en ningún sitio.» Punto 2, textual: «En `odd/tasks/241-reorder-hold-tests.md`, la estrategia de entrega dice «Dos commits», pero el PR tiene tres (el tercero registra la entrega).» Cierra con «Con eso lo apruebo. La etiqueta y la asignación las pongo yo.»
- L7: Aplicados los dos ajustes. El motivo conductual del `<` pasa al JSDoc de `pointerInsideViewport` (el spec S3 ya lo razonaba, pero no vivía en el código) y este documento declara los tres commits con sus hashes reales.
- L8: `upstream/main` avanzó de 29c9188 a f08203b (cuatro commits, entre ellos el selector de idioma #244, que agregó `src/LocalePicker.tsx` y tocó `src/main.tsx`), así que el hash del árbol `src/` cambió y `docs/i18n-inventory.md` quedó en conflicto: era el único archivo compartido entre upstream y esta rama. La rama se reconstruyó sobre f08203b con los mismos tres commits, el inventario regenerado contra el árbol nuevo y las mismas comprobaciones en verde.
20 changes: 9 additions & 11 deletions src/Widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,15 @@ import { needsRefresh } from './lib/widget-refresh';
import { createWidgetRecovery, shouldAuditNativePosition } from './lib/widget-recovery';
import { placementOutcome, readPlacementWithRetries, reasonKey, samePlacement, type WidgetPlacementReason, type WidgetPlacementReport } from './lib/widget-placement';
import {
canPointerOpen,
clickPanel,
closePanel,
holdAfterReorderDrop,
hoverPanel,
isReorderGesture,
NO_PANEL,
NO_REORDER_HOLD,
panelHoldsOpen,
type PanelState,
releaseOnStripLeave,
type ReorderHold,
} from './lib/anchor-panel';
import { pointerInsideViewport } from './lib/pointer-viewport';
import { createFocusTracker, FOCUS_EVENT, type OwnWindow } from './lib/anchor-focus';
import { createLatestQueue, physicalPanelBounds, samePhysical, DETAIL_EVENTS, DETAIL_WINDOW, isCurrentDetailInteraction, panelWidthFor, type DetailInteraction, type DetailState, type PhysicalBounds } from './lib/detail-panel';
import { createDetailPresence, presenceFromPoint, shouldSampleNativePresence } from './lib/detail-presence';
Expand Down Expand Up @@ -252,7 +248,9 @@ export function Widget() {
const orbDrag = useRef<{ id: string; x: number; y: number; moved: boolean } | null>(null);
const lastReorderAt = useRef(0);
// Tras reordenar, el hover no abre hasta que el puntero salga de la tira (#222).
const reorderHold = useRef<ReorderHold>(NO_REORDER_HOLD);
// Solo la libera el `mouseleave` de la tira: el `mouseenter` puede llegar
// segundos después de soltar. Solo frena la vía del puntero; foco y clic no la consultan.
const reorderHold = useRef<boolean>(false);
const openPeakRef = useRef<() => void>(() => {});
const dragWork = useRef<Promise<void>>(Promise.resolve());
const dragMounted = useRef(true);
Expand Down Expand Up @@ -786,7 +784,7 @@ export function Widget() {
cancelTimers();
detailInsideRef.current = false;
insideRef.current = false;
reorderHold.current = NO_REORDER_HOLD;
reorderHold.current = false;
setInside(false);
setPanel(closePanel());
}));
Expand Down Expand Up @@ -1216,8 +1214,8 @@ export function Widget() {
lastReorderAt.current = Date.now();
// `.dock` es `inset: 0`: dentro de la ventana es dentro de la tira. Con
// la captura, `insideRef` queda desactualizado: se usan las coordenadas.
const inside = e.clientX >= 0 && e.clientY >= 0 && e.clientX < window.innerWidth && e.clientY < window.innerHeight;
reorderHold.current = holdAfterReorderDrop(inside);
const inside = pointerInsideViewport(e.clientX, e.clientY, window.innerWidth, window.innerHeight);
reorderHold.current = inside;
}
if (od?.moved && reorder) {
const next = reorder.order;
Expand Down Expand Up @@ -1253,7 +1251,7 @@ export function Widget() {
}}
onMouseLeave={() => {
insideRef.current = false;
reorderHold.current = releaseOnStripLeave(reorderHold.current);
reorderHold.current = false;
setInside(false);
scheduleClose();
}}
Expand Down Expand Up @@ -1317,7 +1315,7 @@ export function Widget() {
dragging={reorder?.id === r.id}
onHover={(el) => openFor(r.id, el)}
onPointerEnter={(el) => {
if (canPointerOpen(reorderHold.current)) openFor(r.id, el);
if (!reorderHold.current) openFor(r.id, el);
}}
onClick={(el) => anchorOn(r.id, el)}
pointer={orbHandlers(r.id)}
Expand Down
59 changes: 0 additions & 59 deletions src/lib/anchor-panel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,9 @@ import {
clickPanel,
closePanel,
createPanelCoordinator,
canPointerOpen,
hoverPanel,
holdAfterReorderDrop,
isReorderGesture,
NO_PANEL,
NO_REORDER_HOLD,
releaseOnStripLeave,
panelHoldsOpen,
type PanelState,
REORDER_GUARD_MS,
Expand Down Expand Up @@ -96,61 +92,6 @@ describe('the reorder guard', () => {
});
});

describe('the reorder hold (hover after a reorder, #222)', () => {
it('holds the hover when a reorder is dropped with the pointer inside the strip', () => {
expect(NO_REORDER_HOLD).toBe(false);
expect(holdAfterReorderDrop(true)).toBe(true);
});

it('keeps the pointer from opening while held, however much time passes', () => {
// El `mouseenter` diferido llega entre 0,2 s y 2,3 s después de soltar:
// por eso `canPointerOpen` no recibe ninguna marca de tiempo.
const held = holdAfterReorderDrop(true);
expect(canPointerOpen.length).toBe(1);
expect(canPointerOpen(held)).toBe(false);
expect(canPointerOpen(held)).toBe(false);
});

it('releases the hold only with a real strip leave, and then the pointer opens', () => {
const held = holdAfterReorderDrop(true);
const released = releaseOnStripLeave(held);
expect(released).toBe(false);
expect(canPointerOpen(released)).toBe(true);
});

it('does not hold when the reorder is dropped outside the strip', () => {
// Con la captura del puntero se puede soltar fuera de la ventana: su
// `mouseleave` ya ocurrió y nada liberaría la retención después.
const hold = holdAfterReorderDrop(false);
expect(hold).toBe(false);
expect(canPointerOpen(hold)).toBe(true);
});

it('keeps a strip leave idempotent while nothing is held', () => {
expect(releaseOnStripLeave(NO_REORDER_HOLD)).toBe(false);
expect(canPointerOpen(releaseOnStripLeave(NO_REORDER_HOLD))).toBe(true);
});

it('never changes the panel state: hover and click do not take the hold', () => {
const states: PanelState[] = [NO_PANEL, { providerId: 'claude', anchored: false }, { providerId: 'claude', anchored: true }];
const results = (state: PanelState) => ({
hover: hoverPanel(state, 'codex'),
click: clickPanel(state, 'codex'),
close: closePanel(),
});
for (const state of states) {
const held = holdAfterReorderDrop(true);
const whileHeld = results(state);
const released = releaseOnStripLeave(held);
const afterRelease = results(state);
expect(held).not.toBe(released);
expect(afterRelease).toEqual(whileHeld);
expect(hoverPanel.length).toBe(2);
expect(clickPanel.length).toBe(2);
}
});
});

describe('the panel coordinator (delayed open and escape cancellation)', () => {
it('cancels pending open when Escape is pressed before the delay expires', () => {
let timerRun: (() => void) | null = null;
Expand Down
27 changes: 0 additions & 27 deletions src/lib/anchor-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,30 +137,3 @@ export const REORDER_GUARD_MS = 400;
export const isReorderGesture = (lastReorderAt: number, now: number): boolean =>
now - lastReorderAt < REORDER_GUARD_MS;

/**
* La retención del hover tras reordenar (#222): `true` mientras el puntero no
* haya salido de la tira desde que se soltó el orbe.
*
* Una ventana de tiempo no puede acotar el `mouseenter` sintetizado tarde por
* el navegador (medido entre 0,2 s y 2,3 s después de soltar): el orbe se
* mueve bajo un puntero quieto y la entrada llega con el siguiente movimiento.
* Por eso solo la libera el `mouseleave` real de `.dock`, nunca un
* `mouseenter`; pasar a otro orbe tampoco la libera (para abrir otro orbe
* queda el clic). Solo frena la vía del puntero: foco y clic no la consultan.
*/
export type ReorderHold = boolean;

/** Sin retención: el hover abre con normalidad. */
export const NO_REORDER_HOLD: ReorderHold = false;

/**
* Al soltar un reordenado: retiene solo si el puntero sigue dentro de la
* ventana. Fuera, el `mouseleave` ya ocurrió y nada la liberaría después.
*/
export const holdAfterReorderDrop = (pointerInside: boolean): ReorderHold => pointerInside;

/** El `mouseleave` real de la tira libera la retención (y es idempotente). */
export const releaseOnStripLeave = (_hold: ReorderHold): ReorderHold => false;

/** True si el hover del puntero puede abrir el panel; no depende del tiempo. */
export const canPointerOpen = (hold: ReorderHold): boolean => !hold;
26 changes: 26 additions & 0 deletions src/lib/pointer-viewport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';

import { pointerInsideViewport } from './pointer-viewport';

describe('pointerInsideViewport', () => {
it('accepts a point inside the viewport', () => {
expect(pointerInsideViewport(10, 20, 100, 50)).toBe(true);
});

it('accepts the top-left border and rejects one pixel before it', () => {
expect(pointerInsideViewport(0, 0, 100, 50)).toBe(true);
expect(pointerInsideViewport(-1, 0, 100, 50)).toBe(false);
expect(pointerInsideViewport(0, -1, 100, 50)).toBe(false);
});

it('accepts the last pixel inside the viewport and rejects the first one past it', () => {
expect(pointerInsideViewport(99, 49, 100, 50)).toBe(true);
expect(pointerInsideViewport(100, 49, 100, 50)).toBe(false);
expect(pointerInsideViewport(99, 50, 100, 50)).toBe(false);
});

it('rejects a point outside the viewport', () => {
expect(pointerInsideViewport(150, 20, 100, 50)).toBe(false);
expect(pointerInsideViewport(10, 80, 100, 50)).toBe(false);
});
});
12 changes: 12 additions & 0 deletions src/lib/pointer-viewport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// El puntero dentro del viewport de la ventana.

/**
* True cuando el puntero cae dentro del viewport.
*
* El borde derecho e inferior quedan fuera: `width` y `height` ya están fuera.
* Al soltar un reordenado decide si se retiene el hover: fuera de la ventana el
* `mouseleave` ya ocurrió y nada liberaría la retención después.
*/
export function pointerInsideViewport(x: number, y: number, width: number, height: number): boolean {
return x >= 0 && y >= 0 && x < width && y < height;
}
Loading