Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
72f5c9e
mobile: frame against a canvas that has finished laying out
Batuis Aug 22, 2026
16f8f2e
mobile: touch targets in the header, and the language selector moves …
Batuis Aug 22, 2026
225f0ca
mobile: the right panel becomes a bottom sheet
Batuis Aug 22, 2026
5328e22
docs: hand off the mobile work so it can be picked up cold
Batuis Aug 22, 2026
8dfaa07
mobile: one Basic, not two — the ribbon at every width
Batuis Aug 22, 2026
3c0a184
docs: bring the mobile handoff up to date with §4 and §5.1
Batuis Aug 22, 2026
d3c6780
docs: correct the touch-target count — it depends on what is open
Batuis Aug 22, 2026
3ca2fd6
mobile: gather the ribbon into clusters, and let the sheet be dragged
Batuis Aug 22, 2026
47fde46
docs: the handoff catches up with the clustered row and the drag
Batuis Aug 22, 2026
81a178f
mobile: the data tabs ARE the modelling buttons, and four smaller fixes
Batuis Aug 22, 2026
2108ce4
docs: record that the Modelado menu became the data panel's tabs
Batuis Aug 22, 2026
e68f71b
mobile: the six data tabs become one row of icons
Batuis Aug 22, 2026
6a35008
mobile: give Settings a door on the phone
Batuis Aug 22, 2026
36380f1
docs: the one-row icon strip, and the room with no door
Batuis Aug 22, 2026
a8b9d6c
mobile: the row fills the width, and Settings comes back to its corner
Batuis Aug 22, 2026
1ec4af3
tour: the eight walkthroughs work on a phone
Batuis Aug 23, 2026
38bcb62
docs: §5.4 closes, and the audit grows a viewport argument
Batuis Aug 23, 2026
f033176
pro/mobile: option C — the stage in the bar, its commands in the panel
Batuis Aug 23, 2026
302e595
pro/mobile: tidy the prototype — grouping, one highlight, the drag, S…
Batuis Aug 23, 2026
8f2ff82
pro: the Project screen, and finding your way around the phone panel
Batuis Aug 25, 2026
b12f6ea
pro/mobile: both halves of "where am I" in the sheet, and the camera …
Batuis Aug 25, 2026
511a7f1
fix: the restore-a-project offer was 19 px wide on every phone
Batuis Aug 25, 2026
6207d65
pro: density reaches PRO's panel, and a guard for the command tree
Batuis Aug 25, 2026
d945274
Merge remote-tracking branch 'origin/basic/demos' into basic/mobile-ui
Batuis Aug 25, 2026
823e7eb
docs: the solver test everyone kept reporting was a stale local WASM …
Batuis Aug 25, 2026
1ecf9b0
fix: restore three definitions the post-merge re-extraction removed
Batuis Aug 25, 2026
473ba04
fix(pro): the phone shell threw on mount, and Calcular could not open it
diegokingston Aug 28, 2026
a866ed4
refactor(mobile): drop branches and a throttle that cannot do anything
diegokingston Aug 28, 2026
2be80be
feat(a11y): the sheet handle takes focus and answers arrow keys
diegokingston Aug 28, 2026
f910361
test(e2e): the section walkthrough undid its own pick
Batuis Sep 3, 2026
0f60fa9
test(e2e): make the walkthrough's CI failure say what it was
Batuis Sep 3, 2026
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
621 changes: 621 additions & 0 deletions docs/handoffs/mobile-ui-pr166.md

Large diffs are not rendered by default.

