USABILITY_PLAN.md
Rama main · réplica de lectura del repositorio original.
# 🧭 Übersichtlichkeit — Design Doc
> **Status:** all six phases shipped · **Author:** design pass with Claude · **Date:** 2026-08-20
> **Measured against** `main` @ `d3c1c66`, at 1366×768, 1280×720 and 375×812, through
> `scripts/static-page-server.js` — the same stripped page the mobile tooling measures.
`MOBILE_REDESIGN.md` fixed the **floor**: type, touch targets, overflow, tables. That work
is finished and its ratchet reads zero on all three dimensions. What it deliberately did
not touch is the layer above — *how many places there are, which one answers a given
question, and what happens when you get something wrong.* That is what this document is
about, and unlike the floor it is not a phone problem: four of the six findings below hurt
the desk more than the hand.
---
## 1. What is already right
Stated first because the plan below must not undo it.
- **The floors hold.** `npm run mobile-audit` → `inline: 0 · declared: 0 · base: 0`.
- **One DOM, one stylesheet.** No second render path was introduced, and none is proposed.
- **Landing is a launcher, not a dashboard.** `p-work` asks "was hast du gemacht?" and
offers seven jobs. That is the right idea and the plan keeps it.
- **Admin's thirteen sub-tabs are already solved twice** — grouped vertically in the
sidebar above 769px, a drill-down list below it. That mechanism is the one §4.1 reuses.
- **Destructive actions are undoable** (`#undo-bar`) and the scanner is always reachable
(`#cam-fab`).
---
## 2. Measured state
| What | Count | Where |
|---|---:|---|
| Top-level pages | 12 | `#p-work` … `#p-pickups` |
| Sidebar entries | 15 + Admin | `.sb-nav .sb-btn`, [index.html:1170](index.html:1170) |
| …of those, sub-views of **one** page (`p-orders`) | **5** | `#n-orders-*`, [app.js:20535](app.js:20535) |
| Sidebar height needed vs. available at 1366×768 | **778px / 611px** | 167px, 4 entries, below the fold |
| Same at 1280×720 | 688px / 563px | 125px, 3 entries |
| Static dialogs | 23 | `div.modal` in `index.html` |
| Sub-tab strips | 6 | `.stabs`; Admin 13, Orders 5 (hidden), 4 others |
| Page-local search boxes | 4 | `dash.search` ×3 + `harvest.searchBatch` |
| Global search / jump-to | **0** | — |
| Native `alert()` | **77** | `app.js` |
| …of those, raw server text (`res.error`, `e.message`) | 22 | e.g. [app.js:17320](app.js:17320) |
| Native `confirm()` / `prompt()` | 9 / 1 | `app.js` |
| Mobile topbar page title | **none** | `go()` never writes one, [app.js](app.js) |
| `p-work` scroll height at 375px | 1595px | viewport 812px — two screens |
---
## 3. Findings
### 3.1 The sidebar does not fit a laptop — and the reason is an IA inconsistency
At 1366×768, the most common business-laptop viewport, `.sb-nav` needs 778px and has 611.
**Kunden and Versand are not on the screen and nothing says they exist**: the list simply
stops after Produkte, and there is no fade, shadow or scrollbar to contradict it. At
1280×720 three entries are gone the same way.
The cause is not that the app has too many pages. It is that **five of the fifteen entries
are sub-views of a single page**. `#n-orders-inbox`, `-demand`, `-mapping`, `-customers`
and `-versand` all call `go('orders', …)` and then `openStab()` — `p-orders`'s own strip is
still in the DOM at [index.html:3179](index.html:3179) with `display: none` and a comment
explaining that it was promoted.
So the app applies two opposite rules to the same shape: Admin's **thirteen** sub-tabs live
behind one entry, Orders' **five** are all top-level. Applying Admin's rule to Orders takes
the sidebar to 11 entries — it fits at 720px with room left — and costs one click on the
four secondary views while making Bestellungen itself *closer*, because it moves up.
### 3.2 Two landing pages that point at each other
`p-work` carries a strip reading "Weißt du nicht, was ansteht?" whose button goes to the
Dashboard ([app.js:21845](app.js:21845)). The Dashboard opens with two full-width primary
buttons, **+ Neue Charge** and **+ Laborarbeit**, which are `wk-t-batch` and `wk-t-lab` —
two of the seven tiles the user just left.
That is a loop, and it is the same defect Phase 1 of the mobile redesign deleted the speed-
dial FAB for: *an entry point whose every item is a strict subset of another screen's.* The
FAB went; these two survived because they are on a different page.
Neither screen answers "what now" on its own. `p-work` knows the seven **verbs** and
nothing about today; the Dashboard knows today (`#dash-batch-tasks`, `#dash-alerts`) and
repeats two verbs. The answer is not a third screen — it is to put the *today* list where
the verbs already are.
### 3.3 There is no way to find a thing by name
Four search boxes exist, each scoped to the list it sits above. There is no way to type
`XY-1` and get to it, from anywhere. With 12 pages, 34 sub-tabs and roughly 50
destinations, every lookup is a navigation problem the user has to solve first: *which page
holds cultures again — Labor or Chargen?*
This is a desk problem more than a phone one. The desk has a keyboard and no scanner; the
phone has a scanner and the barcode **is** the search box. That asymmetry is the design:
`Ctrl/Cmd + K` on the desk, the scan button on the phone, both landing on the same record.
### 3.4 The phone never says which page it is on
The mobile topbar is hamburger · **Meistertracker** · bell · sync dot, and `go()` does not
touch it. The bottom nav covers five pages, so for the other seven — Zonen, Drucken,
Pilzsorten, Abholungen, Bestellungen, Lager, Admin — there is **nothing on screen that
names where you are**. Open the drawer, tap Abholungen, drawer closes, and the only
evidence of what happened is the content itself.
### 3.5 Validation speaks through an OS dialog, 77 times
`alert(t('batch.fillQty'))`, `alert(t('harvest.enterWeight'))`,
`alert(t('print.selectBatchFirst'))` — the app's entire form-validation vocabulary is the
browser's modal. It is the wrong control for the job in four distinct ways:
1. **The message is detached from the field.** It names a problem; the user dismisses it,
then has to re-find which of nine inputs it meant.
2. **It blocks.** A gloved thumb has to hit one small OK before anything else can happen.
3. **It is not the app.** On a phone it renders as `192.168.x.x sagt:` above the text, in
the OS font, ignoring both the theme and the 56px touch floor the rest of the app now
guarantees.
4. **Twenty-two of them show raw server text** — `alert(res.error)`, `alert(e.message)` —
so an internal string reaches a worker unedited.
The nine `confirm()`s have a fifth problem: the destructive choice is labelled **OK**.
"Kamera löschen?" answered with *OK* is a worse button than one that says *Löschen*.
### 3.6 The phone landing page is two screens tall
Seven tiles at `min-height: 156px` plus gaps: `p-work` is 1595px tall in an 812px viewport,
and **`wk-t-harvest` starts at y=1115**. Logging a harvest — the most repeated job in the
building — begins with a scroll past four tiles that are not it.
The tiles are in production-chain order (Labor → Korn → Charge → Substrat → Umziehen →
Ernte → Kontamination). That order teaches the process, which is worth something on day
one and nothing on day two hundred.
---
## 4. Phases
Each is a PR, shippable and revertible on its own, and each is checked against
`scripts/capture-desktop-baseline.js --compare` and `scripts/measure-mobile.js`.
### Phase 1 — Give the sidebar back its floor · §3.1 · **shipped**
Fold the five `#n-orders-*` entries into one **Verkauf** entry that opens `p-orders` on its
default sub-view, and un-hide `p-orders`'s own `.stabs` strip. Both halves already exist:
the strip is in the DOM, and below 769px `.stabs` is already the drill-down list Phase 1 of
the mobile redesign built. Nothing new is invented; a hidden thing is shown and five
buttons become one.
*Measured: 15 entries → 11, `.sb-nav` 778px → 611px at 1366×768 and 563px at 1280×720 —
in both cases exactly what is available, nothing below the fold. Abholungen stayed
top-level: it is a different page, not an orders sub-view.*
Two things fell out rather than being decided. `go()` lands on the strip's active pill the
way Admin already does, instead of calling `renderOrders()` and letting the four other
views draw over it. And `go('orders', 'n-orders')` at [app.js:792](app.js:792) — the eBay
account-closure notification — had been naming a button that did not exist; `go()`
dereferences `btnId` with no guard, so that link threw and the admin landed nowhere.
➖ **The first sub-tab and the section share a name.** "Bestellungen › Bestellungen", the
same shape as "Admin › Server". Renaming the tab (*Eingang*?) is a domain call, not a
layout one, and is left open deliberately.
### Phase 2 — One "what now", not two · §3.2 · **shipped, and it emptied a mode**
- Delete the two duplicate primary buttons from the Dashboard.
- Move the *today* list — `#dash-batch-tasks` and `#dash-alerts` — to the top of `p-work`,
above the tiles, replacing the "Weißt du nicht, was ansteht?" strip with the answer
itself.
- The Dashboard keeps what only it has: KPIs, history, charts.
*What was not foreseen: `#dash-farm-section` held nothing but those two buttons and that
row, so moving them emptied the Dashboard's Farm mode entirely. The Farm/Overview toggle
went with it — a toggle to a blank screen is worse than no toggle — and the page is the
Übersicht it used to be half of. `dashMode` retired with them, and took a coupling nobody
could have guessed at: two `dashMode === 'farm' &&` conditions in `renderStatus()` decided
whether the location sections on the **Chargen** page opened, so switching the Dashboard to
Übersicht once left every zone on a different page collapsed with no visible cause.*
*Two phone defects had been latent behind the mode and are fixed with it: the tasks/alerts
row carried `align-items: flex-start` inline, which outranks every rule in the stylesheet —
so the phone block could stack the cards and could not stop them shrinking to the width of
their own text (137px inside a 343px page); and the Übersicht toolbar is 382px wide at
375px, invisible to `measure-mobile.js` for as long as the section around it was hidden.*
*Reversed on 2026-09-11, on the phone's evidence: with the list on top the app opened on
what is due and the seven tiles — the reason the page exists — began below the fold. Tiles
first now, the list under them; the same page answers the same two questions in the other
order. The same day the Chargen page's **Neue Charge** button stopped opening the long form
and opens the guided flow instead, so there is one way to make a Charge; and Laborarbeit got
the label dialog a Charge already had, where the person is standing, instead of a result
card inside a form the quick dialog never shows. See `test/etiketten-nach-laborarbeit.test.js`.*
### Phase 3 — Field-level validation and a toast · §3.5 · **shipped**
One `toast(msg, kind)` and one `fieldError(input, msg)`, then convert the 77 sites:
| Kind | Count | Becomes |
|---|---:|---|
| "you left a field empty / wrong" | ~45 | `fieldError()` — red hint under the field, focus moves there |
| "that worked" | ~10 | `toast(…, 'ok')`, auto-dismiss |
| "the server said no" | 22 | `toast(…, 'err')`, sticky until tapped, raw text mapped to a sentence |
| destructive `confirm()` | 9 | the existing `.modal` with a named danger button |
**3a — shipped, and it was not page by page.** The bar already existed: `flashUndoBar()` is
the undo snackbar with its button hidden, positioned clear of the bottom nav and the scan
button by Phase 0 of the mobile redesign. `toast(msg, kind)` is that bar with an error skin,
`role="alert"` and six seconds instead of two and a half; a tap dismisses it. All 77 sites
converted in one sweep, because the shape was uniform — and checked before rather than
after: only four are not immediately followed by a `return`, and all four are followed by a
re-render, which is what should happen while the message is up. Nothing depended on the
blocking. `test/toast.test.js` holds the three cleanups that would rot silently (a receipt
inheriting the error colour, a message leaving `role="alert"` behind, an undo offer keeping
the tap-to-dismiss handler) and fails if a 78th `alert(` appears.
**3b — shipped, on machinery that was already there.** `confirm2()` has taken a named
button since it was written and deleting a pickup location already used it; the eight had
never been moved onto it. `askConfirm()` is that dialog as a promise, so each call site
keeps `if (!(await askConfirm(…))) return;`. The buttons read *Löschen*, *Entfernen*,
*Stilllegen*, *Schlüssel widerrufen*, *Trotzdem fortfahren*. The ninth, `mayLeavePage()`,
stays native and always will: `beforeunload` is synchronous by specification.
*Two bugs fell out. Escape took the class off `#m-confirm` rather than calling
`closeConfirm()` — already wrong, since the callback stayed set for the next caller, and
with a promise behind it the caller would have been suspended for the life of the page.
And `#m-confirm3` was not in the Escape list at all, so the recurring-event delete dialog
was the one modal in the app Escape could not dismiss.*
**3c — shipped smaller than it was written.** Twenty checks call `fieldError(id, msg)`,
which gives the field a red ring, the focus and a scroll while the bar keeps the words.
The words are **not** under the input, as §3.5 asked: those twenty fields sit in grid
cells, plain blocks and non-wrapping flex rows, and a block inserted after the input lands
differently in each. Marking and focusing answers *which one* identically everywhere, and
that was the complaint. Three checks choose between two fields — a ring on an empty box
explains nothing.
### Phase 4 — Find by name · §3.3 · **shipped**
`Ctrl/Cmd + K`, a field at the top of the sidebar, and a magnifier in the phone topbar. One
index over batches, cultures, strains, zones, orders and customers; type-ahead; Enter opens
the record. Pages and sub-pages are results too, so it doubles as the jump-to the 12-entry
sidebar cannot make instant.
*Ranking is by where the match landed, not by type: an id typed out in full, then an id
that starts with the query, then a record whose id is a **prefix** of the query — which is
how a scanned bag code (`NORD-150126-01-07`) finds the batch that owns it. Single bags are
not indexed; twenty per batch would outnumber every other record four to one for a case
that rule already covers. The four-character floor on that rank is what keeps a
three-letter zone id (`XYZ`) from claiming every query beginning X-Y-Z.*
*A scanner types the whole code, and the field says so — "Suchen oder scannen". A printed
label is a number that appears in no index, so it goes through `barcodeRegistry` first, the
same lookup `processScan()` does; a German HID scanner sends underscores where a bag id has
hyphens, so that reading is tried second, never first, because zone barcodes carry real
underscores. When the query is answered by exactly one record the palette opens it without
waiting for a keypress the scanner may not send — after the typing stops, because the batch
id is a **prefix** of the bag code and jumping at the prefix left the last three characters
to land in whatever had focus next.*
*Orders and customers are the only two sources not already in memory. The palette fetches
them on the way in and redraws when they land, rather than making the fast path wait for
the slow one — and on every open rather than once per page load, because a latch set before
the request went out could not be retried and a customer erased on another machine stayed
searchable for ever.*
*A result goes to its destination the way a user would: by clicking the nav entry and the
sub-tab pill, not by calling `go()` and `openStab()` past them. Seven pills load their own
panel from their own handler, and the nav entries clear filters and reset state, so the
short cut opened panels blank and lists still filtered. `mayLeavePage()` is asked before
anything closes, or its "cancel" arrives too late to stop the steps behind it.*
*A result lands on its record: the page, scrolled to, flashed. Batches reuse `goToBatch()`,
which already did this for the dashboard's "Zur Charge" button; the other five got a
`data-find` hook on their row, and `test/global-search.test.js` asks the reverse question —
for every type `gsGoto()` flashes, is there still a renderer emitting the hook? Losing one
is silent: right page, no flash.*
*On a phone the footer goes: "↑↓ wählen · ↵ öffnen" describes a keyboard that is not there,
and it was 40px of a screen with an on-screen keyboard over half of it. The box is bounded
in `dvh` and the list scrolls inside it, because with the keyboard up there is room for
three rows — and `vh`, which is what `max-height: 100%` of a `position: fixed` backdrop
comes to, does not know the keyboard is there. `.wkf-list` says the same thing at length.*
*One table describes a kind of result — heading, monospace, destination filter, route,
flash. It used to be four, and forgetting one of them failed quietly and differently each
time.*
### Phase 5 — Say where you are · §3.4 · **shipped**
`go()` writes the current page's `nav.*` label into the mobile topbar, replacing the
wordmark below 769px. Free on the desktop, which has the sidebar.
*The label is read off the nav entry `go()` was already handed, not from a second table of
page names. `data-i18n` travels with the text, so a language switch reaches it through
`translatePage()`. Measured at 320px: pt `nav.workSteps` — "Etapas de trabalho" — is 4px
over the bar and now ellipsises instead of pushing the bell and the sync dot off the edge;
every other label in all three languages fits.*
### Phase 6 — Fit the seven tiles on one phone screen · §3.6 · **shipped**
Not a reorder — chain order is the right order and frequency order would be a guess. Take
the height instead.
*And the height was not the tile's to keep: `min-height: 156px` is a good proportion for a
tile 261px wide on a desk and pure air at full phone width. The measurement that says so —
every tile reported its when-line **box** at 88px for one to three lines of 13px text,
because `grid-template-rows: auto 1fr` hands the second row whatever the min-height left
spare. Below 768px the tile is as tall as its content, floored at `--tap` rather than at a
literal, and the drawing goes 134px → 96px or it swallows the smaller box.*
*Tiles 156px → 76–95px. The page 1368px → 866px. `wk-t-harvest` 1117 → 774. Five tiles on
the first screen instead of two and a half. The rules had to be moved **below** the base
`.wk-tile` block to take effect at all — a media query adds no specificity, so the first
version read correctly and changed nothing.*
### Beyond the six findings — what the sweeps turned up
The `.dash-top-row` bug in Phase 2 was not a one-off, so both of its shapes were looked for
systematically rather than waited for.
**An inline style against a media query that means to change it** — walked in a browser,
190 media rules against 748 inline styles. Two real, both silent: five `.btn-sm`/`.btn-xs`
whose inline padding beat `(pointer: coarse) { padding: 10px 14px }`, and eight fields
whose inline `max-width` beat the `max-width: 100%` rule written for exactly them. The cap
is data now — `--w-cap` read by `.w-cap` — the same device as `.fs-floor` and `--fs-own`.
Two more the sweep raised and checking dismissed: `@media print` resets `.card` against
seven inline paddings, but the same block hides every `.page`, so none of those cards is
ever printed; and a short-landscape `.modal h3` wants 6px against an inline 4px, which is
already tighter than the block is asking for.
**An element lookup for an id that does not exist** — the shape behind the `n-orders`
crash. Four, in two groups, both code whose UI was removed without it: `exportKpiCSV()`
with its `#kpi-csv-period`, and the team-member editor, whose `addMember()` dereferenced
`#member-name` unguarded. `teamMembers` itself stays — it is loaded, saved, and read by
`getSelectableAssignees()`.
`test/inline-overrides.test.js` and `test/sidebar-entries.test.js` forbid both shapes as
greps. Neither is the full check — that needs a browser, and both files say so rather than
implying otherwise.
---
## 5. How this is verified
Same two tools, same rules as `MOBILE_REDESIGN.md` §8:
- `node scripts/capture-desktop-baseline.js --compare` — desktop pixels must not move,
except where a phase says which selector moves and why.
- `node scripts/measure-mobile.js 375` and `320` — type, touch and overflow stay at zero.
- `npm run mobile-audit` — the ratchet may only fall.
- `npm test` — plus one new file per phase that pins the thing that would silently rot.
**1236 tests**, and each new assertion was run against the tree as it stood and fails
there; a test that passes before the fix is checking nothing.
- `test/sidebar-entries.test.js` — one entry per page, and no `go()` naming a button
that is not in the markup.
- `test/toast.test.js` — the bar's three jobs cleaning up after each other, the dialog
settling on every way out, no `alert(` back, and every `fieldError()` id existing.
- `test/inline-overrides.test.js` — the two shapes where an inline style outranks the
rule written to change it.
- `test/mobile-nav.test.js` — the topbar title derived from the nav entry, and
truncating rather than shoving the bell off the bar.
- `test/global-search.test.js` — the ranking run for real; the census that asks whether
a renderer still emits the `data-find` hook `gsGoto()` flashes; the census of
full-screen overlays the palette must not open on top of, held against `index.html`;
and `gsZoneCounts()` checked against `getZoneBags()` on four hundred random scan logs
from a fixed seed, because the fast version has to give the same number as the zone
card, not a reasonable one.
- `npm run lint` — the warning ceiling is a ratchet too: **73 → 65** as the unreachable
code went.
---
## 6. Non-goals
- **No new pages, no new framework, no component library.** Every phase removes a surface
or reuses one that exists.
- **No desktop redesign.** Phases 1, 2 and 3 change what is on the screen, not how it is
drawn.
- **No reordering of the seven Arbeitsgänge tiles.**
- **Not offline, not the scanner engine, not i18n coverage** — although Phase 3 adds keys.