Skip to content

mobile: one shell for Basic and PRO on a phone - #166

Open
Batuis wants to merge 31 commits into
mainfrom
basic/mobile-ui
Open

mobile: one shell for Basic and PRO on a phone#166
Batuis wants to merge 31 commits into
mainfrom
basic/mobile-ui

Conversation

@Batuis

@Batuis Batuis commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Basic and PRO each had two interfaces: one for a desktop and a different, smaller one for a phone. This makes each of them one interface that adapts.

The problem

Everything built for Basic since the ribbon landed — the pointer mode on the model, the Selection panel, the results selectors, the colour-scale switch, the eight walkthroughs — was reachable only through the ribbon, and the phone mounted no ribbon. It got the old left Toolbar in a drawer plus a floating tool strip instead. Any new work on Basic had to be done twice or it silently shipped to desktop alone.

PRO's phone shell was more deliberate — a five-button bar and a native <select> listing thirteen panel tabs — but it could not reach the four stages, the eight diagrams or the two colour maps at all, because those are not tabs.

Basic

The ribbon mounts at every width and degrades instead of forking. Below 768 px it is nine controls sharing the width — Project, undo, redo, Selection, 2D/3D, Model, Solve, Advanced, Results — with the group captions giving way to the rules that already separate the groups.

Model opens the Model-data panel rather than a menu, because that panel's six tabs already were those six buttons: pickTab arms each tab's tool, and has since it was written. On a phone the tab strip becomes one row of six icon targets, pinned above the table.

BasicPanel is the bottom sheet, and it shares the screen rather than covering it: .app-body reserves --st-sheet-h, so the canvas is the size it appears to be. Without that, choosing a moment diagram drew it behind the panel that had just opened to control it. A grab handle drags the sheet between 22 % and 86 %; the handle is the only surface that resizes and the body scrolls as it always did.

Removed from Basic, all of it a second route to what the ribbon carries: FloatingTools, both mobile drawers, the mobile bottom bar and MobileResultsPanel.

PRO

Four stages and twenty-five commands do not fit a touch row — ANALYSE alone carries fifteen. So the bar keeps only verbs, and both halves of "where am I" are two pills in the sheet: command on the left, stage on the right. Project is an entry in the left pill and greys the right one, because the document belongs to no stage.

The nine camera buttons that ran down the right edge of the model become one split button: the face is whichever control you used last, the caret opens the rest with their names.

src/lib/pro/stages.ts is what makes this survive PRO growing. The command tree was inside ProRibbon.svelte; two surfaces draw it now, and a definition owned by one is a definition the other forgets. A command added there gets a ribbon slot and a grid cell, and nothing else has to be revisited. src/lib/pro/camera-actions.ts does the same for the view controls.

stages-coherence.test.ts guards it. The realistic failure is not a careless edit — it is someone on another branch adding a good command without knowing PRO_TAB_STAGE is a second list that has to learn about it: the ribbon looks right and the phone silently names the wrong stage, because the lookup falls back to 'model'. Verified by breaking it.

Defects found and fixed along the way

  • Toolbar was mounted twice on a phone — in the drawer, and again in .app-body behind leftSidebarOpen where .sidebar { display: none } hid it. Two live copies of a 2,400-line component that was never shown, with every id inside it duplicated.
  • PRO's Calcular was wired to nothing. proPanelRef was bound only to the desktop ProPanel, so on a phone it was null: the button read canSolve() off nothing and rendered permanently disabled. Both instances are bound now.
  • The restore-a-project prompt was 19 px wide on every phone, in both modes. It renders inline in a header that holds a logo, a mode selector, a project name and a settings button in 375 px, and got what was left. Present in the DOM, so every check asking whether it exists passed. It is drawn over the top of the model now.
  • The toast container swallowed every touch. The phone rule set a top without clearing the desktop bottom, so a fixed invisible box spanned the viewport at z-index 1100 for as long as each message lived — after every solve, the canvas could not be panned or drawn on.
  • Settings had no opener on a phone, in either mode, while the language selector had already been moved into it for that width. The control was hidden from where it was and hosted where nothing could open it.
  • A basico && isMobile branch sat inside a !isMobile block, unreachable since it was written.

The walkthroughs work on a phone

They never had — until this branch the phone had no ribbon to point at. Five anchors did not resolve at 375 px and needed two different repairs: sections and materials are data-panel tabs there, and the diagrams live inside a menu the step now opens first.

Verified by walking every step at both widths and diffing. A step that lights its target on a desktop and not on a phone is the defect, and neither run alone can show it — a phone-only run cannot tell "this step lost its target" from "this step never had one".

Verification

  • npm run typecheck — 479, unchanged from baseline
  • npm run test — 379 files pass. chs-shear-agreement fails, unchanged from this branch's base and untouched here (circular tube shear, fix(engine): a circular tube's shear was half its true peak #157)
  • npx playwright test — full suite
  • The walkthrough audit reports sin problemas at 1500 × 950 and at 375 × 667. It takes a viewport now; the second run is new and is the one that means anything about a phone
  • Every visible, enabled control in both phone shells pressed with app state read before and after: zero dead controls

Known and deliberate

  • Nine controls at 44 px come to 396 px, so Basic's row gives each 39–41 px of width at 375. Always visible beats correctly sized and off screen; the height stays 44
  • pointer-mode and zoom-to-fit remain 32 × 32. They are a documented pair on the canvas and have to move together, where 44 px costs model area
  • Control size in the panels is a setting (Ajustes → Móvil), defaulting to compact. 44 px everywhere adds 124 px of scrolling before the results table, and neither side wins outright on a 375 px screen

docs/handoffs/mobile-ui-pr166.md carries the measurements, the traps and what is left.