55 changes: 51 additions & 4 deletions web/e2e/basic-demos.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,14 +292,61 @@ test.describe('@smoke the section walkthrough', () => {
await expect.poll(() => stepId(page), { timeout: 60_000 }).toBe('arm');
await advance(page, 'pick');

/*
* Stop clicking once the step's own condition is met — not once the card
* has moved.
* ────────────────────────────────────────────────────────────────────
* The two are about a second apart. `pick` waits on
* `resultsStore.stressQuery !== null` and advances from a 300 ms poll
* plus a deliberate 800 ms pause, so a hit is invisible to `stepId` for
* roughly 1.1 s while this loop comes back around every 700 ms.
*
* That mattered because a click that MISSES the member does not merely
* fail to help: Viewport's stress branch takes its `else` and sets
* `stressQuery = null`. The old ladder ran to ±0.1 of the canvas height
* — about 54 px against a 0.3 m ≈ 36 px pick radius — so its last rung
* was a guaranteed miss, and on a runner loaded enough to delay the poll
* past the loop it undid a pick nobody had observed yet. The step then
* waited on a condition that had been true and was not any more, which is
* how this failed three times in a row on CI and never once locally.
*
* So: read `met` rather than the card, leave the instant it is true, and
* keep every rung inside the pick radius.
*/
const met = () => page.evaluate(() => window.__stabileo.tourStep()?.met ?? false);
const pick = () => page.evaluate(() => window.__stabileo.viewportPick());
const box = (await page.locator('canvas:not(.axis-gizmo)').first().boundingBox())!;
for (const fy of [0.5, 0.55, 0.45, 0.6]) {

/*
* Say what the viewport thought, on every rung.
* ────────────────────────────────────────────
* This step has now failed on CI in a way no artifact explained. A click
* is recorded as a station only when `selectMode` is 'stress' and there
* are results; the walkthrough arms the first and solves for the second,
* several steps earlier. When neither the screenshot nor the a11y tree
* shows those, "the mode was disarmed" and "the click missed the member"
* produce the same picture — a card still waiting.
*
* So the run reports both, per rung, and the failure message carries the
* last reading. Cheap on a step that is waiting by definition, and it
* turns the next red run into a diagnosis instead of another guess.
*/
const trail: string[] = [];
for (const fy of [0.5, 0.52, 0.48, 0.54, 0.46]) {
await page.mouse.click(box.x + box.width * 0.5, box.y + box.height * fy);
await page.waitForTimeout(700);
if ((await stepId(page)) !== 'pick') break;
await page.waitForTimeout(300);
const p = await pick();
trail.push(
`fy=${fy} mode=${p.selectMode} tool=${p.tool} results=${p.hasResults} query=${p.hasStressQuery}`,
);
if (await met()) break;
}
const seen = trail.join('\n ');

// It hung here: the condition read the DOM, which nothing re-evaluates.
await expect.poll(() => stepId(page), { timeout: 15_000 }).toBe('sliders');
await expect
.poll(() => stepId(page), { timeout: 15_000, message: `viewport per click:\n ${seen}` })
.toBe('sliders');
});
});

Expand Down
107 changes: 107 additions & 0 deletions web/e2e/pro-mobile-shell.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* PRO's phone shell: the bar's controls have to work with the sheet SHUT.
*
* ── The defect this exists for ─────────────────────────────────────
*
* `proPanelRef` is bound by `ProPanel`, and on a phone that component mounts
* only inside `{#if uiStore.isMobile && uiStore.rightDrawerOpen ...}`. Calcular
* asked that ref whether it could solve:
*
* disabled={!(proPanelRef?.canSolve() ?? false)}
*
* With the sheet shut there is no instance, so the ref is null, so the button
* renders disabled — and being disabled its own onclick cannot fire, so it
* cannot open the sheet that would create the panel that would enable it. The
* sheet starts shut, so Calcular was dead on arrival and went dead again on
* every close.
*
* ── Why no existing test caught it ────────────────────────────────
*
* Two reasons, and both are worth keeping in mind when adding to this file.
*
* The suite had NOTHING on `pmt-solve` — the whole PRO phone bar was untested.
* And the manual audit that accompanied the shell work pressed "every visible,
* ENABLED control", which by construction skips a control that is wrongly
* disabled. A dead button is invisible to a method that only presses live ones.
*
* So the assertions here are about the button being ENABLED and about a press
* producing a solve — not about it merely existing.
*/

