From 72f5c9e08839901d3ac040d8441b7ec61818c20f Mon Sep 17 00:00:00 2001 From: Bauti Date: Sat, 22 Aug 2026 13:50:39 -0300 Subject: [PATCH 01/31] mobile: frame against a canvas that has finished laying out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening the mobile work. This is the first thing measured rather than a plan. `zoom-to-fit` frames the model against `canvas.width`, the backing store, which the resize observer sets from the CSS box. On a phone the two disagree for a few frames while the layout settles — a drawer closing, the address bar resizing the viewport — and framing during that window fits the model to a canvas that no longer exists. It now waits, up to three frames, for the backing store to match the box before framing. Defensive rather than a fix for an observed symptom, and worth saying which: the off-screen model I first saw came from loading an example through the e2e hook, which does not dispatch the fit at all. That was my measurement being wrong, not the app. The disagreement it guards against is real and phone-specific, but nothing has yet been shown to hit it in the product. --- web/src/components/Viewport.svelte | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) 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); }; }); From 16f8f2efe0bc95a414444b14e2c258523c170de4 Mon Sep 17 00:00:00 2001 From: Bauti Date: Sat, 22 Aug 2026 13:56:23 -0300 Subject: [PATCH 02/31] mobile: touch targets in the header, and the language selector moves to Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sized for 375 px — an iPhone SE or a mini. Whatever fits there fits a larger handset, and the reverse is how the header ended up as it was. Fourteen controls in mobile Basic sat below the 44 px minimum and the worst were in the header: the mode switcher at 133×26 and the tab-add button at 23×22. A 23 px target is not a small button, it is one that takes two or three attempts, in the row a new user touches first. Header controls are now 44 px in both dimensions below 768 px; measured after: zero undersized controls at 375 and at 430, with the header itself still 55 px tall. The language selector leaves the header on a phone. It held a permanent slot in the tightest row in the application for a control somebody touches once — and hiding it there without giving it somewhere else would have removed the setting from phone users entirely, so it lands in Settings beside the other things you set and forget. Same values, same effect, with a hint saying where it lives on a bigger screen. --- web/src/App.svelte | 29 +++++++++++++++++++ .../components/toolbar/ToolbarConfig.svelte | 26 ++++++++++++++++- web/src/lib/i18n/locales/en.ts | 1 + web/src/lib/i18n/locales/es.ts | 1 + web/src/lib/i18n/locales/pt.ts | 1 + 5 files changed, 57 insertions(+), 1 deletion(-) diff --git a/web/src/App.svelte b/web/src/App.svelte index 93255d7ef..55e7a4ded 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -2570,6 +2570,35 @@ .mode-select-mobile option { background: var(--st-surface); color: var(--st-text); font-weight: 500; padding: 6px; } /* ===== Mobile Responsive ===== */ + /* + * Touch targets on a phone. + * + * Fourteen controls in this shell sat below the 44 px minimum, and the worst + * of them are in the header: the mode switcher at 133×26 and the tab-add + * button at 23×22. A 23 px target is not a small button, it is a button that + * takes two or three attempts, and the header is the first thing a new user + * touches. + * + * Sized for 375 px — an iPhone SE or a mini. Whatever fits there fits a + * larger handset; the reverse is what produced this. + */ + @media (max-width: 767px) { + .app-header :global(button), + .app-header select { + min-height: 44px; + min-width: 44px; + } + + /* + * The language selector leaves the header. + * + * It spends a permanent slot on a control a phone user touches once, in + * the row with the least space in the application. It lives in Settings + * on a phone, with the other things you set and forget. + */ + .lang-select { display: none; } + } + @media (max-width: 767px) { .sidebar { display: none !important; diff --git a/web/src/components/toolbar/ToolbarConfig.svelte b/web/src/components/toolbar/ToolbarConfig.svelte index adf7cc8a7..3683d286c 100644 --- a/web/src/components/toolbar/ToolbarConfig.svelte +++ b/web/src/components/toolbar/ToolbarConfig.svelte @@ -2,6 +2,7 @@ import { uiStore, resultsStore } from '../../lib/store'; import { unitLabel } from '../../lib/utils/units'; import { t } from '../../lib/i18n'; + import { setLocale, OFFERED_LOCALES, i18n } from '../../lib/i18n/store.svelte'; import HelpTip from '../HelpTip.svelte'; /** When true, skip outer toggle and show content directly (used in PRO dropdown). */ @@ -194,7 +195,30 @@ {/if}
- + + {#if uiStore.isMobile} +
+ + +
+ {/if} + + + + +
{/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 { From 2108ce4fe5628ac70b56304d6b773ab5fa4ab181 Mon Sep 17 00:00:00 2001 From: Bauti Date: Sat, 22 Aug 2026 19:55:46 -0300 Subject: [PATCH 11/31] docs: record that the Modelado menu became the data panel's tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §5.1b's cluster is marked superseded for the modelling half and §5.1c explains what replaced it — including that the tabs already armed their tools, which is the fact that made the menu redundant rather than merely redundant-looking. The toast container bug is written up as a general lesson and not as an incident: overriding one edge of a `position: fixed` box without clearing the opposite one silently makes it full-size, and here that meant an invisible full-screen div swallowing every touch for the life of each message. §5.4 gets sharper again in the other direction. Six of the anchors are reachable without a menu now, but `rb-cmd-sections` and `rb-cmd-materials` have stopped being ribbon commands on a phone and are tabs instead — so those two ids do not exist there at all, which is worse than being hidden and worth saying plainly. --- docs/handoffs/mobile-ui-pr166.md | 65 ++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/docs/handoffs/mobile-ui-pr166.md b/docs/handoffs/mobile-ui-pr166.md index de025f81e..c14af039b 100644 --- a/docs/handoffs/mobile-ui-pr166.md +++ b/docs/handoffs/mobile-ui-pr166.md @@ -7,7 +7,7 @@ Written to be picked up cold: everything below is measured, not remembered. ## 1. Where this stands -Five commits landed, each verified at 375×667 and 430×932 in Chromium with +Six commits landed, each verified at 375×667 and 430×932 in Chromium with touch enabled: | commit | what it does | evidence | @@ -17,6 +17,7 @@ touch enabled: | `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 | Nothing is merged. CI has not been run on this branch. @@ -177,8 +178,9 @@ page contains is still unusable. Proyecto │ ↶ ↷ │ Selección 2D/3D Modelado▾ Calcular Avanzado Resultados▾ ``` -- `Modelado` opens node, element, support, load, materials, sections. -- `Resultados` opens the diagram commands. +- `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 @@ -218,6 +220,53 @@ Two traps found building it, both invisible until a menu is opened on a phone: - 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, **113 × 44 px**, in a **fixed 3×2 grid** spanning 367 of + 375 px; +- `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 @@ -322,10 +371,12 @@ What is NOT established is that a walkthrough is *followable on a phone*. The anchors now exist there for the first time, so this is newly worth testing and newly possible to test. Two specific doubts: -- A step pointing at a command that is now INSIDE a cluster menu. `3ca2fd6f` - put node, element, support, load, materials, sections and every diagram - behind `Modelado`/`Resultados`, and `demos/*` anchor `rb-cmd-sections`, - `rb-cmd-materials`, `rb-cmd-stress` and `ribbonGroup('results')` directly. +- A step pointing at a command that is now INSIDE a cluster menu. `81a178f3` + gave the six modelling commands back — they are the data panel's tabs and need + no menu — so what is left behind a cluster is the DIAGRAMS. `demos/*` anchor + `rb-cmd-stress` and `ribbonGroup('results')` directly, and `rb-cmd-sections` + and `rb-cmd-materials` now resolve to tabs rather than ribbon commands, which + is a different id: **those two anchors do not exist on a phone at all.** On a desktop they are all still flat, which is why the audit passes; on a phone a step would spotlight a button that is not on screen until the reader opens its menu, and nothing opens it for them. **This is the most likely From e68f71b5fa4c7e6ef4a3ca4d374c426232c3dcfa Mon Sep 17 00:00:00 2001 From: Bauti Date: Sat, 22 Aug 2026 20:13:14 -0300 Subject: [PATCH 12/31] mobile: the six data tabs become one row of icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rows of three worked but told the reader a small lie about the strip's shape: which three land on the second line depends on how many fit, so the same six controls sit differently on a 375 px screen than on a 430 px one, and a control that moves is one you have to find again. One row of six, each an equal share of the width — 59 × 44 at 375 px. Under a 44 px square and wider than it is tall, which is the trade a single row costs and a fair one for a strip that is now the same shape on every handset. At 59 px a word does not fit, so each button carries the glyph the ribbon already uses for that entity on a desktop: node, element, support, load, material, section, straight out of `ribbon/Icon.svelte`. The reader recognises them rather than learning a second vocabulary for the same six things. The name survives underneath at 0.5rem and the count moves to a corner badge, where a number you glance at belongs — inline it would compete with the name for the same 59 px and lose. The six hand-written buttons collapse to one list. They repeated the same four things with only the entity changing, which is how the phone variant came to need six near-identical edits the first time. --- web/src/components/DataTable.svelte | 134 +++++++++++++++++++++------- 1 file changed, 104 insertions(+), 30 deletions(-) diff --git a/web/src/components/DataTable.svelte b/web/src/components/DataTable.svelte index 3f19d8732..16f75aab6 100644 --- a/web/src/components/DataTable.svelte +++ b/web/src/components/DataTable.svelte @@ -8,6 +8,7 @@ import LoadsTable from './tables/LoadsTable.svelte'; import MaterialsTable from './tables/MaterialsTable.svelte'; import SectionsTable from './tables/SectionsTable.svelte'; + import Icon from './ribbon/Icon.svelte'; type TabId = 'nodes' | 'elements' | 'supports' | 'loads' | 'materials' | 'sections'; interface Props { @@ -57,28 +58,50 @@ function handleKeydown(e: KeyboardEvent) { e.stopPropagation(); } + + /** + * The six tabs, in one list. + * + * They were six hand-written buttons that repeated the same four things — + * label, count, active test, `pickTab` — with only the entity changing, which + * is how the phone variant came to need six near-identical edits. + * + * `icon` is the name the RIBBON uses for the same entity, so the strip on a + * phone shows the glyph the reader already learned on a desktop rather than a + * second drawing of a node. See `ribbon/Icon.svelte`. + */ + const TABS: { id: TabId; labelKey: string; icon: string; count: () => number }[] = [ + { id: 'nodes', labelKey: 'data.nodes', icon: 'node', count: () => modelStore.nodes.size }, + { id: 'elements', labelKey: 'data.elements', icon: 'element', count: () => modelStore.elements.size }, + { id: 'supports', labelKey: 'data.supports', icon: 'support', count: () => modelStore.supports.size }, + { id: 'loads', labelKey: 'data.loads', icon: 'load', count: () => modelStore.loads.length }, + { id: 'materials', labelKey: 'data.materials', icon: 'material', count: () => modelStore.materials.size }, + { id: 'sections', labelKey: 'data.sections', icon: 'section', count: () => modelStore.sections.size }, + ];
- - - - - - + {#each TABS as tab (tab.id)} + + {/each} + {#if uiStore.isMobile} + + {/if} {#if flat}

{t('project.fileSection')}

{/if}
- - - + + + + + + + + + - - {#if uiStore.currentTool === 'select'} + + +
+ + + {#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} - + ] as const as sm (sm.id)} + {/each} - {/if} +
+ {/if} + + {#if proStageMenu} + + +
proStageMenu = false}>
+
+ {#each PRO_STAGE_MENU as st (st.id)} + + {/each} +
+ {/if}
{/if} @@ -1294,8 +1421,23 @@ showed, and every id inside it, `ex-group-2d` among them, existed twice. --> {#if uiStore.isMobile && uiStore.rightDrawerOpen && uiStore.appMode !== 'basico'} -
uiStore.rightDrawerOpen = false}>
-
- + + {/if} {/if} @@ -1003,22 +1116,89 @@ .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 { + /* ── 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. + ──────────────────────────────────────────────────────────────── */ + .pm-grid-head { + display: flex; + align-items: center; + justify-content: space-between; width: 100%; - padding: 8px 10px; + min-height: 44px; + padding: 0 10px; background: var(--st-surface-3); - border: 1px solid var(--st-surface-3); - border-radius: 4px; + border: 1px solid var(--st-hair); + border-radius: var(--st-radius); color: var(--st-text); - font-size: 0.82rem; + font-family: var(--st-mono); + font-size: 0.68rem; + letter-spacing: 0.1em; + text-transform: uppercase; 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; + margin-bottom: 4px; } - .pm-tab-select:focus { border-color: var(--st-text-2); outline: none; } + .pm-grid-count { + font-size: 0.62rem; + color: var(--st-text-3); + } + .pm-grid-head.open .pm-grid-count::after { content: ' ▾'; } + .pm-grid-head:not(.open) .pm-grid-count::after { content: ' ▸'; } + + .pm-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 4px; + } + + .pm-cell { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + min-height: 48px; + 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; + overflow: hidden; + } + + .pm-cell-icon { display: flex; color: var(--st-text); line-height: 1; } + + /* N, My, Vz are notation, so they take the mono face rather than an icon. */ + .pm-cell-sym { + font-family: var(--st-mono); + font-size: 0.8rem; + font-weight: 600; + } + + .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, + .pm-cell.active .pm-cell-sym { 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; diff --git a/web/src/components/pro/ProRibbon.svelte b/web/src/components/pro/ProRibbon.svelte index fa51e3e02..dfeca317c 100644 --- a/web/src/components/pro/ProRibbon.svelte +++ b/web/src/components/pro/ProRibbon.svelte @@ -11,6 +11,7 @@ import { uiStore, modelStore, resultsStore, historyStore, verificationStore } from '../../lib/store'; import { saveProject } from '../../lib/store/file'; import Icon from '../ribbon/Icon.svelte'; + import { buildProStages, PRO_TAB_STAGE, type ProStage, type ProCmd } from '../../lib/pro/stages'; import { TWO_D_INTERNAL_FORCE_LABELS as F2D } from '../../lib/geometry/coordinate-system'; /** @@ -84,33 +85,6 @@ let exampleBtn: HTMLButtonElement | undefined = $state(); let openMenu = $state(null); - /* ── Stages ─────────────────────────────────────────────────────────── - * - * Four, not five. Examples and DXF are document commands and live in the - * block on the left 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 tab had nothing left to hold. - */ - type Cmd = { - 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; - /** Shown only when the group is expanded. */ - overflow?: boolean; - }; - type Group = { id: string; labelKey: string; cmds: Cmd[] }; - type Stage = { id: string; labelKey: string; home: string; groups: Group[] }; - const solved = $derived(resultsStore.results3D != null || resultsStore.results != null); /* @@ -121,149 +95,24 @@ resultsStore.diagramType === 'axialColor' ? 'axial' : resultsStore.diagramType, ); - const STAGES: Stage[] = $derived([ - { - 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' }, - ], - }, - ], - }, - { - 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 inside the Results panel — eleven entries in a + * dropdown, for the control an engineer touches more than any other + * after solving. Basic reaches them in one click from the row that is + * always on screen, and there is no reason PRO should be slower at the + * same job. Same order and same symbols as Basic (N, My, Vz, Mz, Vy, + * T), because a user who moves between modes should not have to + * relearn where the moment diagram is. + */ + { + id: 'diagrams', + labelKey: 'ribbon.tabResults', + cmds: [ + { id: 'none', labelKey: 'ribbon.noDiagram', icon: 'none', diagram: 'none', enabled: () => solved }, + { id: 'deformed', labelKey: 'ribbon.deformed', icon: 'deformed', diagram: 'deformed', enabled: () => solved }, + { id: 'axial', label: F2D.axial, labelKey: 'ribbon.nameAxial', icon: 'axial', diagram: 'axial', enabled: () => solved }, + { id: 'momentY', label: 'My', labelKey: 'ribbon.nameMomentY', icon: 'moment', diagram: 'momentY', enabled: () => solved }, + { id: 'shearZ', label: 'Vz', labelKey: 'ribbon.nameShearZ', icon: 'shear', diagram: 'shearZ', enabled: () => solved }, + { id: 'momentZ', label: 'Mz', labelKey: 'ribbon.nameMomentZ', icon: 'moment', rotate: 90, diagram: 'momentZ', enabled: () => solved }, + { id: 'shearY', label: 'Vy', labelKey: 'ribbon.nameShearY', icon: 'shear', rotate: 90, diagram: 'shearY', enabled: () => solved }, + { id: 'torsion', label: 'T', labelKey: 'ribbon.nameTorsion', icon: 'torsion', diagram: 'torsion', enabled: () => solved }, + ], + }, + /* + * Not quantities: whole-model colourings. A colour map paints every + * member by a variable you choose, and the verification map paints them + * by their code-check outcome — neither is "a diagram of X", so they do + * not belong in the row of six that are. + */ + { + id: 'maps', + labelKey: 'proRibbon.groupMaps', + cmds: [ + { id: 'colorMap', labelKey: 'pro.diagColorMap', icon: 'view2d', diagram: 'colorMap', enabled: () => solved }, + { id: 'verification', labelKey: 'pro.diagVerification', icon: 'support', diagram: 'verification', enabled: () => solved }, + ], + }, + { + id: 'inspect', + labelKey: 'proRibbon.groupInspect', + cmds: [ + { id: 'results', labelKey: 'ribbon.results', icon: 'data', tab: 'results', enabled: () => solved }, + { id: 'diagnostics', labelKey: 'pro.tabDiagnostics', icon: 'advanced', tab: 'diagnostics' }, + ], + }, + { + id: 'output', + labelKey: 'proRibbon.groupOutput', + cmds: [ + { id: 'report', labelKey: 'pro.reportBtn', icon: 'project', action: onReport, enabled: () => canReport }, + ], + }, + ], + }, + { + id: 'design', + labelKey: 'proRibbon.stageDesign', + home: 'design', + groups: [ + { + id: 'rc', + labelKey: 'proRibbon.groupDesign', + cmds: [ + { id: 'design', labelKey: 'pro.tabDesign', icon: 'settings', tab: 'design' }, + { id: 'connections', labelKey: 'pro.tabConnections', icon: 'element', tab: 'connections' }, + ], + }, + ], + }, + ]; +} + +/** + * Which stage owns each panel view. + * + * Beside the stages because it is the same fact read the other way round, and + * two lists that describe one relationship drift the moment only one is + * updated. Project is reached from its own button rather than from a stage, so + * it maps to none — see the callers, which keep showing the stage you came + * from rather than jumping to the first one. + */ +export const PRO_TAB_STAGE: Record = { + project: '', + nodes: 'model', elements: 'model', shells: 'model', materials: 'model', sections: 'model', + supports: 'conditions', constraints: 'conditions', loads: 'conditions', + advanced: 'analyse', results: 'analyse', diagnostics: 'analyse', + design: 'design', connections: 'design', +}; + +/** Every command in every stage, flattened — for callers that want a lookup. */ +export function proCmds(stages: ProStage[]): ProCmd[] { + return stages.flatMap((s) => s.groups.flatMap((g) => g.cmds)); +} From 302e595168add1c9d37475f583fc477171075328 Mon Sep 17 00:00:00 2001 From: Bauti Date: Sun, 23 Aug 2026 03:58:08 -0300 Subject: [PATCH 19/31] =?UTF-8?q?pro/mobile:=20tidy=20the=20prototype=20?= =?UTF-8?q?=E2=80=94=20grouping,=20one=20highlight,=20the=20drag,=20Settin?= =?UTF-8?q?gs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five things the first pass got wrong, all of them visible at a glance. **File actions everywhere.** Open, Save and Examples sat above every tab — a permanent three-button header over the nodes table, over diagnostics, over RC design, for an errand you run twice a session. They belong to the DOCUMENT, so they show where the document is: the Project tab. **The grid was rows and rows with no order.** It flattened the groups the stages already carry, so ANALYSE was fifteen buttons in five rows with nothing saying where one kind of thing ended — Solve beside "no diagram" beside a colour map beside the report. It is drawn BY GROUP now, with headings: Ejecutar 2, Resultados 8, Mapas 2, Inspeccionar 2, Salida 1. The desktop ribbon draws the same division as ruled sections; a column draws it as headings. **And no figures.** Every cell now carries its icon, and the SHORT name goes under it — for a diagram that is the symbol. It used to put the symbol where the icon belongs and the full name underneath, which at 116 px rendered "Momento flector respect…": a truncation pretending to be a label. The full name is in the tooltip, exactly as the ribbon does it. **Two things lit at once.** The pointer, the stage and Project could all be accented together. The accent means one thing — "the panel below is showing this" — so exactly one control can carry it, and now exactly one does: Project or the stage, never both, never neither. A pointer mode is not a panel; it says which mode it is in by changing its glyph between the hand and the arrow, and takes a plain filled key rather than the accent. Same for an open menu. **No drag, and no Settings.** The sheet's handle moves out of `BasicPanel` into `SheetGrab.svelte` and PRO mounts it too — the two panels are the same object and only one having a drag was never a decision, just an artefact of which was built first. PRO's Settings button loses its `!isMobile`, like Basic's did. The phone-only settings also move to the TOP of the panel as their own section. They had been placed inside the Model sub-section, which PRO renders collapsed — so in PRO the control size could not be found at all, and in Basic it sat under a heading reading MODELO, which is not what it configures. Broke Basic on the way and caught it before the suites: pulling the sheet drag out of `BasicPanel` also removed `publishWidth` and `startResize`, which sit between it and the next comment and belong to the DESKTOP width drag. The panel stopped mounting entirely. Restored, and verified: 45 → 69 → 22.8 vh with the body's scrollTop untouched. --- web/src/App.svelte | 90 ++++++++- web/src/components/SheetGrab.svelte | 155 ++++++++++++++ web/src/components/pro/ProPanel.svelte | 118 +++++++---- web/src/components/ribbon/BasicPanel.svelte | 190 +++--------------- .../components/toolbar/ToolbarConfig.svelte | 101 +++++----- web/src/lib/i18n/locales/en.ts | 1 - web/src/lib/i18n/locales/es.ts | 1 - web/src/lib/i18n/locales/pt.ts | 1 - 8 files changed, 397 insertions(+), 260 deletions(-) create mode 100644 web/src/components/SheetGrab.svelte diff --git a/web/src/App.svelte b/web/src/App.svelte index 044eb6163..07638466f 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -14,6 +14,7 @@ import { OFFERED_LOCALES } from './lib/i18n/store.svelte'; import { resolveDeleteTargets } from './lib/store/delete-selection'; import { buildProStages, PRO_TAB_STAGE } from './lib/pro/stages'; + import SheetGrab from './components/SheetGrab.svelte'; import { loadAutosave, clearAutosave, loadWorkspaceFromLocalStorage, saveWorkspaceToLocalStorage, @@ -978,7 +979,13 @@ Every click worked. The state flipped, the panel mounted, and it rendered where nobody could see it. --> - {#if uiStore.appMode === 'pro' && !uiStore.isMobile} + + {#if uiStore.appMode === 'pro'}
+
{:else if uiStore.appMode === 'educativo'} @@ -2792,12 +2827,28 @@ } .pmt-btn:hover { color: var(--st-text); } .pmt-btn:disabled { opacity: 0.34; cursor: default; } + /* + ONE accent in the row at a time, and it means "the panel is showing this". + Project and the stage are the only two that can carry it, and exactly one + of them does whenever the panel is open. + */ .pmt-btn.active { background: var(--st-selected-bg); border-color: var(--st-accent); color: var(--st-accent); } + /* + Armed and open are NOT that. A pointer mode and an open menu are states of + the control itself, so they are drawn as a filled key — no accent, nothing + that could be mistaken for a second selection. + */ + .pmt-btn.armed, + .pmt-btn.open { + background: var(--st-surface-3); + color: var(--st-text); + } + /* The select-mode row. Scrolls rather than wraps: five translated words do not fit in 375 px in any language, and a bar that changes height when a @@ -3004,6 +3055,35 @@ .drawer-right.drawer-shared { height: var(--st-sheet-h); max-height: var(--st-sheet-h); + display: flex; + flex-direction: column; + } + + /* The handle row: the grab fills it, the ✕ sits on top at the right. */ + .drawer-sheet-top { + display: flex; + align-items: center; + flex: none; + position: relative; + } + .drawer-sheet-top :global(.grab) { flex: 1; } + .drawer-sheet-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; + touch-action: manipulation; } @keyframes sheet-slide-up { diff --git a/web/src/components/SheetGrab.svelte b/web/src/components/SheetGrab.svelte new file mode 100644 index 000000000..aaf65a383 --- /dev/null +++ b/web/src/components/SheetGrab.svelte @@ -0,0 +1,155 @@ + + + + + + diff --git a/web/src/components/pro/ProPanel.svelte b/web/src/components/pro/ProPanel.svelte index 17eff0f9d..338908ab5 100644 --- a/web/src/components/pro/ProPanel.svelte +++ b/web/src/components/pro/ProPanel.svelte @@ -776,8 +776,17 @@ const gridStage = $derived( proStages.find((s) => s.id === (mappedProStage || lastProStage)) ?? proStages[0], ); - /** Flat, because the grid does not draw the group divisions — see below. */ - const gridCmds = $derived(gridStage ? gridStage.groups.flatMap((g) => g.cmds) : []); + /* + * 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. @@ -826,6 +835,14 @@ {#if uiStore.isMobile}
+ + {#if uiStore.proActiveTab === 'project'}
-
+ {/if} + + + + + {c.label ?? t(c.labelKey)} + + {/each} +
+ {/each} {/if} @@ -1147,6 +1176,27 @@ .pm-grid-head.open .pm-grid-count::after { content: ' ▾'; } .pm-grid-head:not(.open) .pm-grid-count::after { content: ' ▸'; } + /* + 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; + } + + .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); + } + .pm-grid { display: grid; grid-template-columns: repeat(3, 1fr); @@ -1171,11 +1221,12 @@ .pm-cell-icon { display: flex; color: var(--st-text); line-height: 1; } - /* N, My, Vz are notation, so they take the mono face rather than an icon. */ - .pm-cell-sym { + /* 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.8rem; + font-size: 0.72rem; font-weight: 600; + letter-spacing: 0.02em; } .pm-cell-label { @@ -1193,8 +1244,7 @@ border-color: var(--st-accent); color: var(--st-text); } - .pm-cell.active .pm-cell-icon, - .pm-cell.active .pm-cell-sym { color: var(--st-accent); } + .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; } diff --git a/web/src/components/ribbon/BasicPanel.svelte b/web/src/components/ribbon/BasicPanel.svelte index 3f01c13ad..c468b64d1 100644 --- a/web/src/components/ribbon/BasicPanel.svelte +++ b/web/src/components/ribbon/BasicPanel.svelte @@ -13,6 +13,7 @@ import StepWizard from '../dsm/StepWizard.svelte'; import { dsmStepsStore } from '../../lib/store/dsmSteps.svelte'; import { uiStore } from '../../lib/store/ui.svelte'; + import SheetGrab from '../SheetGrab.svelte'; import { resultsStore } from '../../lib/store/results.svelte'; /** @@ -60,99 +61,6 @@ let dragging = $state(false); let widthPublishFrame = 0; - /* ── The phone: height, dragged from a handle ────────────────────────── - * - * The desktop panel is resized by dragging its leading EDGE, which is the - * gesture that fits a panel whose size is a width. A sheet's size is a - * height, and its leading edge is a 1 px line at the top of the screen's - * lower half — so the sheet gets its own control: a grab handle above the - * title, which is the only thing on the panel that drags. - * - * That separation is the point. The body scrolls and the handle resizes, and - * a finger on one never does the other — `touch-action: none` on the handle - * keeps the browser from claiming the gesture as a scroll, and the body keeps - * ordinary `overflow-y: auto` because nothing intercepts it. - * - * 45vh at rest rather than the 58 it opened at. Fifty-eight was picked to - * make a results table worth reading and it did not even manage that — the - * table began 10 px above the bottom of the screen — while costing the model - * more than half the height. A lower default plus a drag serves both ends - * better than any single number can. - */ - const SHEET_MIN = 22; - const SHEET_MAX = 86; - const SHEET_DEFAULT = 45; - const SHEET_KEY = 'stabileo-basic-sheet-vh'; - - function storedSheet(): number { - try { - const v = Number(localStorage.getItem(SHEET_KEY)); - return Number.isFinite(v) && v >= SHEET_MIN && v <= SHEET_MAX ? v : SHEET_DEFAULT; - } catch { return SHEET_DEFAULT; } - } - - let sheetVh = $state(storedSheet()); - let sheetDragging = $state(false); - let sheetPublishFrame = 0; - - /* - * Published on the root, not set inline, because two elements need it: this - * panel's own box and the padding `.app-body` gives up so the canvas is the - * size it appears to be. The token in `styles/tokens.css` is the default the - * stylesheet starts from; this overrides it once the reader has an opinion. - */ - function publishSheet() { - // One decimal. A pointer delta divided by a viewport hundredth produces - // fifteen significant figures, and every one of them past the first lands - // in the DOM and in whatever anyone reads it back with. - document.documentElement.style.setProperty('--st-sheet-h', `${sheetVh.toFixed(1)}vh`); - } - - function startSheetDrag(e: PointerEvent) { - sheetDragging = true; - const startY = e.clientY; - const startVh = sheetVh; - const vh = window.innerHeight / 100; - (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); - - const move = (ev: PointerEvent) => { - // Dragging UP grows the sheet, which is the direction it grows on screen. - const next = startVh + (startY - ev.clientY) / vh; - sheetVh = Math.min(SHEET_MAX, Math.max(SHEET_MIN, next)); - if (!sheetPublishFrame) { - sheetPublishFrame = requestAnimationFrame(() => { - sheetPublishFrame = 0; - publishSheet(); - }); - } - }; - - const up = () => { - sheetDragging = false; - try { localStorage.setItem(SHEET_KEY, String(Math.round(sheetVh))); } catch { /* private mode */ } - window.removeEventListener('pointermove', move); - window.removeEventListener('pointerup', up); - window.removeEventListener('pointercancel', up); - /* - * Re-frame once, at the END of the drag. - * - * The canvas has just changed height by as much as 60 % of the screen, so - * whatever framing preceded the drag is wrong for what is left. Doing it - * on every pointermove instead would make the model chase the handle, - * and it would re-fit sixty times a second against a canvas that is - * still being resized. - */ - requestAnimationFrame(() => requestAnimationFrame(() => { - window.dispatchEvent(new Event('stabileo-zoom-to-fit')); - })); - }; - - window.addEventListener('pointermove', move); - window.addEventListener('pointerup', up); - window.addEventListener('pointercancel', up); - e.preventDefault(); - } - function publishWidth() { document.documentElement.style.setProperty('--st-right-panel-w', `${width}px`); } @@ -185,6 +93,14 @@ e.preventDefault(); } + /* + * The sheet's height and its drag live in `SheetGrab.svelte`. + * + * They were here, and PRO's panel did not have them — which is the kind of + * difference nobody decides, it just follows from which surface was built + * first. Both are the same object, so both mount the same handle. + */ + /** Heading, so the panel always says what it is showing. */ const title = $derived(t(`ribbon.${panel}`)); @@ -203,17 +119,9 @@ */ onMount(() => { publishWidth(); - publishSheet(); return () => { if (widthPublishFrame) cancelAnimationFrame(widthPublishFrame); - if (sheetPublishFrame) cancelAnimationFrame(sheetPublishFrame); document.documentElement.style.removeProperty('--st-right-panel-w'); - /* - * Handed back on unmount so `.app-body` stops reserving height the moment - * the sheet closes. Leaving it would keep a band of the canvas walled off - * for a panel that is no longer there. - */ - document.documentElement.style.removeProperty('--st-sheet-h'); }; }); @@ -245,7 +153,6 @@