Batuis added 24 commits August 22, 2026 13:50
Opening the mobile work. This is the first thing measured rather than a plan.

`zoom-to-fit` frames the model against `canvas.width`, the backing store,
which the resize observer sets from the CSS box. On a phone the two disagree
for a few frames while the layout settles — a drawer closing, the address bar
resizing the viewport — and framing during that window fits the model to a
canvas that no longer exists.

It now waits, up to three frames, for the backing store to match the box
before framing. Defensive rather than a fix for an observed symptom, and worth
saying which: the off-screen model I first saw came from loading an example
through the e2e hook, which does not dispatch the fit at all. That was my
measurement being wrong, not the app. The disagreement it guards against is
real and phone-specific, but nothing has yet been shown to hit it in the
product.
…to Settings

Sized for 375 px — an iPhone SE or a mini. Whatever fits there fits a larger
handset, and the reverse is how the header ended up as it was.

Fourteen controls in mobile Basic sat below the 44 px minimum and the worst
were in the header: the mode switcher at 133×26 and the tab-add button at
23×22. A 23 px target is not a small button, it is one that takes two or three
attempts, in the row a new user touches first. Header controls are now 44 px
in both dimensions below 768 px; measured after: zero undersized controls at
375 and at 430, with the header itself still 55 px tall.

The language selector leaves the header on a phone. It held a permanent slot
in the tightest row in the application for a control somebody touches once —
and hiding it there without giving it somewhere else would have removed the
setting from phone users entirely, so it lands in Settings beside the other
things you set and forget. Same values, same effect, with a hint saying where
it lives on a bigger screen.
As a side drawer it was 319 px of a 375 px screen — 85 % of the width and the
full height — so opening the results, the model data or the settings hid the
structure they describe. Reading a table of node coordinates while the nodes
are behind the table is the shape of the problem.

On a phone the two have to share the screen along the axis there is more of,
which is vertical. Just over half the height: measured at 375×667 it leaves
226 px of model above the sheet, with the data table below it still worth
reading.

Sized in vh rather than px so it holds its proportion on a taller handset,
and it slides up rather than in, because a sheet that arrives from the side
and lands at the bottom reads as two different components.
Three commits in, and the substance of this PR — making mobile Basic the
ribbon rather than a second interface — is the part that needs room to do.
Rather than start it and stop halfway, what is known is written down.

The handoff carries the measured baseline, the decision to size for 375 px and
why, the order of the remaining work, and the verification commands. It also
carries the open question honestly: the model does not appear to render on the
phone canvas, I attributed that to my own measurement twice, and the second
explanation no longer convinces me. That is the first thing to settle and it
is written as such, with how to reproduce it.

The walkthrough audit script moves into `web/scripts/` with it. It was living
in a temporary directory, which is the wrong place for the one tool that
checks all eight tutorials in a single run — and the tutorials are about to be
disturbed by the ribbon migration.
Basic mounted a different application on a phone: the old left Toolbar in a
drawer, a floating tool strip, a second results panel, and a right drawer with
its own copy of the model-data table. Everything built since the ribbon landed
— the Selection panel, the results selectors, the colour-scale switch, the
eight walkthroughs — reached the phone through none of them, so new work on
Basic had to be done twice or it shipped to desktop alone.

The ribbon now mounts at every width and degrades instead of forking. Below
768 px it is one horizontally scrollable row of icons: the group captions give
way to the rules that already separate the groups, targets go to 44 px, and
Project/Save/Undo/Redo move to the end so the per-gesture commands keep the
reachable side. No variant drops a command. They are reordered rather than
folded behind an overflow, which would have put `hdr-project` — the entry point
eight walkthroughs reach for by test id — behind a tap nothing knows to make.

BasicPanel comes with it, laid out as a bottom sheet below 768 px. The sheet
SHARES the screen rather than covering it: `.app-body` reserves `--st-sheet-h`,
so the canvas is the size it appears to be and the model gets framed into what
is left. Without that, choosing a moment diagram drew it behind the panel that
had just opened to control it. The framing is redone on the open/shut
transition only, since that is the one that changes the canvas height.

Removed from Basic, all of it a second route to what the ribbon now carries:
FloatingTools, both mobile drawers, the bottom bar, and MobileResultsPanel —
which floated over the sheet showing the same results, two panels disagreeing
about which diagram was on. Education keeps FloatingTools while authoring; PRO
keeps its drawer, its bar and its results panel.

Two things were already broken and are fixed on the way past. Toolbar was
mounted TWICE on a phone — once in the drawer and once in `.app-body` behind
`leftSidebarOpen`, where `.sidebar { display: none }` hid it — so every id
inside it existed twice. And a `basico && isMobile` branch sat inside a
`!isMobile` block, unreachable since it was written.

Measured at 375×667 and 430×932, Chromium with touch: no horizontal overflow,
one ribbon, no duplicate mounts, and the build → solve → read-a-diagram loop
completes on a 375 px screen, which it could not do before. Canvas share is
79 % at 375 and 85 % at 430; the ribbon is what it costs, and it is what buys
the phone the other half of the application.
§4 is answered and not a bug: the model renders through the UI path, and the
same example through the e2e hook reproduces the empty canvas exactly. Recorded
with the control run, because that pairing is what settles it and three
sessions were spent doubting the renderer without it.

§5.1 is done, with before/after at both widths rather than a description. The
one deviation from the suggested shape is written down with its reason —
`rb-quick` moved to the end of the row instead of folding behind an overflow,
because the overflow would have taken `hdr-project` off screen and §5.4 was
already the warning about exactly that.