import { test, expect, loadModel } from './fixtures';

/** Small enough that a solve is quick; real enough that `hasModel` is true. */
const SMALL = 'rc-qa-diagnostic';

/** iPhone SE. The narrowest width the shell claims to support. */
const PHONE = { width: 375, height: 667 };

test.describe('@smoke PRO phone bar — Calcular', () => {
test('is enabled with the sheet shut, and solves when pressed', async ({ pro: page }) => {
await page.setViewportSize(PHONE);

const solve = page.getByTestId('pmt-solve');
await expect(solve, 'the phone bar mounts below 768 px').toBeVisible();

/*
* The precondition that produced the bug. If this ever fails because
* something started opening the sheet on boot, the test below stops
* covering the reported defect even while passing — so it is asserted
* rather than assumed.
*/
await expect(
page.getByTestId('pm-stage-toggle'),
'the sheet must start shut, or this test is not exercising the defect',
).toHaveCount(0);

await loadModel(page, SMALL);

/*
* THE assertion. Before the fix this was `disabled`, because the panel
* that answers `canSolve()` had not been mounted.
*/
await expect(
solve,
'Calcular must be live once a model exists, sheet open or not — it cannot ' +
'depend on the panel it is meant to open',
).toBeEnabled();

const before = await page.evaluate(() => window.__stabileo.solveCount());
await solve.click();

/*
* Pressing has to SOLVE, not merely open the sheet. The press sets
* `rightDrawerOpen` and then awaits `tick()` before calling into the panel,
* because the panel does not exist at the moment of the click — it is
* mounted by that very assignment.
*/
await expect
.poll(() => page.evaluate(() => window.__stabileo.solveCount()), {
message: 'the press must reach ProPanel.solve(), not just open the sheet',
})
.toBeGreaterThan(before);

await expect(page.getByTestId('pm-stage-toggle'), 'and the sheet opens').toBeVisible();
});

test('stays enabled after the sheet is closed again', async ({ pro: page }) => {
await page.setViewportSize(PHONE);
await loadModel(page, SMALL);

// Open it the way a reader would, then shut it.
await page.getByTestId('pmt-solve').click();
await expect(page.getByTestId('pm-stage-toggle')).toBeVisible();
await page.getByTestId('pro-sheet-close').click();
await expect(page.getByTestId('pm-stage-toggle')).toHaveCount(0);

/*
* Closing the sheet unmounts `ProPanel`, and Svelte sets a `bind:this` back
* to null on destroy — which is the same null the first-load case had. A
* fix that only seeded the ref once would pass the test above and fail here.
*/
await expect(
page.getByTestId('pmt-solve'),
'closing the sheet unmounts the panel and nulls the ref; Calcular must survive it',
).toBeEnabled();
});
});
109 changes: 109 additions & 0 deletions web/scripts/audit-demos.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* Walk every demo, every step, and check the things a reader would notice.
*
* 1. the spotlight has something to point at (target exists and is visible)
* 2. a step that asks for an action can REACH the thing it asks about —
* the hole in the overlay has to be over what you must click
* 3. a step that claims to show a result actually switched to it
* 4. no page errors anywhere
*/
import { chromium } from 'playwright';

const DEMOS = ['basics-2d', 'basics-3d', 'modelling-2d', 'navigation', 'results', 'kinematics', 'section-analysis', 'settings'];

/** Steps that require the reader to act on the CANVAS, with what they must do. */
const CANVAS_STEPS = new Set(['nodes', 'member', 'supports', 'load', 'pick', 'window-crossing', 'drag']);

/** Steps that claim a result is on screen → what diagramType must be. */
const EXPECT_DIAGRAM = {
deformed: 'deformed', moment: ['moment', 'momentY'], axial: 'axial',
shearZ: ['shear', 'shearZ'], stress: 'colorMap',
};

const b = await chromium.launch();
let problems = 0;

