# π± Mobile Redesign β Design Doc
> **Status:** Phase 0 shipped Β· **Author:** design pass with Claude Β· **Date:** 2026-08-19
> **Measurements** taken against `main` @ `e656fbd`. Where building Phase 0 proved a
> number or a claim wrong, the correction is in place and flagged **[corrected]** β the
> original reasoning is kept where it explains why the wrong thing looked right.
The lab walks. Workers carry a phone in one hand and a bag in the other, wear gloves, and
work in a room that is warm and humid. The phone layout they get today is the desktop
layout with the padding turned down β at β€480px the primary button is **smaller** than it is
on a 27" monitor.
This document proposes the opposite direction for the screens that are actually used on
foot, and a "don't break it" floor for the rest.
---
## 1. Goals / Non-goals
**Goals**
- Every screen a worker uses **standing up, gloved, one-handed, at arm's length** works:
β₯56px touch targets, β₯17px body text, one column, one primary action per screen.
- A real **token layer** for size / space / type β **mobile-first**, desktop as the override.
This replaces the current 38 hand-rolled media queries in four different dialects.
- **No horizontal scroll anywhere.** 29 tables become cards on phone; 34 sub-tab pills
become drill-downs.
- Restore a **persistent scan affordance** β the app is barcode-first and currently has no
always-reachable scanner entry on a phone.
- Every phase is **independently shippable and revertible**. No phase leaves the app in a
half-migrated state.
**Non-goals**
- **No second render path.** No mobile stylesheet, no mobile template tree, no framework.
One DOM, one `styles.css`. Two render paths in a 910 KB `app.js` drift within weeks.
- **No desktop redesign.** Desktop pixels must not move β see Β§8 for how that is enforced.
- **Not a facelift for the BΓΌro screens** (Β§3). They get a floor, not a redesign.
- Not offline/PWA work, not the scanner decode engine, not i18n.
- Not a component library. Classes, not components.
---
## 2. Measured state
Everything below was counted against the working tree, not recalled.
| What | Count | Where |
|---|---:|---|
| Top-level pages | 12 | `#p-work` β¦ `#p-pickups`, [index.html:2098](index.html:2098)ff |
| Sidebar entries | 16 | `#n-*`, [index.html:1211](index.html:1211) |
| Admin sub-entries | 13 | `#sn-settings-*`, [index.html:1469](index.html:1469) |
| Sub-tab pills | 34 | 6 `.stabs` strips; Settings alone has 13 |
| `
` elements | 29 | 16 static + 13 built by string concat in `app.js` |
| Media queries | 38 | `max-width`, `pointer: coarse`, `hover: none`, `min-width: 769` β mixed |
| **Inline `font-size` below the 13px floor** | **495** | 242 in `index.html`, 253 in `app.js` |
| β¦of those, fractional | 11 | `10.5px`, `11.5px`, `9.5px`, `12.5px` β all in `app.js` |
| Inline `font-size` at exactly 13px | 65 | at the floor, so not the bridge's problem |
| Inline `style="` attributes | 1335 | 766 in `index.html`, 569 in `app.js` |
| Size / space / type tokens | **0** β 11 | was: `:root` had only colours, 2 radii, 2 shadows |
> **[corrected]** This table first said 549 inline sizes β€13px. The survey behind it used a
> shell pattern with no decimal point in it, so eleven fractional sizes never appeared β
> and the first version of the bridge, built from the same list, silently missed exactly
> those eleven. `scripts/mobile-audit.js` and `test/mobile-tokens.test.js` both exist
> partly so the next omission is caught by a number instead of by someone squinting at a
> phone.
### 2.1 The direction is inverted
The base stylesheet is the desktop, and the phone is derived from it by subtraction:
```css
/* styles.css:1541 β base */
.btn { padding: 9px 16px; font-size: 13px; }
/* styles.css:3627 β @media (max-width: 480px) */
.btn { padding: 8px 12px; font-size: 12px; }
```
Same story at [styles.css:3536](styles.css:3536): `table { font-size: 12px }` on phones.
[styles.css:3627](styles.css:3627): `.main { padding: 8px }`.
The intent to do better exists β [styles.css:185](styles.css:185) lifts `.sb-btn` to a 48px
minimum under `pointer: coarse`, and the calendar toolbar gets 44px targets at
[styles.css:3482](styles.css:3482). But it is applied component-by-component, after the
fact, wherever someone noticed. That is the thing worth rebuilding: not the look, the
**direction**.
### 2.2 What is already right
[`#p-work`](index.html:2098) β "Was hast du gemacht?" β is the target paradigm, already
built and already the default landing page:
- Seven large illustrated tiles, one per job.
- Each carries a *when do I use this* line: **KΓΆrnerbrut ansetzen** β *wenn eine
FlΓΌssigkultur fertig ist*.
- [styles.css:5166](styles.css:5166) already drops to one column below 520px.
This doc does not invent a design language. It generalises this one.
Even here the type is desktop-sized: `.wk-tile-t` is `14.5px` and `.wk-tile-when` is
`11.5px` ([styles.css:5231](styles.css:5231)). At arm's length through a face shield,
11.5px is not a subtitle, it is a rumour.
### 2.3 Two defects found while measuring
- **`.scan-float` was dead CSS.** Styled twice β once with `env(safe-area-inset-*)`
handling, once to hide it in print β carried by **no element in the source**, with
`scan.fabLabel` still translated in all three language files.
> **[corrected]** The conclusion drawn from this β "on a phone there is no way to the
> scanner" β was **wrong**, and Phase 0 shipped a duplicate button before a screenshot
> showed `#cam-fab` already sitting there doing the job. The dead CSS was the corpse of
> a button that had been *replaced*, not removed. It is now deleted and the duplicate
> reverted. Worth keeping as a lesson: three independent traces (CSS, print rule, i18n
> keys) all agreed with each other, and all three were evidence about the past.
- **Three floating controls sat on top of the bottom nav.** The genuine defect, found by
looking at the screen rather than at the source: `.cam-fab` sits at `bottom: 24px` and
`.undo-bar` at `20px`, both under the 56px bottom nav, so the "Scannen" pill covers
*Chargen*, *Labor* and *Kalender* on a 375px screen. `.action-fab-wrap` already used
`72px` β the right value was in the file, applied to one of the three. Fixed in Phase 0.
> **[corrected]** Measured after the fact, the pill covered **three** nav entries, not
> two. `.action-fab-wrap` no longer exists β see Phase 1, which deleted it.
- **The bottom nav's comment disagreed with the bottom nav** β "4 most-used pages" above
five buttons. Fixed.
---
## 3. Two kinds of screen
The single most important decision in this plan is **where the redesign stops**.
A large-target layout costs vertical space. It pays for itself where the choices are few
and the hands are busy. It is pure tax where the task is "fill in twelve fields once a
year" β a large-target Settings page would need thirteen taps to reach the DuckDNS token.
| | **Feld** β redesigned | **BΓΌro** β floor only |
|---|---|---|
| Pages | `p-work`, `p-batch`, `p-lab`, `p-cal`, `p-inv` (Bestand), `p-zones`, scan overlay | `p-settings` (+13 admin), `p-orders` (5), `p-print`, `p-strains`, `p-pickups`, `p-dash` |
| Posture | standing, gloved, one hand | sitting, two hands, no gloves |
| Target | β₯56px, one column, one primary action | β₯44px, no horizontal scroll, readable |
| Work | Phases 2β3 | Phase 4 |
`p-dash` sits in BΓΌro deliberately: it is charts and KPIs, a thing you *read*, and the
"what do I do now" job it used to serve moved to `p-work` and the Chargen page already
(see the routing comments at [app.js:1059](app.js:1059)).
> **Naming:** call this *Handschuh-Modus* or *Werkbank-Layout* internally. Name the layout
> after the gloves and the work, never after the people using it.
---
## 4. The token layer
The whole architectural idea is one inversion: **the phone is the base, the desktop is the
override.** Everything else follows.
```css
:root {
/* Type β phone values. */
--fs-xs: 13px; /* the floor. nothing user-facing goes below this. */
--fs-sm: 15px;
--fs-base: 17px;
/* Sketched, deliberately NOT shipped in Phase 0 β see the note below. */
--fs-lg: 20px;
--fs-xl: 26px;
/* Touch. 44px is WCAG 2.5.5 AAA; 56 is the deliberate step beyond it for gloves. */
--tap: 56px;
--tap-sm: 48px;
/* Space β also sketched, also not shipped yet. */
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px;
--sp-4: 16px; --sp-5: 24px; --sp-6: 32px;
--pad-page: 16px;
--pad-card: 16px;
}
@media (min-width: 769px) and (hover: hover) {
:root {
--fs-xs: 11px; --fs-sm: 13px; --fs-base: 15px;
--tap: auto; --tap-sm: auto;
--pad-page: 24px; --pad-card: 20px;
--pad-btn: 9px 16px; --pad-stab: 7px 16px; --pad-sb: 9px 12px; --pad-modal: 28px;
}
}
```
> **[corrected]** Two changes from the sketch, both made while building it.
>
> **`and (hover: hover)`.** Width alone hands a coarse-pointer tablet the desk's numbers
> at 1024px. The reason for a big target is the input device, so the query asks about the
> device. This also absorbs the old `@media (pointer: coarse)` `.sb-btn` block, which was
> reaching for the same thing one component at a time.
>
> **`--tap: auto`, not `36px`.** A desktop number would have *added* a minimum where
> there was none and moved the desktop by a pixel or two. `auto` means "intrinsic height,
> exactly as before" β which is what "the desktop must not move" actually requires.
>
> Padding tokens carry paired values rather than sitting on a 4/8/12/16 scale, for the
> same reason: `.btn` is `9px 16px` today, 9 is not on any sane scale, and rounding it to
> 12 would have been a redesign of the desktop disguised as a refactor.
Then every rule that hard-codes a size consumes a token instead:
```css
body { font-size: var(--fs-base); }
.btn { padding: var(--pad-btn); font-size: var(--fs-sm); min-height: var(--tap); }
.main { padding: var(--pad-page); }
.card { padding: var(--pad-card); }
```
> **[corrected]** Phase 0 shipped **11 tokens**, and the `--sp-*` scale, `--fs-lg` and
> `--fs-xl` are **not among them** β nothing consumed them, and a token nobody reads is
> just a number in a different place. The `.btn` line above originally read
> `padding: var(--sp-3) var(--sp-4)`, which would have been `12px 16px` on a desktop
> where `.btn` is `9px 16px` β a desktop redesign smuggled in as a refactor. Paired
> `--pad-*` tokens exist precisely because today's values are not on any scale.
>
> This is a real open question, not a settled one: with no scale, every new component
> gets a bespoke token **by default rather than by decision**. Whoever lands Phase 2
> should either introduce `--sp-*` at the point something actually consumes it, or say
> plainly that per-component padding tokens are the house style.
β¦and **every `max-width` rule that only shrinks something gets deleted**, because the
desktop override now does that job in one place.
**Why this is safe for the desktop:** the `min-width: 769px` block restores today's exact
values. A rule that reads `font-size: var(--fs-sm)` resolves to `13px` on a desktop, which
is what `.btn` says today. Desktop output is unchanged by construction, and Β§8 proves it.
---
## 5. The four patterns
Four rules cover almost every Feld screen. Anything not covered by them is a genuine design
question, not a mechanical conversion.
### 5.1 The touch floor
Every interactive element gets `min-height: var(--tap)`. No exceptions on Feld screens; on
BΓΌro screens `var(--tap-sm)`. Icon-only buttons get `min-width` too β a 24px icon in a 56px
box, not a 24px hit area.
### 5.2 One column, one primary action
Below 769px every grid collapses (`.g2`β¦`.g5` already do at 480px β move it up to 769 and
delete the 480 rule). Each screen names exactly **one** primary action and puts it in the
bottom third, in thumb reach, full width. Secondary actions go below the fold or into an
overflow. Today several screens present four equal-weight buttons in a row that wraps.
### 5.3 Drill-down instead of tab strips
Thirteen pills in a horizontal strip is the worst interaction in the app. The current
mitigations β wrap for work pages ([styles.css:3536](styles.css:3536)), sideways scroll with
a fade edge for Settings ([styles.css:5703](styles.css:5703)) β are both treating the
symptom.
Below 769px, a `.stabs` strip renders as a **full-width list**: label, count badge, chevron.
Choosing one swaps in that sub-page with a back row at the top. This is one CSS block plus
one small JS helper shared by all six strips β the strips are already wired through
`openStab()`, so the routing exists.
Desktop keeps the pills, untouched.
### 5.4 Table β card
The mechanism, chosen because it needs no change to how rows are built:
```css
@media (max-width: 768px) {
.t-cards thead { position: absolute; left: -9999px; } /* not display:none β keeps AT */
.t-cards tr {
display: block; padding: 12px; margin-bottom: 12px;
border: 1px solid var(--c-border); border-radius: var(--radius);
}
.t-cards td {
display: flex; justify-content: space-between; gap: 12px;
border: none; padding: 8px 0; font-size: var(--fs-sm);
}
.t-cards td::before { content: attr(data-l); font-weight: 600; color: var(--c-text-muted); }
}
```
Per-table cost: add `class="t-cards"` to the table and `data-l="β¦"` to each `| ` emitter,
once. The DOM shape, the JS, the sort/filter logic and the desktop rendering are all
untouched, and a ` | ` that is missed simply renders without its label rather than breaking.
29 tables. This is where most of the hours in this plan actually go β budget accordingly,
and see Β§7 for how they are sliced.
**There is prior art in the file already.** Four tables β including `#t-log` and
`#t-harvest` β have a hand-rolled card mode on mobile, using a `flex: 0 0 84px` label
column and a promoted date header. The pattern was invented, applied four times, and never
generalised. Read those rules before writing new ones; the generalisation should absorb
them rather than sit beside them.
The existing `overflow-x: auto` wrappers ([app.js:7567](app.js:7567), [app.js:10444](app.js:10444),
[app.js:11170](app.js:11170), [app.js:11505](app.js:11505), [app.js:11597](app.js:11597))
come out as each table converts. Sideways scroll inside a page is the thing being removed,
not the fallback.
---
## 6. The blocker: 549 inline font sizes
A token layer has no power over `style="font-size:10px"`. There are **549** of those at
β€13px across `index.html` and `app.js`, plus 1335 inline `style` attributes overall. This is
already a known wound β [styles.css:3463](styles.css:3463) carries the scar:
```css
/* !important is required to override inline style="font-size:12px" that several
compact filter/search inputs still carry from desktop layout work. */
input, select, textarea { font-size: 16px !important; }
```
Two moves, and they must both happen.
**The bridge (Phase 0, ships day one).** Generalise that existing workaround into one
deliberate, documented block: a narrow-viewport floor that lifts any inline size below
`--fs-xs`.
```css
@media (max-width: 768px) {
[style*='font-size:8px'], [style*='font-size:9px'],
[style*='font-size:10px'], [style*='font-size:11px'],
[style*='font-size:12px'] { font-size: var(--fs-xs) !important; }
}
```
This is a hack. It is written down as a hack, with an exit condition, and it buys legible
text across the entire app in one commit instead of in twenty.
**The real fix (folded into Phases 2β4).** As each page is touched, its inline sizes move
into classes. Not as one 549-site sweep β a sweep that large across 1.2 MB of source, with
no browser test to catch a regression, is exactly the change that breaks things invisibly.
Per page, reviewable, revertible.
**The exit.** The bridge block is deleted when the count reaches zero. The count is
ratcheted in CI (Β§8) so it can only ever go down β the same technique `npm run lint`
already uses with `--max-warnings 73`.
> **[corrected] β the count reached zero, and it was a sweep after all.**
>
> The paragraph above rules out the sweep for one stated reason: *no browser test to catch
> a regression*. That premise stopped being true when `scripts/measure-mobile.js` was
> built, and the sweep is what the premise had been protecting against, not the number of
> sites. What actually landed:
>
> - **index.html, 241 sites in one commit**, verified by capturing the computed font-size
> of every element in the document β 2528 of them β before and after. First run: 13 had
> changed. After doubling the class name: **0 changed, 2528 compared.**
> - **app.js, 254 sites in one commit**, where no census can reach markup that only exists
> at runtime. Verified instead by extracting every distinct before/after *shape* the diff
> produced β 36 of them, covering every tag, every prior class, and both card-mode
> tables β and rendering both forms in a real `.card` and a real `.t-cards` row at 1440px
> and 375px. **All 36 identical at both widths.**
>
> Two of the 254 were never inline: `app.js` writes a print window's ` |