§5.2 re-measured: 14 undersized controls down to 3, all of them canvas
overlays, two of them a deliberate pair that has to move together. §5.3 and
§5.4 rewritten for what changed underneath them — the sheet is no longer an
overlay, and the walkthrough anchors now exist on a phone for the first time,
which is newly worth testing and newly possible to test.

§8 gains what this session cost: the selection colour is not the member colour,
`display: none` still means mounted, a fixed panel does not resize the canvas,
and tokens without a fallback have to live in `tokens.css`.
The "14 → 3" in the previous commit compared two different states. Three is
what a phone shows with no panel open; with results on screen it is fourteen,
and they are not the fourteen the baseline counted either.

The corrected measurement changes what §5.2 IS. The shell is genuinely done —
5.1 built the ribbon and the sheet header at 44 px. Eleven of the fourteen are
inside the results sheet: `ToolbarResults` and the selectors around it, written
for a 320 px desktop side panel and now rendered on a phone at desktop density.
Every one is short in height and not in width, which is the signature. The two
diagram-scale steppers are 13 px tall.

So the remaining work is a density pass over panel CONTENT, not a handful of
stray buttons in the shell. Scoped into three sizes with a recommendation, and
the probe now reports the parent class — without it the report says a control
is too small but not which component to open.
The phone row was seventeen commands at 44 px in a 375 px viewport — 748 px of
content, so everything past the seventh needed a swipe and the swipe WAS the
interface. Reordering it, which is what the previous commit did, moved the
problem around rather than removing it.

Gathered instead of scrolled, the way PRO already works. Nine controls: Project,
undo, redo, Selection, 2D/3D, Model, Solve, Advanced, Results. The two long
groups collapse to one button each that opens the group in a menu with labels —
Model holds node, element, support, load, materials and sections; Results holds
the diagrams. A cluster lights when the command it stands in for is the one the
panel is showing, so the ribbon's single rule survives the folding. The menu
carries a backdrop: without it the tap that dismisses it falls through to the
canvas and places a node.

Nine × 44 is 396 px, so 375 still gives up 32 px to a scroll and 390 and up fit
outright. Thirty-two pixels and four hundred and sixty are not the same problem.

Save leaves the row — it is inside the Project panel that button opens, beside
Save session and Open. Undo and redo stay: those ARE per-gesture where a mis-tap
places a node.

The Data command is removed from Basic at both widths. Every command that
produces model data already opens that panel on its own tab, so the button
opened a panel the user reached anyway. The panel is untouched, and the
walkthroughs use `openPanel('data')` rather than the button, which is why the
audit does not notice.

The sheet opens at 45vh instead of 58 and is dragged from a grab handle above
the title. Fifty-eight was chosen to make a results table worth reading and did
not manage it — the table began 10 px above the bottom of the screen — while
taking more than half the height from the model. Dragging now spans 22–86 %:
375 px of model at the peek, 67 px with the table fully open, 227 px at rest.
The handle is the ONLY thing that resizes, `touch-action: none` keeps the
browser from claiming the gesture, and the body scrolls as it always did —
verified: a full drag leaves `scrollTop` untouched.

Control size inside the panels becomes a setting rather than a verdict.
`compact` is the default and is what the panels already are; `comfortable`
lifts every control to 44 px and takes the count of undersized controls in the
results sheet from 11 to 0, at the cost of 124 px more scrolling before the
table. Both are defensible on a phone, so the reader picks, in Settings.

Measured at 375×667 and 430×932 with touch: nine controls, all ≥ 44 px, no
duplicate mounts, no console errors, and the demo audit still reports
`sin problemas`.
§5.1's flat scrolling row is marked superseded rather than deleted: it was
measured, shipped and then rejected on sight, and why it was wrong is worth
more than the fact that it was. A row can have no page overflow and still be
unusable, which is the kind of thing a metric will not tell you.

§5.1b, §5.2 and §5.3 record what replaced it, with the before/after at both
widths. §5.2 in particular changed shape: the answer to "are the controls big
enough" turned out to be a setting, because 44 px everywhere costs 124 px of
scrolling and neither side of that wins outright on a 375 px screen.

§5.4 gets a sharper warning. The cluster menus put six modelling commands and
every diagram behind a button, and four walkthroughs anchor those commands
directly. Desktop keeps them flat so the audit still passes — which means the
audit passing is now weaker evidence than it was, and the section says so.
Modelado was a menu of six buttons that opened the Model data panel on an
entity's tab with its tool armed. The panel's own tab strip is those same six
choices and already does exactly that — `pickTab` arms each tab's tool, and has
since it was written. The menu was a second copy of the strip, shown for one tap
and thrown away, and a second thing to keep in step with the first.

So the command opens the panel and the tabs are the buttons. Below 768 px the
strip stops looking like tabs: six equal targets in a fixed 3×2 grid across the
full width, 113×44 each, pinned to the top of the panel's scroll so a long table
never takes the way out of it off screen. Fixed on purpose — a control that
moves depending on how many loads exist is one you have to find again each time.
Modelado lands on the tab last used and arms its tool, so it leaves you able to
draw rather than merely looking.

The "DATOS" heading is gone with it. It was the word "data" spent on 40 px of a
667 px screen, directly above six controls that say the same thing more
precisely. Every other panel keeps its heading — Results and Project have
nothing else naming them — and the ✕ moves to the grab-handle row so there is
one place to close from whichever panel is up. The header's own ✕ is hidden
there, having briefly been a second one four millimetres below the first.

Toasts move below the shell. At `top: 50px` "Cálculo exitoso" landed across the
diagram commands the reader was about to press because of it; they now start at
146, under the options bar, over the canvas, and stop short of the model's own
two buttons so the ✕ does not sit on top of them. The ✕ is 44 px and opaque
instead of 12 px at half opacity, which on a touch screen is the difference
between a control and a decoration.