for (const id of DEMOS) {
const c = await b.newContext({ viewport: { width: 1500, height: 950 } });
await c.addInitScript(() => {
localStorage.setItem('stabileo-lang', 'es');
localStorage.setItem('stabileo-lang-manual', '1');
});
const p = await c.newPage();
const errs = [];
p.on('pageerror', (e) => errs.push(e.message));
await p.goto('http://localhost:4258/app/basic?e2e=1', { waitUntil: 'networkidle' });
await p.waitForFunction(() => !!window.__stabileo, null, { timeout: 60000 });
await p.waitForFunction(() => window.__stabileo.solverReady?.(), null, { timeout: 60000 });

await p.locator('[data-testid="hdr-project"]').click();
await p.waitForTimeout(400);
await p.locator('[data-testid="demo-menu-toggle"]').click();
await p.waitForTimeout(300);
if (!(await p.locator(`[data-testid="demo-${id}"]`).count())) {
console.log(`\n■ ${id}: NO EXISTE en el menú`);
await c.close();
continue;
}
await p.locator(`[data-testid="demo-${id}"]`).click();
await p.waitForTimeout(1500);

console.log(`\n■ ${id}`);
const card = p.locator('.tour-card').first();

for (let i = 0; i < 14; i++) {
if (!(await card.isVisible().catch(() => false))) break;

const info = await p.evaluate(() => {
return window.__stabileo.tourStep();
});
const title = ((await card.innerText()).split('\n')[1] ?? '?').slice(0, 30);
const stepId = info?.id ?? `#${i}`;
const notes = [];

// 1 + 2: is there something to point at, and can it be reached?
if (info && info.target && info.target !== 'none') {
const n = await p.locator(info.target).count();
if (n === 0) { notes.push(`❌ target ausente: ${info.target}`); problems++; }
else if (!(await p.locator(info.target).first().isVisible())) {
notes.push(`❌ target invisible: ${info.target}`); problems++;
}
}

// 2: a canvas step must let the reader reach the canvas
if (CANVAS_STEPS.has(stepId)) {
const spot = await p.evaluate(() => {
const r = document.querySelector('#tour-spotlight-mask rect[fill="black"]');
if (!r) return null;
const g = (a) => Number(r.getAttribute(a));
return { x: g('x'), y: g('y'), width: g('width'), height: g('height') };
});
const canvas = await p.locator('canvas:not(.axis-gizmo)').first().boundingBox();
const covers = spot && canvas
&& spot.x <= canvas.x + canvas.width * 0.5 && spot.x + spot.width >= canvas.x + canvas.width * 0.5
&& spot.y <= canvas.y + canvas.height * 0.5 && spot.y + spot.height >= canvas.y + canvas.height * 0.5;
if (!info?.allowInteraction) { notes.push('❌ pide acción pero no deja interactuar'); problems++; }
else if (!covers) { notes.push('❌ el hueco no está sobre el modelo — no se puede dibujar'); problems++; }
}

// 3: a step that claims a result must have switched to it
const want = EXPECT_DIAGRAM[stepId];
if (want) {
const dt = await p.evaluate(() => window.__stabileo.diagramType());
const ok = Array.isArray(want) ? want.includes(dt) : dt === want;
if (!ok) { notes.push(`❌ dice mostrar ${stepId} pero diagramType=${dt}`); problems++; }
}

console.log(` ${String(i).padStart(2)} ${stepId.padEnd(16)} ${title.padEnd(31)} ${notes.join(' ') || '✓'}`);

const next = card.locator('button').filter({ hasText: /Siguiente|→|Calcular|Listo|Finalizar/ }).first();
if (!(await next.count())) { console.log(` ⏸ espera acción del lector`); break; }
await next.click().catch(() => {});
await p.waitForTimeout(900);
}
if (errs.length) { console.log(` ❌ errores de página: ${errs[0].slice(0, 80)}`); problems++; }
await c.close();
}

console.log(`\n${problems ? `${problems} problemas` : 'sin problemas'}`);
await b.close();
Loading
Loading