diff --git a/docs/handoffs/mobile-ui-pr166.md b/docs/handoffs/mobile-ui-pr166.md new file mode 100644 index 000000000..6e69e34e0 --- /dev/null +++ b/docs/handoffs/mobile-ui-pr166.md @@ -0,0 +1,623 @@ +# Handoff — PR #166, mobile UI for Basic + +Branch `basic/mobile-ui`, draft, targets `basic/demos` (#163). +Written to be picked up cold: everything below is measured, not remembered. + +--- + +## 1. Where this stands + +The branch now carries the whole phone shell for both modes; `git log basic/demos..` is the list, each verified at 375×667 and 430×932 in Chromium with +touch enabled: + +| commit | what it does | evidence | +|---|---|---| +| `72f5c9e0` | `zoom-to-fit` waits for the canvas backing store to match its CSS box | defensive; see §4 | +| `16f8f2ef` | header touch targets ≥ 44 px; language selector moves to Settings | 14 undersized controls → 0 | +| `225f0ca1` | right panel becomes a bottom sheet on phones | 226 px of model stays visible | +| `8dfaa071` | one Basic instead of two: the ribbon at every width (§5.1) | see the table in §5.1 | +| `3ca2fd6f` | the phone row gathers into clusters; the sheet is dragged (§5.1b, §5.3) | see §5.1b | +| `81a178f3` | the data tabs become the modelling buttons; toasts, sliders (§5.1c) | see §5.1c | +| `e68f71b5` | the six data tabs become one row of icons | 6 × 59×44, one row, 367/375 px | +| `6a350081` | Settings gets an opener on the phone — it had none | §8 | +| `a8b9d6cb` | the row fills the width; Settings back in its corner | 0 px overflow at 375/390/430 | +| `1ec4af30` | the eight walkthroughs work on a phone (§5.4) | audit `sin problemas` at 375×667 | + +Nothing is merged. CI has not been run on this branch. + +--- + +## 2. The problem this PR exists to solve — SOLVED in `8dfaa071` + +**Desktop Basic and mobile Basic were two different applications.** They are +one now; §5.1 has the measurements. This section is kept as written because it +is the argument the rest of the work rests on, and because the baseline table +at the end of it is what the after-numbers are compared against. + +- Desktop mounts `Ribbon` + `BasicPanel` (`App.svelte`, gated on + `uiStore.appMode === 'basico' && !uiStore.isMobile`). +- Mobile mounts `Toolbar` (the old left panel) inside a drawer, plus + `FloatingTools` — gated on `uiStore.appMode === 'basico' && uiStore.isMobile`. + +Everything built in the last several PRs targets the ribbon: the pointer mode +on the model, the Selection panel, the results panel with its selectors, the +colour-scale legend switch, the tutorials menu. On a phone those are reached +through a different set of controls, or not at all. Any new work on Basic +currently has to be done twice or it silently only ships to desktop. + +Measured baseline, before this branch: + +``` + 375×667 430×932 +ribbon absent absent +left toolbar drawer only drawer only +floating tool bar present present +horizontal overflow none none +canvas share of screen 85 % 89 % +controls under 44 px 14 14 +``` + +--- + +## 3. Decision already taken + +**Optimise for 375 px** (iPhone SE / mini). What fits there fits a larger +handset; the reverse is how the header ended up with a 23×22 px button. The +user was asked twice which handset and did not specify, so this was chosen on +that reasoning — revisit if they say otherwise. + +--- + +## 4. ANSWERED — the model does render. There was no bug. + +Settled by driving the UI path at 375×667 in Chromium with touch: menu → +Ejemplos 2D → tap one. The canvas draws the member, both supports, the +distributed load with its `D: 10.0 kN/m` label and the node numbers. + +The control run is what makes it conclusive. The *same* example loaded through +`window.__stabileoActions.loadExample()` in the *same* session reproduces the +old symptom exactly — grid, axes gizmo, no members — and dispatching +`stabileo-zoom-to-fit` by hand immediately afterwards brings the model back, +pixel-identical to the UI path. + +So the two earlier attributions were right after all: the hook does not frame, +`ToolbarExamples` does (it dispatches the fit 50 ms after loading), and every +mobile screenshot that showed an empty canvas had been taken through the hook. +`72f5c9e0` is the whole of it. Nothing about rendering is outstanding. + +The lesson is the one already in §8 and it earned its place: **measure the UI +path when what you are measuring is what a user sees.** A `memberPx` counter is +also a trap — members are drawn in grey, `#4ecdc4` is the *selection* colour, so +a pixel probe keyed to it reports zero on a perfectly good canvas. Look at the +screenshot. + +Reproduce: + +```bash +cd web +NODE_OPTIONS= VITE_E2E=1 npm run build +NODE_OPTIONS= npx vite preview --port 4258 --strictPort & +# then drive Chromium at 375×667, isMobile: true, hasTouch: true +``` + +--- + +## 5. Work remaining, in order + +### 5.1 DONE — one shell instead of two (`8dfaa071`) + +Mobile Basic is the ribbon adapted. Measured at 375×667 and 430×932, Chromium +with touch: + +``` + 375×667 430×932 + before after before after +ribbon absent present absent present +left toolbar drawer removed drawer removed +floating tool bar present removed present removed +mobile bottom bar present removed present removed +mobile results pnl present removed present removed +right drawer present removed present removed +horizontal overflow none none none none +canvas share 85 % 79 % 89 % 85 % +ribbon commands 0 17 0 17 +duplicate mounts 2 0 2 0 +controls under 44px 14 3 / 14 14 3 / 14 +``` + +**Read that last row carefully — it is two measurements, not one.** 3 with no +panel open; 14 once results are on screen. The number depends entirely on +what is showing, and quoting it without the state is how "14" got compared +against a "3" measured somewhere else. §5.2 has the breakdown. + +Canvas share goes DOWN and that is the trade, stated plainly: the ribbon costs +about 6 % of the screen and buys the phone the other half of the application — +Selection, the results selectors, the colour scale, Advanced, the walkthroughs, +none of which a phone could reach at all before. + +What was built, against the suggested shape: + +- Ribbon degrades to one horizontally scrollable row of 44 px icons below + 768 px. Group captions are hidden; the hairline rules between groups already + carried the grouping, so the caption was the redundant half of the pair. +- `rb-quick` was reordered to the end rather than folded behind an overflow. + **Superseded by `3ca2fd6f` — see §5.1b.** Reordering moved the swipe around + instead of removing it, and the reader said so. +- `FloatingTools`, both mobile drawers, the mobile bottom bar and + `MobileResultsPanel` are gone from Basic. Education keeps `FloatingTools` + while authoring; PRO keeps its drawer, its bar and its results panel. +- `BasicPanel` is the bottom sheet below 768 px, and it **shares** the screen + rather than covering it — `.app-body` reserves `--st-sheet-h` (in + `styles/tokens.css`; 58vh then, 45 now), so the canvas is the size it appears + to be. Overlaying + it instead drew the moment diagram behind the panel opened to control it. + +Two defects found on the way past and fixed in the same commit: + +- `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, and every + id inside it existed twice. +- A `basico && isMobile` branch sat inside a `!isMobile` block. Unreachable + since it was written. + +Files touched: `src/App.svelte`, `src/components/ribbon/Ribbon.svelte`, +`src/components/ribbon/BasicPanel.svelte`, +`src/components/MobileResultsPanel.svelte`, +`src/components/toolbar/ToolbarExamples.svelte`, `src/lib/store/ui.svelte.ts`, +`src/styles/tokens.css`. + +### 5.1b DONE — the row gathers instead of scrolling (`3ca2fd6f`) + +5.1 kept all seventeen commands on one flat row and made it scroll well. That +was the wrong target: at 44 px they are 748 px of content in a 375 px viewport, +so the swipe *is* the interface no matter how the row is ordered. The reader +rejected it on sight, which is the right call and worth recording — a +measurement can say "no horizontal overflow of the page" while the thing the +page contains is still unusable. + +**Gathered, the way PRO already does it.** Nine controls: + +``` +Proyecto │ ↶ ↷ │ Selección 2D/3D Modelado▾ Calcular Avanzado Resultados▾ +``` + +- `Modelado` opened node, element, support, load, materials, sections. + **Superseded by `81a178f3` — see §5.1c.** It is a plain command now. +- `Resultados` opens the diagram commands. Still a cluster. +- Both are drawn as commands with a caret, and **light when the command they + stand in for is the active one** — otherwise the ribbon's one rule ("lit means + this is what the panel is showing") dies the moment the lit command is inside + a closed button. +- The menu has a **backdrop**. Without it the tap that dismisses it falls + through to the canvas and places a node. +- `CLUSTERS` reads the existing `GROUPS` rather than restating their contents. + A command added to `draw` reaches the phone for free; a second list would be a + second definition of the ribbon. + +Save left the row — it is inside the Project panel that button opens. Undo and +redo stayed: those ARE per-gesture where a mis-tap places a node. + +``` + 375×667 430×932 + 5.1 5.1b 5.1 5.1b +controls in the row 21 9 21 9 +content width 748 px 407 px 748 px 407 px +swipe to the last ~460 px 32 px ~460 px 0 px +all ≥ 44 px yes yes yes yes +``` + +**The 32 px is gone** — `a8b9d6cb`. Nine fixed 44 px squares came to 396, so +375 scrolled and 430 left 34 px of dead space; both are a fixed size on a row +whose job is to be the width of the screen. Every slot is `flex: 1 1 0` now: +39–41 px at 375, 43 at 390, 47 at 430, nothing scrolling at any of them. Four +pixels under the reference at the smallest size, deliberately — always visible +beats correctly sized and off screen — and only in width, since the height +stays 44. + +**The `data` command is gone 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 reader reached anyway. Nothing anchors it: the walkthroughs +call `openPanel('data')`. Verified against all eight. + +Two traps found building it, both invisible until a menu is opened on a phone: + +- `.ribbon` had no `position`, so the menu's `top: 100%` resolved against a + distant ancestor and the menu opened at the BOTTOM of the page, behind the + sheet. A popover's anchor must be positioned; nothing warns you. +- The sheet is `z-index: 60`. A menu at 59 is painted under it, so half the + commands are visible and untappable. The menu is 70/71. + +### 5.1c DONE — the data tabs are the modelling buttons (`81a178f3`) + +The `Modelado` cluster was six buttons that each 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 did exactly that — `pickTab` in `DataTable.svelte` arms each +tab's tool, and has since it was written, precisely so the tab and the ribbon +agree. The menu was a second copy of the strip, shown for one tap and discarded. + +So the command opens the panel, and below 768 px the tab strip stops looking +like tabs: + +- six equal targets in **one row** — `e68f71b5`; 59 × 44 px each, spanning 367 + of 375. Two rows of three made the strip a different shape on every handset, + since which three wrapped depended on what fit. At 59 px a word does not fit, + so each button carries the RIBBON's glyph for that entity — the reader + recognises it from the desktop instead of learning a second vocabulary — with + the name at 0.5rem beneath and the count as a corner badge; +- `position: sticky` at the top of the panel's scroll, so a long table never + takes the way out of it off screen; +- fixed on purpose — a strip that reflows with the number of loads is one the + reader has to re-read every time; +- `Modelado` lands on the tab last used and arms that tab's tool, so it leaves + you able to draw. Materials and sections correctly arm none. + +`.bp-body` loses its horizontal padding for this panel so the grid and the table +go edge to edge. + +**The "DATOS" heading is gone**, and the ✕ moved to the grab-handle row — one +place to close from whichever panel is up. Other panels keep their heading; +Results and Project have nothing else that names them. Watch for the duplicate: +hiding `.bp-close` on mobile is what stops the ✕ appearing twice, four +millimetres apart. + +Three smaller things in the same commit, all measured on a phone: + +- **Toasts** start at `top: 146px` — under the options bar, over the canvas — + instead of `50px`, where "Cálculo exitoso" covered the diagram commands the + reader was about to press because of it. They stop at `right: 56px` so the ✕ + does not land on the canvas's own two buttons. The ✕ is 44 px and opaque + rather than 12 px at half opacity. +- **The toast container was eating the screen.** The phone rule set a `top` + without clearing the desktop `bottom`, so a fixed invisible box spanned the + whole viewport at z-index 1100 and swallowed every touch that was not on the + toast — after every solve, for as long as the message lived. `pointer-events: + none` on the container, `auto` on the toasts. **This is the general lesson: + overriding one edge of a `position: fixed` box without clearing the opposite + one silently makes it full-size.** +- **Scale sliders** were `style="width: 80px"` inline, which no stylesheet could + beat. Moved to CSS; on a phone the label takes its own line and the track gets + 271 px of 375. Fifty steps need somewhere to land. + +### 5.2 DONE, as a setting rather than a verdict (`3ca2fd6f`) + +The shell is at 44 px. What stayed small is panel CONTENT: `ToolbarResults` and +the selectors around it were written for a 320 px desktop sidebar, so their rows +are 22–25 px and the two diagram-scale steppers are 13. Every one is short in +HEIGHT and not in width — the signature of a desktop row in a phone-width box. + +Enlarging them is not free, and the cost is not cosmetic: it adds 124 px to the +panel's content, which is 124 px more scrolling before the results table. Denser +rows show more of the answer; bigger rows are easier to hit. Which wins depends +on the hand and the handset, so it is **Ajustes → Tamaño de los controles**, +phone only, defaulting to `compact` — which is what the panels already were. + +``` +with results on screen compact comfortable +controls under 44 px 11 0 +content height 415 px 539 px +hidden scroll, sheet at 45 % 197 px 321 px +hidden scroll, sheet at 69 % 37 px 161 px +``` + +Which is why §5.3 mattered first: at a fixed 58vh, `comfortable` would have been +strictly worse. With the drag, it costs nothing you cannot undo with a thumb. + +Lives in `src/styles/touch-density.css` — global, because the rows belong to +several components with scoped styles and no useful shared parent. Keyed on +`data-touch-density` on ``, written by `uiStore.touchDensity`, and scoped +under `.basic-panel` so it can only reach the sheet's contents. + +**The model data table is deliberately exempt.** It is a grid of numbers to +read, not controls to hit; 44 px rows would put four on a screen and turn +reading a model into scrolling one. Its editable cells still follow the setting. + +The three controls OUTSIDE the panel are unchanged and still under 44 px: the +tab rename field (139×19) and the pointer-mode / zoom-to-fit pair (32×32). The +pair is documented as a pair and has to move together, and both sit on the +canvas, where 44 px costs model area. Left as a deliberate open question. + +When adding a control to the density sheet, **measure with a panel open** — the +probe reports nothing useful against an empty model. + +The probe used: + +```js +[...document.querySelectorAll('button, select, input')] + .map(e => { const r = e.getBoundingClientRect(); return { + t: e.textContent?.trim().slice(0,18), + id: e.getAttribute('data-testid') || e.getAttribute('title'), + // Without the parent class the report says a control is too small and not + // which component to open. That cost a whole extra run. + parent: e.parentElement?.getAttribute('class'), + w: Math.round(r.width), h: Math.round(r.height) }; }) + .filter(x => x.w > 0 && x.h > 0 && (x.w < 44 || x.h < 44)) +``` + +### 5.3 DONE — the sheet opens lower and is dragged (`3ca2fd6f`) + +Opens at **45vh** rather than 58, and resizes between 22 % and 86 % from a grab +handle above the title. The chosen height is remembered +(`stabileo-basic-sheet-vh`). + +58 was picked to make a results table worth reading. It did not manage it — the +table began 10 px above the bottom of the screen — and it charged the model more +than half the height to fail at it. + +``` + model sheet hidden scroll in the body +peek (22 %) 375 px 152 px 345 px +open (45 %) 227 px 300 px 197 px +tall (69 %) 67 px 460 px 37 px +before (58 %) 139 px 387 px 87 px ← the single fixed value +``` + +**Dragging is not scrolling, and that was the requirement.** The handle is the +only surface that resizes; the body keeps ordinary `overflow-y: auto`. What +makes it hold is `touch-action: none` on the handle — without it the browser +claims the gesture as a scroll and a drag flings the list underneath. Verified: +a full drag leaves the body's `scrollTop` at 0. + +Three things whoever touches this next needs to know: + +- The height is published to `--st-sheet-h` on the ROOT element, not set inline, + because `.app-body` reserves the same value so the canvas is really the size + it looks. The token is the value it opens at; `publishSheet()` overrides it. +- It is handed back on unmount. Leaving it would keep a band of canvas walled + off for a panel that is no longer there. +- The re-frame fires at the END of the drag, not during. The canvas has just + changed height by up to 60 % of the screen, so the previous framing is wrong — + but refitting per pointermove makes the model chase the handle. + +### 5.4 DONE — the eight walkthroughs work on a phone (`1ec4af30`) + +They never had, and the reason was structural rather than neglect: until this +PR the phone mounted no ribbon for them to point at. + +Five anchors did not resolve at 375 px, and they needed two different repairs: + +| anchor | why it broke | repair | +|---|---|---| +| `rb-cmd-sections` | is a data-panel TAB on a phone | `ANCHORS.ribbonCommand` answers `dt-tab-sections` | +| `rb-cmd-materials` | idem | idem | +| `rb-cmd-stress` | inside the Results cluster | step opens the cluster first | +| `rb-cmd-momentY` etc. | idem | idem | +| `[data-group="results"]` | the group is one button there | `ANCHORS.ribbonGroup` answers the cluster button | + +`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 a step calls it unconditionally. +`ANCHORS.needsCluster(id)` tells a reader of the demos which steps depend on it. + +**How it was verified, and why the obvious check is not enough.** Walk every +step at BOTH widths and diff: 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 has no target" from "this step never had one". +Zero remain. + +The audit cannot finish `results` or `modelling-2d`; both stop on a step that +waits for the reader, at either width. Those two were walked separately with +the waits satisfied through the store: all ten and all nine steps light, the +Results menu opens for the diagram cards, and the Secciones tab carries the +highlight for the section card. + +`audit-demos.mjs` now takes a viewport (`node audit-demos.mjs 375 667`) and +reports `sin problemas` at both. **Run it at both from now on** — passing at +1500×950 was never evidence about a phone, and this section is the proof. + +### 5.5 PRO — the phone shell is built (option C) + +Proposed as four options and built as the one that survives PRO growing. The +desktop shows four STAGES and, under the one you are in, its groups; that does +not shrink, because ANALYSE alone carries fifteen commands and a touch row holds +about nine. + +**Shape.** The bar keeps only verbs — undo, redo, pointer, solve, camera. Both +halves of "where am I" are two pills in the sheet, half the width each: 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. + +**Why it survives change, which was the requirement.** +`src/lib/pro/stages.ts` holds the command tree; the desktop ribbon and the phone +grid both read it. A command added there gets a ribbon slot and a grid cell and +nothing else has to be revisited — the bar is a fixed set of verbs whatever size +PRO reaches. `src/lib/pro/camera-actions.ts` does the same for the view +controls. + +`src/lib/pro/__tests__/stages-coherence.test.ts` is the guard. 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 must 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**: adding +a `buckling` command with an unmapped tab fails the suite with a message saying +which command and what to add. + +**Measured at 375×667:** + +``` + before after +camera buttons on the model 9 0 (one split button in the bar) +controls under 44 px 15 5 +bar overflow — 0 (also 0 at 430) +grid columns — 4 at 375, 5 at 430 +dead controls 1 0 (Calcular; see below) +``` + +The five that remain are the split button's caret (20×44, deliberate), the +sheet's ✕ (44×40) and three of PRO's own panel controls at 26 px tall, which +answer to the density setting. + +**Calcular was wired to nothing.** `proPanelRef` was bound only to the desktop +`ProPanel`, so on a phone it was null: the bar read `canSolve()` off nothing and +rendered permanently disabled. Both instances are bound now, and they are +mutually exclusive, so the bar's Calcular and the ANALYSE grid's are one method +on one component. **This is the class of defect to look for** — a control that +is present, enabled-looking and connected to a reference that only exists at the +other width. + +### 5.6 Both phone shells, audited + +Every visible, enabled control in both modes, pressed, with app state read +before and after. `audit-mobile.mjs` in the scratchpad. + +**Zero dead controls in either shell.** A sequential sweep flagged five in PRO; +all five were disproved individually and were artefacts of the sweep's own +state drift — pressing controls in order leaves the shell somewhere that makes +the next one a legitimate no-op. **A sequential audit cannot tell a dead button +from one whose precondition the previous step removed.** Re-check candidates +from a clean state before believing them; that is what `audit-five.mjs` does. + +Remaining under 44 px, both modes, and all deliberate or already tracked: + +| where | size | note | +|---|---|---| +| Basic's row, 9 slots | 39–41 × 44 | they share the width; §5.1b | +| pointer-mode / zoom-to-fit | 32 × 32 | a documented pair, on the canvas; §5.2 | +| PRO's camera caret | 20 × 44 | half of a split button | +| PRO's sheet ✕ | 44 × 40 | four short | +| PRO's panel rows | 26 tall | answers to the density setting | + +--- + +## 6. How to verify + +**The demo audit** — walks all eight walkthroughs and checks that every step +points at something visible, that steps requiring an action can reach it, and +that a card claiming a result switched to it: + +``` +/private/tmp/claude-501/-Users-bautistachesta-Claude/483f0ef8-8bd9-4e17-8146-6008da7ceb15/scratchpad/audit-demos.mjs +``` + +It imports `playwright`, so it needs the package to resolve. Rather than +copying it into `web/` — temporary scripts stay out of the repository, §7 — +symlink the modules next to it: + +```bash +ln -sfn /web/node_modules /node_modules +node /audit-demos.mjs +``` + +Expects the preview on `:4258`. Prints `sin problemas` or a list. It hardcodes +its viewport at line 27 (1500×950) — change it there to audit a phone. + +**Suites** — all four run as of `8dfaa071`: + +```bash +cd web +NODE_OPTIONS= npm run typecheck # 479 = baseline. PASS +NODE_OPTIONS= npm run test # two vitest passes +# If a solver test fails here and nowhere else, rebuild the engine first: +NODE_OPTIONS= npm run wasm +NODE_OPTIONS= npx playwright test e2e/basic-demos.spec.ts # 12 passed, 1 flaky +``` + +Known: one test in `basic-demos.spec.ts` is declared with two retries and +reports flaky roughly one run in twenty. It drives a canvas whose layout moves +between steps; the walkthrough itself is deterministic. It flaked once during +5.1 and then passed 4/4 on first attempt when run alone — and 5.1's changes are +all behind `isMobile` or `max-width: 767px` while that spec runs at desktop +size, so it cannot be causal. Do the same check before blaming a change for it. + +**`chs-shear-agreement.test.ts` is NOT a real failure, and this note was wrong +twice.** It fails locally and passes in CI. The cause is neither the branch nor +`main`: it is a stale `web/src/lib/wasm/` build. Diego's fix — "a circular +tube's shear was half its true peak" — landed in the Rust engine on 2026-08-19, +and a worktree whose `.wasm` predates that runs the engine without it. CI +rebuilds with `wasm-pack` on every run, so it sees the fixed engine. + +Earlier versions of this note said "fails on `main`, verified by stashing". +Stashing proves only that the branch's own changes did not cause it — never +that the failure is real, which is the question that mattered. + +**When a solver test fails locally and nowhere else, compare the date on +`web/src/lib/wasm/dedaliano_engine_bg.wasm` against `git log -- engine/src/` +before reporting it.** `npm run wasm` rebuilds it. + +**Before trusting a preview on `:4258`**: check what is already bound to it. +A stale `vite preview` from an earlier session serves the bundle it was built +from, and it will happily answer 200 while you measure the wrong code. Confirm +the served `assets/index-*.js` hash matches the build you just made. + +--- + +## 7. Project rules that apply + +- **Never touch the solver** — `engine/src/solver/`, `engine/src/element/`. + If a fix seems to need it, re-audit instead. +- **Every commit is Bauti's**, `syngoviano@gmail.com`, G-signed. No + co-authorship, no tool attribution. +- Do not touch ports 4000 or 4002 (in use). This work uses 4258. +- Temporary scripts stay outside the repository. +- The CI e2e job runs `@smoke` only. A spec without that tag can rot unseen — + that is how a pointer regression survived several commits. + +--- + +## 8. Things learned here that will bite again + +- `openBasicPanel(panel)` **toggles by default**. Two consecutive calls close + it. Pass `{ toggle: false }` when the intent is "open". +- A tour step's `waitFor` runs inside an effect and must read **store state**. + Reading `Map.size` or the DOM produces a condition that is right once and + never re-evaluated. Three separate hangs came from this; the auto-advance + now polls at 300 ms, which removes the class. +- `getBoundingClientRect()` on a scrolled panel returns the whole content + rectangle. The tour highlight is clamped to the viewport in + `tour.svelte.ts` for this reason. +- The e2e hook `loadExample` does not frame the model. Use the UI path when + what you are measuring is what a user sees. §4 is the whole cautionary tale: + three sessions spent doubting the renderer over a measurement artefact. +- **Do not key a pixel probe to `#4ecdc4`.** That is the *selection* colour. + Members are drawn grey, so a "are the members there" counter built on it + reports zero on a correct canvas. Look at the screenshot. +- An element hidden by `display: none` is still **mounted**. `.sidebar` is + hidden below 768 px, which is how a phone came to carry two live copies of + `Toolbar` with every test id inside it duplicated — invisible on screen and + invisible in a screenshot, but it breaks strict-mode locators and it is real + work the phone was doing for nothing. Count mounts, not pixels. +- A `position: fixed` panel does not resize the canvas. If a sheet covers the + model, `zoom-to-fit` still frames against the full canvas and puts the model + behind the sheet. Reserve the height on `.app-body` so the two share the + screen — `--st-sheet-h` in `styles/tokens.css` is that reservation. +- Every `var(--st-*)` **without a fallback** must be defined in + `src/styles/tokens.css`. `design-tokens-resolve.test.ts` enforces it and it + scans that file only — declaring a token in a component's `:global(:root)` + fails the suite, which is the right answer: it is the convention, not a + technicality. +- `uiStore.floatingToolsTopOffset` was derived from `showFloatingTools`, a + **persisted user setting**, not from whether the strip is mounted. Any gate + that unmounts a component whose size something else reserves has to update + the reservation too. +- **A control can be present, sized to nothing, and pass every existence + check.** The restore-a-project prompt renders inline in the header. On a + phone that header holds a logo, a mode selector, a project name and a settings + button in 375 px, and the prompt got what was left: **19 px wide by 54 tall**, + in both modes. It was in the DOM, so it looked fine to anything asking whether + it existed. Assert a minimum SIZE, not presence, for anything that has to be + read. +- **A sequential control sweep produces false "dead button" reports.** Pressing + every control in order leaves the shell in states where the next one is a + legitimate no-op — five of PRO's were flagged this way and all five worked + when re-checked from a clean start. Confirm candidates individually. +- **`bind:this` on one of two mutually exclusive instances silently disables + whatever reads it.** PRO's mobile `ProPanel` was unbound, so the phone's + Calcular read `canSolve()` off `null`, rendered disabled forever, and would + have done nothing if pressed. Nothing errors; the button just never works. +- **A control moved into a panel whose opener is gated can become unreachable, + and nothing fails.** `16f8f2ef` moved the language selector into Settings + *for phones*, on good reasoning — the header slot was too expensive. But the + Settings button itself is `{#if ... && !uiStore.isMobile}` in the header, so + the panel had no opener at that width: the control was hidden from where it + was and hosted where it could not be reached. `3ca2fd6f` then added control + size to the same panel. Two phone-only settings, unreachable from a phone, + for two commits. Fixed in `6a350081` — Settings opens from Project, the + phone's app menu. **When moving a control "into X for width W", check that X + can be opened at width W.** +- Left behind by 5.1 and swept in review: `uiStore.leftDrawerOpen` and + `uiStore.leftSidebarOpen` were vestigial, their only remaining readers in + `src/lib/tour/tour-steps.ts`, which nothing imported. The file and both store + fields are gone. The lesson worth keeping is how it read from outside: a + field with a getter, a setter and live writers looks maintained, and only a + reader census says otherwise — `solve.ts` was still setting `leftDrawerOpen` + on every mobile solve, for a drawer that had stopped existing. diff --git a/web/e2e/basic-demos.spec.ts b/web/e2e/basic-demos.spec.ts index e2726d8ef..57123a12c 100644 --- a/web/e2e/basic-demos.spec.ts +++ b/web/e2e/basic-demos.spec.ts @@ -328,25 +328,57 @@ test.describe('@smoke the section walkthrough', () => { await advance(page, 'pick'); /* - * The box is measured before EACH click, not once — the same correction the walkthrough test - * above already carries, for the same reason. + /* + * Two independent corrections, both needed. + * ──────────────────────────────────────── + * MAIN's, and the one that explains why nothing registered: the canvas + * box is measured before EACH click, not once. A click that lands + * reflows the canvas, so a box captured before the loop aims every + * later attempt at where the canvas used to be — four misses in a row, + * reported only as `sliders` never arriving. + * + * THIS BRANCH's, and the reason a miss is not free: Viewport's stress + * branch takes its `else` and sets `stressQuery = null`, which is + * exactly the condition `pick` waits on. Measured directly — a click at + * 0.50 gives `met=true`, the next at 0.60 gives `met=false` with the + * step still on `pick`. The 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 that could undo a hit. * - * A click that lands on a member opens the property panel, and that reflows the canvas. So a - * box captured before the loop aims attempts 2 to 4 at where the canvas used to be, and the - * step never leaves `pick`: it fails having missed four times in a row while reporting only - * that `sliders` never arrived. Observed on CI, on a run whose only delta from a green one was - * a vitest file this spec does not read. + * It could undo one because the advance is not immediate: a 300 ms poll + * plus a deliberate 800 ms pause, roughly 1.1 s, while the loop came + * back every 700 ms and asked whether the CARD had moved. So: read + * `met`, leave the instant it is true, and keep every rung inside the + * radius. */ + const met = () => page.evaluate(() => window.__stabileo.tourStep()?.met ?? false); + const pick = () => page.evaluate(() => window.__stabileo.viewportPick()); const canvasBox = async () => (await page.locator('canvas:not(.axis-gizmo)').first().boundingBox())!; - for (const fy of [0.5, 0.55, 0.45, 0.6]) { + + /* + * And the run says what the viewport thought, per rung. A click counts as + * a station only when `selectMode` is 'stress' and there are results; + * neither is in a screenshot or the a11y tree, so a disarmed mode and a + * missed member used to produce the same picture — a card still waiting. + */ + const trail: string[] = []; + for (const fy of [0.5, 0.52, 0.48, 0.54, 0.46]) { const box = await canvasBox(); 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'); }); }); diff --git a/web/e2e/pro-mobile-shell.spec.ts b/web/e2e/pro-mobile-shell.spec.ts new file mode 100644 index 000000000..3bdea4cfc --- /dev/null +++ b/web/e2e/pro-mobile-shell.spec.ts @@ -0,0 +1,171 @@ +/** + * 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'; + +type Page = import('@playwright/test').Page; + +/** 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(); + }); +}); + +/** + * The floating results panel, and the door it lost. + * + * `MobileResultsPanel` is PRO-only and used to be opened by a button on the + * toolbar this bar replaced. The bar arrived without it, so the panel was + * reachable one way only — it opened itself after every solve, over the + * results sheet — and its ✕ was a one-way exit. + * + * Both halves are asserted, because fixing either alone leaves a defect: an + * opener with the auto-open still in place restores two panels arguing over + * one screen, and dropping the auto-open without an opener makes the panel + * unreachable outright. + */ +test.describe('@smoke PRO phone bar — the results panel', () => { + const panel = (page: Page) => page.locator('.mrp-panel'); + + test('a solve does not raise it, and the bar can', async ({ pro: page }) => { + await page.setViewportSize(PHONE); + await loadModel(page, SMALL); + + const opener = page.getByTestId('pmt-results'); + await expect(opener, 'nothing to read yet, so nothing to open').toBeDisabled(); + + await page.getByTestId('pmt-solve').click(); + await expect + .poll(() => page.evaluate(() => window.__stabileo.solveCount())) + .toBeGreaterThan(0); + + /* + * The sheet answers the solve. A second surface on top of it is the bug, + * not the feature — `ProPanel.solve()` has already switched to Results. + */ + await expect(page.getByTestId('pm-stage-toggle'), 'the sheet opens').toBeVisible(); + await expect( + panel(page), + 'a solve must not raise the floating panel over the sheet', + ).toHaveCount(0); + + await expect(opener, 'with results, the bar can offer it').toBeEnabled(); + await opener.click(); + await expect(panel(page), 'the bar opens it').toHaveCount(1); + }); + + test('its ✕ is not a one-way exit', async ({ pro: page }) => { + await page.setViewportSize(PHONE); + await loadModel(page, SMALL); + await page.getByTestId('pmt-solve').click(); + await expect.poll(() => page.evaluate(() => window.__stabileo.solveCount())).toBeGreaterThan(0); + + const opener = page.getByTestId('pmt-results'); + await opener.click(); + await expect(panel(page)).toHaveCount(1); + + await page.locator('.mrp-close').click(); + await expect(panel(page)).toHaveCount(0); + + // THE regression. Before this, the only way back was to solve again. + await opener.click(); + await expect(panel(page), 'closing it must not strand the reader').toHaveCount(1); + }); +}); diff --git a/web/scripts/audit-demos.mjs b/web/scripts/audit-demos.mjs new file mode 100644 index 000000000..fbe0dcf58 --- /dev/null +++ b/web/scripts/audit-demos.mjs @@ -0,0 +1,128 @@ +/** + * 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', +}; + +/* + * The viewport is an argument, because the anchors depend on it. + * + * `ANCHORS.ribbonCommand` resolves differently below 768 px — a phone points + * at `dt-tab-*` inside the data sheet where a desktop points at `rb-cmd-*` — + * so a walkthrough can be whole at 1500 px and broken at 375 with the audit + * reporting nothing. It only ever ran the desktop width, which is the one + * width these repairs were NOT about. + * + * node scripts/audit-demos.mjs # 1500×950, as before + * node scripts/audit-demos.mjs 375 667 # the phone + */ +const [argW, argH] = process.argv.slice(2); +const VIEWPORT = { width: Number(argW) || 1500, height: Number(argH) || 950 }; + +const b = await chromium.launch(); +let problems = 0; +console.log(`viewport ${VIEWPORT.width}×${VIEWPORT.height}`); + +for (const id of DEMOS) { + const c = await b.newContext({ + viewport: VIEWPORT, + ...(VIEWPORT.width < 768 ? { isMobile: true, hasTouch: true, deviceScaleFactor: 2 } : {}), + }); + 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(); diff --git a/web/scripts/take-screenshots.ts b/web/scripts/take-screenshots.ts index b9b5ed2c8..40066722c 100644 --- a/web/scripts/take-screenshots.ts +++ b/web/scripts/take-screenshots.ts @@ -168,7 +168,6 @@ async function main() { const { modelStore, resultsStore, uiStore } = await import('/src/lib/store/index.ts'); modelStore.loadExample('portal-frame'); resultsStore.clear(); - uiStore.leftSidebarOpen = true; uiStore.rightSidebarOpen = false; setTimeout(() => window.dispatchEvent(new Event('stabileo-zoom-to-fit')), 100); }); @@ -192,7 +191,6 @@ async function main() { const { modelStore, resultsStore, uiStore } = await import('/src/lib/store/index.ts'); modelStore.loadExample('portal-frame'); resultsStore.clear(); - uiStore.leftSidebarOpen = false; uiStore.rightSidebarOpen = false; setTimeout(() => window.dispatchEvent(new Event('stabileo-zoom-to-fit')), 100); }); @@ -240,7 +238,6 @@ async function main() { await page.evaluate(async () => { const { modelStore, resultsStore, uiStore } = await import('/src/lib/store/index.ts'); uiStore.analysisMode = '3d'; - uiStore.leftSidebarOpen = false; uiStore.rightSidebarOpen = false; }); await sleep(1500); @@ -288,7 +285,6 @@ async function main() { await page.evaluate(async () => { const { uiStore } = await import('/src/lib/store/index.ts'); uiStore.analysisMode = '3d'; - uiStore.leftSidebarOpen = false; uiStore.rightSidebarOpen = false; }); await sleep(1500); diff --git a/web/src/App.svelte b/web/src/App.svelte index 2e1e0977d..566697686 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,22 +1,20 @@
- - - - - - + {#each TABS as tab (tab.id)} + + {/each} -{#if uiStore.isMobile && !uiStore.mobileResultsPanelOpen && uiStore.appMode === 'basico'} - -{/if} - - -{#if uiStore.isMobile && uiStore.mobileResultsPanelOpen && (uiStore.appMode === 'basico' || uiStore.appMode === 'pro')} -
+ +{#if uiStore.isMobile && uiStore.mobileResultsPanelOpen && uiStore.appMode === 'pro'} + +
{t('mobile.results')} @@ -54,7 +58,7 @@
{#if hasResults} @@ -77,9 +81,8 @@ - {#if uiStore.appMode === 'pro'} - - {/if} + + {/if}
@@ -143,30 +146,6 @@ {/if} diff --git a/web/src/components/Viewport.svelte b/web/src/components/Viewport.svelte index 76022dc44..feeb4b478 100644 --- a/web/src/components/Viewport.svelte +++ b/web/src/components/Viewport.svelte @@ -336,13 +336,34 @@ ro.observe(canvas.parentElement!); // Listen for zoom-to-fit events (same mechanism as Viewport3D) - const handleZoomToFitEvent = () => { + const handleZoomToFitEvent = (retries = 3) => { if (modelStore.nodes.size === 0) return; + /* + * Wait for the canvas to have its final size before framing against it. + * + * On a phone the fit fires while the layout is still settling — a drawer + * closing, the address bar resizing the viewport — and `canvas.width` + * is still the previous size or zero. The model is then framed for a + * canvas that no longer exists: loading the portal frame on a 375 px + * screen put node 1 at x = 400, off the right edge, and what the user + * saw was an empty grid with one load label clipped at the border. + * + * The backing store is set from the CSS box by the resize observer, so + * disagreement between the two means the layout has not caught up. + */ + const box = canvas.getBoundingClientRect(); + const settled = box.width > 0 + && Math.abs(canvas.width - box.width * (window.devicePixelRatio || 1)) < 2; + if (!settled && retries > 0) { + requestAnimationFrame(() => handleZoomToFitEvent(retries - 1)); + return; + } const projected = [...modelStore.nodes.values()].map(n => project2DNode(n)); uiStore.zoomToFit(projected, canvas.width, canvas.height); invalidate(); }; - window.addEventListener('stabileo-zoom-to-fit', handleZoomToFitEvent); + const onZoomToFit = () => handleZoomToFitEvent(); + window.addEventListener('stabileo-zoom-to-fit', onZoomToFit); // Initial draw — needsRedraw is already true, so schedule the first frame directly rafId = requestAnimationFrame(drawOnce); @@ -352,7 +373,7 @@ rafId = null; ro.disconnect(); if (resizeTimer) clearTimeout(resizeTimer); - window.removeEventListener('stabileo-zoom-to-fit', handleZoomToFitEvent); + window.removeEventListener('stabileo-zoom-to-fit', onZoomToFit); }; }); diff --git a/web/src/components/Viewport3D.svelte b/web/src/components/Viewport3D.svelte index 94bd0c764..dce1dbf35 100644 --- a/web/src/components/Viewport3D.svelte +++ b/web/src/components/Viewport3D.svelte @@ -610,6 +610,15 @@ // Listen for global zoom-to-fit event (dispatched by F key from Toolbar) const handleZoomToFitEvent = () => { zoomToFit(); }; // zoomToFit() calls invalidate() internally window.addEventListener('stabileo-zoom-to-fit', handleZoomToFitEvent); + /* + * The preset views, asked for from outside. + * + * PRO's phone bar draws the whole camera stack as one split button, and it + * cannot call `setView` — that needs the live camera, which lives here. So + * it dispatches and this answers, the same arrangement `zoom-to-fit` + * already used. See `lib/pro/camera-actions.ts`. + */ + window.addEventListener('stabileo-camera-view', handleCameraViewEvent); // Listen for camera restore event (dispatched on tab switch) const handleRestoreCamera = () => { @@ -654,6 +663,7 @@ renderer.dispose(); controls.dispose(); window.removeEventListener('stabileo-zoom-to-fit', handleZoomToFitEvent); + window.removeEventListener('stabileo-camera-view', handleCameraViewEvent); window.removeEventListener('stabileo-restore-camera-3d', handleRestoreCamera); window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keydown', onNavKeyDown); @@ -2411,6 +2421,11 @@ invalidate(); } + function handleCameraViewEvent(e: Event) { + const which = (e as CustomEvent<'top' | 'front' | 'side'>).detail; + if (which === 'top' || which === 'front' || which === 'side') setView(which); + } + function setView(view: 'top' | 'front' | 'side' | 'iso') { _setView(view, camera, controls, modelStore.nodes); invalidate(); @@ -2553,6 +2568,16 @@
{/if} + + {#if !(uiStore.isMobile && uiStore.appMode === 'pro')}
@@ -2600,6 +2625,7 @@ {uiStore.renderMode3D === 'sections' ? '◫' : '⬡'}
+ {/if} {#if uiStore.clippingEnabled} diff --git a/web/src/components/pro/ProPanel.svelte b/web/src/components/pro/ProPanel.svelte index c076fac37..aaa87cc26 100644 --- a/web/src/components/pro/ProPanel.svelte +++ b/web/src/components/pro/ProPanel.svelte @@ -3,6 +3,25 @@ import { t } from '../../lib/i18n'; import ProProjectFileActions from './ProProjectFileActions.svelte'; import { modelStore, resultsStore, uiStore, verificationStore, tabManager, historyStore } from '../../lib/store'; + import { buildProStages, PRO_TAB_STAGE, type ProCmd } from '../../lib/pro/stages'; + /* + * The four the phone's command grid needs. + * + * `proStages` below builds the same context `ProRibbon` builds, and it was + * copied without these: `openRebar3D`, `detailingAuthor`, `canOpenRebar3D` + * and `detailingStore` were all referenced and none were imported. The phone + * grid reads `proStages` and evaluates each command's gate, so the panel + * threw `ReferenceError: canOpenRebar3D is not defined` the moment it + * mounted — the whole PRO phone shell, not one button. + * + * Nothing caught it because the desktop ribbon builds its own context from + * its own imports and never touches this one, and no test mounted the phone + * panel. `e2e/pro-mobile-shell.spec.ts` is the one that does now. + */ + import { detailingStore } from '../../lib/store/detailing.svelte'; + import { detailingAuthor } from '../../lib/store/detailing-author.svelte'; + import { canOpenRebar3D, openRebar3D } from '../../lib/store/rebar-open'; + import Icon from '../ribbon/Icon.svelte'; import { openReport } from '../../lib/engine/pro-report'; import type { ReportData, ReportConfig } from '../../lib/engine/pro-report'; import type { ElementVerification } from '../../lib/engine/codes/argentina/cirsoc201'; @@ -745,6 +764,133 @@ setTimeout(() => window.dispatchEvent(new Event('stabileo-zoom-to-fit')), 600); } + /* ── The phone's command grid ────────────────────────────────────────── + * + * A desktop shows PRO as a ribbon: a row of stages, and under it the groups + * of whichever stage is open. That does not fit in 375 px — the ANALYSE stage + * alone carries fifteen commands, and a touch row holds about nine. + * + * So on a phone the stage is chosen in the top bar and its commands are drawn + * HERE, as a grid above the panel's content. The grid grows downward, which a + * panel can afford and a row cannot: adding a sixteenth command to ANALYSE + * makes this one cell longer and changes nothing else. + * + * Read from `lib/pro/stages.ts`, the same definition the desktop ribbon uses. + */ + const proStages = $derived(buildProStages({ + solved: resultsStore.results3D != null || resultsStore.results != null, + canSolve: hasModel && !solving, + canReport: modelStore.nodes.size > 0, + onSolve: handleSolve, + onReport: handleOpenReportDialog, + /* + * The 3-D reinforcement workspace is opened from the panel that owns it, so + * the phone grid's Rebar-3D command lands on the same operation the ribbon's + * does — three ways in, one thing that happens. + */ + onRebar3D: () => openRebar3D({ + author: detailingAuthor.resolve(t('detailing.doc.unnamedAuthor')), + at: new Date().toISOString(), + }), + canRebar3D: () => canOpenRebar3D(), + rebar3DMissingSteps: () => { + const steps: string[] = []; + if (resultsStore.results3D == null && resultsStore.results == null) steps.push('proRibbon.need.solve'); + if (verificationStore.providedSummary.total === 0) steps.push('proRibbon.need.design'); + if (detailingStore.assemblies.length === 0) steps.push('proRibbon.need.detailing'); + return steps; + }, + })); + + /* + * Which stage the grid is showing. Follows the open tab rather than being a + * separate selection — two sources of truth for "where am I" is how a ribbon + * comes to show one stage while the panel shows another. Project belongs to + * no stage, so the grid keeps showing the one you came from. + */ + let lastProStage = $state('model'); + const mappedProStage = $derived(PRO_TAB_STAGE[uiStore.proActiveTab] ?? 'model'); + $effect(() => { if (mappedProStage) lastProStage = mappedProStage; }); + const gridStage = $derived( + proStages.find((s) => s.id === (mappedProStage || lastProStage)) ?? proStages[0], + ); + /* + * BY GROUP, not flattened. + * + * Flat, ANALYSE was fifteen buttons in five rows with nothing saying where + * one kind of thing ended and the next began — solve sat beside "no diagram" + * beside a colour map beside the report. The stages already carry the + * grouping the desktop ribbon draws as ruled sections; the phone draws it as + * headings, which is the same information in the shape a column can hold. + */ + const gridGroups = $derived(gridStage ? gridStage.groups : []); + const gridCmds = $derived(gridGroups.flatMap((g) => g.cmds)); + + /* + * The grid folds away once it has been used. + * + * ANALYSE has fifteen commands, which is five rows — 256 px of a 300 px + * sheet. Left permanently open it would push the tab's own content below the + * fold on the stage where that content matters most. So picking a command + * collapses it: the errand is over, and what you asked for is what you should + * be looking at. The header re-opens it, and it re-opens itself when the + * stage changes, because that IS the errand starting again. + */ + let proGridOpen = $state(true); + $effect(() => { gridStage; proGridOpen = true; }); + + /** + * The command the panel is currently showing, if any. + * + * `proCmdActive` already answers this per command — it is what lights a cell — + * so the head asks it rather than deciding again from `proActiveTab`. Two + * answers to "where am I" is how a header comes to name one place while the + * content shows another. + */ + const hereCmd = $derived(gridCmds.find((c) => proCmdActive(c))); + + /* + * Two halves of "where am I", side by side. + * ──────────────────────────────────────── + * The stage used to be picked in the top bar and the command in a grid down + * here, which put one half of the address in each place. Both are in the + * panel now: the right pill chooses the stage, the left one the command + * inside it, and each is exactly half the width so neither reads as the + * senior of the two. + * + * They 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. Nothing in + * this component enumerates either. + */ + let stageMenuOpen = $state(false); + + /* + * Project is an entry in the COMMAND pill, not a stage. + * It is the document rather than a step of the work — `PRO_TAB_STAGE` maps it + * to no stage at all — so it cannot be a fifth row on the right. It sits at + * the head of the left pill, and choosing it greys the right one out, because + * there is no stage to be in while you are looking at the document. + */ + const onProject = $derived(uiStore.proActiveTab === 'project'); + + function runProCmd(c: ProCmd) { + if (c.enabled && !c.enabled()) return; + if (c.diagram) resultsStore.diagramType = c.diagram as never; + if (c.tab) { tabError = null; uiStore.proActiveTab = c.tab as never; } + if (c.action) c.action(); + proGridOpen = false; + } + + /** Lit when this command is what the panel or the model is showing. */ + function proCmdActive(c: ProCmd): boolean { + if (c.tab) return uiStore.proActiveTab === c.tab; + if (c.diagram) { + const shown = resultsStore.diagramType === 'axialColor' ? 'axial' : resultsStore.diagramType; + return shown === c.diagram; + } + return false; + } + /** What the panel calls each destination. */ const TAB_TITLE: Record = { project: 'ribbon.project', nodes: 'pro.tabNodes', elements: 'pro.tabElements', @@ -768,26 +914,115 @@ {#if uiStore.isMobile}
-
+ + + +
+ + - - - - +
- + + + {#if uiStore.currentTool === 'select'} +
+ {#each [ + { id: 'nodes', key: 'float.selectNodes' }, + { id: 'elements', key: 'float.selectElements' }, + { id: 'shells', key: 'float.selectShells' }, + { id: 'supports', key: 'float.selectSupports' }, + { id: 'loads', key: 'float.selectLoads' }, + ] as const as sm (sm.id)} + + {/each} +
+ {/if} + + {#if stageMenuOpen} +
+ {#each proStages as st (st.id)} + + {/each} +
+ {/if}
{/if} @@ -811,7 +1046,15 @@ explain it. -->
- {t(TAB_TITLE[activeTab] ?? 'pro.tabNodes')} + + {#if !uiStore.isMobile} + {t(TAB_TITLE[activeTab] ?? 'pro.tabNodes')} + {/if}
+ + {#if uiStore.isMobile} + {#if proGridOpen} +
+ +
+

{t('proProject.documentSection')}

+
+ +
+
+ {#each gridGroups as g (g.id)} +
+

{t(g.labelKey)}

+
+ {#each g.cmds as c (c.id)} + {@const on = !c.enabled || c.enabled()} + + {/each} +
+
+ {/each} +
+ {/if} + {/if} {#if tabError}

{t('pro.errorInTab').replace('{tab}', activeTab)}

@@ -961,29 +1271,6 @@ flex-shrink: 0; background: var(--st-surface); } - .pm-tools-row { - display: flex; - gap: 4px; - align-items: center; - } - .pm-tool { - padding: 6px 10px; - font-size: 0.78rem; - color: var(--st-text-2); - background: var(--st-surface-3); - border: 1px solid var(--st-surface-3); - border-radius: 5px; - cursor: pointer; - } - .pm-tool:hover { color: var(--st-text); } - .pm-tool.active { color: var(--st-text); background: var(--st-accent); border-color: var(--st-danger); } - .pm-tool.pm-undo { - padding: 6px 8px; - font-size: 0.85rem; - min-width: 36px; - text-align: center; - } - .pm-tool.pm-undo:disabled { opacity: 0.25; cursor: not-allowed; } .pm-select-modes { display: flex; gap: 3px; @@ -1000,40 +1287,221 @@ } .pm-sel:hover { color: var(--st-text); } .pm-sel.active { color: var(--st-text); background: var(--st-accent); border-color: var(--st-danger); } - .pro-mobile-actions { + /* ── The phone's stage grid ───────────────────────────────────────── + Three columns, because at 375 px that is a 113 px cell — wide enough for + "Diagnósticos" at a readable size and tall enough to be a 48 px target. + It wraps downward without limit, which is the property the row it replaced + did not have and the reason this is a grid at all. + ──────────────────────────────────────────────────────────────── */ + /* ── The two pills ──────────────────────────────────────────────── + Half the width each, so neither reads as the senior of the two. The left + one carries the address and is the half that survives a fold. + ──────────────────────────────────────────────────────────────── */ + .pm-pills { display: flex; gap: 4px; + margin-bottom: 4px; } - .pm-action { - flex: 1; - padding: 8px 4px; - font-size: 0.72rem; - font-weight: 600; - border: none; - border-radius: 4px; - cursor: pointer; + + .pm-pill { + flex: 1 1 0; + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + min-height: 44px; + padding: 0 8px; + background: var(--st-surface-3); + border: 1px solid var(--st-hair); + border-radius: var(--st-radius); color: var(--st-text); + font-family: var(--st-mono); + font-size: 0.64rem; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; } - .pm-action:disabled { opacity: 0.35; } - .pm-example { background: linear-gradient(135deg, var(--st-warn), var(--st-warn)); } - .pm-solve { background: linear-gradient(135deg, var(--st-value), var(--st-value)); } - .pm-report { background: linear-gradient(135deg, var(--st-accent), var(--st-accent)); } - .pm-tab-select { - width: 100%; - padding: 8px 10px; - background: var(--st-surface-3); - border: 1px solid var(--st-surface-3); - border-radius: 4px; + + .pm-pill.open { background: var(--st-surface-2); border-color: var(--st-hair-strong); } + .pm-pill:disabled { opacity: 0.4; cursor: default; } + + .pm-pill-face { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; + overflow: hidden; + } + .pm-pill-icon { display: flex; flex: none; color: var(--st-accent); } + .pm-pill-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .pm-pill-dim { color: var(--st-text-3); } + + .pm-caret { + flex: none; + width: 0; height: 0; + border-left: 3.5px solid transparent; + border-right: 3.5px solid transparent; + border-top: 4px solid currentColor; + opacity: 0.7; + } + + .pm-stage-list { + display: flex; + flex-direction: column; + gap: 3px; + padding: 4px 8px 8px; + } + .pm-stage-item { + min-height: 44px; + padding: 0 12px; + text-align: left; + background: var(--st-surface-2); + border: 1px solid var(--st-hair); + border-radius: var(--st-radius); + color: var(--st-text-2); + font-family: var(--st-mono); + font-size: 0.68rem; + letter-spacing: 0.1em; + text-transform: uppercase; + cursor: pointer; + } + .pm-stage-item.active { + background: var(--st-selected-bg); + border-color: var(--st-accent); color: var(--st-text); - font-size: 0.82rem; + } + + + /* + One column of groups, each a grid of three. The heading is what turns + fifteen buttons into four things to choose between — the same job the + vertical rules do between the desktop ribbon's groups. + */ + .pm-groups { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px; + } + + /* ── The table gets the panel's whole scroll ────────────────────── + Each PRO tab wraps its table in `*-table-wrap`, a box with its own + `overflow-y: auto`. On a desktop that is right: the panel is tall and the + controls above the table should stay put while the rows move. + + On a phone it is the reason the table reads as a slot. The panel is ~300 px, + the tab's own controls take most of it, and the table scrolls inside + whatever is left — a scroller inside a scroller, the smaller one holding + the thing you came to read. + + Opened up, the panel is the only scroller: the grid and the tab's controls + scroll away, and the table's `thead` — already `position: sticky; top: 0` + in every tab — pins to the top of `.pro-content`, which is directly under + the head. Exactly one row of column titles, right below the address. + ──────────────────────────────────────────────────────────────── */ + @media (max-width: 767px) { + /* + `*=`, not `$=`. Svelte appends its scope class, so the attribute reads + "pro-elems-table-wrap svelte-1abc" and an ends-with match never fires — + the rule looked right, changed nothing, and the table went on scrolling + inside its own box. + */ + .pro-content :global([class*='-table-wrap']) { + flex: none; + max-height: none; + overflow: visible; + } + + /* + And the tab's own root has to let go too. Each is + `display: flex; height: 100%`, which pins the whole tab to the panel's + height and makes the wrap the only thing that can scroll. Height `auto` + lets the tab be as tall as its table, and `.pro-content` — the panel's + scroller — takes over. + */ + .pro-content :global(> div[class^='pro-']) { + height: auto; + min-height: 0; + } + + /* Above the rows it holds, and above the grid if that is still open. */ + .pro-content :global(thead) { + z-index: 4; + background: var(--st-surface); + } + } + + .pm-group-title { + margin: 0 0 3px; + font-family: var(--st-mono); + font-size: 0.58rem; + font-weight: 400; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--st-text-3); + } + + /* + The count per row follows the screen instead of being three. + ─────────────────────────────────────────────────────────── + `auto-fill` with a 76 px floor: four across at 375, five at 430, more on a + tablet — and the cells stay near-square rather than stretching into + letterboxes as the screen grows, which is what a fixed three columns did. + */ + .pm-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(76px, 1fr)); + gap: 4px; + } + + .pm-cell { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + /* Near-square: the icon needs the height as much as the word needs width. */ + min-height: 62px; + padding: 4px 2px; + background: var(--st-surface-2); + border: 1px solid var(--st-hair); + border-radius: var(--st-radius); + color: var(--st-text-2); cursor: pointer; - -webkit-appearance: none; - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M2 4l4 4 4-4'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 10px center; + overflow: hidden; } - .pm-tab-select:focus { border-color: var(--st-text-2); outline: none; } + + .pm-cell-icon { display: flex; color: var(--st-text); line-height: 1; } + + /* N, My, Vz are notation, so the label takes the mono face when it is one. */ + .pm-cell-label.symbol { + font-family: var(--st-mono); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.02em; + } + + .pm-cell-label { + font-size: 0.56rem; + line-height: 1.15; + text-align: center; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .pm-cell.active { + background: var(--st-selected-bg); + border-color: var(--st-accent); + color: var(--st-text); + } + .pm-cell.active .pm-cell-icon { color: var(--st-accent); } + + /* Greyed, never removed — the same rule the ribbon follows. */ + .pm-cell:disabled { opacity: 0.34; cursor: default; } + .pro-panel { display: flex; @@ -1293,6 +1761,37 @@ padding: 0; } + @media (max-width: 767px) { + /* + The panel's header goes entirely, not just its title. + ──────────────────────────────────────────────────── + Hiding the title left the
in place with its padding: an empty + ~21 px band between the pills and the table, which is the "small space + above the column titles" — it reads as a seam because it belongs to + neither the pills above it nor the rows below. The pills say what the + panel is showing, so the header has nothing left to do here. + */ + .pro-head { display: none; } + + /* + Sticky on the CELLS, not on `thead`. + ─────────────────────────────────── + Every PRO table is `border-collapse: collapse`, and a collapsed table + paints its borders itself rather than letting the row own them — so a + sticky `thead` travels while a hairline of the row beneath it does not, + and a sliver of moving values shows along its edge. Sticking the cells + gives each one its own painted box, and an explicit background and bottom + border close the seam. + */ + .pro-content :global(thead th) { + position: sticky; + top: 0; + z-index: 4; + background: var(--st-surface); + box-shadow: inset 0 -1px 0 var(--st-hair-strong); + } + } + .pro-tab-error { padding: 16px; color: var(--st-danger); diff --git a/web/src/components/pro/ProProjectTab.svelte b/web/src/components/pro/ProProjectTab.svelte index acd7ccd83..fbed11c80 100644 --- a/web/src/components/pro/ProProjectTab.svelte +++ b/web/src/components/pro/ProProjectTab.svelte @@ -2,8 +2,7 @@ import { t } from '../../lib/i18n'; import { uiStore, modelStore, resultsStore } from '../../lib/store'; import { - saveProject, saveSession, loadFile, downloadResultsCSV, - downloadExcel, isMode3D, + saveProject, saveSession, loadFile, isMode3D, } from '../../lib/store/file'; import { autosaveStatus, autosaveRevisions } from '../../lib/store/autosave-db'; @@ -105,11 +104,72 @@
+
+

{t('proProject.newModel')}

+ + + + {#if showExamples} + + {/if} + + +
+ + +
+
+ + +
+
+ +
-

{t('proProject.documentSection')}

+

{t('proProject.documentSection')}

{t('proProject.documentName')}
{docName}
@@ -183,78 +243,13 @@
-
-

{t('proProject.newModel')}

- - - - {#if showExamples} - - {/if} -
- - -
-
- - -
-
- -
-

{t('project.export')}

-
- - - -
- -
-
- {title} - -
+ +
+ + +
+ + {#if !(uiStore.isMobile && panel === 'data')} +
+ {title} + +
+ {/if}
{#if panel === 'selection'} @@ -253,4 +291,117 @@ .bp-resize:hover, .bp-resize.dragging { background: var(--st-accent); } + + /* The handle row does not exist on a desktop, where the panel is resized + from its leading edge and the header keeps its own ✕. */ + .bp-sheet-top { display: none; } + + /* ── The phone: a bottom sheet, not a side panel ────────────────────── + Same panel, same contents, laid out along the axis a phone has more of. + + As a side panel it would take its remembered desktop width — 320 px of a + 375 px screen — and opening the results would hide the structure they + describe. That is the measurement the old right drawer was already moved + for; this is the same decision applied to the panel that replaced it, so + the two do not disagree about what a phone should do. + + The height is `--st-sheet-h`, a token because `.app-body` in `App.svelte` + reserves exactly that much: the sheet shares the screen with the canvas + rather than covering it, so the model above it is really there and gets + framed into what is left. Fixed for now — §5.3 of the handoff is the grab + handle and the drag between a peek height and full, and it is the next + piece of work rather than part of this one. + ────────────────────────────────────────────────────────────────── */ + @media (max-width: 767px) { + .basic-panel { + position: fixed; + top: auto; + bottom: 0; + left: 0; + right: 0; + /* Beats the inline `style:width` the drag handle writes, which is a + desktop measurement and meaningless here. */ + width: 100% !important; + /* Written by `SheetGrab` as the reader drags; the token in + `styles/tokens.css` is the value it starts from. `.app-body` reserves + exactly this much, so the canvas is the size it appears to be. */ + height: var(--st-sheet-h); + max-height: var(--st-sheet-h); + z-index: 60; + border-left: none; + border-top: 1px solid var(--st-hair-strong); + border-radius: 12px 12px 0 0; + animation: bp-sheet-up 0.25s ease; + } + + @keyframes bp-sheet-up { + from { transform: translateY(100%); } + to { transform: translateY(0); } + } + + /* A horizontal drag on the leading edge cannot widen a full-width sheet. */ + .bp-resize { display: none; } + + /* + Model data goes edge to edge. + ───────────────────────────── + The body's 0.65rem gutters are right for prose and for the stacked + controls in Results, and wrong for this panel: they cost the six-button + grid and the table 21 px of a 375 px screen for whitespace nobody reads. + The tab strip carries its own 4 px instead. + */ + .basic-panel[data-panel='data'] .bp-body { + padding-left: 0; + padding-right: 0; + padding-top: 0; + } + + /* ── The handle row ───────────────────────────────────────────── + The handle itself is `SheetGrab.svelte`; this row exists to put the ✕ + beside it, so there is one place to close from whatever the panel shows. + ──────────────────────────────────────────────────────────── */ + .bp-sheet-top { + display: flex; + align-items: center; + flex: none; + position: relative; + } + + .bp-sheet-top :global(.grab) { flex: 1; } + + .bp-grab-close { + position: absolute; + right: 2px; + top: 50%; + transform: translateY(-50%); + width: 44px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--st-text-2); + font-size: 1.5rem; + line-height: 1; + cursor: pointer; + /* Its own gesture, not the handle's. */ + touch-action: manipulation; + } + + .bp-grab-close:active { color: var(--st-text); } + + + + /* + One ✕, on the handle row. The header's own would be a second one four + millimetres below the first, both closing the same panel — which is what + shipped for exactly one build of this branch. + */ + .bp-close { display: none; } + + /* Reduced at the top: the grab handle above already carries that space, + and stacking both left a tall empty band before the title. */ + .bp-head { padding: 0.1rem 0.4rem 0.4rem 0.75rem; } + } diff --git a/web/src/components/ribbon/Ribbon.svelte b/web/src/components/ribbon/Ribbon.svelte index cdc32e0fe..d4983c7f0 100644 --- a/web/src/components/ribbon/Ribbon.svelte +++ b/web/src/components/ribbon/Ribbon.svelte @@ -80,14 +80,22 @@ enabled?: () => boolean; /** Only meaningful for a 3D frame; explains its own greying in 2D. */ needs3d?: boolean; - /** Sole command of its group: drawn larger, since it carries the group. */ - prominent?: boolean; }; type Group = { id: string; labelKey: string; cmds: Cmd[] }; const solved = $derived(resultsStore.results != null || resultsStore.results3D != null); + /* + * The phone gets a different ARRANGEMENT of the same commands, and that is a + * structural change rather than a styling one, so it needs a flag and not + * only a media query. `uiStore.isMobile` is `windowWidth < 768`, which is the + * same threshold as the `max-width: 767px` variant in the stylesheet — the + * two must agree or the row would be laid out for one shape and painted for + * the other. + */ + const narrow = $derived(uiStore.isMobile); + /* * Greying beats hiding for a command that could apply here but cannot run * yet — that is why the whole results group is present and disabled before @@ -224,32 +232,22 @@ ], }, /* - * Model data is not an analysis. - * ───────────────────────────── - * It sat in Analyse next to Solve, which reads as "a thing you do to get - * results". The panel is the opposite: it is where the nodes, elements, - * supports, loads, materials and sections you DREW live, in editable form, - * with results as one tab among eight. It spans drawing, conditions and - * results, so it belongs to none of them — hence its own group, ahead of - * the drawing commands whose output it holds. + * There is no standalone Data command any more. + * ───────────────────────────────────────────── + * It had its own group, on the reasoning that model data spans drawing, + * conditions and results and so belongs to none of them. True, and beside + * the point: EVERY command that produces model data already opens that + * panel on its own tab — `run()` below sends node, element, support, load, + * materials and sections to `panel: 'data'` with `dataTab` set. So the + * button opened a panel the user reached anyway, on whichever tab they had + * last used, and pressing it was never the shortest way to anything. * - * The button carries no label: the group is already called Data directly - * beneath it, and a button labelled the same as its own group is a word - * printed twice. + * Removed at both widths. On a phone it was one of nine slots in a row that + * has exactly nine; on a desktop it was a command that did nothing its + * neighbours did not already do. The panel itself is untouched — the tools + * open it, `openPanel('data')` opens it, and the walkthroughs use that + * event rather than this button, which is why none of them notices. */ - { - id: 'data', - labelKey: 'ribbon.groupData', - cmds: [ - /* - * Opens on whichever tab the ribbon is already pointing at — Nodes if - * none. Landing on Nodes after the user had just picked Sections would - * discard the choice they had made a second earlier. - */ - { id: 'data', icon: 'data', nameKey: 'ribbon.data', panel: 'data', prominent: true, - dataTab: activeDataTab || 'nodes' }, - ], - }, { id: 'draw', labelKey: 'ribbon.groupDraw', @@ -310,6 +308,133 @@ }, ]); + /* ── The phone's row ─────────────────────────────────────────────────── + * + * Same commands, gathered instead of scrolled. + * + * Laid out flat, seventeen commands at 44 px are 748 px of content in a + * 375 px row: everything past the seventh needs a swipe, and the swipe is + * the interface. Two of the groups are the ones you cross constantly and + * they are also the two that are long — so those two collapse to a single + * button each that opens the group, and everything else stays flat. + * + * Nine controls: Project, undo, redo, Selection, 2D/3D, Model, Solve, + * Advanced, Results. That is the whole application, and it very nearly fits + * without scrolling at 375 px — see the size variant in the stylesheet. + * + * PRO already works this way, which is where the shape comes from. + * + * `cmdsOf` reads the groups defined above rather than restating their + * contents. A command added to `draw` or `conditions` appears on the phone + * for free; a second list here would be a second definition of the ribbon + * and would drift the first time only one of them was updated. + */ + function cmdsOf(...groupIds: string[]): Cmd[] { + return groupIds.flatMap((id) => GROUPS.find((g) => g.id === id)?.cmds ?? []); + } + + type Cluster = { id: string; labelKey: string; icon: string; cmds: Cmd[] }; + + const CLUSTERS: Cluster[] = $derived([ + { + id: 'results', + labelKey: 'ribbon.tabResults', + icon: 'moment', + cmds: cmdsOf('results'), + }, + ]); + + /* + * Modelado is a COMMAND, not a menu. + * ────────────────────────────────── + * It was a cluster: tap it, get six buttons, tap one, get the Model data + * panel opened on that entity's tab with its tool armed. But the panel's own + * tabs are those same six choices and they already do exactly that — see + * `pickTab` in `DataTable.svelte`, which arms the tab's tool for precisely + * this reason. The menu was a second copy of the panel's tab strip, shown + * for one tap and then thrown away. + * + * So the button opens the panel and the tabs ARE the six buttons. Below + * 768 px the strip is laid out as a 3×2 grid of real targets across the full + * width, pinned above the table — see `DataTable.svelte`. + * + * It lands on the tab the reader last used and arms that tab's tool, so the + * command leaves you able to draw rather than merely looking at a table. + * Materials and sections have no tool and correctly arm none. + */ + function toolForTab(tab: string): string | undefined { + // Read off the ribbon's own definitions rather than restating the pairing. + return cmdsOf('draw', 'conditions').find((c) => c.dataTab === tab)?.tool; + } + + const modelCmd: Cmd = $derived.by(() => { + const tab = activeDataTab || 'nodes'; + return { + id: 'model', + icon: 'element', + labelKey: 'ribbon.groupModel', + panel: 'data', + dataTab: tab, + tool: toolForTab(tab), + }; + }); + + /** The phone's row, in order, with the one remaining cluster spliced in. */ + const PHONE_ROW: ({ kind: 'cmd'; cmd: Cmd } | { kind: 'cluster'; cluster: Cluster })[] = $derived([ + ...cmdsOf('view').map((cmd) => ({ kind: 'cmd' as const, cmd })), + { kind: 'cmd' as const, cmd: modelCmd }, + ...cmdsOf('analyse').map((cmd) => ({ kind: 'cmd' as const, cmd })), + { kind: 'cluster' as const, cluster: CLUSTERS[0] }, + ]); + + /** + * Which cluster is open, or null. + * + * Closed by everything that ends the errand: running one of its commands, + * pressing the button again, or touching anything else — the backdrop below + * covers the rest of the screen for that last one, so a tap outside does not + * also arm a tool on the canvas it landed on. + */ + let openCluster = $state(null); + + /* + * The walkthroughs need to open a cluster before pointing inside it. + * + * A step whose target is a diagram command has nothing to spotlight while + * this menu is shut, and on a phone it is shut by default — the tour would + * darken the screen and say nothing, which is the exact failure mode + * `basic-demos.spec.ts` exists to catch. Driven by an event rather than by + * exporting the state, for the same reason `openPanel` is: a step definition + * describes cards, not the shell's layout. + */ + $effect(() => { + const onOpen = (e: Event) => { + const id = (e as CustomEvent).detail; + if (id === null) { openCluster = null; return; } + if (narrow && CLUSTERS.some((c) => c.id === id)) openCluster = id; + }; + window.addEventListener('stabileo-open-cluster', onOpen); + return () => window.removeEventListener('stabileo-open-cluster', onOpen); + }); + + /* + * A cluster lights when the command it is standing in for is the active one. + * + * Without this the phone loses the ribbon's one rule — lit means "this is + * what the panel is showing" — because the lit command is inside a closed + * button. Reusing `isActive` rather than restating the condition keeps the + * two from ever disagreeing about what is on. + */ + function clusterActive(c: Cluster): boolean { + return c.cmds.some(isActive); + } + + /** The phone drops to icons, so the tooltip is all the naming there is. */ + function clusterTitle(c: Cluster): string { + const open = c.cmds.find(isActive); + return open ? `${t(c.labelKey)} — ${cmdLabel(open)}` : t(c.labelKey); + } + /* * Editing and viewing results are two different jobs, and the ribbon should * only ever look like it is doing one of them. @@ -488,7 +613,111 @@ } + +{#snippet cmdButton(c: Cmd, labelled: boolean)} + {@const on = !c.enabled || c.enabled()} + +{/snippet} +
+ {#if narrow} + +
+ +
+ + + +
+ + {#each PHONE_ROW as item (item.kind === 'cmd' ? item.cmd.id : item.cluster.id)} + {#if item.kind === 'cmd'} + {@render cmdButton(item.cmd, false)} + {:else} + {@const c = item.cluster} + + {/if} + {/each} +
+ + {#if openCluster} + {@const c = CLUSTERS.find((x) => x.id === openCluster)} + {#if c} + + + +
openCluster = null}>
+
+

{t(c.labelKey)}

+
+ {#each c.cmds as cmd (cmd.id)} + {@render cmdButton(cmd, true)} + {/each} +
+
+ {/if} + {/if} + {:else}
{#if showConfig || inline || flat}
+ + {#if uiStore.isMobile} +

{t('config.mobileSection')}

+ +
+ + +
+ +
+ + +
+ {/if} {#if flat} {t('config.grid')} {:else} @@ -194,7 +245,7 @@ {/if}
- + + {Math.round(resultsStore.deformedScale)}×
@@ -182,7 +182,7 @@
- + {resultsStore.diagramScale.toFixed(1)}x
@@ -195,7 +195,7 @@ {#if resultsStore.animateDeformed}
- + {resultsStore.animSpeed.toFixed(2)}x
{/if} @@ -208,7 +208,7 @@ {#if resultsStore.ilAnimating}
- + {resultsStore.ilAnimSpeed.toFixed(2)}x
{/if} @@ -609,12 +609,54 @@ color: var(--st-text); } + /* + The width used to be an inline `style="width: 80px"` on each of the four + sliders. Inline beats a stylesheet, so a phone could not widen them without + `!important` — and an 80 px track asked a thumb to hit one of fifty steps + inside a fifth of the screen. Declared here instead, so the media query + below can simply take over. + */ .input-group input[type="range"] { -webkit-appearance: auto; appearance: auto; accent-color: var(--st-accent); background: transparent; border: none; + width: 80px; + } + + /* ── The phone ──────────────────────────────────────────────────── + A scale slider takes the whole width it can get. It is the control the + reader touches most while reading a diagram — the value it sets is the + difference between a curve you can see and a flat line — and on a phone + the row has nothing else competing for that space once the label and the + steppers have taken theirs. + ─────────────────────────────────────────────────────────────── */ + @media (max-width: 767px) { + .input-group input[type="range"] { + /* Grows into whatever the row has left, rather than a fixed guess. */ + flex: 1 1 auto; + width: auto; + min-width: 0; + } + + /* + The label goes on its own line so the slider gets the whole one. + ──────────────────────────────────────────────────────────────── + Kept inline, "Escala diagrama:" took 40 % of a 375 px row and left the + slider 157 px — wider than the 80 it started at, and still a fifth of + the screen for a control with fifty steps. The label is four words the + reader takes in once; the track is what the thumb has to land on. + */ + .input-group:has(input[type="range"]) { + width: 100%; + flex-wrap: wrap; + row-gap: 0.2rem; + } + + .input-group:has(input[type="range"]) label { + flex: 0 0 100%; + } } .input-group select { diff --git a/web/src/lib/actions/solve.ts b/web/src/lib/actions/solve.ts index 68181ead4..ebcad0664 100644 --- a/web/src/lib/actions/solve.ts +++ b/web/src/lib/actions/solve.ts @@ -62,11 +62,16 @@ export function runSolve() { } else { uiStore.toast(t('results.emptyModelError'), 'error'); } - // Auto-close drawer on mobile after solve, show floating results panel - if (uiStore.isMobile) { - uiStore.leftDrawerOpen = false; - uiStore.mobileResultsPanelOpen = true; - } + /* + * A solve no longer opens anything by itself on a phone. + * + * `leftDrawerOpen` closed the old Toolbar drawer, which nothing renders any + * more — the write survived the drawer. `mobileResultsPanelOpen` raised the + * floating results panel, which on Basic does not exist at all (the panel + * requires `appMode === 'pro'`) and on PRO landed on top of the results + * sheet that `ProPanel.solve()` had just opened. The panel is now opened by + * the bar's own button, and only when it is asked for. + */ } export async function runSolve3D() { @@ -114,8 +119,5 @@ export async function runSolve3D() { } else { uiStore.toast(t('results.emptyModelError'), 'error'); } - if (uiStore.isMobile) { - uiStore.leftDrawerOpen = false; - uiStore.mobileResultsPanelOpen = true; - } + // Same as above: a solve opens no panel of its own on a phone. } diff --git a/web/src/lib/engine/live-calc.ts b/web/src/lib/engine/live-calc.ts index f95d3baa6..7f79ffe8f 100644 --- a/web/src/lib/engine/live-calc.ts +++ b/web/src/lib/engine/live-calc.ts @@ -213,7 +213,6 @@ async function globalSolve3D(isStale: () => boolean): Promise { return null; } resultsStore.setResults3D(r); - if (uiStore.isMobile) uiStore.mobileResultsPanelOpen = true; const timeStr = formatSolveTiming(r.timings); uiStore.toast( `${t('results.analysis3dSuccess')}${timeStr} — ${r.elementForces.length} ${t('results.bars')}, ${r.reactions.length} ${t('results.reactions')}`, @@ -249,7 +248,6 @@ async function globalSolve3D(isStale: () => boolean): Promise { for (const c of modelStore.model.combinations) comboNames.set(c.id, c.name); resultsStore.setGoverning3D(computeGoverning3D(comboResult.perCombo, comboNames)); - if (uiStore.isMobile) uiStore.mobileResultsPanelOpen = true; const elapsed = performance.now() - t0; const timeStr = elapsed >= 1000 ? (elapsed / 1000).toFixed(2) + ' s' : elapsed.toFixed(0) + ' ms'; const nBars = firstCaseResult?.elementForces.length ?? 0; @@ -344,7 +342,6 @@ async function globalSolve2D(isStale: () => boolean): Promise { } } - if (uiStore.isMobile) uiStore.mobileResultsPanelOpen = true; const timeStr = formatSolveTiming(r.timings); uiStore.toast( `${t('results.calcSuccess')}${classText}${timeStr} — ${r.elementForces.length} ${t('results.bars')}, ${r.reactions.length} ${t('results.reactions')}${comboText}`, diff --git a/web/src/lib/i18n/locales/en.ts b/web/src/lib/i18n/locales/en.ts index e65beb51f..e8b091bc4 100644 --- a/web/src/lib/i18n/locales/en.ts +++ b/web/src/lib/i18n/locales/en.ts @@ -1798,6 +1798,12 @@ const en: Record = { 'demo.basics2d.examplesDesc': 'Nineteen 2D structures, from a single beam to a framed building. Pick one to look at, or continue and a portal frame is loaded.', 'demo.basics3d.examplesTitle': 'The 3D catalogue', 'demo.basics3d.examplesDesc': 'Ten 3D structures, from a cantilever to an industrial shed. Pick one, or continue and a 3D portal frame is loaded.', + 'config.tip.language': 'The language of the whole interface. Also in the header on a larger screen.', + 'config.mobileSection': 'Mobile', + 'config.touchDensity': 'Control size', + 'config.densityCompact': 'Compact', + 'config.densityComfortable': 'Comfortable', + 'config.tip.touchDensity': 'How large the controls inside the panels are, on a phone only. Compact shows more of a table at once; Comfortable grows everything to 44 px and puts the results a little further down the scroll.', 'viewport.selectKindHint': 'To change what gets selected, set it in the top panel under "Selection".', 'viewport.modePan': 'Pan — drag the mouse to move the view', 'viewport.modeSelect': 'Select — drag the mouse to select members', @@ -6109,6 +6115,9 @@ const en: Record = { 'ribbon.tabModel': 'Model', 'ribbon.tabAnalyse': 'Analyse', 'ribbon.tabResults': 'Results', + // On a phone, the one button that gathers drawing, conditions and properties: + // node, element, support, load, sections and materials. + 'ribbon.groupModel': 'Model', 'ribbon.groupCreate': 'Create', 'ribbon.groupConditions': 'Conditions', 'ribbon.groupEdit': 'Edit', @@ -6142,7 +6151,6 @@ const en: Record = { 'ribbon.groupProject': 'Project', 'ribbon.needsSolve': 'solve the model first', 'ribbon.groupDraw': 'Draw', - 'ribbon.groupData': 'Data', 'ribbon.momentY': 'My', 'ribbon.momentZ': 'Mz', 'ribbon.shearZ': 'Vz', diff --git a/web/src/lib/i18n/locales/es.ts b/web/src/lib/i18n/locales/es.ts index 93d2130d8..cfc7c6c54 100644 --- a/web/src/lib/i18n/locales/es.ts +++ b/web/src/lib/i18n/locales/es.ts @@ -1791,6 +1791,12 @@ const es: Record = { 'demo.basics2d.examplesDesc': 'Diecinueve estructuras 2D, desde una viga simple hasta un edificio aporticado. Elegí una para mirar, o seguí y se carga un pórtico.', 'demo.basics3d.examplesTitle': 'El catálogo 3D', 'demo.basics3d.examplesDesc': 'Diez estructuras 3D, desde un voladizo hasta una nave industrial. Elegí una, o seguí y se carga un pórtico 3D.', + 'config.tip.language': 'El idioma de toda la interfaz. También está en el encabezado en pantallas más grandes.', + 'config.mobileSection': 'Móvil', + 'config.touchDensity': 'Tamaño de los controles', + 'config.densityCompact': 'Compacto', + 'config.densityComfortable': 'Cómodo', + 'config.tip.touchDensity': 'Qué tan grandes son los controles dentro de los paneles, sólo en teléfono. Compacto muestra más tabla de una vez; Cómodo agranda todo a 44 px y hay que desplazarse un poco más para llegar a los resultados.', 'viewport.selectKindHint': 'Para cambiar qué se selecciona, editalo en el panel superior en «Selección».', 'viewport.modePan': 'Mover — arrastrá el mouse para mover la vista', 'viewport.modeSelect': 'Seleccionar — arrastrá el mouse para seleccionar barras', @@ -6101,6 +6107,9 @@ const es: Record = { 'ribbon.tabModel': 'Modelo', 'ribbon.tabAnalyse': 'Análisis', 'ribbon.tabResults': 'Resultados', + // El grupo que en un teléfono reúne dibujo, condiciones y propiedades bajo + // un solo botón: nodo, barra, apoyo, carga, secciones y materiales. + 'ribbon.groupModel': 'Modelado', 'ribbon.groupCreate': 'Crear', 'ribbon.groupConditions': 'Condiciones', 'ribbon.groupEdit': 'Editar', @@ -6134,7 +6143,6 @@ const es: Record = { 'ribbon.groupProject': 'Proyecto', 'ribbon.needsSolve': 'primero resolvé el modelo', 'ribbon.groupDraw': 'Dibujar', - 'ribbon.groupData': 'Datos', 'ribbon.momentY': 'My', 'ribbon.momentZ': 'Mz', 'ribbon.shearZ': 'Vz', diff --git a/web/src/lib/i18n/locales/pt.ts b/web/src/lib/i18n/locales/pt.ts index 6cac5f52d..11984eb91 100644 --- a/web/src/lib/i18n/locales/pt.ts +++ b/web/src/lib/i18n/locales/pt.ts @@ -4236,7 +4236,6 @@ const pt: Translations = { 'ribbon.examples': 'Exemplos', 'ribbon.groupConditions': 'Condições', 'ribbon.groupCreate': 'Criar', - 'ribbon.groupData': 'Dados', 'ribbon.groupDiagrams': 'Diagramas', 'ribbon.groupDraw': 'Desenhar', 'ribbon.groupEdit': 'Editar', @@ -5071,5 +5070,12 @@ const pt: Translations = { 'whatif.section': 'Seção', 'whatif.thermal': 'Térmica', 'whatif.title': 'Explorar', + 'config.tip.language': 'O idioma de toda a interface. Também está no cabeçalho em telas maiores.', + 'config.touchDensity': 'Tamanho dos controles', + 'config.densityCompact': 'Compacto', + 'config.densityComfortable': 'Confortável', + 'config.tip.touchDensity': 'O tamanho dos controles dentro dos painéis, apenas no telefone. Compacto mostra mais tabela de uma vez; Confortável aumenta tudo para 44 px e afasta um pouco os resultados.', + 'config.mobileSection': 'Celular', + 'ribbon.groupModel': 'Modelagem', }; export default pt; diff --git a/web/src/lib/pro/__tests__/stages-coherence.test.ts b/web/src/lib/pro/__tests__/stages-coherence.test.ts new file mode 100644 index 000000000..4a6dfcd00 --- /dev/null +++ b/web/src/lib/pro/__tests__/stages-coherence.test.ts @@ -0,0 +1,116 @@ +/** + * PRO's command tree has to stay coherent, because two surfaces draw it. + * + * # What this is guarding + * + * `buildProStages` is read by the desktop ribbon AND by the phone panel's + * command grid. That is the whole point — a command added in one line reaches + * both — but it also means a malformed entry now breaks two places, and one of + * them is a phone nobody runs the suite against. + * + * PRO is under active development on other branches. The realistic failure is + * not someone editing this file carelessly; it is someone adding a genuinely + * good command and not knowing that `PRO_TAB_STAGE` is a second list that has + * to learn about it. When that happens the ribbon looks fine, and the phone + * silently shows the wrong stage — `PRO_TAB_STAGE[tab] ?? 'model'` falls back, + * so the bar names MODELO while the panel shows a diagnostics table. + * + * Every assertion below is a rule a new command has to satisfy, and the message + * says what to do about it rather than only what is wrong. + */ + +import { describe, it, expect } from 'vitest'; +import { buildProStages, proCmds, PRO_TAB_STAGE, type ProStage } from '../stages'; + +/** The context shape the real callers pass; values do not matter here. */ +const stages: ProStage[] = buildProStages({ + solved: true, + canSolve: true, + canReport: true, + onSolve: () => {}, + onReport: () => {}, + onRebar3D: () => {}, + canRebar3D: () => true, + rebar3DMissingSteps: () => [], +}); + +const cmds = proCmds(stages); + +describe('PRO stages', () => { + it('every command id is unique across the whole tree', () => { + /* + * Both surfaces key by id — `data-testid="pm-cmd-{id}"` on the phone and + * `pr-cmd-{id}` on the desktop — so a duplicate makes a Playwright locator + * ambiguous and makes "which one is lit" undecidable. + */ + const ids = cmds.map((c) => c.id); + const dupes = ids.filter((id, i) => ids.indexOf(id) !== i); + expect(dupes, `duplicated command ids: ${[...new Set(dupes)].join(', ')}`).toEqual([]); + }); + + it('every command that names a tab has that tab mapped to a stage', () => { + /* + * THE rule this file exists for. Without the mapping the phone's bar names + * whichever stage `?? 'model'` falls back to, while the panel shows the tab + * you asked for — one control claiming you are somewhere you are not. + */ + const unmapped = cmds + .filter((c) => c.tab && PRO_TAB_STAGE[c.tab] === undefined) + .map((c) => `${c.id} → tab "${c.tab}"`); + expect( + unmapped, + `these commands open a tab that PRO_TAB_STAGE does not know about:\n ${unmapped.join('\n ')}\n` + + 'Add the tab to PRO_TAB_STAGE in lib/pro/stages.ts, mapped to the stage it belongs to.', + ).toEqual([]); + }); + + it('every stage lands somewhere real', () => { + /* + * `home` is where picking a stage takes you. If it names a tab no command + * in that stage opens, the stage selector moves the panel somewhere its own + * grid cannot get back to. + */ + for (const s of stages) { + const tabs = s.groups.flatMap((g) => g.cmds).map((c) => c.tab).filter(Boolean); + expect( + s.home, + `stage "${s.id}" goes home to "${s.home}", which none of its commands opens`, + ).toSatisfy((home: string) => tabs.includes(home) || PRO_TAB_STAGE[home] === s.id); + } + }); + + it('every mapped tab belongs to a stage that exists', () => { + const ids = new Set(stages.map((s) => s.id)); + const orphans = Object.entries(PRO_TAB_STAGE) + // '' is deliberate: Project belongs to no stage. See the module. + .filter(([, stage]) => stage !== '' && !ids.has(stage)) + .map(([tab, stage]) => `${tab} → "${stage}"`); + expect(orphans, `mapped to stages that do not exist: ${orphans.join(', ')}`).toEqual([]); + }); + + it('every command can be drawn — it has a label, and an icon or a symbol', () => { + /* + * The phone grid draws an icon and a short name in an 87 px cell. A command + * with neither renders as an empty box, which is how it would ship: it + * looks like a spacing bug rather than a missing field. + */ + const undrawable = cmds + .filter((c) => !c.labelKey || (!c.icon && !c.label)) + .map((c) => c.id); + expect( + undrawable, + `these would render as blank cells on a phone: ${undrawable.join(', ')}. ` + + 'Give each an `icon` (see ribbon/Icon.svelte) or a `label` symbol.', + ).toEqual([]); + }); + + it('no group is empty, and no stage is', () => { + // An empty group draws a heading with nothing under it. + for (const s of stages) { + expect(s.groups.length, `stage "${s.id}" has no groups`).toBeGreaterThan(0); + for (const g of s.groups) { + expect(g.cmds.length, `group "${s.id}/${g.id}" has no commands`).toBeGreaterThan(0); + } + } + }); +}); diff --git a/web/src/lib/pro/camera-actions.ts b/web/src/lib/pro/camera-actions.ts new file mode 100644 index 000000000..afdff3aa0 --- /dev/null +++ b/web/src/lib/pro/camera-actions.ts @@ -0,0 +1,108 @@ +import { uiStore } from '../store/ui.svelte'; + +/** + * The 3-D view controls, as data — so the same set can be drawn as a stack on + * the model or as a menu in a toolbar. + * + * ## Why they moved out of `Viewport3D.svelte` + * + * They were nine buttons down the right edge of the model. On a phone the last + * of them sat behind the bottom sheet, and the stack cost a 44 px column of the + * only thing the reader came to look at. PRO's phone bar shows them as one + * split button instead: the face is whichever control you used last, the caret + * opens the rest. + * + * Two surfaces, one list. A tenth control is one entry here. + * + * ## Events, not method calls + * + * Four of these are store flags and this module sets them directly. The other + * four — the fit and the three preset views — need the live camera, which lives + * inside `Viewport3D`. Reaching into a component from a toolbar is not + * available and would not be wanted, so those dispatch and the viewport + * listens, exactly as `stabileo-zoom-to-fit` already worked. + */ + +export type CameraAction = { + id: string; + /** Translation key for the menu row and the tooltip. */ + labelKey: string; + /** An `Icon` name when one exists; otherwise `glyph` carries it. */ + icon?: string; + /** The typographic mark these buttons have always used — ⊤, ⊡, P, ✂. */ + glyph?: string; + run: () => void; + /** For the ones that are switches, so the menu can show them lit. */ + active?: () => boolean; +}; + +/** Ask the 3-D viewport for a preset view. */ +function view(which: 'top' | 'front' | 'side') { + window.dispatchEvent(new CustomEvent('stabileo-camera-view', { detail: which })); +} + +/** + * Built fresh on read rather than frozen at module load: `active` closes over + * store state, and the labels of the two switches change with it — "enable + * clipping" and "disable clipping" are the same control saying what pressing it + * will do. + */ +export function cameraActions(): CameraAction[] { + return [ + { + id: 'fit', + labelKey: 'viewport3d.zoomToFit', + icon: 'fit', + run: () => window.dispatchEvent(new Event('stabileo-zoom-to-fit')), + }, + { id: 'top', labelKey: 'viewport3d.topView', glyph: '⊤', run: () => view('top') }, + { id: 'front', labelKey: 'viewport3d.frontView', glyph: '⊡', run: () => view('front') }, + { id: 'side', labelKey: 'viewport3d.sideView', glyph: '⊟', run: () => view('side') }, + { + id: 'projection', + labelKey: uiStore.cameraMode3D === 'perspective' + ? 'viewport3d.switchToOrtho' + : 'viewport3d.switchToPersp', + glyph: uiStore.cameraMode3D === 'perspective' ? 'P' : 'O', + run: () => { + uiStore.cameraMode3D = uiStore.cameraMode3D === 'perspective' ? 'orthographic' : 'perspective'; + }, + }, + { + id: 'clipping', + labelKey: uiStore.clippingEnabled + ? 'viewport3d.disableClipping' + : 'viewport3d.enableClipping', + glyph: '✂', + run: () => { uiStore.clippingEnabled = !uiStore.clippingEnabled; }, + active: () => uiStore.clippingEnabled, + }, + { + id: 'measure', + labelKey: uiStore.measureMode + ? 'viewport3d.disableMeasure' + : 'viewport3d.enableMeasure', + glyph: '📏', + run: () => { uiStore.measureMode = !uiStore.measureMode; }, + active: () => uiStore.measureMode, + }, + { + id: 'sections', + labelKey: uiStore.renderMode3D === 'sections' ? 'config.wireframe' : 'config.sections', + glyph: uiStore.renderMode3D === 'sections' ? '◫' : '⬡', + run: () => { + /* + * Back to WIREFRAME, not to "whatever was on before". + * + * The stack on the model remembered the previous mode in a local, which + * a toolbar cannot see — and a control whose result depends on state + * held by a component that may not be mounted is a control that behaves + * differently depending on where you press it. Wireframe is the mode + * sections is an alternative to, so it is the honest return. + */ + uiStore.renderMode3D = uiStore.renderMode3D === 'sections' ? 'wireframe' : 'sections'; + }, + active: () => uiStore.renderMode3D === 'sections', + }, + ]; +} diff --git a/web/src/lib/pro/stages.ts b/web/src/lib/pro/stages.ts new file mode 100644 index 000000000..3afbc8c10 --- /dev/null +++ b/web/src/lib/pro/stages.ts @@ -0,0 +1,366 @@ +import { TWO_D_INTERNAL_FORCE_LABELS as F2D } from '../geometry/coordinate-system'; + +/** + * PRO's command tree — ONE definition, read by every surface that shows it. + * + * ## Why this is not inside `ProRibbon.svelte` any more + * + * It was, and that was fine while the ribbon was the only thing that drew it. + * The phone draws the same commands too: it cannot show a four-stage ribbon in + * 375 px, so it shows the stage's commands as a grid inside the panel instead. + * Two surfaces, one set of commands — and if the set lived in one of them, + * adding a command would mean remembering to add it to the other. + * + * PRO is under active development, so that is not a hypothetical. The point of + * this module is that **adding a command is one line here** and both the + * desktop ribbon and the phone grid pick it up, with no layout to revisit in + * either. `__tests__/stages-coherence.test.ts` guards the rules a new entry has + * to satisfy — above all that its tab is mapped below, which is the one a + * reader has no reason to know about. + * + * ## Four stages, not five + * + * Examples and DXF are document commands and live in the block with Project and + * Save; Report is the deliverable of an analysis, not a file operation, so it + * sits in ANALYSE beside Solve. With those placed, a DOCUMENT stage had nothing + * left to hold. + * + * ## Why a function and not a constant + * + * Several commands need to know things only a component can answer — whether a + * solve is possible, whether there is anything to report, what to call when + * pressed, and which steps a gated command is still waiting on. Those come in + * as `ProStageContext` rather than being reached for, so this module imports no + * stores and can be read, and tested, on its own. + */ + +export type ProCmd = { + id: string; + labelKey: string; + icon?: string; + /** A literal symbol, for N / My / Vz — these are not translated. */ + label?: string; + /** Turns the icon, for a force about a perpendicular axis. */ + rotate?: number; + /** Destination: which panel view this opens. */ + tab?: string; + /** Sets the diagram drawn on the model. */ + diagram?: string; + action?: () => void; + enabled?: () => boolean; + /** + * One sentence saying what the command opens, when its label cannot. + * + * "Generators" names a category, not a destination. Rendered into the `title` + * AND into a visually-hidden description the button points at, because a + * `title` reaches neither a keyboard nor a screen reader. + */ + descKey?: string; + /** + * The steps still missing, as i18n keys, when the command is gated. + * + * A LIST rather than a sentence so a disabled button can name each one — + * "solve first" is not what is missing when the model is solved and the + * detailing has not been generated. + */ + blockedKeys?: () => string[]; + /** Shown only when the group is expanded. Desktop ribbon only. */ + overflow?: boolean; +}; + +export type ProGroup = { id: string; labelKey: string; cmds: ProCmd[] }; +export type ProStage = { id: string; labelKey: string; home: string; groups: ProGroup[] }; + +/** + * What the stages need from whoever is drawing them. + * + * Everything a command cannot answer for itself. Adding a field here is the + * signal that a command has grown a dependency on live state — which is worth + * noticing, because both surfaces then have to supply it. + */ +export type ProStageContext = { + /** A solve has produced results — the diagrams depend on it. */ + solved: boolean; + canSolve: boolean; + canReport: boolean; + onSolve: () => void; + onReport: () => void; + /** Opens the 3-D reinforcement workspace. */ + onRebar3D: () => void; + canRebar3D: () => boolean; + /** Which steps that workspace is still waiting on, as i18n keys. */ + rebar3DMissingSteps: () => string[]; +}; + +export function buildProStages(ctx: ProStageContext): ProStage[] { + const { + solved, canSolve, canReport, onSolve, onReport, + onRebar3D, canRebar3D, rebar3DMissingSteps, + } = ctx; + return [ + { + id: 'model', + labelKey: 'proRibbon.stageModel', + home: 'nodes', + groups: [ + { + id: 'geometry', + labelKey: 'ribbon.groupDraw', + cmds: [ + { id: 'nodes', labelKey: 'pro.tabNodes', icon: 'node', tab: 'nodes' }, + { id: 'elements', labelKey: 'pro.tabElements', icon: 'element', tab: 'elements' }, + { id: 'shells', labelKey: 'pro.tabShells', icon: 'shell', tab: 'shells' }, + ], + }, + { + id: 'properties', + labelKey: 'proRibbon.groupProperties', + cmds: [ + { id: 'materials', labelKey: 'pro.tabMaterials', icon: 'material', tab: 'materials' }, + { id: 'sections', labelKey: 'pro.tabSections', icon: 'section', tab: 'sections' }, + ], + }, + /* + * Generators are their own sub-section, to the RIGHT of Properties. + * + * They were folded into Draw, beside nodes and elements, on the reasoning that a + * generator draws. It does — but Draw is where you place one thing at a time, and a + * generator replaces the whole model from a parameter form. Sitting in the same group + * as `Nodes` made it read as one more drawing tool, and sitting anywhere near + * Properties made it read as a property of the model. + * + * Last in the stage because that is the order of the work: draw or generate, then + * give what you have its materials and sections. + */ + { + id: 'generators', + labelKey: 'proRibbon.groupGenerators', + cmds: [ + { + // Named for what it opens, not for its category. "Generators" is the SECTION; + // a button repeating it would say the same word twice and still not say that + // what comes out is a truss, a latticed column or a shed. + id: 'generators', + labelKey: 'proRibbon.cmdSteelStructures', + descKey: 'proRibbon.cmdSteelStructuresDesc', + icon: 'examples', + tab: 'generators', + }, + ], + }, + ], + }, + { + id: 'conditions', + labelKey: 'ribbon.groupConditions', + home: 'supports', + groups: [ + { + id: 'restraints', + labelKey: 'proRibbon.groupRestraints', + cmds: [ + { id: 'supports', labelKey: 'pro.tabSupports', icon: 'support', tab: 'supports' }, + { id: 'constraints', labelKey: 'pro.tabConstraints', icon: 'constraint', tab: 'constraints' }, + ], + }, + { + id: 'loads', + labelKey: 'proRibbon.groupLoads', + cmds: [ + { id: 'loads', labelKey: 'pro.tabLoads', icon: 'load', tab: 'loads' }, + ], + }, + ], + }, + { + id: 'analyse', + labelKey: 'ribbon.tabAnalyse', + home: 'results', + groups: [ + { + id: 'run', + labelKey: 'proRibbon.groupRun', + cmds: [ + { id: 'solve', labelKey: 'pro.solve', icon: 'solve', action: onSolve, enabled: () => canSolve }, + { id: 'advanced', labelKey: 'ribbon.advanced', icon: 'advanced', tab: 'advanced' }, + ], + }, + /* + * The diagrams belong in the ribbon, as they do in Basic. + * + * They were a