And the container no longer eats the screen. The phone rule set a `top` without
clearing the desktop `bottom`, so a fixed, invisible div spanned the whole
viewport at z-index 1100 and swallowed every touch that was not on the toast —
for the seconds a toast lived after each solve, the canvas could not be panned
or drawn on. `pointer-events: none` on the box, `auto` on the toasts.

Scale sliders take the row. Their width was an inline `style="width: 80px"`,
which no stylesheet could override; moved to CSS, and on a phone the label goes
to its own line so the track gets 271 px of 375 instead of 80. It has fifty
steps and a thumb has to land on one of them.
§5.1b's cluster is marked superseded for the modelling half and §5.1c explains
what replaced it — including that the tabs already armed their tools, which is
the fact that made the menu redundant rather than merely redundant-looking.

The toast container bug is written up as a general lesson and not as an
incident: overriding one edge of a `position: fixed` box without clearing the
opposite one silently makes it full-size, and here that meant an invisible
full-screen div swallowing every touch for the life of each message.

§5.4 gets sharper again in the other direction. Six of the anchors are reachable
without a menu now, but `rb-cmd-sections` and `rb-cmd-materials` have stopped
being ribbon commands on a phone and are tabs instead — so those two ids do not
exist there at all, which is worse than being hidden and worth saying plainly.
Two rows of three worked but told the reader a small lie about the strip's
shape: which three land on the second line depends on how many fit, so the
same six controls sit differently on a 375 px screen than on a 430 px one, and
a control that moves is one you have to find again.

One row of six, each an equal share of the width — 59 × 44 at 375 px. Under a
44 px square and wider than it is tall, which is the trade a single row costs
and a fair one for a strip that is now the same shape on every handset.

At 59 px a word does not fit, so each button carries the glyph the ribbon
already uses for that entity on a desktop: node, element, support, load,
material, section, straight out of `ribbon/Icon.svelte`. The reader recognises
them rather than learning a second vocabulary for the same six things. The name
survives underneath at 0.5rem and the count moves to a corner badge, where a
number you glance at belongs — inline it would compete with the name for the
same 59 px and lose.

The six hand-written buttons collapse to one list. They repeated the same four
things with only the entity changing, which is how the phone variant came to
need six near-identical edits the first time.
The Settings button is in the header, gated on `!isMobile`. So on a phone the
panel had no opener at all — and `16f8f2ef` had already MOVED the language
selector into it *for that width*, on the reasoning that the header slot was
too expensive. That put the control in a room with no door: hidden from the
header, and hosted by a panel a phone cannot open. Control size joined it in
`3ca2fd6f`, so by then two phone-only settings were unreachable from a phone.

Project is the phone's app menu already — file commands, examples, the
walkthroughs — and it is one tap away on the row, so Settings opens from there.
Not from the row itself: that has nine controls and room for about nine, and a
preference you set once should not hold a permanent slot against a command you
use every minute.

Found by listing which tour anchors resolve at 375 px and noticing
`rb-settings` among the absent, which is not what that check was looking for.
Records the tab strip going to a single row of six icons, and why: two rows of
three made the strip a different shape on every handset, because which three
wrapped depended on what fit.

The anchor audit is written out as the measurement it was rather than as a
worry — which ids resolve at 1500 px and not at 375 — with the two repairs kept
apart. Re-pointing a step at a renamed id and teaching a step to open a menu are
different jobs, and the second needs the tour to reach state that lives inside
`Ribbon.svelte`.

§8 gains the general form of the Settings bug, which is the transferable part:
moving a control into a panel "for phones" is only safe if the panel can be
opened on a phone. It could not, for two commits, and nothing failed — the
language selector and the control-size setting were both in a room with no door.
Fixed 44 px squares made the row 396 px wide, so a 375 px phone gave up 32 px
to a scroll while a 430 px one left 34 px of dead space at the end. Both are
the same mistake: a fixed size on a row whose job is to be as wide as the
screen. Every slot is `flex: 1 1 0` now — nine equal shares of whatever there
is. 39–41 px at 375, 43 at 390, 47 at 430, and nothing scrolls at any of them.

Under the 44 px reference by four pixels at the smallest size, deliberately:
a control slightly narrow but always visible beats one at the reference size
that has to be swiped into view. Height stays 44, so the target is short in one
dimension only — the one a thumb travelling along a row minds least.

Settings returns to the top-right corner at every width, which is where it is
on a desktop and where the application-level controls belong. It had been
`!isMobile` while the language selector was moved INTO the panel it opens
*because* the header was tight at that width — the setting hidden from where it
was and hosted where nothing could open it. The interim opener in the Project
panel is removed; one door, and it is the same door on both.

The phone-only settings get their own section. They were loose among the
general ones, which read as though the whole panel might behave differently on
a phone; two controls do and the rest do not. Verified that they cannot leak
the other way: with `comfortable` stored, a 1400 px window renders no Mobile
section and computes `min-height: auto` on the panel's selects — every rule the
preference drives lives inside a `max-width: 767px` query.
They could not, and until this PR the reason was simple: the phone had no
ribbon for them to point at. Now it does, arranged differently — and a step
whose target is absent darkens the screen and says nothing, which is the exact
silent failure the ANCHORS list was written to prevent.

Five anchors did not resolve at 375 px. Two different repairs:

  * `sections` and `materials` stopped being ribbon commands there — they are
    the Model data panel's tabs, because those tabs already armed the tools the
    commands armed. `ANCHORS.ribbonCommand` answers with `dt-tab-*` on a phone.
  * The diagrams live inside the Results cluster and exist only while it is
    open, so the steps that point at one open it first. `openCluster` goes
    through an event, like `openPanel`, rather than exporting the ribbon's
    state — a step definition describes cards, not the shell's layout. It is a
    no-op on a desktop, so the call is unconditional.

`ANCHORS.needsCluster(id)` is how a reader of the demos can tell which steps
depend on that, without knowing the ribbon's internals.

Verified by walking every step at BOTH widths and diffing: a step that lights
its target on a desktop and not on a phone is the defect, and neither width
alone can show it. Zero remain. `results` and `modelling-2d` — the two the
audit cannot finish because they wait for the reader — were walked separately
with the waits satisfied: all ten and all nine steps light, with the Results
menu opening for the diagram cards and the Secciones tab highlighted for the
section card.

The audit itself now takes a viewport, and reports `sin problemas` at 1500×950
and at 375×667. That second run is new; it is also the one that matters, since
passing at desktop size was never evidence about a phone.
The five broken anchors are recorded with which of the two repairs each needed,
because they are different jobs: a renamed id is re-pointed, a hidden one needs
its menu opened first and that needs the tour to reach state the ribbon owns.

The verification method is written down as the useful part. Walking one width
cannot distinguish "this step lost its target" from "this step never had one" —
the diff between two widths can, and that is what found the five.

Also notes that the audit stops on reader-action steps at BOTH widths, so the
two walkthroughs with the most interesting anchors are exactly the two it never
reaches. They were checked separately; whoever trusts a green audit next should
know what it did not look at.
A prototype to look at, not a finished surface. PRO's desktop shows four stages
and, under the one you are in, its groups. That does not shrink: ANALYSE alone
carries fifteen commands and a touch row holds about nine.

So the row keeps only what is per-gesture — project, undo, redo, pointer, solve
— plus the name of the stage you are in, and the stage's commands are drawn as
a grid inside the panel, where they can wrap. Six slots, `flex: 1 1 0`, exactly
as wide as the screen at 375 and at 430, all 44 px tall. It was 36×34 buttons
with `flex-wrap`, which became two lines whenever Select was armed.

The point of the shape, for a mode still being built: **adding a command
changes the grid's length and nothing else.** The row is a fixed set of verbs
and stays that size however large PRO gets.

`lib/pro/stages.ts` is what makes that true. The command tree was inside
`ProRibbon.svelte`; two surfaces now draw it, and a definition owned by one of
them is a definition the other forgets to update. It is a function rather than
a constant because four commands need to know whether a solve is possible and
what to call — that arrives as a context, so the module imports no stores.

The grid replaces a native `<select>` of the thirteen panel tabs. The select
scaled and was honest, but it hid every destination behind a tap, said nothing
about stages existing, and structurally could not offer the eight diagrams or
the two colour maps — those are not tabs, so on a phone they were unreachable.
The grid folds away once used: fifteen commands is five rows, 256 px of a 300 px
sheet, and after you have picked one what you asked for is what you should be
looking at.

PRO's sheet loses its backdrop and shares the screen instead, like Basic's. With
it, opening the panel to pick a command made the bar above untappable — the two
are halves of one surface and both have to be live. Education keeps its
backdrop: that panel is a modal errand.

Solve and Report leave the panel's mobile action row; they are ANALYSE commands
and appear in the grid, and Solve is in the bar as well. Three copies of one
button is three places to look when it is greyed out.

Still open, and deliberately: the nine camera buttons still stack down the right
edge and the last are behind the sheet; the sheet does not drag yet; the panel's
own controls have not had the density pass. Those were tramo 1 and question 3 of
the proposal.
…ettings

Five things the first pass got wrong, all of them visible at a glance.

**File actions everywhere.** Open, Save and Examples sat above every tab — a
permanent three-button header over the nodes table, over diagnostics, over RC
design, for an errand you run twice a session. They belong to the DOCUMENT, so
they show where the document is: the Project tab.

**The grid was rows and rows with no order.** It flattened the groups the
stages already carry, so ANALYSE was fifteen buttons in five rows with nothing
saying where one kind of thing ended — Solve beside "no diagram" beside a
colour map beside the report. It is drawn BY GROUP now, with headings:
Ejecutar 2, Resultados 8, Mapas 2, Inspeccionar 2, Salida 1. The desktop ribbon
draws the same division as ruled sections; a column draws it as headings.

**And no figures.** Every cell now carries its icon, and the SHORT name goes
under it — for a diagram that is the symbol. It used to put the symbol where
the icon belongs and the full name underneath, which at 116 px rendered
"Momento flector respect…": a truncation pretending to be a label. The full
name is in the tooltip, exactly as the ribbon does it.

**Two things lit at once.** The pointer, the stage and Project could all be
accented together. The accent means one thing — "the panel below is showing
this" — so exactly one control can carry it, and now exactly one does: Project
or the stage, never both, never neither. A pointer mode is not a panel; it says
which mode it is in by changing its glyph between the hand and the arrow, and
takes a plain filled key rather than the accent. Same for an open menu.

**No drag, and no Settings.** The sheet's handle moves out of `BasicPanel` into
`SheetGrab.svelte` and PRO mounts it too — the two panels are the same object
and only one having a drag was never a decision, just an artefact of which was
built first. PRO's Settings button loses its `!isMobile`, like Basic's did.

The phone-only settings also move to the TOP of the panel as their own section.
They had been placed inside the Model sub-section, which PRO renders collapsed
— so in PRO the control size could not be found at all, and in Basic it sat
under a heading reading MODELO, which is not what it configures.

Broke Basic on the way and caught it before the suites: pulling the sheet drag
out of `BasicPanel` also removed `publishWidth` and `startResize`, which sit
between it and the next comment and belong to the DESKTOP width drag. The panel
stopped mounting entirely. Restored, and verified: 45 → 69 → 22.8 vh with the
body's scrollTop untouched.
Six corrections, four of them things that were simply in the wrong place.

**The bottom bar's button was an emoji.** ⚡ renders as a colour sticker at
whatever weight the platform font picks, beside a shell drawn entirely in line
icons on one 24-unit grid. It uses `Icon` now, so it tints with the button
instead of ignoring it.

**The stage selector showed on the Project screen.** Project belongs to no
stage — it is the document, not a step of the work — so the selector named a
place the panel was not showing, which is the one thing this row's highlight
rule exists to prevent. Hidden there; the row is `flex: 1 1 0` so the remaining
five divide the width.

**The panel's action row is gone entirely.** Open, Save and Examples sat above
every tab. They belong to the document and the Project tab already has them, in
sections built for them.

**Export leaves Project, on both web and phone.** Excel, CSV and PNG are done
from where the exported thing lives — the results table exports its numbers,
the viewport its picture — so a second set of buttons here was a parallel route
to keep in step.

**New model comes first.** The panel opened on "Documento abierto", which is
the right first fact for a session under way and the wrong first thing for the
reader who came to begin. Examples, DXF and IFC are the three ways a PRO model
starts, and the reader who needs them has nothing open to read about.

**And the phone panel now says where you are.** The head read "ANÁLISIS 15",
naming the drawer you opened rather than the thing you are looking at — so once
the grid folded, nothing on screen said Barras rather than Nodos. It reads
`Modelo › Barras` with the command's glyph, and it is the line that survives
scrolling, so it is the one that has to carry the address. The panel's own title
band is hidden on a phone, having become the second half of that repeated.

The table gets the panel's whole scroll. Each tab wraps its table in a box with
its own `overflow-y`, which is right on a desktop and is why the table read as a
slot on a phone: a scroller inside a scroller, the smaller one holding what you
came to read. The grid moves inside `.pro-content` so it scrolls away, the tab
roots give up `height: 100%`, and `thead` — already sticky in every tab — pins
to the top of the scroller, directly under the address. Measured: content 6431
px in a 218 px panel, column titles flush with the scroller's top edge.

One selector cost a round trip: `[class$='-table-wrap']` never matched, because
Svelte appends its scope class and the attribute ends in `svelte-1abc`. It read
correctly and did nothing.
…in the bar

**Two pills, half the width each.** The stage was picked in the top bar and the
command in a grid below it, so one half of the address lived in each place.
Both are in the panel now — command on the left, stage on the right — and both
are driven entirely by `lib/pro/stages.ts`: a stage added there gets a row in
the right pill, a command gets a cell in the left one, and this component
enumerates neither.

**Project joins the left pill and greys the right.** It is the document rather
than a step of the work — `PRO_TAB_STAGE` maps it to no stage — so it cannot be
a fifth row on the right, and naming a stage while looking at the document would
claim a place the panel is not showing. It leaves the top bar entirely.

**The command cells follow the screen.** `repeat(auto-fill, minmax(76px, 1fr))`
instead of three fixed columns: four across at 375, five at 430, and near-square
at both rather than stretching into letterboxes as the screen grows.

**Calcular was never connected.** `proPanelRef` was bound only to the desktop
ProPanel, so on a phone it was null — the bar read `canSolve()` off nothing,
rendered permanently disabled, and would have done nothing. The mobile instance
is bound too; the two are mutually exclusive, so the bar's Calcular and the
ANALYSE grid's are now one method on one component. Verified: disabled on an
empty model, enabled once one is loaded.

**The camera stack becomes one split button.** Nine buttons ran down the right
edge of the model, costing a 44 px column of the thing the reader came to look
at, with the last of them behind the sheet. The face is whichever control you
used last — Zoom-to-fit until you choose otherwise — and the caret opens the
rest WITH their names, because ⊤ and ⊡ and ⊟ do not explain themselves at 11 px.
`lib/pro/camera-actions.ts` holds the set: four are store flags it sets, four
need the live camera and dispatch to `Viewport3D`, the way `zoom-to-fit` always
did. Basic keeps its stack — its bar has no room and its canvas is not competing
with a nine-high column.

**The select modes move into the sheet.** Five translated words never fit in a
375 px bar in any language, which is why it carried `flex-wrap` and went to two
lines whenever Select was armed. Pressing Selección now opens the sheet, which
has the width.

**And the seam above the column titles.** Hiding the panel's title left its
`<header>` in place with its padding: an empty band between the pills and the
table belonging to neither. The header goes entirely on a phone — the pills say
what is showing. Sticky also moves from `thead` to the cells: these tables are
`border-collapse: collapse`, and a collapsed table paints its own borders, so a
sticky `thead` travels while a hairline of the row beneath does not and a sliver
of moving values shows along its edge.
It renders inline in the header, beside the tabs, which is right on a desktop —
restoring opens a tab, so that is what it is about. On a phone that header
holds a logo, a mode selector, a project name and a settings button inside
375 px, and the prompt was laid out into whatever was left: **19 px wide by 54
tall**, in both Basic and PRO.

Present in the DOM, so every check that asks whether it exists passes. Simply
unreadable, and unpressable — which is why it looked like the feature had
stopped working.

On a phone it is drawn over the top of the model instead, inside
`.viewport-container` so it lands under whatever shell the mode has: Basic's
ribbon and options bar are 87 px taller than PRO's single row, and an offset
measured against the window would have been wrong for one of them. It wraps —
the name, the date and two answers are more than one 375 px line — and leaves
the 56 px lane the canvas controls and the toasts already share. Restaurar and
Descartar are 44 px.

One instance, not two: the markup is a snippet rendered in the header above
768 px and over the model below it. A second copy would be a second thing to
keep in step, and this prompt has already been lost once — the git history has
it deleted with an older full-width banner and never put back.

Measured: 311 × 84 at both widths in Basic and PRO, against 19 × 54 before. The
desktop layout is untouched at 592 × 28.
The comfortable-density rules were scoped to `.basic-panel` only, so PRO
offered the setting in its own Settings and nothing answered to it. An option
that lies is worse than one that is missing; both sheets are in scope now.

`stages-coherence.test.ts` guards what makes the phone shell survive PRO
growing. The realistic failure is not a careless edit here — it is someone on
another branch adding a good command without knowing `PRO_TAB_STAGE` is a
second list that must learn about it. The ribbon looks right, and the phone
silently names the wrong stage, because the lookup falls back to `'model'`.

Six rules, each with a message saying what to add rather than only what is
wrong: unique ids across the tree, every tab mapped, every stage landing
somewhere its own grid can reach, no orphan mappings, nothing that would render
as a blank cell, no empty groups.

Verified by breaking it: a `buckling` command with an unmapped tab fails with
"these commands open a tab that PRO_TAB_STAGE does not know about: buckling".
A guard that cannot fail is not a guard.

The handoff records §5.5 and §5.6 — the PRO shell as built, and the audit of
both phone shells: zero dead controls, five remaining sub-44 px targets, all
deliberate or tracked. §8 gains the three ways this branch found a control that
looked fine and was not: sized to nothing, unbound at one width, and flagged by
a sweep whose own state drift made it a no-op.
# Conflicts:
#	web/src/App.svelte
#	web/src/components/pro/ProRibbon.svelte
#	web/src/lib/i18n/locales/pt.ts
@Batuis Batuis changed the title mobile: one shell for Basic on a phone mobile: one shell for Basic and PRO on a phone Aug 25, 2026
Batuis added 2 commits August 25, 2026 00:36
…build

`chs-shear-agreement` has been carried in this handoff since it was written as
"fails on main, Diego's #157, verified by stashing". It fails locally and passes
in CI, and the reason is neither the branch nor main: `web/src/lib/wasm/` is a
compiled artefact, and a worktree whose `.wasm` predates 2026-08-19 runs the
engine without the fix that landed that day — the very fix the test checks for.
CI rebuilds with `wasm-pack` on every run and therefore sees the fixed engine.

Stashing was the wrong instrument and gave a true answer to the wrong question.
It proves the branch's own changes did not cause a failure; it says nothing
about whether the failure is real. Two sessions reported it as a known red
because of that.

The note now says how to tell — compare the `.wasm` date against
`git log -- engine/src/` — and the verification block says to rebuild before
believing a solver test that fails only on your machine.
`ProRibbon.svelte` referenced `solved`, `shownDiagram`, `openRebar3DFromRibbon`
and `rebar3DMissingSteps` without defining them. Every PRO surface threw
`ReferenceError` on mount.

The cause is a deletion range, not a rename. Merging the updated base brought a
larger command tree into `ProRibbon`, so the extraction into `lib/pro/stages.ts`
had to be redone against it — and the re-extraction cut from a comment to a
symbol without checking what sat between them. Three helpers did.

**This is the third time in this branch.** The same mistake removed `solved` on
the first extraction, and `publishWidth`/`startResize` when the sheet drag moved
out of `BasicPanel`. Deleting "from X to Y" is only safe if you have read every
line of X..Y, and each time the fix was applied to the one symbol the next error
named rather than to the range. The repair here was to diff the file's
definitions against the base's, list everything removed AND still referenced,
and restore all of it at once — which returned three, not one.

`npm run typecheck` cannot see this. The references are inside the Svelte
template, which `tsc` does not check; the first thing that evaluates them is a
browser, so e2e is the only gate that fails.

And it failed as a slowdown rather than as an error. Every PRO test threw,
Playwright retried each twice, and the ones driving the broken surface spent the
full 60 s timeout — four spec files alone came to about thirteen minutes of pure
failure. The CI job read as "hung on E2E smoke suite" for four hours against a
fifteen-minute baseline, which is what a suite that is failing everywhere
expensively looks like from the outside.

Local `--grep @smoke` after the fix: 317 passed in 8.6 min, no failures and no
flakes. Before it: the same suite took two hours.
@Batuis
Batuis marked this pull request as ready for review August 25, 2026 13:39
@diegokingston
diegokingston changed the base branch from basic/demos to main August 26, 2026 13:27
diegokingston and others added 5 commits August 28, 2026 17:39
Two defects in PRO's phone shell, both found by writing the first test that
mounts it.

## ProPanel referenced four things it did not import

`proStages` builds the same context `ProRibbon` builds, and it was copied
without the imports: `openRebar3D`, `detailingAuthor`, `canOpenRebar3D` and
`detailingStore` were all referenced and none were imported. The phone's
command grid reads `proStages` and evaluates each command's gate, so the
panel threw

  ReferenceError: canOpenRebar3D is not defined

the moment it mounted. Not one button — the whole PRO phone shell.

The desktop ribbon builds its own context from its own imports and never
touches this one, which is why every desktop path stayed green.

## Calcular asked the panel whether it could open the panel

`proPanelRef` is bound by ProPanel, and on a phone that component mounts
only inside `{#if uiStore.isMobile && uiStore.rightDrawerOpen ...}`. The
button read its own enabled state off that ref:

  disabled={!(proPanelRef?.canSolve() ?? false)}

With the sheet shut there is no instance, so the ref is null, so the button
renders disabled — and disabled, its own onclick cannot fire, so it cannot
open the sheet that would create the panel that would enable it.
`rightDrawerOpen` starts false, so Calcular was dead on arrival and went
dead again on every close. The only other control that opens the sheet is
Selection, so the workaround was to press an unrelated button first.

Now it asks the panel while the panel exists and the model otherwise.
`solving` is the one part of `canSolve()` the model cannot answer, and it
does not have to: a solve cannot be running inside a component that is not
mounted.

The press also had to change. It sets `rightDrawerOpen` and the panel is
mounted BY that assignment, so `proPanelRef` is still null on the same tick
— `await tick()` before calling in. It calls `ProPanel.solve()` rather than
dispatching `stabileo-solve`, because the global listener runs
`runGlobalSolve()` directly and would skip PRO's pre-solve quality gate.

## Why nothing caught either

The suite had no coverage of the PRO phone bar at all — `pmt-solve` and
`pmt-pointer` appear nowhere outside App.svelte. And the manual audit that
accompanied this work pressed "every visible, ENABLED control", which by
construction cannot reach a control that is wrongly disabled.

`e2e/pro-mobile-shell.spec.ts` is the first thing to mount the phone panel.
Both tests verified against their defect: reverting the `disabled`
expression fails the first with "Calcular must be live once a model exists";
the import fix is what turned the ReferenceError into a pass.

Claude-Session: https://claude.ai/code/session_01L67REmkuj14xQGpVujp5z9
Three leftovers from the shell work, none of them behaviour changes.

`MobileResultsPanel` is now gated to `appMode === 'pro'` at the top, which
left every `appMode === 'pro' ? ... : ...` inside it taking only its PRO arm
— the class, the `top` offset, the solve label, and one whole `{#if}` around
the verification button. A ternary that cannot go both ways reads like a
decision and is not one. The `floatingToolsTopOffset` reference went with
them; it was reachable only through the dead arm.

`SheetGrab` scheduled a `requestAnimationFrame` inside the drag handler to
throttle the CSS-variable writes. It could not throttle anything: the
`$effect` calls `publish()`, `publish()` reads `vh`, so the effect had
already published synchronously by the time the frame ran. The rAF is gone
rather than the effect — setting one custom property is cheap and the
browser coalesces the style recalc to the next frame regardless.

`SheetGrab`'s cleanup also moved into its own effect. An effect's teardown
runs before every re-run, not only on destroy, so with the cleanup living in
the effect that tracks `vh`, each drag step removed `--st-sheet-h` and set
it again. Nothing painted in between so nothing flickered, but the comment
said "on unmount" and it was not. The new effect reads no state, so its
teardown is genuinely the unmount.

Claude-Session: https://claude.ai/code/session_01L67REmkuj14xQGpVujp5z9
`SheetGrab` carried a `:focus-visible` rule, which says it was meant to take
focus — but with no `tabindex` nothing could give it focus and the rule
never matched. The sheet was drag-only: a keyboard, a switch, or voice
control could open the panel and not choose how much of the screen it takes,
on the surface where that choice matters most.

Arrows move one viewport hundredth, Page ten, Home and End go to the stops.
`aria-valuenow` and its bounds go on the element for the same reason the
keys do — a separator that resizes is a slider to anything that is not a
mouse. The re-frame on release is the same one the drag does, for the same
reason: the canvas just changed height.

This is the one commit in the set that is not a defect fix. The desktop
`.bp-resize` handle in BasicPanel has the same gap and is untouched here, so
dropping this commit costs nothing that was not already missing.

Claude-Session: https://claude.ai/code/session_01L67REmkuj14xQGpVujp5z9
This failed three times in one CI run and never once locally, including
under 8× CPU throttling. It also failed on `main` and on two unrelated
`perf/*` branches on the same days, so it was never about the branch it
was blocking.

`pick` waits on `resultsStore.stressQuery !== null` and advances from a
300 ms poll plus a deliberate 800 ms pause — about 1.1 s — while the
loop came back around every 700 ms and asked whether the CARD had
moved. So a successful click was routinely invisible to the loop, and
the loop kept clicking.

That would be harmless if a miss were a no-op. It is not: Viewport's
stress branch takes its `else` and sets `stressQuery = null`. The
ladder ran to ±0.1 of the canvas height, about 54 px against the 0.3 m
≈ 36 px pick radius, so its last rung was a guaranteed miss. Measured
directly — a click at 0.50 gives `met=true`, and the next at 0.60 gives
`met=false` with the step still on `pick`, which is the exact state in
the CI screenshot: the tour waiting on a condition that had been true
and was not any more.

On a loaded runner the 300 ms poll slips past the 700 ms loop, nothing
ever observes the true window, and the clearing click lands. That is
the whole failure, and it needed both halves — which is why a fast
machine never showed it.

Now the loop reads `met` rather than the card, leaves the instant it is
true, and keeps every rung inside the pick radius.

The product is left alone on purpose. Clicking away from the member to
clear a station is deliberate, and the 800 ms pause exists so the
reader sees what they did.

Verified: the test 3× on its own and the full spec once.
The previous commit fixed a real defect in this test and did not fix
the failure. A click that misses the member sets `stressQuery` back to
null — measured: a click at 0.50 gives `met=true`, the next at 0.60
gives `met=false` with the step still on `pick` — and the old ladder's
last rung was a guaranteed miss. That was worth removing. It was not
what CI was hitting, because the tightened ladder still never met the
condition once.

So no click is being recorded at all, and none of the artifacts can say
why. A station is recorded only when `selectMode` is 'stress' and there
are results; the walkthrough arms the first and solves for the second
several steps earlier. Neither is in the screenshot or the a11y tree,
so a disarmed mode and a missed member look identical — a card still
waiting — which is why this has now been diagnosed wrong twice from the
same evidence.

`viewportPick()` exposes both, next to the hooks the walkthrough audit
already uses, and the step reports them per rung with the last reading
carried in the failure message. Nothing about the product changes; the
next red run answers the question instead of posing it again.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants