styles.css
Rama main · réplica de lectura del repositorio original.
/* ═══════════════════════════════════════════════════════════════
Meistertracker — UI Redesign
═══════════════════════════════════════════════════════════════ */
/* -- CSS Variables -- */
:root {
/* One step down the same green, so a white label on it can be read.
#16a34a carries white at 3.30:1 — under the 4.5 normal text needs, on every
primary button, .stab.active and scan-tab count, and under 4.5 again at the
sixteen places this token is the text colour rather than the fill. The lab
reads this screen in a grow room, gloved, at arm's length, and 3.30 is the
kind of number you feel as squinting rather than see as a defect.
Not a new colour: #15803d was already --c-primary-hover, already on these
exact buttons, already past somebody's eye. Moving it from the hover state
to the resting state is the smallest change that clears AA — and it fixes
the odder half of the old arrangement, where the label only became legible
after you had committed to tapping it.
The hover step moves down with it to #166534, which is --c-green-dark's
value, so pressing still reads as a change. Nothing in the app puts dark
text on this colour — checked, all twenty-one background uses pair it with
white or carry no text — so there is nothing for the darkening to cost. */
--c-primary: #15803d;
--c-primary-light: #f0fdf4;
--c-primary-hover: #166534;
--c-bg: #f8fafc;
--c-surface: #ffffff;
--c-border: #e2e8f0;
--c-border-light: #f1f5f9;
--c-text: #1e293b;
--c-text-sec: #475569;
--c-text-muted: #64748b;
/* Two names this sheet has always used and never defined.
--c-accent arrived with the dashboard summary strip and spread to fifteen
places without ever getting a value; --c-card to two. An undefined custom
property is not "falls back to something sensible" — the declaration is
invalid at computed-value time, so an inherited property takes the parent's
value and everything else takes its initial one. That is why the substrate
progress bar had a transparent fill, why "open" — the commonest substrate
status — drew its pill in whatever colour it inherited, and why today's
date in the calendar looked like every other day.
They are aliases, not new colours: every site that reads them wants the
surface or the brand green this sheet already has a name for. Defining them
as aliases keeps them honest — change --c-primary and the accent follows. */
--c-accent: var(--c-primary);
--c-card: var(--c-surface);
/* The step this palette was missing.
Each family has light / border / accent / mid / dark. None of those is a
surface you can put white text on: the mid tones read 3.8:1 (red) and
2.2:1 (amber) against white, and the -dark tones are so heavy they read as
disabled on a small control. So every author who needed a filled button
picked a one-off — #dc2626 six times, #ea580c eight. That is not
carelessness, it is the palette being short a step, and the two guesses
did not land in the same place: #dc2626 clears 4.5:1, #ea580c does not.
So the step gets a name and one value per family, chosen from what the app
already contained: #dc2626 is kept as-is (4.83:1) and #b45309, which was
already here once, replaces #ea580c (3.56 -> 5.02). "Strong" means exactly
one thing — the tone that carries white text at 4.5:1. */
--c-red-strong: #dc2626;
--c-amber-strong: #b45309;
/* ── Production stage colours ─────────────────────────────────────────
The Chargen tiles paint their bar and their three stage figures with
these. They were referenced from app.js before they existed anywhere:
`background: var(--st-inc)` with no fallback is invalid at computed-value
time and background-color does not inherit, so every segment of the
proportion bar rendered transparent and the bar — the point of the tile —
was a flat grey pill on every Sorte.
Values are the muted zone palette (ZONE_SEED_COLOR in db.js), so a phase
looks the same whether it is a bar segment here, a badge in the Status
column or a dot on a rack. Grain is the slate end: it is the raw material,
not yet a mushroom, and it reads as the "not started" step. */
--st-spawn: #6b7a8c;
--st-inc: #4d829b;
--st-fruit: #438871;
/* Semantic color palette */
--c-red: #ef4444;
--c-red-light: #fee2e2;
--c-red-dark: #991b1b;
--c-red-border: #fecaca;
--c-red-accent: #fca5a5;
--c-blue: #3b82f6;
--c-blue-light: #dbeafe;
--c-blue-dark: #1e40af;
--c-blue-border: #bfdbfe;
--c-blue-accent: #93c5fd;
--c-green: #22c55e;
--c-green-light: #dcfce7;
--c-green-dark: #166534;
--c-green-border: #bbf7d0;
--c-green-accent: #86efac;
--c-amber: #f59e0b;
--c-amber-light: #fef3c7;
--c-amber-dark: #92400e;
--c-amber-border: #fde68a;
--c-amber-accent: #fcd34d;
--c-purple: #8b5cf6;
--c-purple-light: #f3e8ff;
--c-purple-dark: #6b21a8;
--c-purple-border: #e9d5ff;
--c-purple-accent: #c084fc;
--c-slate: #64748b;
--c-teal: #14b8a6;
--c-teal-light: #ccfbf1;
--c-teal-dark: #115e59;
--c-teal-border: #99f6e4;
--c-indigo: #6366f1;
--c-indigo-light: #e0e7ff;
--c-indigo-dark: #3730a3;
--c-indigo-border: #c7d2fe;
--sidebar-w: 240px;
--sidebar-collapsed: 64px;
--radius: 12px;
--radius-sm: 8px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
/* ── SIZE / SPACE / TYPE ─────────────────────────────────────────────────
These values are the PHONE. The desktop is the override below.
It used to run the other way: the base was the desktop and each phone rule
subtracted from it, which is how a 480 px viewport ended up with a smaller
primary button than a 27" monitor. Nobody decided that; it is just what you
get when "mobile" means "the same thing, tighter". The lab works standing,
gloved, one-handed, so the phone gets the generous end of every pair and
the desk has to ask for the tight one.
Rule for adding to this block: a token's :root value is what a phone should
get. If that is also what the desktop should get, it does not need a line
in the override. */
--fs-xs: 0.8125rem;
--fs-sm: 0.9375rem;
--fs-base: 1.0625rem;
/* 12px is the single most common inline size in this app — filter selects,
status lines, card blurbs, preview boxes. --fs-xs cannot absorb them: it is
11px on a desktop and would drop every one of them by a pixel. So it gets
its own pair, phone at the floor and desktop where it already was. */
--fs-meta: 0.8125rem;
/* And the same for 10px, the size id chips and timestamps were written at.
Three pairs cover 497 of the 497 inline sizes in this app; the six
fractional ones in between are named where they are used. */
--fs-micro: 0.8125rem;
/* The floor, as a sentinel rather than a size. 104 rules in this stylesheet
set a font-size below --fs-xs and serve both devices from that one number:
`.sec` at 12px, `.sb-group-label` at 10px, `.chip-l` at 9px. Every one of
those numbers is a considered DESKTOP value, so none of them may move.
`font-size: max(12px, var(--fs-min))` says both things at once. On a phone
the floor wins and the text is 13px; on a desk --fs-min is 0 and max() gives
back exactly the 12px that was written, byte for byte. The desktop cannot
move here — not "was checked and did not", cannot — because the value is
never replaced, only floored on the device that needs a floor.
The alternative was five paired tokens, one per distinct desktop value
(9, 10, 11.5, 12, 12.5). Rejected: five hand-written pairs is five chances
to type the wrong desktop number, and test/desktop-baseline.json watches 31
selectors out of the 104 rules — it is a tripwire, not a proof, and this is
exactly the shape of change it cannot see.
Same trick as --tap: auto below, and for the same reason — the desktop's
honest answer is "no constraint", not "a different constraint". */
--fs-min: 0.8125rem;
/* 44px is WCAG 2.5.5 AAA. 56 is the deliberate step past it for gloves — and
it is not a new number here: .bottom-nav-btn already chose 56px the one
time this codebase sat down and thought about a touch target. */
--tap: 56px;
--tap-sm: 48px;
/* The touch floor, as a sentinel — the --fs-min idea applied to height.
Rules all over the app already chose a minimum and chose it thoughtfully:
44px on the flow's footer buttons, 46px on its batch rows, 48px on its zone
buttons. Those are WCAG-correct numbers for a mouse and a considered
desktop layout, and replacing them would move three desktop values to buy
nothing.
min-height: max(46px, var(--tap-min));
reads as "46, and at least 56 in a hand". --tap itself cannot do this job:
its desktop value is `auto`, which is not a length, and max() with a
keyword is invalid. So this is the numeric twin — 0 on a desktop, which
computes identically to `auto` for min-height (the fixture records 0px for
every --tap consumer). */
--tap-min: 56px;
/* And the same sentinel for the other floor, because §9 chose two. --tap-min
is 56px for a gloved hand on a Feld screen; this is the 48px one, and the
rule for picking is which kind of screen the control lives on. Chrome that
rides along on every screen — the topbar bell, the drawer toggle, the undo
toast — takes this one: it clears the 44px minimum every guideline agrees
on without spending 56px of a phone's height on furniture. Having only the
Feld floor as a sentinel is what left that chrome at 26–36px through four
phases; there was nothing to floor it with that did not also make it huge. */
--tap-sm-min: 48px;
--pad-page: 16px;
--pad-card: 16px;
--pad-btn: 13px 18px;
--pad-stab: 11px 18px;
--pad-sb: 12px 14px;
--pad-modal: 16px 14px;
/* Where the floating chrome sits above the bottom edge. On a phone the 56px
bottom nav is in the way; on a desk there is no bar — same inversion as
everything above. Three pieces of floating chrome used to carry three
unrelated numbers here — a 72px shelf, the 24px .cam-fab was born with, and
an .undo-bar offset derived by hand from the second. One token now, and the
derivation stays visible. */
--fab-bottom: 72px;
/* The launcher's tiles. A pair rather than a max() floor, because this is not
a floor: 14.5px already clears --fs-min, and 17px on a phone is a decision
about what the first screen a worker meets should look like, not a rescue.
max() is for "never smaller than"; a pair is for "this size here, that size
there", and keeping the two apart is what stops either from becoming the
answer to everything. The padding grows with the type — the title gains
2.5px inside a box that would otherwise stay the same size. */
--fs-tile: 1.0625rem;
--pad-tile: 18px 18px 20px;
}
/* The desktop override restores today's values exactly — every number below was
read off the computed styles before the conversion, and test/desktop-baseline.json
plus scripts/capture-desktop-baseline.js exist to prove it stayed that way.
`hover: hover` is doing real work in this query, not decoration. The reason
for a big touch target is the input device, not the viewport width: a tablet
held in a gloved hand is 1024 px wide and still needs the phone's numbers.
Coarse-pointer devices therefore keep the :root values at any width, which is
also what the old `@media (pointer: coarse)` .sb-btn block was reaching for
before the tokens absorbed it. A touchscreen laptop reports hover: hover for
its mouse and lands on the desktop side, correctly.
`auto` for the tap tokens is not a placeholder — it means "intrinsic height,
exactly as before", so min-height contributes nothing on a desktop. */
/* …and print, which is neither a phone nor a desk but is much closer to the
desk. A printed page has no pointer at all, so `hover: hover` is false for
it and every token above resolved to the value chosen for a gloved hand:
13px floors on a barcode label, and `min-height: max(46px, var(--tap-min))`
putting 56px of white space under every control that reached paper. Nothing
read a token from inside @media print, so this was latent rather than
broken — until the inline sizes moved onto classes that do. */
@media (min-width: 769px) and (hover: hover), print {
:root {
--fs-xs: 0.6875rem;
--fs-sm: 0.8125rem;
--fs-base: 0.9375rem;
--fs-meta: 0.75rem;
--fs-micro: 0.625rem;
/* 0, so every max() in the sheet returns the literal beside it unchanged. */
--fs-min: 0px;
/* ⚠️ **24 und nicht `auto`** (Paket P1, Regel R1). `auto` hieß „keine
Untergrenze am Rechner", und das war die fehlende Hälfte der Achse: Die
grobe Seite ist hier seit den Marken richtig, weil sie an `hover: hover`
hängt und nicht an einer Breite, aber die feine Seite hatte gar keine
eigene Marke. 24 px sind WCAG 2.5.8 „Target Size (Minimum)", Stufe AA,
und die Stufe kennt kein Eingabegerät: Sortierknöpfe der Chargentabelle,
„Bearb." und „Drucken" in Sorten und Labor und „Wochenrhythmus
bearbeiten" mit 13 px verletzen sie mit der Maus genauso wie mit dem
Finger. Die 56 und 48 der Handseite bleiben davon unberührt; wo im Blatt
`max(44px, …)` steht, gewinnt weiter die 44. */
--tap: 24px;
--tap-sm: 24px;
--tap-min: 24px;
--tap-sm-min: 24px;
--pad-page: 24px;
--pad-card: 20px;
--pad-btn: 9px 16px;
--pad-stab: 7px 16px;
--pad-sb: 9px 12px;
--pad-modal: 28px;
/* No bottom nav here, so the chrome sits where it always did. Both FABs are
display:none on a desktop anyway; only .undo-bar reads this off-phone,
and its own rule keeps it out of the way. */
--fs-tile: 0.90625rem;
--pad-tile: 15px 15px 16px;
--fab-bottom: 24px;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
system-ui,
-apple-system,
'Segoe UI',
sans-serif;
background: var(--c-bg);
color: var(--c-text);
font-size: var(--fs-base);
line-height: 1.5;
/* body used to carry `overflow-x: hidden`, and its absence is deliberate.
It suppressed the sideways scrollbar, which is the one signal a person
would have noticed, and that made "no horizontal scroll" a claim nobody
could disprove: two mobile rounds asserted it and neither measured it.
Re-measured on 2026-08-22 with scripts/measure-mobile.js --app, both ways,
with the ratchet emptied so raw findings show: 46 touch, 4 overflow and
2 zoomed-out either way, and 14 after-a-jump without the rule against 13
with it. So it hid exactly ONE finding, and a jump one, not an overflow.
(The first version of this comment said two overflow findings, 96 against
98. That was counted while the outermost-offender filter was broken and
every nested child counted separately.)
What has not changed is the second half: the rule did not do the job it
was there for. The pages that widen their layout viewport on a phone widen
it with the rule in place, because that is the meta viewport and not a
scrollbar. test/breakpoint-coverage.test.js keeps it gone. */
}
/* ═══ SIDEBAR ═══════════════════════════════════════════════════ */
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: var(--sidebar-w);
background: var(--c-surface);
border-right: 1px solid var(--c-border);
display: flex;
flex-direction: column;
z-index: 120;
transition:
width 0.2s ease,
transform 0.2s ease;
overflow: hidden;
}
.sb-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 16px 12px;
border-bottom: 1px solid var(--c-border-light);
min-height: 56px;
}
.sb-logo {
display: flex;
align-items: center;
gap: 10px;
color: var(--c-primary);
flex-shrink: 0;
}
.sb-logo svg {
flex-shrink: 0;
}
.sb-logo-text {
font-size: 0.9375rem;
font-weight: 700;
color: var(--c-text);
white-space: nowrap;
}
.sb-logo-text small {
font-weight: 400;
color: var(--c-text-sec);
font-size: max(0.75rem, var(--fs-min));
}
.sb-toggle {
background: none;
border: none;
cursor: pointer;
color: var(--c-text-sec);
padding: 4px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
flex-shrink: 0;
/* 18px of icon in 4px of padding is a 26px target. The floor is a phone-only
0-on-desktop sentinel, so the header keeps its desktop geometry exactly. */
min-width: var(--tap-sm-min);
min-height: var(--tap-sm-min);
}
.sb-toggle:hover {
background: var(--c-border-light);
color: var(--c-text);
}
.sb-nav,
.sb-admin-nav {
flex: 1;
overflow-y: auto;
padding: 12px 10px;
}
.sb-group-label {
font-size: max(0.625rem, var(--fs-min));
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--c-text-muted);
padding: 12px 10px 6px;
}
.sb-group-label:first-child {
padding-top: 4px;
}
.sb-btn {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: var(--pad-sb);
min-height: var(--tap-sm);
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--c-text-sec);
font-size: var(--fs-sm);
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
position: relative;
}
.sb-btn svg {
flex-shrink: 0;
width: 18px;
height: 18px;
}
/* Touch-device sidebar items: bump to 48 px min-height + 15 px font so the
drawer (opened via the hamburger on mobile) has finger-friendly targets.
Defaults below stay at 38 px for desktop where a mouse cursor is precise. */
/* The size half of this block is gone — --tap-sm, --fs-sm and --pad-sb now say
the same thing for every coarse pointer, at any width. The icon is not a
token, so it stays. */
@media (pointer: coarse) {
.sb-btn svg {
width: 20px;
height: 20px;
}
}
.sb-btn:hover {
background: var(--c-border-light);
color: var(--c-text);
}
.sb-btn.active {
background: var(--c-primary-light);
color: var(--c-primary);
}
.sb-btn.active svg {
stroke: var(--c-primary);
}
.sb-btn.alert::after {
content: '';
position: absolute;
top: 8px;
right: 8px;
width: 7px;
height: 7px;
background: #ef4444;
border-radius: 50%;
}
.sb-footer {
border-top: 1px solid var(--c-border-light);
padding: 10px;
}
/* Admin navigation — on a desktop the Admin section borrows the sidebar.
Fourteen sub-tabs do not fit one horizontal strip: .stabs has neither wrap
nor scroll outside the mobile rules, so the pills squeeze until their labels
break. A vertical, grouped list has no such ceiling, and .sb-nav already
scrolls.
Desktop only, and the min-width is the whole point. The drawer used to swap
the same way and stay open on a phone, because closing it left the section
with nothing to navigate by. The strip is now a drill-down list on the page
itself, so a swapped drawer would lay the same thirteen entries over the
thirteen behind it — and take the screen with them. Below 769px the drawer
keeps the main navigation, which is what a phone opens it for. */
.sb-admin-nav {
display: none;
}
@media (min-width: 769px) {
body.admin-mode .sb-nav {
display: none;
}
body.admin-mode .sb-admin-nav {
display: block;
}
}
/* The footer button is the door, and one door needs one place: the same spot
leads in and back out again. Solid green while inside says which side of it
you are standing on, and the arrow replacing the gear says what pressing it
does — a gear that navigates away would be telling you the wrong thing. */
#n-settings .sb-ico-back {
display: none;
}
body.admin-mode #n-settings {
background: var(--c-primary);
color: #fff;
}
body.admin-mode #n-settings svg {
stroke: #fff;
}
body.admin-mode #n-settings .sb-ico-admin {
display: none;
}
body.admin-mode #n-settings .sb-ico-back {
display: block;
}
@media (min-width: 769px) {
body.admin-mode #p-settings > .stabs {
display: none;
}
}
.sb-sync {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
}
/* One line, always. The text under it changes every five seconds — "gerade
eben", then "vor 12 Min." — and wrapping to a second line made the row taller
and pushed the Admin button above it up by a line. A navigation button that
moves while nobody touches it is one the hand has to re-aim at, and it moved
on a timer. min-width: 0 is what lets a flex child shrink far enough to
ellipsize instead of forcing the row wider. */
.sb-sync-label {
font-size: max(0.6875rem, var(--fs-min));
color: var(--c-text-muted);
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sb-lang-sel {
width: auto;
font-size: max(0.6875rem, var(--fs-min));
padding: 3px 6px;
border: 1px solid var(--c-border);
border-radius: 6px;
background: var(--c-surface);
color: var(--c-text-sec);
cursor: pointer;
font-family: inherit;
}
/* Sidebar collapsed (desktop) */
.sidebar.sb-collapsed {
width: var(--sidebar-collapsed);
}
.sidebar.sb-collapsed .sb-logo-text,
.sidebar.sb-collapsed .sb-btn span,
.sidebar.sb-collapsed .sb-group-label,
.sidebar.sb-collapsed .sb-sync-label,
.sidebar.sb-collapsed .sb-lang-sel {
display: none;
}
.sidebar.sb-collapsed .sb-header {
justify-content: center;
padding: 16px 8px 12px;
}
.sidebar.sb-collapsed .sb-btn {
justify-content: center;
padding: 9px 0;
}
.sidebar.sb-collapsed .sb-sync {
justify-content: center;
padding: 8px 4px;
}
.sb-is-collapsed .content-wrap {
margin-left: var(--sidebar-collapsed);
}
/* Worktree warning banner — fixed at the very top of the viewport so it
floats over the sidebar + topbar; unmissable amber on dark text. Body
gets .has-worktree-banner when active to nudge layout down.
The [hidden] override below has higher specificity (0,2,0) than the
base .worktree-banner rule (0,1,0) — otherwise `display: flex` would
defeat the UA `[hidden] { display: none }` rule and the banner would
ship visible to every prod user. */
.worktree-banner[hidden] {
display: none;
}
.worktree-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
background: repeating-linear-gradient(45deg, #f59e0b 0 14px, #fbbf24 14px 28px);
color: #1f1300;
font-weight: 600;
font-size: 0.8125rem;
padding: 8px 16px;
padding-top: calc(8px + env(safe-area-inset-top, 0px));
padding-left: calc(16px + env(safe-area-inset-left, 0px));
padding-right: calc(16px + env(safe-area-inset-right, 0px));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border-bottom: 2px solid #b45309;
text-align: center;
line-height: 1.35;
}
.worktree-banner-icon {
font-size: 1.125rem;
line-height: 1;
}
.worktree-banner-text b {
font-weight: 800;
letter-spacing: 0.3px;
}
/* Push fixed-positioned chrome (sidebar, mobile topbar) down so the banner
doesn't visually cover them. content-wrap flows naturally and is shifted
via body padding-top. The banner text wraps on narrow viewports, so the
inline probe sets --worktree-banner-h to the actual height via
ResizeObserver; 38px is the single-line fallback for the first paint
before the script runs. */
body.has-worktree-banner {
padding-top: var(--worktree-banner-h, 38px);
}
body.has-worktree-banner .sidebar {
top: var(--worktree-banner-h, 38px);
}
body.has-worktree-banner .mobile-topbar {
top: var(--worktree-banner-h, 38px);
}
/* Mobile topbar */
.mobile-topbar {
display: none;
position: sticky;
top: 0;
z-index: 110;
background: var(--c-surface);
border-bottom: 1px solid var(--c-border);
padding: 10px 16px;
padding-top: calc(10px + env(safe-area-inset-top, 0px));
padding-left: calc(16px + env(safe-area-inset-left, 0px));
padding-right: calc(16px + env(safe-area-inset-right, 0px));
align-items: center;
gap: 12px;
}
.sb-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 115;
opacity: 0;
transition: opacity 0.2s;
}
.sb-overlay.sb-show {
display: block;
opacity: 1;
}
/* The drawer is a phone arrangement and so is its overlay, but this class knew
nothing about width: a drawer opened at 760px and a window then widened left
the sheet lying over the desktop, where nothing offered to take it off. app.js
now clears the class on the crossing, and this is the second lock on the same
door. It is worth having twice, because the service worker can be serving a
cached app.js from before that fix while this stylesheet is already new. */
@media (min-width: 769px) {
.sb-overlay.sb-show {
display: none;
}
}
/* The scan button sits at z-index 850, far above the sidebar's 120, so with the
drawer open it floats over the drawer's footer and buries the Admin entry and
the language picker. The drawer is a modal surface; the button does not act on
it, and the overlay behind it is already swallowing taps. */
body.sb-mobile-open .cam-fab {
display: none;
}
/* Same argument, one layer up: a dialog's backdrop is z-index 200 against the
button's 850, so it floats over all 25 of them — and not merely visibly.
elementFromPoint at its centre returns the button while a dialog is open, so
a thumb aiming at the bottom of a form opens the scanner instead. Found while
measuring the guided flow at 375px, where it overlapped the modal's corner.
:has() rather than a body class from JS, because every dialog in this app
opens the same way — .modal-bg gains .open — so one selector covers all of
them and the next one too. The pattern is already in the sheet
(.notif-fab:has(.bell-badge:not([hidden]))). */
body:has(.modal-bg.open) .cam-fab {
display: none;
}
/* ═══ CONTENT WRAP ══════════════════════════════════════════════ */
.content-wrap {
margin-left: var(--sidebar-w);
min-height: 100vh;
min-height: 100dvh;
transition: margin-left 0.2s ease;
}
/* ═══ SCAN MODAL (centered overlay) ════════════════════════════ */
.scan-overlay {
position: fixed;
inset: 0;
z-index: 900;
background: rgba(34, 120, 60, 0.35);
display: none;
align-items: center;
justify-content: center;
transition: background 0.3s ease;
}
.scan-overlay.open {
display: flex;
}
.scan-overlay.scan-bg-ok {
background: rgba(34, 120, 60, 0.35);
}
.scan-overlay.scan-bg-err {
background: rgba(180, 30, 30, 0.4);
}
.scan-modal {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: 12px;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.22);
padding: 0;
width: 80vw;
height: 80vh;
max-width: 1000px;
max-height: 80vh;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
transition: box-shadow 0.25s ease;
}
.scan-overlay.scan-bg-ok .scan-modal {
box-shadow:
0 12px 48px rgba(0, 0, 0, 0.22),
0 0 0 3px rgba(34, 197, 94, 0.55);
}
.scan-overlay.scan-bg-err .scan-modal {
box-shadow:
0 12px 48px rgba(0, 0, 0, 0.22),
0 0 0 3px rgba(239, 68, 68, 0.6);
}
.scan-modal-close {
position: absolute;
top: 4px;
right: 4px;
background: none;
border: none;
font-size: 1.75rem;
cursor: pointer;
color: var(--c-text-muted);
line-height: 1;
width: max(44px, var(--tap-min));
height: max(44px, var(--tap-min));
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
transition: background 0.15s;
z-index: 2;
}
.scan-modal-close:hover {
background: rgba(0, 0, 0, 0.06);
color: var(--c-text);
}
/* Icon close button used in modal headers (bag-info, etc). 44px touch target,
inherits modal text colour, neutral hover. Used in place of the text-pill
close that workers struggled to hit one-handed on a phone. */
.modal-close-icon {
width: max(44px, var(--tap-min));
height: max(44px, var(--tap-min));
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--c-text-muted);
padding: 0;
margin: -8px -8px -8px 0;
transition: background 0.15s;
-webkit-tap-highlight-color: transparent;
}
.modal-close-icon:hover {
background: rgba(0, 0, 0, 0.06);
color: var(--c-text);
}
.modal-close-icon:active {
background: rgba(0, 0, 0, 0.12);
}
.scan-modal-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 1rem;
font-weight: 700;
color: var(--c-text);
padding: 16px 20px 12px;
border-bottom: 1px solid var(--c-border);
transition:
background 0.2s,
border-color 0.2s;
}
/* Action-colored header bands */
.scan-modal.scan-action-add .scan-modal-header {
background: #f0fdf4;
border-bottom-color: #86efac;
}
.scan-modal.scan-action-move .scan-modal-header {
background: #eff6ff;
border-bottom-color: #93c5fd;
}
.scan-modal.scan-action-move_batch .scan-modal-header {
background: #eef2ff;
border-bottom-color: #a5b4fc;
}
.scan-modal.scan-action-remove .scan-modal-header {
background: #fef2f2;
border-bottom-color: #fca5a5;
}
.scan-modal.scan-action-harvest .scan-modal-header {
background: #fffbeb;
border-bottom-color: #fcd34d;
}
.scan-modal-tabs {
display: flex;
gap: 2px;
padding: 0 12px;
background: var(--c-bg);
border-bottom: 1px solid var(--c-border);
flex-shrink: 0;
}
.scan-tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
padding: 10px 16px;
/* --fs-sm's desktop value is 13px, exactly what this had, and --tap resolves
to auto there — so both of these are phone-only in effect. The fixture
records fontSize 13px / minHeight 0px for .scan-tab and still does. */
min-height: var(--tap);
font-size: var(--fs-sm);
font-weight: 600;
color: var(--c-text-muted);
cursor: pointer;
font-family: inherit;
transition:
color 0.15s,
border-color 0.15s,
background 0.15s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.scan-tab:hover {
color: var(--c-text);
background: rgba(0, 0, 0, 0.03);
}
.scan-tab.active {
color: var(--c-primary);
border-bottom-color: var(--c-primary);
}
.scan-tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--c-border);
color: var(--c-text-muted);
font-size: max(0.625rem, var(--fs-min));
font-weight: 700;
}
.scan-tab.active .scan-tab-count {
background: var(--c-primary);
color: #fff;
}
.scan-modal-body {
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
overflow: hidden;
min-height: 0;
}
.scan-tab-panel {
display: none;
flex: 1;
flex-direction: column;
gap: 12px;
min-height: 0;
overflow: hidden;
}
.scan-tab-panel.active {
display: flex;
}
.scan-successes-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding-right: 4px;
}
.scan-successes-empty {
padding: 40px 20px;
text-align: center;
color: var(--c-text-muted);
font-size: 0.8125rem;
font-style: italic;
}
.scan-successes-list:not(:empty) + .scan-successes-empty,
.scan-successes-list:not(:empty) ~ .scan-successes-empty {
display: none;
}
.scan-success-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: var(--c-bg);
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
font-size: max(0.75rem, var(--fs-min));
}
.scan-success-time {
color: var(--c-text-muted);
font-family: ui-monospace, 'Cascadia Code', monospace;
font-size: max(0.6875rem, var(--fs-min));
flex-shrink: 0;
}
.scan-success-body {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scan-success-body b {
font-family: ui-monospace, 'Cascadia Code', monospace;
}
.scan-success-loc {
color: var(--c-text-muted);
font-size: max(0.6875rem, var(--fs-min));
}
.scan-success-undo {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
cursor: pointer;
/* 4px of vertical padding and nothing else: the target was as tall as its
own text. This is the button that takes back a wrong scan, and it appears
on a flash that is already going away. */
min-height: var(--tap);
min-width: var(--tap);
padding: 4px 10px;
font-size: max(0.75rem, var(--fs-min));
color: var(--c-text-muted);
flex-shrink: 0;
transition: all 0.15s;
}
.scan-success-undo:hover {
background: #fee2e2;
color: #991b1b;
border-color: #fca5a5;
}
.scan-modal-chips {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
gap: 6px;
}
.chip {
background: var(--c-bg);
border-radius: var(--radius-sm);
padding: 6px 10px;
text-align: center;
min-width: 50px;
transition:
background 0.2s,
box-shadow 0.2s;
}
.chip-l {
font-size: max(0.5625rem, var(--fs-min));
color: var(--c-text-muted);
line-height: 1;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.chip-v {
font-size: 0.8125rem;
font-weight: 700;
color: var(--c-text);
margin-top: 2px;
}
.chip-last-scan {
background: #f0f9ff;
border: 1px solid #bfdbfe;
}
.chip-last-scan .chip-v {
font-family: ui-monospace, 'Cascadia Code', monospace;
font-size: max(0.6875rem, var(--fs-min));
}
/* Chip pulse animation for missing input hints */
.chip-pulse {
animation: chip-pulse 1.5s ease-in-out infinite;
}
@keyframes chip-pulse {
0%,
100% {
box-shadow: none;
}
50% {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
}
}
.scan-modal-btns {
display: flex;
gap: 6px;
justify-content: flex-end;
padding: 12px 20px;
border-top: 1px solid var(--c-border);
}
.btn-xs {
padding: 6px 12px;
font-size: max(0.75rem, var(--fs-min));
font-weight: 500;
border-radius: var(--radius-sm);
cursor: pointer;
border: 1px solid var(--c-border);
background: var(--c-surface);
font-family: inherit;
white-space: nowrap;
transition: all 0.15s;
display: inline-flex;
align-items: center;
gap: 4px;
}
.btn-xs:hover {
background: var(--c-bg);
}
.btn-xs.green {
background: var(--c-primary-light);
color: #166534;
border-color: #bbf7d0;
}
.btn-xs.green:hover {
background: #dcfce7;
}
.btn-end-session {
background: #fef3c7;
color: #92400e;
border-color: #fcd34d;
}
.btn-end-session:hover {
background: #fde68a;
}
/* -- Scan toast (inline in modal) -- */
.scan-toast-inline {
padding: 10px 14px;
border-radius: var(--radius);
font-size: 0.9375rem;
font-weight: 600;
font-family: ui-monospace, 'Cascadia Code', monospace;
text-align: center;
word-break: break-word;
display: none;
transition: opacity 0.2s;
}
.scan-toast-inline.visible {
display: block;
}
.scan-toast-inline.fb-ok {
background: #dcfce7;
color: #166534;
border: 2px solid #86efac;
}
.scan-toast-inline.fb-err {
background: #fee2e2;
color: #991b1b;
border: 2px solid #fca5a5;
}
.scan-toast-inline.fb-info {
background: var(--c-surface);
color: var(--c-text);
border: 2px solid var(--c-border);
}
.scan-toast-inline.fb-warn {
background: #fef9c3;
color: #854d0e;
border: 2px solid #fde047;
}
.scan-toast-inline.fb-harvest {
background: #fef3c7;
color: #92400e;
border: 2px solid #fcd34d;
}
/* -- Scan log inside modal -- */
.scan-modal-log {
flex: 1;
min-height: 120px;
overflow-y: auto;
font-size: max(0.6875rem, var(--fs-min));
font-family: ui-monospace, 'Cascadia Code', monospace;
display: flex;
flex-direction: column-reverse;
gap: 3px;
}
.scan-modal-log:empty {
display: none;
}
.scan-log-entry {
padding: 6px 10px;
border-radius: var(--radius-sm);
background: var(--c-bg);
color: var(--c-text-muted);
display: flex;
align-items: center;
gap: 8px;
position: relative;
}
.scan-log-entry.log-ok {
border-left: 3px solid #86efac;
}
.scan-log-entry.log-err {
border-left: 3px solid #fca5a5;
}
.scan-log-entry.log-info {
border-left: 3px solid var(--c-border);
}
.scan-log-entry.log-harvest {
border-left: 3px solid #fcd34d;
}
.scan-log-entry .sle-time {
color: var(--c-text-muted);
font-size: max(0.625rem, var(--fs-min));
flex-shrink: 0;
}
.scan-log-entry .sle-msg {
flex: 1;
}
.scan-log-entry .sle-undo {
display: none;
background: none;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
padding: 2px 6px;
font-size: max(0.625rem, var(--fs-min));
cursor: pointer;
color: var(--c-text-muted);
flex-shrink: 0;
transition: all 0.15s;
}
.scan-log-entry:hover .sle-undo {
display: inline-flex;
}
.scan-log-entry .sle-undo:hover {
background: #fee2e2;
color: #991b1b;
border-color: #fca5a5;
}
/* Touch devices have no :hover — always show the undo button and bump it to a
real tap target so workers can undo a mis-scan from the phone. It said that
and then gave it 36px and 12px, both below the floors, on the one control
that exists to correct a mistake made while scanning. */
@media (pointer: coarse) {
.scan-log-entry .sle-undo {
display: inline-flex;
min-height: var(--tap);
padding: 8px 12px;
font-size: var(--fs-sm);
}
}
/* -- Scan session summary -- */
.scan-session-summary {
padding: 16px;
border-radius: var(--radius);
background: #f8fafc;
border: 1px solid var(--c-border);
}
.scan-session-summary h3 {
font-size: 0.875rem;
font-weight: 700;
margin: 0 0 12px;
}
.scan-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.scan-summary-stat {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
padding: 8px 10px;
text-align: center;
}
.scan-summary-stat .ss-num {
font-size: 1.25rem;
font-weight: 700;
color: var(--c-text);
}
.scan-summary-stat .ss-lbl {
font-size: max(0.625rem, var(--fs-min));
color: var(--c-text-muted);
text-transform: uppercase;
}
.scan-summary-batches {
font-size: max(0.75rem, var(--fs-min));
color: var(--c-text-muted);
margin-bottom: 12px;
}
.scan-summary-batches span {
display: inline-flex;
align-items: center;
gap: 3px;
margin-right: 8px;
}
.scan-summary-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
/* -- Count bump animation -- */
@keyframes count-bump {
0% {
transform: scale(1);
}
50% {
transform: scale(1.35);
}
100% {
transform: scale(1);
}
}
.count-bump .chip-v {
animation: count-bump 0.3s ease;
}
/* ═══ CAMERA SCAN MODAL ══════════════════════════════════════
Wrapper inside #m-camscan that holds the cam-reader frame + HUD.
Centred on desktop. On phones we anchor to the top so the camera
sits in the upper third of the screen — workers can present a bag
to the camera with one hand while the other hand reaches the
bottom-area HUD buttons (audit Section 1.4 single-handed-reach
recommendation). */
.cam-scan-wrap {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px;
}
@media (pointer: coarse) {
.cam-scan-wrap {
justify-content: flex-start;
padding-top: 24px;
}
}
/* ═══ CAMERA SCAN HUD (continuous scanning overlay) ══════════ */
.cam-hud {
width: 100%;
max-width: 500px;
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.cam-hud-toast {
width: 100%;
text-align: center;
font-size: 0.8125rem;
font-weight: 600;
padding: 0;
max-height: 0;
overflow: hidden;
border-radius: 8px;
transition:
max-height 0.25s ease,
padding 0.25s ease,
opacity 0.25s ease;
opacity: 0;
}
/* Platz für drei Zeilen. Eine Fehlermeldung sagt jetzt, was los ist UND was zu
tun ist, und das passt nicht in die zwei Zeilen, die 60px hergaben — der Rest
wurde abgeschnitten, und abgeschnitten war immer der Teil mit dem Ausweg. */
.cam-hud-toast.visible {
max-height: 96px;
padding: 8px 14px;
opacity: 1;
}
.cam-hud-toast.ht-ok {
background: #166534;
color: #bbf7d0;
}
.cam-hud-toast.ht-err {
background: #991b1b;
color: #fecaca;
}
.cam-hud-toast.ht-warn {
background: #854d0e;
color: #fef08a;
}
.cam-hud-toast.ht-info {
background: #1e3a5f;
color: #bfdbfe;
}
.cam-hud-toast.ht-harvest {
background: #78350f;
color: #fde68a;
}
.cam-hud-chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: center;
align-items: center;
}
.cam-chip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: max(0.75rem, var(--fs-min));
font-weight: 600;
padding: 4px 10px;
border-radius: 99px;
background: rgba(255, 255, 255, 0.15);
color: #fff;
font-family: var(--ff-mono, monospace);
transition:
background 0.2s,
transform 0.15s;
}
.cam-chip-l {
font-size: max(0.625rem, var(--fs-min));
font-weight: 400;
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.cam-chip-arrow {
padding: 4px 2px;
opacity: 0.4;
font-size: 0.875rem;
}
.cam-chip.ch-set {
background: rgba(255, 255, 255, 0.25);
}
.cam-chip.ch-pulse {
animation: cam-chip-pulse 1.2s ease-in-out infinite;
}
@keyframes cam-chip-pulse {
0%,
100% {
background: rgba(255, 255, 255, 0.15);
}
50% {
background: rgba(255, 255, 255, 0.35);
}
}
/* Action-specific chip colors */
.cam-chip.ch-add {
background: rgba(34, 197, 94, 0.4);
}
.cam-chip.ch-move {
background: rgba(59, 130, 246, 0.4);
}
.cam-chip.ch-remove {
background: rgba(239, 68, 68, 0.4);
}
.cam-chip.ch-harvest {
background: rgba(245, 158, 11, 0.4);
}
.cam-hud-btns {
display: flex;
gap: 10px;
align-items: center;
}
.cam-hud-btn {
display: flex;
align-items: center;
justify-content: center;
width: max(44px, var(--tap-min));
height: max(44px, var(--tap-min));
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.15);
color: #fff;
cursor: pointer;
transition: background 0.15s;
-webkit-tap-highlight-color: transparent;
}
.cam-hud-btn:active {
background: rgba(255, 255, 255, 0.3);
}
/* Ohne diese Zeile wirkte `hidden` hier nicht: die display-Angabe oben ist eine
Autoren-Regel und schlägt die [hidden]-Regel des Browsers (dasselbe wie bei
.btn[hidden]). Die Taschenlampe stand darum auf jedem Telefon da, auch auf
denen ohne Lampe, wo ein Tipp darauf nichts tut. */
.cam-hud-btn[hidden] {
display: none;
}
/* Torch toggle — visible glow when the flashlight is on so the worker doesn't
leave the camera flash burning unintentionally after a scan. */
.cam-hud-btn.torch-on {
background: rgba(250, 204, 21, 0.45);
color: #fef9c3;
box-shadow: 0 0 16px rgba(250, 204, 21, 0.5);
}
.cam-hud-btn.torch-on:active {
background: rgba(250, 204, 21, 0.6);
}
/* Die Serien-Vorgänge als Kacheln: Umziehen, Ernten, Kontamination, Entsorgen,
Verbraucht. Ein Tipp, dann Beutel für Beutel scannen — so einfach muss es
sein, sonst benutzt es niemand. Sie tun etwas anderes als Lampe und
Rückgängig in der Reihe darunter: sie ändern, was der nächste Scan tut, und
das muss man lesen können, ohne es zu kennen. Darum mit Wort, und darum nicht
in der Werkzeugreihe — dort war schon für sechs runde Knöpfe zu 56px kein
Platz (386px auf 358px). Zwei Kacheln je Reihe, damit auch "Kontamination"
ganz dasteht.
Das Raster hat vier Spalten, und jede Kachel nimmt zwei: so steht die fünfte
allein in der dritten Reihe mittig und so breit wie die anderen, statt links
mit einem Loch daneben oder als Balken über die ganze Breite. */
.cam-hud-modes {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
width: 100%;
max-width: 380px;
}
.cam-hud-modes > .cam-mode-btn {
grid-column: span 2;
}
.cam-hud-modes > .cam-mode-btn:last-child:nth-child(odd) {
grid-column: 2 / span 2;
}
.cam-mode-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 0;
min-height: max(44px, var(--tap-min));
padding: 0 12px;
border: none;
border-radius: 14px;
background: rgba(255, 255, 255, 0.15);
color: #fff;
font-family: inherit;
font-size: max(0.875rem, var(--fs-min));
font-weight: 600;
cursor: pointer;
transition:
background 0.15s,
box-shadow 0.15s;
-webkit-tap-highlight-color: transparent;
}
.cam-mode-btn[hidden],
.cam-mode-btn [hidden] {
display: none;
}
.cam-mode-btn svg {
flex: none;
}
.cam-mode-btn:active {
background: rgba(255, 255, 255, 0.3);
}
/* Ein langer Zielname wird gekürzt statt umgebrochen; ganz steht er in der
Meldung nach jedem Scan. */
.cam-mode-l {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* An leuchtet jeder Knopf in seiner Farbe. Ein Vorgang, der unbemerkt
weiterläuft, gehorcht dem nächsten Scan stumm — das ist bei der Lampe so und
hier erst recht, also muss "an" von weitem zu sehen sein. */
.cam-mode-btn.cam-mode-move.on {
background: #1d4ed8;
color: #fff;
box-shadow: 0 0 16px rgba(37, 99, 235, 0.6);
}
.cam-mode-btn.cam-mode-move.on:active {
background: #1e40af;
}
.cam-mode-btn.cam-mode-harvest.on {
background: var(--c-amber-strong);
color: #fff;
box-shadow: 0 0 16px rgba(217, 119, 6, 0.6);
}
/* CONTAM toggle. Armed, every bag scanned opens a contamination report, so it
has to be as obvious as the torch that it is on — the failure mode is the
same shape: a mode left running that the next scan silently obeys. */
.cam-mode-btn.cam-mode-contam.on {
background: rgba(220, 38, 38, 0.55);
color: #fff;
box-shadow: 0 0 16px rgba(220, 38, 38, 0.55);
}
.cam-mode-btn.cam-mode-contam.on:active {
background: rgba(220, 38, 38, 0.7);
}
/* Raus hell auf dunkel: eine vierte Farbe wäre zu nah am Rot der
Kontamination, und die zwei darf man nicht verwechseln. */
.cam-mode-btn.cam-mode-trash.on {
background: #f5f5f4;
color: #1c1917;
box-shadow: 0 0 16px rgba(255, 255, 255, 0.45);
}
.cam-mode-move .cam-hud-badge {
color: #1d4ed8;
}
.cam-mode-harvest .cam-hud-badge {
color: var(--c-amber-strong);
}
.cam-mode-trash .cam-hud-badge {
background: #1c1917;
color: #fff;
}
/* Zähler auf den Serien-Knöpfen: wieviele Beutel der laufende Durchgang schon
hat (bei der Kontamination: gesammelt). Die Zahl sitzt als eigene Scheibe an
der Ecke. */
.cam-hud-badge {
position: absolute;
top: -2px;
right: -2px;
min-width: 20px;
height: 20px;
padding: 0 5px;
border-radius: 10px;
background: #fff;
color: #b91c1c;
font-size: 0.75rem;
font-weight: 700;
line-height: 20px;
text-align: center;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
pointer-events: none;
}
.cam-hud-badge[hidden] {
display: none;
}
.cam-hud-done {
background: #16a34a;
}
.cam-hud-done:active {
background: #15803d;
}
/* Camera button in scan modal */
.btn-scan-cam {
display: none;
}
@media (pointer: coarse) and (hover: none) {
.btn-scan-cam {
display: inline-flex;
align-items: center;
gap: 4px;
}
}
/* ═══ CAMERA FAB (mobile/tablet only) ════════════════════════ */
/* Hidden by default on desktop (pointer:fine = mouse) */
.cam-fab {
display: none;
position: fixed;
bottom: calc(var(--fab-bottom) + env(safe-area-inset-bottom, 0px));
right: calc(24px + env(safe-area-inset-right, 0px));
z-index: 850;
gap: 9px;
height: 56px;
padding: 0 22px;
border-radius: 28px;
border: none;
background: var(--c-primary, #16a34a);
color: #fff;
font-size: 1rem;
font-weight: 600;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
cursor: pointer;
align-items: center;
justify-content: center;
transition:
transform 0.15s,
box-shadow 0.15s;
-webkit-tap-highlight-color: transparent;
}
.cam-fab-label {
white-space: nowrap;
}
.cam-fab:active {
transform: scale(0.92);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
/* Show only on touch devices (coarse pointer = finger/stylus, no hover = no mouse) */
@media (pointer: coarse) and (hover: none) {
.cam-fab {
display: flex;
}
}
/* -- Harvest panel -- */
#harvest-panel {
padding: 12px 24px;
background: #fffbeb;
border-bottom: 1px solid #fde68a;
display: none;
}
#harvest-panel .inner {
max-width: 980px;
margin: 0 auto;
display: flex;
gap: 12px;
align-items: flex-end;
flex-wrap: wrap;
}
/* -- Blockbuch: Gut-Knopf, Notizen, Liste -- */
.block-good {
display: inline-flex;
align-items: center;
gap: 6px;
}
.block-good[hidden] {
display: none;
}
.block-good[aria-pressed='true'] {
background: var(--c-amber-light);
border-color: var(--c-amber-dark);
color: var(--c-amber-dark);
font-weight: 600;
}
.block-good[aria-pressed='true'] svg {
fill: currentColor;
}
#bi-good {
margin-left: auto;
margin-right: 8px;
}
.bb-bi-notes {
margin-top: 4px;
}
.bb-bi-notes textarea {
width: 100%;
margin-top: 6px;
resize: vertical;
}
.bb-bi-save {
margin-top: 6px;
}
.bb-note {
padding: 6px 0;
border-bottom: 1px solid var(--c-border);
}
.bb-note:last-child {
border-bottom: 0;
}
.bb-note-text {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.bb-note-meta {
display: flex;
align-items: center;
gap: 6px;
color: var(--c-text-muted);
margin-top: 2px;
}
.bb-note-del {
margin-left: auto;
min-width: var(--tap);
border: 0;
background: transparent;
color: var(--c-text-muted);
cursor: pointer;
font-size: 1.125rem;
line-height: 1;
}
.bb-entry,
.bb-sum {
padding: 10px 0;
border-bottom: 1px solid var(--c-border);
}
.bb-entry:last-child,
.bb-sum:last-child {
border-bottom: 0;
}
.bb-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 10px;
margin-bottom: 4px;
}
.bb-harvest {
margin-left: auto;
color: var(--c-amber-dark);
}
.bb-star {
fill: var(--c-amber-dark);
}
.bb-line {
margin-top: 2px;
overflow-wrap: anywhere;
}
.bb-mono {
font-family: monospace;
}
.bb-muted {
color: var(--c-text-muted);
}
.bb-notes {
margin-top: 6px;
padding-left: 10px;
border-left: 2px solid var(--c-amber-border);
}
.bb-sum-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 8px;
}
/* ═══ LAYOUT ════════════════════════════════════════════════════ */
.main {
padding: var(--pad-page);
max-width: 1400px;
margin: 0 auto;
/* Regel R2: der Kasten misst, nicht das Fenster.
Alles, was in dieser Spalte steht, kann von hier ab nach IHRER Breite
fragen statt nach der des Fensters — und das sind zwei verschiedene
Zahlen, sobald die Seitenleiste da ist: bei 769 px Fenster bleiben
769 − 240 − 48 = 481 px übrig. Genau diese Lücke war die Klippe bei
769 px, an der zwei Ratschenzeilen hingen.
Der Name ist nötig und nicht Zierde: ohne ihn fragt eine
Container-Abfrage den NÄCHSTEN Vorfahren mit container-type, und wer
später einen zweiten Kasten dazwischensetzt, verschiebt lautlos, worauf
sich jede dieser Regeln bezieht.
Was das sonst noch tut, ist gemessen und nicht angenommen:
`container-type` macht `.main` in keiner der beiden Maschinen zum
Bezugsrahmen für `position: fixed` (P0, maschinen/), und dass nichts
darin an einen anderen Platz rutscht, halten 142 Bilder vorher/nachher
aus `--shots`. */
container-type: inline-size;
container-name: inhalt;
}
.page {
display: none;
}
.page.active {
display: block;
}
.card {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius);
padding: var(--pad-card);
margin-bottom: 14px;
box-shadow: var(--shadow);
}
/* A .sec heading on the left, controls on the right — twenty-three of these in
index.html, all spelled out inline, and eighteen without a wrap. On a desktop
the wrap never fires because everything fits; at 375px the culture library's
two filters and its Check button came to 366px beside a 101px heading and
pushed the whole page to 467. Measured, not guessed: scripts/measure-mobile.js.
The tools group wraps too. Wrapping only the outer row moved the page from
467 to 399 and left it still overflowing, because the group by itself is
wider than the card's 311px content box.
Named for the heading it carries and not the card it sits in, because
`.card-head` is already the illustrated header the work tiles use — same
words, unrelated job, and two rules with one name would have fought over
display, padding and margin at a distance of 4500 lines. */
.sec-head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.sec-head .sec {
margin: 0;
}
.sec-head-tools {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
/* The size utilities, and why this app has three of them when it otherwise
names things after what they are.
§10 of MOBILE_REDESIGN.md draws the line: colours, widths and margins stay
inline, only the font sizes come out. That is not squeamishness — the size
is the one inline property that has a token, and it is the only one that was
wrong on a phone. So these three are the token layer reaching markup that a
stylesheet could not otherwise touch, not the start of a utility system.
Each is a pair: the floor in a hand, the number that was already there on a
desk. --fs-meta 13/12, --fs-xs 13/11, --fs-micro 13/10.
They are what lets the §6 bridge go. That block matches `[style*=
'font-size:11px']` and lifts it with !important, which works and costs an
attribute-substring match against every element in the document; a class
costs nothing and can be read in the markup.
The class name is doubled, and that is the load-bearing part. An inline
style outranks every normal rule, so moving one onto a plain class quietly
hands the element back to whatever component rule it was overriding. That
is not hypothetical: the first version of this measured thirteen elements
changing size at 1440px — four hints under `.modal p` going 12px to 14px,
six buttons under `.btn-sm` going 11px to 12px. Doubling reaches (0,2,0),
which outranks the descendant and component rules these replace.
!important would also have worked and is deliberately not used: the iOS
anti-zoom rule below is `input, select, textarea { font-size: 16px
!important }` at (0,0,3), and an important utility at (0,1,0) would beat it
— handing Safari a 13px field to zoom into on every form control that used
to carry an inline size. A doubled class loses to it, which is correct. */
/* A <canvas> with no width attribute is 300px wide, and that is wider than a
card's content box on a 320px phone — measured at 320, where two harvest
charts hang 13px off the edge and take the page to 333.
Honest about what that measurement is: the page it was taken on has its
scripts stripped, so Chart.js never ran, and Chart.js in responsive mode
sizes the canvas to its container itself. So this is the pre-initialisation
state rather than proof of a permanent defect. It is still worth a rule —
it covers the flash before the first draw, and any container Chart.js cannot
measure — and one line is cheaper than finding out which.
The one canvas that is not a chart — #pa-canvas, the photo annotation
surface — was going to be excluded from this, until a check showed it sets
max-width: 100% for itself two thousand lines further down. So there is
nothing to exclude, and the rule says the plain thing. */
canvas {
max-width: 100%;
}
/* Ein `<summary>` klappt auf und zu; es ist ein Bedienelement, auch wenn es wie
eine Beschriftung aussieht. Das von `#nb-sub-section` war 18 px hoch und
damit am Rechner unter den 24 px der WCAG 2.5.8. Der statische Lauf des
Messstands hat es gefunden, der laufende nie: dort steckt es in einem
zugeklappten Formular und wird nie gemessen. */
summary.fs-meta {
min-height: var(--tap-sm);
display: flex;
align-items: center;
}
/* The two steps at the top of the ladder, added on 2026-08-23 for the inline
sizes in app.js. 13px and 15px are the two commonest numbers there by a long
way -- 26 and 8 of the 44 -- and both are already a mark's desktop value, so
the class changes nothing on a desk and gives the phone the 15 and 17 the
token layer decided on. */
.fs-sm.fs-sm {
font-size: var(--fs-sm);
}
.fs-base.fs-base {
font-size: var(--fs-base);
}
.fs-meta.fs-meta {
font-size: var(--fs-meta);
}
.fs-xs.fs-xs {
font-size: var(--fs-xs);
}
.fs-micro.fs-micro {
font-size: var(--fs-micro);
}
/* And a fourth for the sizes that are not on the scale at all: 10.5px on a
fruiting target, 11.5px on a week strip, 9px on a bag chip, 8px on a bag
label. Sixteen of them across eight components, no two the same, none of
them a considered step — they are what somebody typed to make a row fit.
Rounding them to the nearest of the three above would have moved sixteen
desktop values by up to 2px to save a class, and "the desktop does not move"
is the promise this whole project rests on. So the number stays where it
was, on the element, as a custom property — and gains the floor through the
same max() the base rules use. `--fs-own: 10.5px` reads 10.5px on a desk and
13px in a hand, and the ratchet stops counting it because it is no longer a
font-size the cascade cannot reach. */
.fs-floor.fs-floor {
/* The fallback is not decoration. `var(--fs-own)` with nothing to resolve is
invalid at computed-value time, which does not fall back to the previous
declaration — it makes the element inherit, silently, at whatever size its
parent happens to be. A class whose whole job is to pin a size would then
do the opposite of its job. --fs-xs is the nearest thing to "the size you
probably meant", and an assertion in test/mobile-tokens.test.js fails if
any element wearing this class arrives without its number. */
font-size: max(var(--fs-own, var(--fs-xs)), var(--fs-min));
}
/* A filter that sizes to its own content rather than the form grid. */
.head-select {
width: auto;
font-size: var(--fs-meta);
padding: 4px 8px;
}
.sec {
font-size: max(0.75rem, var(--fs-min));
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--c-text-muted);
margin-bottom: 10px;
}
/* -- Sub-tabs -- */
.stabs {
display: flex;
gap: 4px;
margin-bottom: 14px;
border-bottom: 1px solid var(--c-border);
padding-bottom: 10px;
}
.stab {
padding: var(--pad-stab);
/* --tap-sm, not --tap: these are secondary navigation, and Phase 1 replaces
the strip with a drill-down list on a phone anyway. 56px pills would be
enormous for something on its way out. */
min-height: var(--tap-sm);
border-radius: 99px;
font-size: var(--fs-sm);
font-weight: 500;
cursor: pointer;
border: none;
background: transparent;
color: var(--c-text-sec);
font-family: inherit;
transition: all 0.15s;
}
.stab.active {
background: var(--c-primary);
color: #fff;
}
.stab:hover:not(.active) {
background: var(--c-border-light);
}
.sp {
display: none;
}
.sp.active {
display: block;
}
/* The back row belongs to the drill-down below; on a desktop there is nothing
to go back from, so it never appears. */
.stab-back {
display: none;
}
/* ── Sub-tabs on a phone: a drill-down, not a strip ────────────────────────
A row of pills has no good answer to "there are thirteen of them". The two
it had were both treatments of the symptom: wrap to a second line on the
work pages, sideways scroll with a fade edge on Admin — the first eats a
third of a short screen, the second hides the far end from anyone who does
not think to swipe a thing that does not look swipeable.
So below 769px the strip is the page's index: full width, one per line,
chevron on the right. Picking one shows that sub-page alone, with a back
row above it. `stab-drilled` on the .page element is which of the two
states the page is in; above 769px nothing reads the class and the pills
are untouched.
Landing goes straight to the sub-page the page defaults to, so the common
case — Chargen opens on the batch list — still costs no tap. The one page
that opens on its index instead falls out of the same rule rather than a
name: Admin's default tab is display:none, so there is no sub-page to land
on, and the list is what you get. */
/* zwilling: untertableiste */
@media (max-width: 768px) {
.stabs {
flex-direction: column;
gap: 0;
padding-bottom: 0;
margin-bottom: 12px;
border-bottom: none;
}
.stab {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
/* --tap, not --tap-sm: on a phone this is the page's navigation, which is
the argument the base rule makes for keeping it small in reverse. */
min-height: var(--tap);
border-radius: 0;
border-bottom: 1px solid var(--c-border-light);
text-align: left;
font-size: var(--fs-base);
color: var(--c-text);
}
/* A full-width green bar for "you were last here" would read as the page's
primary action. In a list, the mark is the label. */
.stab.active {
background: transparent;
color: var(--c-primary);
font-weight: 600;
}
.stab:hover:not(.active) {
background: transparent;
}
/* Drawn, not typed: a › from the font renders at a different size and
baseline in every fallback, and this one has to line up with the others. */
.stab::after {
content: '';
flex-shrink: 0;
width: 8px;
height: 8px;
margin-right: 4px;
border-right: 2px solid var(--c-text-muted);
border-bottom: 2px solid var(--c-text-muted);
transform: rotate(-45deg);
}
.stab.active::after {
border-color: var(--c-primary);
}
.page.stab-drilled > .stabs {
display: none;
}
.page.stab-drilled > .stab-back {
display: flex;
}
.stab-back {
align-items: center;
gap: 10px;
width: 100%;
min-height: var(--tap);
padding: 0 4px;
margin-bottom: 12px;
border: none;
border-bottom: 1px solid var(--c-border-light);
background: transparent;
color: var(--c-primary);
font-family: inherit;
font-size: var(--fs-sm);
font-weight: 600;
text-align: left;
cursor: pointer;
}
.stab-back-ico {
font-size: 1.25rem;
line-height: 1;
}
/* On the index there is no sub-page. The .active panel keeps its class, so
going back into it needs no state of its own — this rule outranks
`.sp.active` on specificity alone (0,4,0 against 0,2,0).
`.stab-drill` is not decoration: it is added by stabDrillInit(), so this
reaches only the pages that were given a back row — any page whose strip is
hidden is skipped there and would otherwise render blank on a phone, every
sub-page hidden and no index to reach them by. (Bestellungen was that page
until its five views stopped being sidebar entries.) It also means the whole
mechanism is inert if the script does not run, which is the right way round. */
.page.stab-drill:not(.stab-drilled) .sp {
display: none;
}
}
/* zwilling: untertableiste
Regel R2, dieselbe Rechnung wie beim Kartenmodus: 768 − 2×16 = 736. Die
Leiste wird zur Liste, sobald IHRE Spalte schmal ist, nicht wenn das
Fenster es ist. Der Kontaminationsstreifen des Labors hing zwischen 769
und 800 px bis zu 33 px über den Rand, weil er bei 769 px die Breite des
Fensters annahm, während die Seitenleiste ihm 240 px davon genommen hatte.
Zweimal geschrieben wie der Kartenmodus, und aus demselben Grund; ein Test
hält die beiden Fassungen gleich. */
@container inhalt (max-width: 736px) {
.stabs {
flex-direction: column;
gap: 0;
padding-bottom: 0;
margin-bottom: 12px;
border-bottom: none;
}
.stab {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
/* --tap, not --tap-sm: on a phone this is the page's navigation, which is
the argument the base rule makes for keeping it small in reverse. */
min-height: var(--tap);
border-radius: 0;
border-bottom: 1px solid var(--c-border-light);
text-align: left;
font-size: var(--fs-base);
color: var(--c-text);
}
/* A full-width green bar for "you were last here" would read as the page's
primary action. In a list, the mark is the label. */
.stab.active {
background: transparent;
color: var(--c-primary);
font-weight: 600;
}
.stab:hover:not(.active) {
background: transparent;
}
/* Drawn, not typed: a › from the font renders at a different size and
baseline in every fallback, and this one has to line up with the others. */
.stab::after {
content: '';
flex-shrink: 0;
width: 8px;
height: 8px;
margin-right: 4px;
border-right: 2px solid var(--c-text-muted);
border-bottom: 2px solid var(--c-text-muted);
transform: rotate(-45deg);
}
.stab.active::after {
border-color: var(--c-primary);
}
.page.stab-drilled > .stabs {
display: none;
}
.page.stab-drilled > .stab-back {
display: flex;
}
.stab-back {
align-items: center;
gap: 10px;
width: 100%;
min-height: var(--tap);
padding: 0 4px;
margin-bottom: 12px;
border: none;
border-bottom: 1px solid var(--c-border-light);
background: transparent;
color: var(--c-primary);
font-family: inherit;
font-size: var(--fs-sm);
font-weight: 600;
text-align: left;
cursor: pointer;
}
.stab-back-ico {
font-size: 1.25rem;
line-height: 1;
}
/* On the index there is no sub-page. The .active panel keeps its class, so
going back into it needs no state of its own — this rule outranks
`.sp.active` on specificity alone (0,4,0 against 0,2,0).
`.stab-drill` is not decoration: it is added by stabDrillInit(), so this
reaches only the pages that were given a back row — any page whose strip is
hidden is skipped there and would otherwise render blank on a phone, every
sub-page hidden and no index to reach them by. (Bestellungen was that page
until its five views stopped being sidebar entries.) It also means the whole
mechanism is inert if the script does not run, which is the right way round. */
.page.stab-drill:not(.stab-drilled) .sp {
display: none;
}
}
/* ═══ ORDER HUB ═════════════════════════════════════════════════ */
.oh-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--c-amber-light);
border: 1px solid var(--c-amber-border);
color: var(--c-amber-dark);
border-radius: var(--radius);
padding: 10px 14px;
margin-bottom: 12px;
font-size: 0.8125rem;
}
.oh-ch {
display: inline-block;
font-size: max(0.6875rem, var(--fs-min));
font-weight: 700;
border-radius: 6px;
padding: 1px 7px;
white-space: nowrap;
}
.oh-ch-wix {
background: var(--c-blue-light);
color: var(--c-blue-dark);
}
.oh-ch-etsy {
background: var(--c-amber-light);
color: var(--c-amber-dark);
}
.oh-ch-ebay {
background: var(--c-indigo-light);
color: var(--c-indigo-dark);
}
.oh-ch-manual {
background: var(--c-border-light);
color: var(--c-text-sec);
}
.oh-ch-shopify {
background: var(--c-green-light);
color: var(--c-green-dark);
}
.oh-st {
display: inline-block;
font-size: max(0.6875rem, var(--fs-min));
font-weight: 600;
border-radius: 999px;
padding: 2px 9px;
white-space: nowrap;
}
.oh-st-new {
background: var(--c-blue-light);
color: var(--c-blue-dark);
}
.oh-st-in_production {
background: var(--c-amber-light);
color: var(--c-amber-dark);
}
.oh-st-ready {
background: var(--c-green-light);
color: var(--c-green-dark);
}
.oh-st-shipped {
background: var(--c-border-light);
color: var(--c-text-muted);
}
.oh-st-cancelled {
background: var(--c-red-light);
color: var(--c-red-dark);
}
/* Overbooked pickup slot: the same red as a cancellation, under its own name.
Borrowing .oh-st-cancelled would put "cancelled" in the markup for something
that is the opposite of cancelled — more orders than the slot holds. */
.oh-st-over {
background: var(--c-red-light);
color: var(--c-red-dark);
}
.oh-fchip {
font-size: max(0.75rem, var(--fs-min));
font-weight: 600;
border: 1px solid var(--c-border);
background: var(--c-surface);
border-radius: 999px;
padding: 5px 12px;
cursor: pointer;
color: var(--c-text-sec);
/* 27.5px, and there are four of them in a row: the smallest targets left in
the app after the chrome pass. A pill's whole job is to be tapped. */
min-height: var(--tap-sm-min);
}
/* The worklist dialog's "group by" pair. Spelled out inline on both buttons,
nine declarations each and identical apart from the label, which is why
neither could pick up a touch floor: there was nothing to put it on. */
.wl-group-btn {
flex: 1;
padding: 9px 10px;
border: 1px solid var(--c-border);
border-radius: 9px;
background: transparent;
/* --fs-sm and not --fs-xs: the inline value was a flat 13px, and --fs-xs is
11px on a desktop, so the obvious-looking token would have shrunk it
there. --fs-sm is 13px on a desktop — unchanged — and 15px in a hand. */
font-size: var(--fs-sm);
font-weight: 600;
cursor: pointer;
min-height: var(--tap-sm-min);
}
.oh-fchip.active {
background: var(--c-primary);
border-color: var(--c-primary);
color: #fff;
}
/* Packen (Bestellungen → Packen): one card per parcel, read on a phone at the
packing table. Cards rather than table rows, so nothing scrolls sideways. */
.ob-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ob-count {
font-size: var(--fs-xs);
color: var(--c-text-muted);
white-space: nowrap;
}
.ob-hint {
margin-bottom: 8px;
}
.ob-sync {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 12px;
font-size: var(--fs-sm);
padding: 8px 12px;
margin-bottom: 12px;
border-radius: var(--radius);
background: var(--c-surface);
border: 1px solid var(--c-border-light);
}
.ob-sync.is-warn {
background: var(--c-amber-light);
border-color: var(--c-amber-border);
color: var(--c-amber-dark);
}
.ob-empty {
padding: 14px 4px;
color: var(--c-text-muted);
font-size: var(--fs-sm);
}
.ob-order {
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
background: var(--c-surface);
padding: 10px 12px;
margin-bottom: 8px;
}
.ob-order.is-packed {
background: var(--c-bg);
border-style: dashed;
}
.ob-order.is-packed .ob-items {
color: var(--c-text-muted);
}
.ob-customer {
font-weight: 700;
font-size: var(--fs-sm);
margin-bottom: 4px;
overflow-wrap: anywhere;
}
.ob-line-sku {
font-family: monospace;
font-weight: 600;
overflow-wrap: anywhere;
}
/* Alles zusammen: one row per product, the count big enough to read across a
table. */
.ob-total {
border-bottom: 1px solid var(--c-border-light);
padding: 8px 0;
}
.ob-total:last-child {
border-bottom: none;
}
.ob-total-top {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 10px;
}
.ob-total-n {
font-size: 1.5rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
min-width: 2ch;
}
.ob-total-name {
font-family: monospace;
font-weight: 700;
font-size: var(--fs-sm);
}
.ob-total-orders {
margin-left: auto;
font-size: var(--fs-xs);
color: var(--c-text-muted);
}
.ob-sizes {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.ob-size {
font-size: var(--fs-sm);
font-weight: 600;
border-radius: 999px;
padding: 2px 10px;
background: var(--c-primary-light);
color: var(--c-text);
}
.ob-total-skus {
margin-top: 4px;
font-family: monospace;
font-size: var(--fs-xs);
color: var(--c-text-muted);
overflow-wrap: anywhere;
}
.ob-order-top {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px 8px;
margin-bottom: 6px;
}
.ob-num {
font-family: monospace;
font-weight: 700;
font-size: var(--fs-sm);
}
.ob-when {
font-size: var(--fs-xs);
color: var(--c-text-muted);
}
.ob-link {
margin-left: auto;
font-size: var(--fs-xs);
color: var(--c-primary);
display: inline-flex;
align-items: center;
min-height: var(--tap-sm-min);
}
.ob-items {
list-style: none;
margin: 0;
padding: 0;
font-size: var(--fs-sm);
}
.ob-items li {
padding: 3px 0;
}
.ob-qty,
.ob-make-qty {
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.ob-sku {
font-family: monospace;
font-size: var(--fs-xs);
color: var(--c-text-muted);
}
.ob-tag {
display: inline-block;
font-size: max(0.6875rem, var(--fs-min));
font-weight: 600;
border-radius: 999px;
padding: 1px 8px;
white-space: nowrap;
}
.ob-tag-make {
background: var(--c-amber-light);
color: var(--c-amber-dark);
}
.ob-order-foot {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 6px 10px;
margin-top: 8px;
}
.ob-plan {
margin-right: auto;
font-size: var(--fs-xs);
color: var(--c-amber-dark);
font-weight: 600;
}
.ob-done {
font-size: var(--fs-sm);
color: var(--c-green-dark);
font-weight: 600;
}
.ob-group {
border-left: 4px solid var(--c-primary);
padding-left: 8px;
margin-bottom: 10px;
}
.ob-group-head,
.ob-combine-solo {
font-size: var(--fs-xs);
font-weight: 700;
color: var(--c-primary);
margin-bottom: 6px;
}
.ob-make {
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
padding: 10px 12px;
margin-bottom: 8px;
}
.ob-make.is-late {
border-color: var(--c-red-border);
}
.ob-make-top {
display: flex;
align-items: baseline;
gap: 8px;
font-size: var(--fs-sm);
}
.ob-make-qty {
font-size: 1.25rem;
}
.ob-make-meta {
font-size: var(--fs-xs);
color: var(--c-text-muted);
margin-top: 2px;
}
.ob-make-days {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.ob-day {
font-size: var(--fs-xs);
border-radius: 999px;
padding: 2px 9px;
background: var(--c-amber-light);
color: var(--c-amber-dark);
}
.ob-day.is-late {
background: var(--c-red-light);
color: var(--c-red-dark);
}
.ob-done-list > summary {
cursor: pointer;
min-height: var(--tap-sm-min);
align-items: center;
}
/* Billbee's state on the card: information, never what takes an order off the
list. A bought label is the loud one, because that parcel leaves today. */
.ob-bb {
display: inline-block;
font-size: max(0.6875rem, var(--fs-min));
font-weight: 600;
border-radius: 999px;
padding: 1px 8px;
white-space: nowrap;
background: var(--c-border-light);
color: var(--c-text-sec);
}
.ob-bb-label,
.ob-bb-shipped {
background: var(--c-blue-light);
color: var(--c-blue-dark);
}
.ob-plan.is-due {
color: var(--c-red-dark);
}
.oh-manual {
background: var(--c-bg);
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
padding: 12px;
margin-bottom: 10px;
}
.oh-maprow {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid var(--c-border-light);
}
.oh-maprow:last-child {
border-bottom: none;
}
.oh-warn {
color: var(--c-amber-dark);
cursor: help;
}
/* ═══ FORMS ═════════════════════════════════════════════════════ */
label {
font-size: max(0.75rem, var(--fs-min));
font-weight: 500;
color: var(--c-text-sec);
display: block;
margin-bottom: 4px;
}
/* A <summary> is a button that does not look like one, and every one in this
app was the height of its own line — 19.5px. Floored generically rather than
per instance, because the next <details> someone writes has the same problem
and no reason to know about it.
min-height and not display:flex, deliberately: flex on a summary drops the
disclosure triangle in every engine, and the triangle is the only thing that
says the row opens. align-content centres the text inside the taller box on
anything from 2024 on; where it is not supported the text sits at the top of
the box, which is untidy and still hits the floor — the target is correct
either way, which is the half that matters. */
summary {
min-height: var(--tap-sm-min);
align-content: center;
}
input,
select,
textarea {
width: 100%;
padding: 9px 12px;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
font-size: 0.875rem;
background: var(--c-surface);
color: var(--c-text);
font-family: inherit;
transition:
border-color 0.15s,
box-shadow 0.15s;
resize: vertical;
min-height: 64px;
line-height: 1.5;
}
/* The anti-zoom floor, and it moved axis on 2026-08-23 rather than moving
value. iOS Safari zooms the whole page in when a focused field's text is
under 16 CSS px and never zooms back out; the way back is a pinch the user
has to know about.
It used to live inside `@media (max-width: 768px)`, which is rule R1 broken
in the plainest way there is: a WIDTH deciding a TOUCH behaviour. A tablet
in landscape is 1024px wide and still has a finger, so from 769px up the
rule stopped applying and 26 fields across nine pages went back to 13 and
14px — measured, not argued: measure-mobile.js --app reported every one of
them starting at exactly 769px.
`any-pointer` and not `pointer`, for the same reason the shop's axis rules
use it: a touchscreen laptop reports `pointer: fine`, because its PRIMARY
pointer is the trackpad, and `any-pointer: coarse` alongside. It is the
device most likely to be carried into a growing room, and `pointer: coarse`
is exactly the spelling that misses it.
max() and not a flat 16px, because the two halves say different things.
`1rem` is the reader's own setting and must be allowed to grow; 16px is
WebKit's threshold and must never shrink below it, whatever the reader set.
A flat 16px would throw a reader who chose 20px back down to 16.
!important because .fs-meta is (0,2,0) against this selector's (0,0,3), and
.fs-meta is what the compact filter and search fields wear since the inline
sizes were cleaned up. Without it Safari gets a 13px field to zoom into on
exactly the pages that were tidied. */
@media (any-pointer: coarse) {
input,
select,
textarea {
font-size: max(1rem, 16px) !important;
}
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--c-primary);
box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.1);
}
/* The floor, applied to the element rather than to a list of classes.
§5.1 gave the phone a 56px touch target and the token layer delivered it, but
only the controls somebody remembered to give a class consumed it: .btn,
.stab, .sb-btn, the bottom nav. Twenty-five buttons had no height rule at
all and came out at whatever their padding made — the Woche/Monat/Jahr
toggle at roughly 28px, "alles gelesen" at 20px, the pack-size delete at 20.
Patching those twenty-five leaves the twenty-sixth to be written next week,
so the rule goes where a button cannot avoid it.
`button` is (0,0,1), so every class in this sheet still outranks it — .btn
keeps its 56px --tap, .stab its 48px --tap-sm — and this only reaches the
controls nothing else spoke for. --tap-sm rather than --tap because that is
what a control of unknown importance should get, and because it is `auto` on
a desk: the desktop cannot move here, which the baseline fixture checks. */
button {
min-height: var(--tap-sm);
}
/* `hidden` muss auch auf Knöpfen wirken. Die display-Angabe unten ist eine
Autoren-Regel und schlägt damit die [hidden]-Regel des Browsers; ohne diese
Ausnahme bleibt ein Knopf mit hidden sichtbar (siehe .worktree-banner). */
.btn[hidden] {
display: none;
}
.btn {
padding: var(--pad-btn);
/* Eine Tippfläche hat ZWEI Maße. Gemessen bei 900 px mit der Maus: der
Schließknopf im Labor war 23 x 24 px, also hoch genug und trotzdem unter
der Marke. `min-height` allein deckt jeden Knopf mit Text und keinen mit
einem einzelnen Zeichen darauf. */
min-height: var(--tap);
min-width: var(--tap);
border-radius: var(--radius-sm);
font-size: var(--fs-sm);
font-weight: 500;
cursor: pointer;
border: 1px solid var(--c-border);
background: var(--c-surface);
color: var(--c-text);
font-family: inherit;
transition: all 0.15s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn:hover {
background: var(--c-bg);
}
.btn-p,
.btn-green {
background: var(--c-primary);
color: #fff;
border-color: transparent;
}
.btn-p:hover,
.btn-green:hover {
background: var(--c-primary-hover);
}
/* Die eine Handlung eines Abschnitts, über seinen Reitern.
Größer als ein gewöhnlicher Knopf durch Polsterung, Schrift und Symbol —
nicht durch Breite. Ein Knopf, der die Zeile ausfüllt, liest sich als Banner
und nicht als etwas zum Drücken, und volle Breite ist ohnehin keine Größe,
die jemand gewählt hat. Groß genug muss nur heißen: nicht zu verwechseln mit
den Reiter-Pillen darunter, die die Ansicht wechseln statt etwas anzulegen. */
.btn-lead {
gap: 8px;
padding: 11px 20px;
font-size: max(0.875rem, var(--fs-min));
font-weight: 600;
margin-bottom: 10px;
}
/* Ein Formular ist so breit wie seine Felder, nicht so breit wie das Fenster.
Die beiden Karten, die das hier tragen, standen bis vor kurzem nebeneinander
in einem Zwei-Spalten-Raster und waren für dessen halbe Breite gebaut; als
das Raster wegfiel, erbten sie die ganze Zeile — vier kurze Felder über
1400px. Dieselbe Sache wie beim Knopf darüber und bei der Hinweiskarte davor. */
.form-card {
max-width: 640px;
}
.btn-r {
background: #fff5f5;
color: #b91c1c;
border-color: #fecaca;
}
.btn-r:hover {
background: #fee2e2;
}
/* A width the desk caps and a phone does not. The number arrives as data on the
element (`--w-cap: 480px`) rather than as an inline `max-width` declaration,
because an inline declaration wins against every rule in this file — including
the phone rule that exists to undo exactly this cap. */
/* The field a validation message is about. Not a colour alone — a 3px ring, so
it survives a red/green colour deficiency and a bright greenhouse — and it is
removed on the next keystroke by fieldError() rather than on submit, because a
ring that outlives the mistake makes a field look broken while it is being
fixed. */
input.is-invalid,
select.is-invalid,
textarea.is-invalid {
border-color: var(--c-red);
box-shadow: 0 0 0 3px var(--c-red-light);
}
.w-cap {
max-width: var(--w-cap);
}
.btn-sm {
padding: 5px 10px;
font-size: max(0.75rem, var(--fs-min));
}
/* Touch-device tap targets — covers phones AND tablets in landscape, which the
old (max-width:768px) breakpoint missed. Applies to every .btn-xs / .btn-sm
site-wide so post-scan actions, scan-modal controls, and table action columns
all clear the 44px minimum. */
/* Two literals here quietly outranked the token layer: this block is later than
`.btn` at the same specificity, so 44px beat var(--tap) and 14px beat
var(--fs-sm) on every coarse pointer — which is every device the tokens were
written for. It never applies while the baseline is captured (the fixture
records minHeight 0px for .btn.btn-sm), so saying it in tokens moves nothing. */
@media (pointer: coarse) {
.btn-xs,
.btn-sm {
min-height: var(--tap-sm);
padding: 10px 14px;
font-size: var(--fs-sm);
}
}
/* ═══ GRID ══════════════════════════════════════════════════════ */
/* minmax(0, 1fr) and not 1fr, on every one of these. A bare `1fr` is
`minmax(auto, 1fr)`, and `auto` there is the track's min-content — so one
child that cannot shrink drags the whole column past its share and the page
with it. On Pilzsorten that child is a <select> whose longest option reads
"— Rezept von anderer Sorte übernehmen —": a select is at least as wide as
its widest option, 363px of it, inside a 343px grid. The page scrolled to
443px at 375px wide. With the minimum pinned to 0 the track keeps its share
and the select shrinks like everything else. */
.g2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.g3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
/* Die Anteile im Ansatz-Formular: vier oder fünf kurze Zahlenfelder. Keine
feste Spaltenzahl, weil das Maismehl-Feld nur bei den Rezepten dabei ist, die
welches vorsehen — und weil am Telefon zwei nebeneinander passen, am Tisch
alle. */
.sb-blend-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(104px, 1fr));
gap: 8px 10px;
margin-top: 4px;
}
.sb-blend-row label {
color: var(--c-text-sec);
}
.g4 {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
/* Six, for the one place that needs six: the average-composition row in
Inventory. It was an inline `repeat(6,1fr)` written straight into
renderThresholds(), and a bare 1fr is minmax(auto, 1fr) — the track cannot
go below its content's min-content width, and an <input> has a wide one. Six
of them held a 285px container open to 486px, so the Inventory page scrolled
sideways by 111px at 375px. Nothing could see it: the grid is a template
string in app.js, so the source census reads it as text and measure-mobile.js
serves the page with the scripts stripped. */
.g6 {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
}
/* ═══ METRIC CARDS ══════════════════════════════════════════════ */
.met {
background: var(--c-surface);
border-radius: var(--radius);
padding: 16px 18px;
border: 1px solid var(--c-border);
box-shadow: var(--shadow);
border-left: 4px solid var(--c-primary);
}
.met-l {
font-size: max(0.75rem, var(--fs-min));
color: var(--c-text-sec);
margin-bottom: 4px;
font-weight: 500;
}
.met-v {
font-size: 1.625rem;
font-weight: 700;
color: var(--c-text);
line-height: 1.2;
}
/* ═══ BADGES ════════════════════════════════════════════════════ */
.badge {
display: inline-block;
font-size: max(0.6875rem, var(--fs-min));
font-weight: 600;
padding: 3px 9px;
border-radius: 99px;
}
/* Phase badges — the Status column of every table that lists a batch.
These are the second half of a colour decision whose first half lives in the
database. A zone's colour says which room a bag is in; this badge says which
phase a batch is in; on this farm those are the same fact seen twice, so the
two agreeing is not decoration, it is the point. They were both the
full-saturation Tailwind 500 family, and both are now the muted version of
the same three hues — see ZONE_SEED_COLOR in db.js.
Backgrounds are the hue at 30% saturation and 94% lightness; the text is
picked per hue to land on 6.0:1 against its own background, which is matched
across the three so none of them reads as louder than its neighbours. Small
text, so the floor is 4.5, not 3.
The one thing this does NOT follow is the slate-to-green ramp used inside the
Chargen tiles. That ramp encodes a sequence and works because the three
phases sit side by side in one bar. A badge stands alone in its row, with no
neighbour to compare against, which is the case the ramp is worst at — the
same reason the zone colours kept their hues. */
.b-inc {
background: #ebf1f4;
color: #306077;
}
.b-tent {
background: #ebf4f1;
color: #2a6652;
}
.b-spawn {
background: #f0ebf4;
color: #7241a0;
}
.b-contam {
background: #fee2e2;
color: #991b1b;
}
.b-done {
background: var(--c-bg);
color: var(--c-text-muted);
}
.b-add {
background: #dcfce7;
color: #166534;
}
.b-move {
background: #dbeafe;
color: #1e40af;
}
.b-remove {
background: #fee2e2;
color: #991b1b;
}
.b-harvest {
background: #fef3c7;
color: #92400e;
}
.badge-mc {
background: #f3e8ff;
color: #6b21a8;
}
.badge-pd {
background: #dbeafe;
color: #1e40af;
}
.badge-lc {
background: #dcfce7;
color: #166534;
}
.badge-g2g {
background: #fef3c7;
color: #92400e;
}
.badge-sy {
background: #cffafe;
color: #155e75;
}
.badge-du {
background: #f5ebe0;
color: #7c4a1e;
}
.badge-active {
background: #dcfce7;
color: #166534;
}
.badge-stored {
background: #dbeafe;
color: #1e40af;
}
.badge-used {
background: var(--c-bg);
color: var(--c-text-muted);
}
.badge-sold {
background: #ede9fe;
color: #5b21b6;
}
.badge-contam {
background: #fee2e2;
color: #991b1b;
}
/* ═══ TABLES ════════════════════════════════════════════════════ */
table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
th {
font-size: max(0.6875rem, var(--fs-min));
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--c-text-muted);
text-align: left;
padding: 8px 10px;
border-bottom: 2px solid var(--c-border);
position: sticky;
top: 0;
background: var(--c-surface);
z-index: 1;
}
th.sortable {
cursor: pointer;
user-select: none;
}
@media (hover: hover) {
th.sortable:hover {
color: var(--c-text-sec);
}
}
th.sortable .arrow {
font-size: max(0.5625rem, var(--fs-min));
opacity: 0.35;
margin-left: 3px;
}
/* Touch devices have no hover, so workers can't tease out which columns are
sortable. Bump the arrow opacity so all sort indicators are always visible. */
@media (pointer: coarse) {
th.sortable .arrow {
opacity: 0.7;
}
}
th.sortable.active .arrow {
opacity: 1;
color: var(--c-primary);
}
td {
padding: 10px;
border-bottom: 1px solid var(--c-border-light);
color: var(--c-text);
vertical-align: middle;
}
tr:last-child td {
border-bottom: none;
}
tr:nth-child(even) td {
background: var(--c-bg);
}
tr:hover td {
background: #f1f5f9;
}
/* ═══ MISC ══════════════════════════════════════════════════════ */
.empty {
text-align: center;
color: var(--c-text-muted);
font-size: 0.8125rem;
padding: 3rem;
}
.sp-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 5px;
vertical-align: middle;
/* Species colours track the real mushroom, so some land near-white (Lions
Mane) and some near-black (Black Pearl King). An inset ring keeps both
readable on the light card and on a dark surface. */
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.22);
}
.cal-event .sp-dot {
width: 8px;
height: 8px;
border: 1.5px solid rgba(255, 255, 255, 0.8);
margin-right: 4px;
}
/* No `display` here on purpose — see the .t-cards .sub-tag rule in the phone
block. As a bare inline box this pill splits across line boxes when it
wraps, which is a real blemish on the desktop table too, but the desktop
Substrat column is ~63px wide: making the pill a box there wraps it into
three lines of padded box and takes #t-batches from 760px to 1093px tall.
That is a desktop layout change, not a phone fix, and it needs the column
widths looked at rather than a display keyword. */
.sub-tag {
font-size: max(0.6875rem, var(--fs-min));
background: var(--c-bg);
color: var(--c-text-sec);
padding: 3px 8px;
border-radius: 99px;
margin-right: 3px;
}
.harvest-bar {
height: 5px;
border-radius: 3px;
background: var(--c-border);
overflow: hidden;
margin-top: 4px;
}
.harvest-bar-fill {
height: 100%;
border-radius: 3px;
background: #f59e0b;
}
/* ── Dashboard week grid ──────────────────────────────────────────────────
Seven days across, each column carrying its own work, so the whole week is
readable without opening anything. The day headers and the day bodies are
both direct grid children rather than the body sitting inside its header's
column — that is what lets a narrow screen collapse to one open day spanning
the full width, from the same markup. */
.wk {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 4px;
margin-bottom: 8px;
}
/* The column comes in as --col rather than an inline grid-column, because an
inline declaration outranks any stylesheet rule — including the media query
below, which then silently failed to widen the open day and left it in a
43px column on a phone. */
.wk-h {
grid-row: 1;
grid-column: var(--col);
min-width: 0;
padding: 4px 1px 5px;
border: 1px solid transparent;
border-bottom: 2px solid var(--c-border);
border-radius: 6px 6px 0 0;
background: transparent;
cursor: pointer;
font-family: inherit;
line-height: 1.15;
overflow: hidden;
}
.wk-h.sel {
border-bottom-color: var(--c-accent);
background: var(--c-bg);
}
.wk-b {
grid-row: 2;
grid-column: var(--col);
min-width: 0;
font-size: max(0.6875rem, var(--fs-min));
/* A rule between the days, not just a gap. Seven columns of similar-looking
rows run together without one, and the eye loses which day it is reading.
Left edge on every column but the first, so the outer edges stay clean. */
border-left: 1px solid var(--c-border);
padding: 4px 5px 0;
}
.wk-b:first-of-type {
border-left: 0;
}
.wk-h {
border-right: 1px solid transparent;
}
/* Ein Punkt im Spaltenkopf, wenn an diesem Tag eine eigene Aufgabe fällt.
Unter 769px ist der Spaltenkörper ausgeblendet, also sieht man von sechs der
sieben Tage nur den Kopf — und die benannte Aufgabe mit ihrem Haken steckt im
Körper. Die Zahl in der Fußzeile bewegte sich zwar, sagte aber nicht, dass
etwas Eigenes dahintersteckt. Ein Punkt passt in 43px und tut es. */
.wk-h-own {
display: inline-block;
width: 5px;
height: 5px;
margin-left: 3px;
border-radius: 50%;
background: var(--c-purple-dark);
vertical-align: middle;
}
/* The selected day is the one you are working from, so it gets a tinted
channel down the whole column rather than only a marked header. */
.wk-b.sel {
background: var(--c-bg);
}
/* Der Arbeitszettel des gewählten Tages, über die ganze Breite.
Er stand in der Spalte des Tages, und eine Spalte ist ein Siebtel der Karte.
Auf dem Telefon fiel das nicht auf, weil dort nur diese eine Spalte sichtbar
ist und über alle sieben geht; am Rechner wurde derselbe Zettel in 170px
gepresst — „Black..." statt der Sorte, „0,0 von 10,0" über drei Zeilen, und
ein Knopf breiter als seine Spalte.
Oben die Woche auf einen Blick, darunter der Tag, an dem gearbeitet wird. An
jedem Schirm dieselbe Anordnung, statt zweier Layouts, von denen eines nie
angesehen wurde. */
.wk-open {
grid-row: 3;
grid-column: 1 / -1;
min-width: 0;
margin-top: 6px;
padding-top: 8px;
border-top: 1px solid var(--c-border);
}
/* Die Zählung einer Spalte — ein Knopf, der den Tag öffnet.
Vorher war sie totes Papier: „4 Achtung, 4 Ansetzen, 5 überfällig" stand da,
man tippte darauf, und nichts geschah. Ein Kasten mit Zahlen darin sieht aus
wie etwas zum Aufklappen, also klappt er jetzt auf.
Randlos und ohne Fläche, wie die Mengenzahl darüber: eine antippbare Fläche,
kein Knopf. Ein Knopf um alle Zeilen und nicht einer je Zeile — sie führen
alle an dieselbe Stelle. */
.wk-counts {
display: block;
width: 100%;
padding: 0;
border: 0;
background: none;
font-family: inherit;
font-size: inherit;
text-align: left;
cursor: pointer;
border-radius: 4px;
}
.wk-counts:hover {
background: var(--c-bg);
}
/* Der Tag, der schon offen ist: dieselbe Zählung, aber nichts zu drücken. Ein
Zeiger und ein Aufleuchten unter der Maus versprechen sonst etwas, das
bereits geschehen ist. */
.wk-counts.is-open {
cursor: default;
}
.wk-counts.is-open:hover {
background: none;
}
.wk-count {
display: flex;
align-items: baseline;
gap: 5px;
padding: 2px;
margin-bottom: 2px;
border-left: 2px solid var(--c-text-muted);
}
.wk-count-n {
font-weight: 700;
flex-shrink: 0;
}
.wk-count-t {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--c-text-sec);
}
/* Versäumtes ist das Einzige, was den Blick quer über die Woche ziehen soll. */
.wk-count-late {
display: block;
padding: 1px 2px;
color: var(--c-red-dark);
font-size: var(--fs-micro);
}
/* Woche oder Tag. Sieben Spalten sind eine Übersicht und ein Kompromiss: jede
ist ein Siebtel breit, und was darin steht, muss klein sein. Wer den Tag
arbeitet, braucht die Übersicht gerade nicht — dann fällt sie weg und der Tag
bekommt die ganze Karte samt größerer Schrift. */
/* Kein gap: die beiden Knöpfe stoßen aneinander, und der rechte spart sich
seinen linken Rand. Mit 2px Abstand DAZWISCHEN fehlte dem rechten Knopf eine
Kante — er las sich als offener Kasten, nicht als zweites Segment. */
.wk-view {
display: flex;
margin-bottom: 8px;
}
.wk-view-btn {
min-height: var(--tap-sm);
padding: 4px 12px;
border: 1px solid var(--c-border);
background: var(--c-surface);
color: var(--c-text-sec);
font-family: inherit;
font-size: var(--fs-xs);
font-weight: 600;
cursor: pointer;
}
.wk-view-btn:first-child {
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
}
.wk-view-btn:last-child {
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
border-left-width: 0;
}
.wk-view-btn.sel {
background: var(--c-accent);
border-color: var(--c-accent);
color: #fff;
}
/* Ohne den Streifen braucht der Tag einen anderen Weg zum nächsten. */
.wk.day-only {
display: block;
}
.wk.day-only .wk-open {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
.wk-daynav {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.wk-daynav-t {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 700;
text-align: center;
}
.wk-daynav-btn {
flex: 0 0 auto;
justify-content: center;
padding: 4px 12px;
}
/* Am ersten und am letzten Tag ist ein Pfeil aus. .btn setzt Farbe und Fläche
als Autorenregeln und schlägt damit das Ausgrauen des Browsers, also sah der
tote Pfeil aus wie der lebende daneben — wieder ein Feld, das gedrückt wird
und nichts tut. Es gab im ganzen Blatt genau eine :disabled-Regel. */
.wk-daynav-btn:disabled {
opacity: 0.35;
cursor: default;
}
/* Der ganze Sinn der Tagesansicht: der Platz, den die sechs anderen Spalten
nicht mehr brauchen, geht in die Schrift. Zwei Klassen tief, weil .fs-sm sich
selbst verdoppelt (.fs-sm.fs-sm) und sonst gewänne. */
.wk.day-only .wk-open .fs-sm.fs-sm {
font-size: var(--fs-base);
}
.wk.day-only .wk-open .fs-xs.fs-xs {
font-size: var(--fs-sm);
}
/* Unter dieser Breite sind die sieben Spalten zu schmal für irgendeinen Inhalt,
also bleibt vom Raster der Kopfstreifen — der Tag darunter steht ohnehin
schon über alle sieben. 768px ist die Marke, die das Dashboard sonst auch
benutzt. */
@media (max-width: 768px) {
.wk-b {
display: none;
}
}
/* Die Vorgabezahl eines Tages, im Kopf. Sie steht auch im Spaltenkörper, und
dort ist sie bearbeitbar — also gehört sie oben nur dorthin, wo der Körper
nicht zu sehen ist. Über 768px sind alle sieben Körper offen, und dieselbe
Zahl zweimal übereinander war genau das, was die Woche doppelt aussehen
liess; darunter zeigen sechs von sieben Tagen nur ihren Kopf, und ohne sie
waere der Wochenüberblick auf dem Telefon eine Reihe blosser Themen. */
@media (min-width: 769px) {
.wk-h-t {
display: none;
}
}
.todo-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
border-radius: var(--radius-sm);
margin-bottom: 8px;
border: 1px solid var(--c-border);
/* Der Streifen beantwortet genau eine Frage: wie dringend ist diese Zeile.
Vorher stand hier die Sortenfarbe, während Rahmen und Hintergrund derselben
Zeile den Zustand färbten — Rot hieß dann „Reishi" oder „dringend", je
nachdem, worauf man sah. Die Sorte steht jetzt als Punkt neben ihrem Namen,
wo sie nie gestört hat. --action-color bleibt die Übersteuerung des
Kalenders. */
border-left: 4px solid var(--action-color, var(--c-border));
background: var(--c-surface);
transition: box-shadow 0.15s;
}
@media (hover: hover) {
.todo-row:hover {
box-shadow: var(--shadow);
}
}
.todo-row.urgent {
border-color: #fecaca;
border-left-color: var(--c-red);
background: #fff5f5;
}
.todo-row.warn {
border-color: #fde68a;
border-left-color: var(--c-amber);
background: #fffbeb;
}
.todo-row input[type='checkbox'] {
width: 16px;
height: 16px;
margin-top: 2px;
flex-shrink: 0;
cursor: pointer;
accent-color: var(--c-primary);
}
/* Bigger checkboxes on touch — 16px is unreachable for gloved fingers and
even bare-handed it's a precision tap. The .todo-row already has 12px
padding so the combined hit area lands above the 44px minimum once the
checkbox itself bumps to 24px. */
@media (pointer: coarse) {
.todo-row input[type='checkbox'] {
width: 24px;
height: 24px;
margin-top: 0;
}
}
/* Priority indicator dot for dashboard tasks. Was a 9 px color-only chip
(red/amber/green) with no text fallback — fails for color-blind users
per audit Section 3.4. Bumped to 14 px with a letter inside (H/M/L)
so the priority is legible without relying on color. */
.bc-cell {
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
padding: 8px;
text-align: center;
background: var(--c-surface);
}
.tag {
display: inline-block;
font-size: max(0.6875rem, var(--fs-min));
padding: 2px 8px;
border-radius: 99px;
margin-left: 4px;
vertical-align: middle;
}
.caldav-status {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-left: 6px;
vertical-align: middle;
}
.caldav-status.synced {
background: var(--c-green);
}
.caldav-status.pending {
background: var(--c-amber);
}
.caldav-status.error {
background: var(--c-red);
}
/* Sync dots */
.sync-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #ddd;
cursor: pointer;
flex-shrink: 0;
}
.sync-dot.ok {
background: var(--c-green);
}
.sync-dot.err {
background: var(--c-red);
}
.sync-dot.busy {
background: var(--c-amber);
}
/* ═══ CALENDAR ══════════════════════════════════════════════════ */
.cal-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 10px;
}
.cal-toolbar-left {
display: flex;
gap: 6px;
align-items: center;
}
.cal-toolbar-right {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.cal-nav-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
background: var(--c-surface);
color: var(--c-text);
cursor: pointer;
font-size: 1rem;
transition: all 0.15s;
}
.cal-nav-btn:hover {
background: var(--c-primary-light);
border-color: var(--c-primary);
color: var(--c-primary);
}
.cal-today-btn {
height: 36px;
padding: 0 16px;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
background: var(--c-surface);
color: var(--c-text);
cursor: pointer;
font-size: 0.8125rem;
font-weight: 600;
font-family: inherit;
transition: all 0.15s;
}
.cal-today-btn:hover {
background: var(--c-primary);
color: #fff;
border-color: var(--c-primary);
}
.cal-title {
font-size: 1.25rem;
font-weight: 800;
color: var(--c-text);
margin-left: 8px;
letter-spacing: -0.3px;
}
.cal-view-toggle {
display: flex;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.cal-vbtn {
padding: 7px 18px;
font-size: 0.8125rem;
font-weight: 600;
border: none;
background: var(--c-surface);
color: var(--c-text-sec);
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
white-space: nowrap;
}
.cal-vbtn:first-child {
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
}
.cal-vbtn:last-child {
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.cal-vbtn.active {
background: var(--c-primary);
color: #fff;
}
.cal-vbtn:hover:not(.active) {
background: var(--c-primary-light);
color: var(--c-primary);
}
.cal-print-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
background: var(--c-surface);
color: var(--c-text-sec);
cursor: pointer;
font-size: 1rem;
transition: all 0.15s;
flex-shrink: 0;
}
.cal-print-btn:hover {
background: var(--c-primary-light);
border-color: var(--c-primary);
color: var(--c-primary);
}
.cal-add-btn {
height: 36px;
padding: 0 16px;
border: none;
border-radius: var(--radius-sm);
background: var(--c-primary);
color: #fff;
cursor: pointer;
font-size: 0.8125rem;
font-weight: 600;
font-family: inherit;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap;
flex-shrink: 0;
}
.cal-add-btn:hover {
background: var(--c-primary-hover);
}
.cal-legend {
display: flex;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 14px;
font-size: max(0.75rem, var(--fs-min));
color: var(--c-text-sec);
padding: 8px 12px;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
}
.cal-legend span {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
}
.cal-legend span::before {
content: '';
display: inline-block;
width: 10px;
height: 10px;
border-radius: 3px;
flex-shrink: 0;
}
.cal-legend .leg-batch::before {
background: #ef4444;
}
.cal-legend .leg-task::before {
background: #3b82f6;
}
.cal-legend .leg-harvest::before {
background: #f59e0b;
}
.cal-legend .leg-custom::before {
background: #16a34a;
}
.cal-legend .leg-meeting::before {
background: #8b5cf6;
}
.cal-legend .leg-delivery::before {
background: #14b8a6;
}
.cal-legend .leg-maintenance::before {
background: #64748b;
}
.cal-legend .leg-import::before {
background: #6366f1;
}
/* Month grid */
.cal-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 1px;
background: var(--c-border);
border: 1px solid var(--c-border);
border-radius: var(--radius);
overflow: hidden;
}
.cal-hdr {
background: var(--c-bg);
text-align: center;
font-size: max(0.6875rem, var(--fs-min));
font-weight: 700;
color: var(--c-text-muted);
padding: 10px 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.cal-cell {
background: var(--c-surface);
min-height: 110px;
padding: 6px 8px;
position: relative;
transition: background 0.15s;
cursor: pointer;
}
.cal-cell:hover {
background: #fafffe;
}
.cal-cell.other {
background: var(--c-bg);
}
.cal-cell.other .cal-day {
color: #cbd5e1;
}
.cal-cell.other:hover {
background: #f1f5f9;
}
.cal-cell.today {
background: var(--c-primary-light);
}
.cal-day {
font-size: 0.8125rem;
font-weight: 600;
color: var(--c-text-sec);
margin-bottom: 5px;
/* Der Kreis öffnet den Tag, ist also ein Ziel und kein Datum. Mit 28 px war
er unter jeder Breite ab 769 px zu klein für einen Finger, und ein
Monatsraster wird stehend an der Wand bedient. `--tap-sm-min` und nicht
`--tap-min`: 48 statt 56, weil sieben Spalten davon bei 769 px noch reichlich
Platz haben (109 px je Spalte) und der Kreis nicht die Zelle werden soll.
Am Rechner bleibt es bei 28, weil `max()` dort gegen 24 gewinnt. */
width: max(28px, var(--tap-sm-min));
height: max(28px, var(--tap-sm-min));
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.15s;
}
.cal-cell.today .cal-day {
background: var(--c-primary);
color: #fff;
font-weight: 700;
}
.cal-day:hover {
background: var(--c-primary-light);
color: var(--c-primary);
}
.cal-cell.today .cal-day:hover {
background: var(--c-primary-hover);
color: #fff;
}
.cal-event {
font-size: max(0.6875rem, var(--fs-min));
color: #fff;
padding: 2px 7px;
border-radius: 4px;
margin-bottom: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: grab;
line-height: 1.5;
font-weight: 500;
transition:
opacity 0.15s,
transform 0.15s;
}
/* Touch devices: HTML5 drag-and-drop doesn't work on iOS Safari without a
pointer-event polyfill, and `cursor:grab` triggers iOS text-selection on
long-press, which competes with the tap-to-open. Make the event read as
tappable on touch — drag is desktop-only. */
@media (pointer: coarse) {
.cal-event {
cursor: pointer;
}
}
@media (hover: hover) {
.cal-event:hover {
opacity: 0.9;
transform: translateY(-1px);
}
}
.cal-event.no-drag {
cursor: default;
opacity: 0.8;
}
@media (hover: hover) {
.cal-event.no-drag:hover {
transform: none;
opacity: 0.85;
}
}
.cal-ev-assignees {
font-size: max(0.625rem, var(--fs-min));
opacity: 0.85;
display: inline;
white-space: nowrap;
}
.assignee-chip {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: max(0.6875rem, var(--fs-min));
padding: 1px 8px;
background: #eee;
border-radius: 12px;
}
.assignee-chip button {
background: none;
border: none;
cursor: pointer;
font-size: 0.8125rem;
padding: 0;
line-height: 1;
}
#cal-ev-assignee-dropdown label {
display: block;
padding: 4px 8px;
cursor: pointer;
font-size: max(0.75rem, var(--fs-min));
}
#cal-ev-assignee-dropdown label:hover {
background: #f0f0f0;
}
.cal-more {
font-size: max(0.6875rem, var(--fs-min));
color: var(--c-primary);
cursor: pointer;
padding: 2px 7px;
font-weight: 600;
border-radius: 4px;
transition: background 0.15s;
}
.cal-more:hover {
background: var(--c-primary-light);
}
.cal-cell.drag-over {
background: #dcfce7;
box-shadow: inset 0 0 0 2px var(--c-primary);
}
/* Week view */
.cal-week {
display: flex;
flex-direction: column;
border: 1px solid var(--c-border);
border-radius: var(--radius);
overflow: hidden;
background: var(--c-surface);
}
.cal-week-hdr {
display: grid;
grid-template-columns: 56px repeat(7, minmax(0, 1fr));
background: var(--c-bg);
border-bottom: 1px solid var(--c-border);
}
.cal-week-hdr-cell {
text-align: center;
padding: 10px 4px;
font-size: max(0.75rem, var(--fs-min));
font-weight: 700;
color: var(--c-text-muted);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.cal-week-hdr-cell .wk-day-num {
display: block;
font-size: 1.25rem;
font-weight: 800;
color: var(--c-text);
letter-spacing: -0.5px;
margin-top: 2px;
}
.cal-week-hdr-cell.today-col {
color: var(--c-primary);
background: var(--c-primary-light);
}
.cal-week-hdr-cell.today-col .wk-day-num {
color: var(--c-primary);
}
.cal-week-allday {
display: grid;
grid-template-columns: 56px repeat(7, minmax(0, 1fr));
border-bottom: 1px solid var(--c-border);
min-height: 36px;
}
.cal-week-allday-label {
font-size: max(0.625rem, var(--fs-min));
color: var(--c-text-muted);
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
background: var(--c-bg);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.cal-week-allday-cell {
padding: 4px 5px;
border-left: 1px solid var(--c-border-light);
background: var(--c-surface);
min-height: 36px;
}
.cal-week-body {
display: grid;
grid-template-columns: 56px repeat(7, minmax(0, 1fr));
position: relative;
max-height: 600px;
overflow-y: auto;
}
.cal-week-time {
font-size: max(0.625rem, var(--fs-min));
color: var(--c-text-muted);
text-align: right;
padding: 0 8px 0 0;
height: 48px;
display: flex;
align-items: flex-start;
justify-content: flex-end;
background: var(--c-bg);
border-top: 1px solid var(--c-border-light);
font-weight: 500;
}
.cal-week-slot {
border-left: 1px solid var(--c-border-light);
border-top: 1px solid var(--c-border-light);
height: 48px;
position: relative;
background: var(--c-surface);
cursor: pointer;
transition: background 0.1s;
}
.cal-week-slot:hover {
background: var(--c-primary-light);
}
.cal-week-slot.today-col {
background: #fafffc;
}
.cal-week-ev {
position: absolute;
left: 2px;
right: 2px;
border-radius: 5px;
padding: 2px 6px;
font-size: max(0.6875rem, var(--fs-min));
color: #fff;
overflow: hidden;
z-index: 2;
cursor: grab;
font-weight: 500;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
transition: opacity 0.15s;
line-height: 1.3;
}
.cal-week-ev:hover {
opacity: 0.9;
}
.cal-week-ev .ev-resize {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 6px;
cursor: ns-resize;
border-radius: 0 0 5px 5px;
}
.cal-week-ev .ev-resize:hover {
background: rgba(255, 255, 255, 0.3);
}
.cal-week-now-line {
position: absolute;
left: 56px;
right: 0;
height: 2px;
background: var(--c-primary);
z-index: 5;
pointer-events: none;
}
.cal-week-now-line::before {
content: '';
position: absolute;
left: -4px;
top: -3px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--c-primary);
}
/* Day view */
.cal-day-view {
border: 1px solid var(--c-border);
border-radius: var(--radius);
overflow: hidden;
background: var(--c-surface);
}
.cal-day-allday {
padding: 10px 14px;
background: var(--c-bg);
border-bottom: 1px solid var(--c-border);
min-height: 44px;
}
.cal-day-allday .sec {
margin-bottom: 6px;
font-size: max(0.6875rem, var(--fs-min));
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--c-text-muted);
font-weight: 700;
}
.cal-day-allday-empty {
font-size: max(0.75rem, var(--fs-min));
color: var(--c-text-muted);
font-style: italic;
}
.cal-day-body {
display: grid;
grid-template-columns: 56px 1fr;
max-height: 600px;
overflow-y: auto;
position: relative;
}
.cal-day-time {
font-size: max(0.625rem, var(--fs-min));
color: var(--c-text-muted);
text-align: right;
padding: 0 8px;
height: 48px;
display: flex;
align-items: flex-start;
justify-content: flex-end;
background: var(--c-bg);
border-top: 1px solid var(--c-border-light);
font-weight: 500;
}
.cal-day-slot {
border-top: 1px solid var(--c-border-light);
height: 48px;
position: relative;
background: var(--c-surface);
cursor: pointer;
transition: background 0.1s;
}
.cal-day-slot:hover {
background: var(--c-primary-light);
}
.cal-day-ev {
position: absolute;
left: 4px;
right: 4px;
border-radius: 5px;
padding: 3px 8px;
font-size: max(0.75rem, var(--fs-min));
color: #fff;
overflow: hidden;
z-index: 2;
cursor: grab;
font-weight: 500;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
transition: opacity 0.15s;
line-height: 1.3;
}
.cal-day-ev:hover {
opacity: 0.9;
}
.cal-day-ev .ev-resize {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 6px;
cursor: ns-resize;
border-radius: 0 0 5px 5px;
}
.cal-day-ev .ev-resize:hover {
background: rgba(255, 255, 255, 0.3);
}
.cal-day-now-line {
position: absolute;
left: 56px;
right: 0;
height: 2px;
background: var(--c-primary);
z-index: 5;
pointer-events: none;
}
.cal-day-now-line::before {
content: '';
position: absolute;
left: -4px;
top: -3px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--c-primary);
}
/* Drag/resize active states */
.cal-week-ev.dragging,
.cal-day-ev.dragging {
opacity: 0.7;
z-index: 10;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
}
/* Eight columns of numbers that mean nothing squashed. The width was inline on
the table, which is why it survived into the card layout and forced a 640px
scroll on a phone that no longer has a table to scroll. Stated where it is
true instead: above the breakpoint, where this is still a table. */
@media (min-width: 769px) {
#t-harvestrelease {
min-width: 640px;
}
}
/* ═══ CALENDAR — AGENDA VIEW ═══════════════════════════════════
The calendar below 769px. Not a fourth tab: a month cell at 375px is 47.9px
wide and gives an event label 29.9px of text, four glyphs and an ellipsis,
and week view is worse at 40.7px per column. Seven columns cannot be made to
work by making rows taller — the constraint is horizontal.
A list has the opposite shape: one column, full width, as many rows as the
month has entries. Everything the grid had to abbreviate fits here in words. */
.cal-agenda-day {
display: flex;
gap: 12px;
padding: 10px 0;
border-top: 1px solid var(--c-border);
}
.cal-agenda-day:first-child {
border-top: 0;
}
/* The date column is fixed so every row's text starts on the same line, which
is what makes a list scannable rather than merely short. */
.cal-agenda-date {
flex: 0 0 40px;
text-align: center;
padding-top: 2px;
}
.cal-agenda-num {
display: block;
font-size: var(--fs-base);
font-weight: 700;
line-height: 1.1;
}
.cal-agenda-dow {
display: block;
font-size: var(--fs-xs);
color: var(--c-text-muted);
}
.cal-agenda-day.today .cal-agenda-num {
color: var(--c-primary);
}
.cal-agenda-rows {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
/* A button, not a div: this opens the same editor the month grid opens, and
the row is the target — 56px of it, the Feld floor, because the calendar is
read walking between tents. */
.cal-agenda-row {
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
min-height: var(--tap-min);
padding: 8px 10px;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
background: var(--c-surface);
color: var(--c-text);
font-family: inherit;
font-size: var(--fs-xs);
text-align: left;
cursor: pointer;
}
.cal-agenda-dot {
flex: 0 0 10px;
width: 10px;
height: 10px;
border-radius: 50%;
margin-top: 4px;
}
.cal-agenda-when {
flex: 0 0 auto;
color: var(--c-text-sec);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.cal-agenda-what {
flex: 1;
min-width: 0;
}
.cal-agenda-label {
display: block;
font-weight: 600;
}
.cal-agenda-meta,
.cal-agenda-desc {
display: block;
color: var(--c-text-muted);
}
.cal-agenda-who::before {
content: ' · ';
}
.cal-agenda-empty {
padding: 24px 0;
text-align: center;
color: var(--c-text-muted);
font-style: italic;
}
/* ═══ DASHBOARD LOCATION OVERVIEW ══════════════════════════════ */
.location-section {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 14px;
box-shadow: var(--shadow);
}
.location-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
cursor: pointer;
user-select: none;
/* Sie klappt eine ganze Ortsgruppe auf und zu, ist also ein Bedienelement
und kein Titel — mit 21 px war sie auf beiden Achsen unter der Marke, mit
der Maus unter 24 und mit dem Finger unter 44. Die Marke kommt aus dem
Token, damit sie der Eingabeachse folgt und nicht einer Breite. */
min-height: var(--tap-sm);
}
.location-section.collapsed .location-section-header {
margin-bottom: 0;
}
.location-section-body {
overflow: hidden;
transition: max-height 0.25s ease;
}
.location-section.collapsed .location-section-body {
max-height: 0 !important;
margin-top: 0;
}
.location-section-toggle {
width: 18px;
height: 18px;
flex-shrink: 0;
transition: transform 0.2s ease;
color: var(--c-text-muted);
}
.location-section.collapsed .location-section-toggle {
transform: rotate(-90deg);
}
.location-section-title {
font-size: 0.875rem;
font-weight: 700;
color: var(--c-text);
display: flex;
align-items: center;
gap: 8px;
}
.location-section-title .zone-dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.location-section-count {
font-size: max(0.75rem, var(--fs-min));
color: var(--c-text-muted);
font-weight: 500;
}
.rack-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.rack-grid.rack-grid-5col {
grid-template-columns: repeat(5, 1fr);
}
.rack-card-new {
border: 1px solid var(--c-border);
border-radius: var(--radius);
padding: 14px;
background: var(--c-surface);
transition: all 0.15s;
}
@media (hover: hover) {
.rack-card-new:hover {
background: var(--c-bg);
box-shadow: var(--shadow);
}
}
.rack-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.rack-card-name {
font-size: 0.8125rem;
font-weight: 600;
color: var(--c-text);
}
.rack-card-count {
font-size: max(0.75rem, var(--fs-min));
color: var(--c-text-muted);
font-weight: 500;
}
.rack-card-bar {
height: 4px;
border-radius: 2px;
background: var(--c-border-light);
overflow: hidden;
margin-bottom: 8px;
}
.rack-card-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease;
}
.rack-card-batches {
display: flex;
flex-direction: column;
gap: 6px;
}
.batch-card {
padding: 8px 10px;
border-radius: var(--radius-sm);
background: var(--c-bg);
border: 1px solid var(--c-border-light);
border-left: 3px solid var(--c-border-light);
cursor: pointer;
transition: all 0.15s;
}
@media (hover: hover) {
.batch-card:hover {
border-color: var(--c-border);
box-shadow: var(--shadow);
}
}
.batch-card.batch-overdue {
border-color: #fecaca;
border-left-color: var(--c-red);
background: #fff5f5;
}
.batch-card-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: max(0.75rem, var(--fs-min));
}
.batch-card-species {
font-weight: 600;
color: var(--c-text);
display: flex;
align-items: center;
gap: 4px;
}
.batch-card-count {
font-size: max(0.6875rem, var(--fs-min));
color: var(--c-text-muted);
font-weight: 500;
}
.batch-card-meta {
font-size: max(0.6875rem, var(--fs-min));
color: var(--c-text-muted);
margin-top: 3px;
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.batch-card-meta .overdue-text {
color: #b91c1c;
font-weight: 600;
}
.batch-card-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
display: none;
}
.batch-card.expanded .batch-card-chips {
display: flex;
}
.bag-chip {
font-size: max(0.6875rem, var(--fs-min));
font-family: ui-monospace, 'Cascadia Code', monospace;
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
background: var(--c-surface);
border: 1px solid var(--c-border);
color: var(--c-text);
transition: all 0.1s;
user-select: none;
}
.bag-chip.selected {
background: #1a1a1a;
color: #fff;
border-color: #1a1a1a;
}
.tent-columns {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.tent-column {
min-height: 80px;
}
.tent-column-header {
font-size: 0.8125rem;
font-weight: 600;
color: var(--c-text);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.tent-column-empty {
font-size: max(0.75rem, var(--fs-min));
color: var(--c-text-muted);
font-style: italic;
text-align: center;
padding: 20px 10px;
border: 1px dashed var(--c-border);
border-radius: var(--radius-sm);
}
.contam-section {
border-color: #fecaca;
background: #fff5f5;
}
.contam-section .location-section-title {
color: #b91c1c;
}
.action-bar-sticky {
position: sticky;
bottom: 0;
z-index: 50;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius);
padding: 10px 14px;
margin-top: 10px;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08);
}
.action-bar-sticky .action-bar-count {
font-size: max(0.75rem, var(--fs-min));
font-weight: 600;
color: var(--c-text);
}
/* ═══ OVERVIEW PERIOD TOGGLE ════════════════════════════════════ */
/* The period pills and the CSV button. Wrapping is not decoration: measured at
375px the row is 382px wide, and it was invisible to scripts/measure-mobile.js
for as long as the section around it carried an inline display:none. */
.ov-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.ov-period-toggle {
display: flex;
/* Drei Pillen, die nicht umbrechen konnten, und auf Deutsch sind sie
zusammen 344 px breit: „Diese Woche | Diesen Monat | Dieses Jahr". Auf
einem 320-px-Telefon war das der einzige Grund, warum die Übersicht als
Ganzes breiter war als der Schirm — mit dem Finger verkleinerte das
Telefon die ganze Seite auf 89 Prozent, mit der Maus lief sie 17 bis 40 px
über den Rand. Ein Fehler mit zwei Gesichtern, wie im Labor.
`flex-wrap` und `max-width` statt einer Umbruchstelle: Es gibt keine
Breite, ab der das richtig wird, es gibt nur „passt" und „passt nicht",
und genau das rechnet der Umbruch selbst aus. Auf Englisch passt dieselbe
Zeile auch bei 320 px in eine Reihe und bleibt dort. */
flex-wrap: wrap;
background: var(--c-border-light);
border-radius: 10px;
padding: 3px;
gap: 2px;
width: fit-content;
max-width: 100%;
margin-bottom: 14px;
}
.ov-period-btn {
padding: 6px 16px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--c-text-muted);
font-size: max(0.75rem, var(--fs-min));
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.ov-period-btn.active {
background: var(--c-surface);
color: var(--c-text);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
/* ═══ LOCATIONS HEADER + INLINE SEARCH ═════════════════════════ */
.dash-locations-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.dash-search-inline {
width: 200px;
font-size: max(0.75rem, var(--fs-min));
padding: 6px 10px;
border: 2px solid var(--c-border-strong, #94a3b8);
border-radius: var(--radius-sm);
background: var(--c-surface);
color: var(--c-text);
font-family: inherit;
transition: border-color 0.15s;
}
.dash-search-inline:focus {
outline: none;
border-color: var(--c-primary);
box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.1);
}
/* ═══ SUPPLY LIGHT ═══════════════════════════════════════════════
Under a Sorte's name on the Chargen list (Sortenliste): whether its supply
needs acting on. */
/* The supply light. A dot and a word, not a filled pill — only the state that
needs acting on today carries weight, so a card of healthy Sorten reads as
plain text. */
.sup {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: max(0.6875rem, var(--fs-min));
font-weight: 600;
color: var(--c-text-sec);
white-space: nowrap;
}
.sup::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--sup-c, var(--c-slate));
flex: none;
}
.sup-now {
color: #7f1d1d;
font-weight: 700;
--sup-c: #b91c1c;
}
.sup-nospawn {
--sup-c: #c2410c;
}
.sup-low {
--sup-c: #a16207;
}
.sup-ok {
color: var(--c-text-muted);
--sup-c: #4d9c6a;
}
/* Out of the season's programme. Deliberately NOT green: green would claim the
supply is fine, when the truth is that nobody is asking. A hollow dot says
"no opinion", which is the honest one. */
.sup-off {
color: var(--c-text-muted);
--sup-c: transparent;
}
.sup-off::before {
box-shadow: inset 0 0 0 1.5px var(--c-text-muted);
}
/* ═══ LAB STOCK TILES ════════════════════════════════════════════
The Labor card reuses the Chargen tile wholesale — same chrome, same bar,
same supply light — because it answers the same shape of question about a
different shelf. Only three things are its own: a type chip, the per-Sorte
rows, and the fact that the tile is not a button. */
.stile-static {
cursor: default;
}
@media (hover: hover) {
.stile-static:hover {
border-color: var(--c-border);
box-shadow: none;
}
.stile-static.urgent:hover {
border-color: var(--c-red-strong);
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
}
}
.lab-chip {
font-size: max(0.625rem, var(--fs-min));
font-weight: 700;
padding: 1px 6px;
border-radius: 5px;
margin-right: 6px;
flex: none;
}
/* Eine Woche je Zeile: Nummer, Zeitraum, Balken, Zahl gegen Ziel, Ampel.
Dieselben Bausteine wie die Sorten- und Labor-Kacheln — .sbar für den Balken,
.sup für die Ampel — damit "geschafft" hier aussieht wie "Bestand reicht"
dort und nicht wie eine neue Sprache. */
.lab-rows {
display: flex;
flex-direction: column;
gap: 3px;
}
.lab-row {
display: flex;
align-items: center;
gap: 6px;
font-size: max(0.6875rem, var(--fs-min));
color: var(--c-text-sec);
min-width: 0;
}
/* Eine Bestandszeile, die ihre eigene Untergrenze setzt. Sie muss ein Knopf
sein, damit sie mit der Tastatur erreichbar ist — und dann muss ihr alles
genommen werden, was ein Knopf mitbringt, sonst sitzt sie anders im Raster
als die Zeilen ohne Pilzsorten-Eintrag daneben. */
.lab-row-btn {
background: none;
border: 0;
padding: 0;
margin: 0;
width: 100%;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
border-radius: 4px;
}
.lab-row-btn:hover {
background: var(--c-border-light);
}
.lab-row-btn:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: 1px;
}
.lab-row-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex: none;
border: 1px solid rgba(0, 0, 0, 0.12);
}
.lab-row-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lab-row-n {
font-weight: 700;
color: var(--c-text);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* Laborbestand je Sorte (renderLabStock, renderNachproduzieren, lbSheetRender).
One row per species with the same five fields in the same places; amber under
its target, red for an order that cannot ship. */
.vb-legend {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
margin-top: 8px;
color: var(--c-text-muted);
font-size: max(0.75rem, var(--fs-min));
}
.vb-bb {
margin-top: 10px;
padding: 8px 10px;
border-radius: 8px;
background: var(--c-primary-light);
color: var(--c-text-sec);
font-size: max(0.8125rem, var(--fs-min));
line-height: 1.4;
}
.vb-bb[hidden] {
display: none;
}
.vb-bb.vb-bb-warn {
background: var(--c-amber-light);
color: var(--c-amber-dark);
}
/* A species: a button the width of the card, because the whole row opens it. */
.lb-sp {
display: block;
width: 100%;
padding: 10px 0;
border: 0;
border-bottom: 1px solid var(--c-border-light);
background: transparent;
color: inherit;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.lb-sp:last-child {
border-bottom: 0;
}
.lb-sp:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: 2px;
}
.lb-sp-h {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 6px;
}
.lb-sp-h b {
flex: 0 1 auto;
min-width: 0;
overflow-wrap: anywhere;
}
.lb-paused {
color: var(--c-text-muted);
font-size: max(0.75rem, var(--fs-min));
}
.lb-chev {
margin-left: auto;
color: var(--c-text-muted);
font-size: 1.25rem;
line-height: 1;
}
.lb-row {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 4px;
}
.lb-st {
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
padding: 4px 2px;
border-radius: 8px;
background: var(--c-bg);
font-variant-numeric: tabular-nums;
}
.lb-st small {
color: var(--c-text-muted);
font-size: max(0.6875rem, var(--fs-min));
white-space: nowrap;
}
.lb-st b {
font-size: 0.9375rem;
white-space: nowrap;
}
.lb-st em {
color: var(--c-text-muted);
font-size: max(0.75rem, var(--fs-min));
font-style: normal;
font-weight: 500;
}
.lb-st.lb-zero b {
color: var(--c-text-muted);
font-weight: 500;
}
.lb-st.lb-under {
background: var(--c-amber-light);
}
.lb-st.lb-under b {
color: var(--c-amber-dark);
}
.lb-st.lb-now {
background: var(--c-red-light);
}
.lb-st.lb-now b {
color: var(--c-red-dark);
}
/* Sold in a shop: a dot after the field's name, explained in the legend. */
.lb-shopdot {
display: inline-block;
width: 6px;
height: 6px;
margin-left: 3px;
border-radius: 50%;
background: var(--c-primary);
vertical-align: 2px;
}
/* Nachproduzieren: one row per species, what it lacks as chips, one button. */
.np-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid var(--c-border-light);
}
.np-row:last-of-type {
border-bottom: 0;
}
.np-dot {
flex: 0 0 10px;
width: 10px;
height: 10px;
border-radius: 50%;
}
.np-dot.np-now {
background: var(--c-red-strong);
}
.np-dot.np-low {
background: var(--c-amber-strong);
}
.np-main {
flex: 1 1 auto;
min-width: 0;
}
.np-what {
font-weight: 600;
overflow-wrap: anywhere;
}
.np-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.np-chip {
padding: 2px 6px;
border-radius: 6px;
background: var(--c-amber-light);
color: var(--c-amber-dark);
font-size: max(0.75rem, var(--fs-min));
font-weight: 600;
white-space: nowrap;
}
.np-chip.np-now {
background: var(--c-red-light);
color: var(--c-red-dark);
}
.np-go {
flex: 0 0 auto;
white-space: nowrap;
}
.np-more {
margin-top: 8px;
}
.np-foot {
margin-top: 6px;
color: var(--c-text-muted);
font-size: max(0.8125rem, var(--fs-min));
line-height: 1.45;
}
/* The species sheet (m-lb). */
.lb-prog {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 8px 0 6px;
padding-bottom: 10px;
border-bottom: 1px solid var(--c-border-light);
cursor: pointer;
}
.lb-prog input {
width: auto;
margin-top: 3px;
}
.lb-prog small {
display: block;
color: var(--c-text-muted);
font-size: max(0.75rem, var(--fs-min));
line-height: 1.35;
}
.lb-line {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--c-border-light);
}
.lb-line-l {
flex: 1 1 auto;
min-width: 0;
}
.lb-line-l .lb-info {
display: block;
color: var(--c-text-muted);
font-size: max(0.75rem, var(--fs-min));
line-height: 1.35;
}
.lb-line.lb-under .lb-line-l b {
color: var(--c-amber-dark);
}
.lb-line.lb-now .lb-line-l b {
color: var(--c-red-dark);
}
.lb-shop {
display: inline-block;
padding: 1px 4px;
border-radius: 4px;
background: var(--c-primary-light);
color: var(--c-primary);
font-size: max(0.6875rem, var(--fs-min));
font-weight: 700;
}
.lb-step {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 4px;
}
.lb-step b {
min-width: 48px;
font-size: 1.0625rem;
text-align: center;
font-variant-numeric: tabular-nums;
}
#m-lb-make[hidden] {
display: none;
}
/* What to make: 0 is the start, not a figure, so it stays quiet. */
.lb-step b.lb-null {
color: var(--c-text-muted);
font-weight: 400;
}
/* The target sits under the stock as a small link; a tap opens its own
minus and plus, smaller than the one for what to make. */
.lb-soll-btn {
display: inline-block;
margin-top: 2px;
padding: 4px 0;
border: 0;
background: none;
color: var(--c-primary);
font: inherit;
font-size: max(0.75rem, var(--fs-min));
cursor: pointer;
}
.lb-step-soll {
display: flex;
margin-top: 2px;
}
.lb-step-soll b {
min-width: 40px;
font-size: 0.9375rem;
}
/* Delete sits next to the target link, in the same quiet style, in red. */
.lb-weg-btn {
margin-left: 14px;
color: var(--c-red-dark);
}
/* What is gone: one row per culture, tap to mark. Rows are list items, so
they span the sheet; the delete button below sizes to its words. */
.lb-weg {
padding: 4px 0 10px;
border-bottom: 1px solid var(--c-border-light);
}
.lb-weg-id {
display: flex;
width: 100%;
min-height: 44px;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 0 0 4px;
padding: 6px 10px;
border: 1px solid var(--c-border);
border-radius: 6px;
background: var(--c-surface, #fff);
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.lb-weg-id span {
min-width: 0;
font-family: monospace;
font-size: max(0.8125rem, var(--fs-min));
overflow-wrap: anywhere;
}
.lb-weg-id small {
flex: 0 0 auto;
color: var(--c-text-muted);
font-size: max(0.75rem, var(--fs-min));
}
.lb-weg-id.on {
border-color: var(--c-red-dark);
background: var(--c-red-light, #fee2e2);
text-decoration: line-through;
}
.lb-weg-id:disabled {
cursor: default;
opacity: 0.6;
}
/* Admin only: the reason goes under the ID, so the ID stays on one line. */
.lb-weg-id:disabled {
flex-wrap: wrap;
}
.lb-weg-id:disabled small {
flex: 1 1 100%;
}
.lb-weg .btn {
margin-top: 4px;
}
/* ── Sortenliste ──
Kulturbibliothek, Körnerbrut and Chargen: one row per Sorte with its counts;
a tap opens its entries by group, one line each. Rows are list items and
span the card; the buttons on top size to their words. */
.sl-head {
display: flex;
align-items: baseline;
gap: 8px;
}
.sl-head .sec {
margin: 0;
}
.sl-count {
color: var(--c-text-muted);
font-size: max(0.8125rem, var(--fs-min));
}
.sl-tools {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 10px 0 6px;
}
.sl-tools .btn[aria-pressed='true'] {
border-color: var(--c-primary);
color: var(--c-primary);
}
.sl-sorte {
border-bottom: 1px solid var(--c-border-light);
}
.sl-sorte.open {
padding-bottom: 8px;
}
.sl-kopf,
.sl-eintrag,
.sl-mehr {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 8px;
border: 0;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.sl-kopf {
min-height: 48px;
padding: 8px 2px;
}
.sl-name {
min-width: 0;
font-weight: 600;
overflow-wrap: anywhere;
}
.sl-name .sup {
display: flex;
margin-top: 2px;
font-weight: 600;
}
.sl-eintrag.batch-row-flash {
animation: batch-row-flash 1.5s ease;
}
.sl-chips {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 4px;
}
.sl-chip {
padding: 2px 6px;
border-radius: 6px;
background: var(--c-bg);
color: var(--c-text-sec);
font-size: max(0.75rem, var(--fs-min));
white-space: nowrap;
}
.sl-chip b {
color: var(--c-text);
}
.sl-sorte.open .sl-kopf {
color: var(--c-primary);
}
.sl-teil {
margin: 2px 0 6px;
padding-left: 10px;
border-left: 2px solid var(--c-border-light);
}
.sl-teil-t {
padding: 4px 0;
color: var(--c-text-muted);
font-size: max(0.75rem, var(--fs-min));
font-weight: 600;
}
.sl-eintrag {
min-height: 44px;
padding: 6px 8px;
border-radius: 6px;
}
.sl-eintrag:hover,
.sl-eintrag:focus-visible {
background: var(--c-bg);
}
.sl-id {
min-width: 0;
font-family: monospace;
font-size: max(0.8125rem, var(--fs-min));
overflow-wrap: anywhere;
}
.sl-r {
flex: 0 0 auto;
color: var(--c-text-muted);
font-size: max(0.75rem, var(--fs-min));
white-space: nowrap;
}
.sl-mehr {
padding: 6px 8px;
color: var(--c-primary);
font-size: max(0.8125rem, var(--fs-min));
}
.lb-help,
.lb-tap {
margin: 0 0 8px;
color: var(--c-text-muted);
font-size: max(0.75rem, var(--fs-min));
line-height: 1.35;
}
.lb-ruhen {
margin: 8px 0 4px;
}
/* Under its floor: the number goes red, not the whole row. The Sorte's name is
not the problem, the count is. */
.lab-row.under .lab-row-n {
color: var(--c-red-dark);
}
.lab-row-min {
font-weight: 400;
color: var(--c-text-muted);
}
.lab-empty {
font-style: italic;
}
/* ═══ SORTE IN THE PROGRAMME ═════════════════════════════════════ */
.ms-prog {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: max(0.75rem, var(--fs-min));
color: var(--c-text-sec);
user-select: none;
}
.ms-prog input {
width: auto;
margin: 0;
/* Der Schalter ist die Tippfläche, nicht das Label darum: Das Label ist
`inline-flex` und genau so hoch wie sein Inhalt, also hilft es nicht.
20 px stand hier mit dem Kommentar „coarse pointers get a real target" —
das war 20, und die Marken sind 24 (Maus, WCAG 2.5.8 AA) und 44 (Finger).
Gemessen vom Messstand über das ganze Band auf beiden Achsen; der
Kommentar hat eine Absicht beschrieben und keine Zahl geprüft.
`--tap-sm-min` und nicht `--tap-min`: Das ist die 48er-Marke für Chrom,
das auf jedem Bildschirm mitfährt, nicht die 56er für eine Feld-Fläche.
Am Rechner sind es 24 px, in der Hand 48. */
min-width: max(20px, var(--tap-sm-min));
min-height: max(20px, var(--tap-sm-min));
}
tr.ms-off td {
opacity: 0.6;
}
tr.ms-off .ms-prog {
opacity: 1;
}
/* ═══ BATCH ROW FLASH ═══════════════════════════════════════════ */
.batch-row-flash > td {
animation: batch-row-flash 1.5s ease;
}
@keyframes batch-row-flash {
0% {
background: var(--c-amber-light);
}
100% {
background: transparent;
}
}
/* ═══ OVERVIEW KPI CARDS ════════════════════════════════════════ */
.ov-kpi-group-label {
font-size: max(0.6875rem, var(--fs-min));
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--c-text-muted);
margin-bottom: 8px;
}
.ov-kpi-card {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius);
padding: 14px 16px;
box-shadow: var(--shadow);
display: flex;
align-items: flex-start;
gap: 12px;
transition: box-shadow 0.15s;
}
.ov-kpi-card:hover {
box-shadow: var(--shadow-md);
}
.ov-kpi-icon {
width: 38px;
height: 38px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ov-kpi-body {
min-width: 0;
flex: 1;
}
.ov-kpi-value {
font-size: 1.5rem;
font-weight: 700;
line-height: 1.1;
margin-bottom: 2px;
}
.ov-kpi-label {
font-size: max(0.75rem, var(--fs-min));
font-weight: 600;
color: var(--c-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ov-kpi-sub {
font-size: max(0.6875rem, var(--fs-min));
color: var(--c-text-muted);
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* The shared confirm dialog (#m-confirm) uses textContent for body, so \n
in the i18n string would otherwise collapse. pre-line keeps newlines while
still wrapping long single lines. */
#m-body {
white-space: pre-line;
}
/* ═══ MODALS ════════════════════════════════════════════════════ */
.modal-bg {
display: none;
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.5);
backdrop-filter: blur(4px);
z-index: 200;
align-items: center;
justify-content: center;
/* overscroll-behavior:contain still prevents the underlying page from
bouncing when the modal scrolls past its limits. The previous
`touch-action:none` here disabled scrolling on the modal content too
on iOS Safari — workers couldn't scroll a long bag-info modal. The
allow-vertical-pan rule lives on .modal itself below. */
overscroll-behavior: contain;
}
.modal-bg.open {
display: flex;
}
#m-confirm {
z-index: 210;
}
/* Die Zielliste über der Kamera. Beide sind .modal-bg mit z-index 200, und bei
Gleichstand malt das spätere Element oben — m-camscan steht in index.html
hinter m-move-batch. Die Liste, die das "Nach"-Feld der Kamera öffnete, ging
darum HINTER dem Kamerabild auf: der Tipp tat scheinbar nichts, und der
nächste Tipp daneben schloss die Kamera. Unter m-confirm (210) bleibt sie. */
body:has(#m-camscan.open) #m-move-batch {
z-index: 205;
}
.modal {
background: var(--c-surface);
border-radius: 16px;
padding: var(--pad-modal);
width: 92%;
max-width: 480px;
max-height: 92vh;
max-height: min(92vh, calc(100dvh - 32px));
overflow-y: auto;
/* Allow vertical pan inside the modal even when the parent .modal-bg
would otherwise suppress touch gestures on iOS. */
touch-action: pan-y;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.2);
}
.modal h3 {
font-size: 1rem;
font-weight: 700;
margin-bottom: 10px;
color: var(--c-text);
}
.modal p {
font-size: 0.875rem;
color: var(--c-text-sec);
margin-bottom: 14px;
line-height: 1.6;
}
/* The question in a confirm dialog can be several lines — the stock shortage
lists one line per material — and it arrives as a string with \n in it,
because that is the shape it had when window.confirm() rendered it. Collapsed
to spaces, a three-material shortage reads as one run-on sentence. */
#m-body {
white-space: pre-line;
}
.modal-btns {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 16px;
}
/* Die Knopfreihe unter dem Ansatz-Fenster.
Sie kam mit einem Knopf aus und brauchte deshalb nie eine Regel. Seit die
Ist-Feuchte danebensteht, sind es zwei, und .btn ist inline-flex: appendChild
setzt keinen Textknoten dazwischen, also stossen sie ohne Abstand aneinander
und werden auf dem Telefon zu einem Ziel, bei dem der Daumen den falschen
Treffer nicht bemerkt. Umbruch statt Quetschen, damit der zweite Knopf auf
einem schmalen Schirm eine Zeile tiefer geht, statt den ersten zu stauchen. */
#si-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
/* Die Wochensumme im Rhythmus-Fenster.
Sie klebt am unteren Rand, weil sie die Antwort auf die Frage ist, die man
beim Tippen hat ("bin ich bei 70?"). Sieben Wochentage mit Menge, Sorte und
Notiz sind auf einem Telefon höher als der Schirm, und eine Summe unter
diesen sieben Zeilen ist genau dann nicht zu sehen, wenn man sie braucht.
.modal scrollt selbst (overflow-y: auto), also ist es dessen Scrollbereich,
an dem hier geklebt wird; unten angekommen löst sie sich und die letzten
Zeilen des Fensters schieben sich normal darüber. */
.rhythm-total {
position: sticky;
bottom: 0;
margin-top: 10px;
padding: 8px 0 2px;
background: var(--c-surface);
border-top: 0.5px solid var(--c-border);
}
.rhythm-total-sum {
font-weight: 600;
}
.rhythm-total-hint {
color: var(--c-text-muted);
margin-top: 3px;
}
/* Eine eigene Aufgabe in der Wochenspalte: Kästchen, Name, ein Tipp.
Randlos und ohne Fläche — das ist KEIN auf volle Breite gezogener Knopf,
sondern eine Zeile, die man antippen kann, genau wie die Mengenzahl zwei
Zeilen darüber (_rhythmTargetBtn) es schon ist. Wer hier eine Fläche und
einen Rahmen einzieht, macht daraus den breitgetretenen Knopf, den es in
diesem Blatt nirgends geben soll.
Die Breite braucht sie trotzdem: ohne sie kann der Name in einer 170px-Spalte
nicht mit Auslassungspunkten enden, und ein umgebrochener Aufgabenname
schiebt die sieben Spalten auf verschiedene Höhen. */
.wk-chore {
display: flex;
align-items: center;
gap: 5px;
width: 100%;
min-height: var(--tap-sm);
margin-bottom: 3px;
padding: 3px 3px 3px 5px;
border: 0;
border-left: 2px solid var(--c-purple-dark);
border-radius: 0 4px 4px 0;
background: none;
font-family: inherit;
font-size: inherit;
text-align: left;
cursor: pointer;
}
.wk-chore:hover {
background: var(--c-bg);
}
/* In em, nicht in Pixeln: das Kästchen wächst mit der Zeile, und der Haken
darin bleibt bei der Schriftgröße der Zeile statt bei einer eigenen. Eine
feste Zahl hier wäre eine Schriftgröße, an die der Boden der Handseite nicht
herankommt — genau das, was scripts/mobile-audit.js zählt. */
.wk-chore-box {
width: 1.15em;
height: 1.15em;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1.5px solid var(--c-text-muted);
border-radius: 3px;
line-height: 1;
color: var(--c-surface);
}
.wk-chore.done .wk-chore-box {
background: var(--c-green-dark);
border-color: var(--c-green-dark);
}
.wk-chore-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wk-chore.done .wk-chore-name {
color: var(--c-text-muted);
text-decoration: line-through;
}
/* Versäumt: derselbe rote Streifen, den eine überfällige Zeile überall trägt. */
.wk-chore.late {
border-left-color: var(--c-red-dark);
}
.wk-chore.late .wk-chore-name {
color: var(--c-red-dark);
font-weight: 600;
}
/* Die eigenen Aufgaben, unter der Wochensumme. Eine Zeile je Aufgabe: Name,
Takt, und der Weg hinaus. Der Name darf schrumpfen, Takt und Knopf nicht —
ein umgebrochener Takt neben einem abgeschnittenen Namen liest sich als
zwei Zeilen, die nicht zusammengehören. */
.rhythm-own {
margin-top: 14px;
padding-top: 10px;
border-top: 0.5px solid var(--c-border);
}
.rhythm-own[hidden] {
display: none;
}
.rhythm-own-head {
font-weight: 600;
}
.rhythm-own-hint {
color: var(--c-text-muted);
margin: 2px 0 8px;
}
.rhythm-own-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.rhythm-own-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rhythm-own-when {
color: var(--c-text-muted);
flex-shrink: 0;
}
/* Eine Aufgabe in Bearbeitung: dieselben drei Felder wie beim Anlegen, also
dieselbe Umbruchregel. */
.rhythm-own-edit {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 6px 0;
border-top: 0.5px solid var(--c-border);
border-bottom: 0.5px solid var(--c-border);
}
.rhythm-own-edit input {
flex: 1 1 100%;
min-width: 0;
}
.rhythm-own-edit select {
flex: 1 1 96px;
min-width: 0;
}
.rhythm-own-edit .btn {
flex: 0 0 auto;
}
/* Pausiert: bleibt lesbar, aber nicht mehr im Vordergrund. */
.rhythm-own-row.ruht .rhythm-own-name {
color: var(--c-text-muted);
}
/* Auf dem Telefon umbrechen statt vier Felder in eine 300px-Zeile zu quetschen:
der Name nimmt die volle Breite, Takt und Wochentag teilen sich die nächste. */
.rhythm-own-add {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
/* Auch hier keine eigene Mindesthöhe: die allgemeine input-Regel hat schon
64px, und eine Klasse davor hätte sie verkleinert. */
.rhythm-own-add input {
flex: 1 1 100%;
min-width: 0;
}
.rhythm-own-add select {
flex: 1 1 96px;
min-width: 0;
}
.rhythm-own-add .btn {
flex: 0 0 auto;
}
/* Die Menge eines einzelnen Tages: Regel und Ausnahme untereinander.
Beschriftung links, Zahl rechts — auf dem Telefon bleibt das nebeneinander,
weil die Beschriftung hier die eigentliche Auskunft ist ("gilt für jeden
Montag" gegen "nur für den 25.08.") und ein Umbruch sie von ihrem Feld
trennen würde. Das Feld ist schmal genug, dass beides passt. */
.dayqty-sub {
color: var(--c-text-muted);
margin: -4px 0 14px;
}
.dayqty-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
cursor: pointer;
}
.dayqty-lbl {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.dayqty-hint {
color: var(--c-text-muted);
}
/* Keine eigene Mindesthöhe. Hier stand `min-height: var(--tap-sm)` mit der
Begründung, die allgemeine input-Regel setze keine — sie setzt aber
`min-height: 64px` (ganz oben in diesem Blatt), und eine Klasse schlägt den
Elementselektor. Die Zeile, die als größere Tippfläche gedacht war, machte
das Feld also von 64px auf 48px am Telefon und rund 39px am Rechner: die
genaue Umkehrung ihrer eigenen Absicht. Ohne sie gilt wieder die 64. */
.dayqty-in {
width: 84px;
flex-shrink: 0;
padding: 8px;
border: 1px solid var(--c-border);
border-radius: 6px;
font-family: inherit;
text-align: right;
}
/* Ein gesperrtes Feld muss als gesperrt zu erkennen sein, sonst tippt jemand
hinein und wundert sich. Der Grund steht in der Beschriftung darunter. */
.dayqty-in:disabled {
background: var(--c-bg);
color: var(--c-text-muted);
cursor: not-allowed;
}
.dayqty-sum {
font-weight: 700;
padding-top: 10px;
border-top: 0.5px solid var(--c-border);
}
/* ═══ SUCHE ════════════════════════════════════════════════════════════════
The palette. It is a .modal-bg, so it inherits the backdrop, the blur and
the z-index; what it overrides is the placement. A centred dialog that grows
downwards moves its own first row away from the eye on every keystroke, so
this one is pinned near the top and grows into empty space instead. */
.gs-bg {
align-items: flex-start;
justify-content: center;
padding: 10vh 16px 16px;
}
.gs-box {
display: flex;
flex-direction: column;
width: 100%;
max-width: 560px;
/* Bounded, so the list scrolls inside the box instead of running off the
bottom of the screen. It never reaches this on a desk — seven results and a
footer come to roughly 450px. The phone value is in the media query below,
and it is in dvh for a reason spelled out there. */
max-height: 100%;
background: var(--c-surface);
border-radius: 16px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
overflow: hidden;
}
.gs-field {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--c-border);
color: var(--c-text-muted);
}
/* The field carries no border of its own — the row it sits in is the border.
!important is not needed: the input rules in this file are all element or
class selectors, and .gs-field input is (0,2,1). */
.gs-field input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
padding: 0;
font-size: max(1rem, var(--fs-min));
color: var(--c-text);
}
.gs-field input:focus {
outline: none;
box-shadow: none;
}
.gs-esc {
flex-shrink: 0;
border: 1px solid var(--c-border);
background: transparent;
color: var(--c-text-muted);
border-radius: var(--radius-sm);
padding: 3px 8px;
font-family: monospace;
font-size: max(0.6875rem, var(--fs-min));
cursor: pointer;
/* Both floors, not just the tall one. On a phone the word "esc" is swapped
for a ✕ and this stops being a reminder and starts being the only way out
that a thumb can reach — at which point 24px tall and as wide as one glyph
is not a target. */
min-height: max(24px, var(--tap-sm-min));
min-width: max(28px, var(--tap-sm-min));
}
.gs-results {
overflow-y: auto;
/* The field and the footer stay put; this is the part that gives. */
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.gs-esc-x {
display: none;
}
/* Out of sight, still announced. Not display:none and not visibility:hidden —
both take an element out of the accessibility tree, which is the one thing
this element exists to be in. */
.gs-live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.gs-group {
padding: 8px 14px 3px;
font-size: max(0.6875rem, var(--fs-min));
color: var(--c-text-muted);
}
.gs-row {
display: block;
padding: 7px 14px;
cursor: pointer;
min-height: max(0px, var(--tap-sm-min));
box-sizing: border-box;
}
.gs-row-t,
.gs-row-s {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gs-row-t {
font-size: max(0.875rem, var(--fs-min));
color: var(--c-text);
}
.gs-mono {
font-family: monospace;
}
.gs-row-s {
font-size: max(0.75rem, var(--fs-min));
color: var(--c-text-sec);
}
/* Selection is one thing, and it is the same thing whether the keyboard or the
mouse put it there. Pairing .gs-on with :hover here said that and did the
opposite: arrow down to row two, leave the pointer over row four, and two
rows are lit while Enter opens one of them. The pointer now moves the
selection instead (gsInit's mousemove handler), so there is one rule and one
highlight. */
.gs-row.gs-on {
background: var(--c-primary-light);
}
.gs-row mark {
background: var(--c-amber-light);
color: inherit;
border-radius: 3px;
}
.gs-empty,
.gs-more {
padding: 14px;
font-size: max(0.8125rem, var(--fs-min));
color: var(--c-text-sec);
}
.gs-more {
padding: 7px 14px 10px;
color: var(--c-text-muted);
}
.gs-foot {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 9px 14px;
border-top: 1px solid var(--c-border);
font-size: max(0.75rem, var(--fs-min));
color: var(--c-text-muted);
}
.gs-hint {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Where the search landed. .batch-row-flash only reaches table rows (the
animation is on its td children, because a tr's own background is painted
under the cells); zones are divs, so this pairs the two selectors and reuses
the keyframes rather than writing a second amber fade. */
.gs-flash,
.gs-flash > td {
animation: batch-row-flash 1.5s ease;
}
/* The sidebar's own entry point. It is an .sb-btn so the collapse rules already
know what to do with it; what the wrapper adds is .sb-nav's own horizontal
padding, so the button lines up with the entries it sits above rather than
running 10px wider on each side. */
.sb-search-wrap {
padding: 12px 10px 0;
}
.sb-search-key {
margin-left: auto;
border: 1px solid var(--c-border);
border-radius: 4px;
padding: 1px 5px;
font-family: monospace;
font-size: max(0.625rem, var(--fs-min));
color: var(--c-text-muted);
}
.sidebar.sb-collapsed .sb-search-key {
display: none;
}
.mobile-search {
display: none;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
background: transparent;
color: var(--c-text-sec);
border-radius: var(--radius-sm);
cursor: pointer;
padding: 0;
/* Same pair as .mobile-bell, and for the same reason: 36 is the desk value
nothing renders, the floor is what a thumb gets. */
min-width: max(36px, var(--tap-sm-min));
min-height: max(36px, var(--tap-sm-min));
}
.mobile-search:hover {
background: var(--c-border-light);
color: var(--c-text);
}
@media (max-width: 768px) {
/* Nearer the top: the on-screen keyboard takes the lower half, and 10vh of a
phone is a strip of backdrop nobody can reach past. */
.gs-bg {
padding: 12px 10px 10px;
}
.gs-box {
max-width: none;
/* dvh, not vh, and not the 100% it used to be. .gs-bg is position:fixed
inset:0, so 100% of it is the window — and with the on-screen keyboard up
the window is still the whole window as far as vh and a fixed backdrop are
concerned. The bound therefore never applied on the one device it was
written for: a phone with the keyboard open has around 400px left, which
is three rows, and the other four ran off the bottom with no way to reach
them. Same reason and same fix as .wkf-list, which says it at length.
22px is this block's own vertical padding on .gs-bg. */
max-height: calc(100dvh - 22px);
}
/* The rows are what a thumb aims at, so they get the chrome floor rather than
the 7px a mouse is happy with. */
.gs-row {
padding: 10px 14px;
}
/* "↑↓ wählen · ↵ öffnen" over a device with no arrow keys and no Enter is a
line that describes somebody else's machine — and it is 40px of a screen
that has a keyboard sitting on half of it. The hint beside it says which
result Enter would open, which is the same sentence again. */
.gs-foot {
display: none;
}
/* And the same for the word on the dismiss button. It is a real tap target
here rather than a reminder, so it says what tapping it does. */
.gs-esc-key {
display: none;
}
.gs-esc-x {
display: inline;
}
.mobile-search {
display: flex;
}
/* The title takes the slack so the magnifier and the bell sit together at the
right edge, instead of the bell's margin-left:auto splitting it with them. */
.mobile-topbar .sb-logo {
flex: 1;
}
}
/* Destination chips open the shared zone picker (for when a location barcode
can't be scanned), so they need to read as tappable — and be tappable. They
carried a cursor and nothing else, which is a mouse affordance on a control
whose whole purpose is the case where scanning failed and a gloved hand has
to pick the zone by hand. */
#chip-to,
#cam-chip-to {
cursor: pointer;
min-height: var(--tap);
}
/* ═══ STOCKTAKE SCAN FLASH ═════════════════════════════════════
Per-bag confirmation during a zone check. The normal success flash paints
#scan-overlay, which sits behind the camera and is therefore invisible while
scanning — this flashes the camera view itself so a scan is unmistakable
without opening anything. */
#m-camscan.cam-flash-ok::after,
#m-camscan.cam-flash-err::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: 5;
animation: camFlash 0.28s ease-out;
}
#m-camscan.cam-flash-ok::after {
box-shadow: inset 0 0 0 6px #16a34a;
background: rgba(22, 163, 74, 0.16);
}
#m-camscan.cam-flash-err::after {
box-shadow: inset 0 0 0 6px #dc2626;
background: rgba(220, 38, 38, 0.16);
}
@keyframes camFlash {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
#m-camscan.cam-flash-ok::after,
#m-camscan.cam-flash-err::after {
animation-duration: 0.01s;
}
}
/* ═══ UNDO SNACKBAR ════════════════════════════════════════════ */
/* Bottom-centered "Rückgängig" bar for reversible → Fruchtung moves. */
.undo-bar {
position: fixed;
left: 50%;
bottom: calc(20px + env(safe-area-inset-bottom, 0px));
transform: translate(-50%, 150%);
z-index: 900;
display: flex;
align-items: center;
gap: 14px;
max-width: min(90vw, 420px);
padding: 11px 12px 11px 16px;
border-radius: 12px;
background: #14201a;
color: #eaf3ed;
font-size: 0.8125rem;
line-height: 1.3;
box-shadow: 0 12px 30px -8px rgba(0, 0, 0, 0.5);
opacity: 0;
pointer-events: none;
transition:
transform 0.2s ease,
opacity 0.2s ease;
}
/* The error skin. Same bar, same place, same size — only the colour and the
role change, because a message that moves to a different corner depending on
whether it is good news is a message the eye has to hunt for. Dark red rather
than the app's --c-red: this bar sits on #14201a in both themes, and a light
surface colour on it is unreadable. */
.undo-bar.is-err {
background: #3a1618;
color: #ffe1e1;
}
.undo-bar.show {
transform: translate(-50%, 0);
opacity: 1;
pointer-events: auto;
}
.undo-bar #undo-msg {
flex: 1;
min-width: 0;
}
.undo-bar #undo-btn {
flex: none;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.25);
color: #7ee2a1;
font-weight: 700;
font-size: 0.8125rem;
padding: 7px 14px;
border-radius: 8px;
cursor: pointer;
/* 31.5px, on the control that takes back a scan that went to the wrong zone.
Chrome floor rather than the Feld one: the bar is a transient overlay and
56px of it would cover a row of whatever is underneath. */
min-height: var(--tap-sm-min);
padding-inline: 18px;
}
.undo-bar #undo-btn:hover {
background: rgba(255, 255, 255, 0.08);
}
/* On phones the camera scan FAB sits bottom-right — lift the undo bar above it.
56px is the FAB's height and 12px the gap, so this tracks --fab-bottom instead
of restating a number derived from it: when the shelf moved to clear the
bottom nav, the hand-computed 92px did not, and the toast landed on the FAB. */
@media (pointer: coarse) and (hover: none) {
.undo-bar {
bottom: calc(var(--fab-bottom) + 56px + 12px + env(safe-area-inset-bottom, 0px));
}
}
/* "Neue Version der App da" — oben, weil unten schon der Rückgängig-Balken, der
Scannen-Knopf und die Leiste stehen. Er schwebt, statt die Seite
herunterzuschieben: er steht meist nur Sekunden, bis die App sich selbst neu
lädt. Die [hidden]-Regel ist nötig, weil display:flex die des Browsers schlägt. */
.update-bar[hidden] {
display: none;
}
.update-bar {
position: fixed;
left: 50%;
top: calc(8px + env(safe-area-inset-top, 0px));
transform: translateX(-50%);
z-index: 950;
display: flex;
align-items: center;
gap: 14px;
width: max-content;
max-width: min(92vw, 420px);
padding: 6px 6px 6px 16px;
border-radius: 12px;
background: #1e3a5f;
color: #e0ecff;
font-size: max(0.875rem, var(--fs-min));
font-weight: 600;
line-height: 1.3;
box-shadow: 0 12px 30px -8px rgba(0, 0, 0, 0.5);
}
.update-bar #update-btn {
flex: none;
min-height: var(--tap-sm-min);
padding: 0 16px;
border: none;
border-radius: 8px;
background: #e0ecff;
color: #1e3a5f;
font-family: inherit;
font-size: inherit;
font-weight: 700;
cursor: pointer;
}
/* Über der Kamera nicht: dort wird gescannt, und neu geladen wird ohnehin erst,
wenn sie zu ist. */
body:has(#m-camscan.open) .update-bar {
display: none;
}
/* Batch tasks and Alerts: side by side on a desk, stacked in a hand. These four
were an inline style, and an inline value outranks every rule in the sheet — so
the phone block below could turn the row into a column and could not touch the
alignment, leaving both cards at the width of their own text. Measured at 375px:
137px of card inside a 343px page. */
/* Der Farbleitfaden erklärt die Zeichen in der Aufgabenliste. Er gehört unter
die Liste und nicht über die halbe Karte: rechts, klein, zurückgenommen —
und nur eingeblendet, wenn eine Liste da ist (renderDashBatchTasks). Hier
steht bewusst kein display, damit das hidden-Attribut wirkt. */
/* Der Durchschnitt wird gerechnet, nicht getippt — also sieht er auch nicht wie
ein Eingabefeld aus. Gleiche Höhe wie ein Feld, damit die Reihe nicht
springt, aber ohne Rahmen und ohne Cursor. */
.avg-wert {
font-size: 0.8125rem;
padding: 5px 8px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--c-text);
}
.avg-einheit {
font-weight: 400;
color: var(--c-text-muted);
margin-left: 2px;
}
/* Über der Grenze: schraffiert statt nur rot. Rot war schon dreimal vergeben,
und eine Schraffur liest auch, wer Farben schlecht unterscheidet. */
.kap-voll {
background-image: repeating-linear-gradient(45deg, rgb(255 255 255 / 0.5) 0 3px, rgb(255 255 255 / 0) 3px 6px);
}
.dash-top-row {
display: flex;
gap: 10px;
align-items: flex-start;
/* Folgt den Kacheln; eine Liste, die an der letzten Kachel klebt, liest sich
als deren Fortsetzung. */
margin-top: 16px;
margin-bottom: 10px;
}
/* ═══ DESKTOP: batch tasks own a full-width row ════════════════ */
/* The move list is the primary card. On wide screens it takes its own
row so its two-button rows aren't crushed into a quarter-column; the
harvest / alerts / split cards flow onto the row below. Mobile keeps
the single-column stack from the max-width:768px block. */
@media (min-width: 769px) {
.dash-top-row {
flex-wrap: wrap;
}
.dash-top-row #dash-batch-tasks-card {
flex: 1 1 100% !important;
}
}
/* ═══ TABLET ═══════════════════════════════════════════════════ */
@media (max-width: 1024px) {
.g4,
.g6 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.rack-grid.rack-grid-5col {
grid-template-columns: repeat(3, 1fr);
}
.tent-columns {
grid-template-columns: repeat(2, 1fr);
}
.scan-modal {
width: 90vw;
max-width: 90vw;
height: 85vh;
max-height: 85vh;
}
}
/* ═══ MOBILE ════════════════════════════════════════════════════ */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
}
.sidebar.sb-open {
transform: translateX(0);
}
.mobile-topbar {
display: flex;
}
/* The title gives, the chrome does not. The bar holds the page's name now, and
the longest of them — pt `nav.workSteps`, "Etapas de trabalho" — is 4px over a
320px bar. Without these the overflow pushes the bell and the sync dot off the
right edge instead; .sb-logo carries flex-shrink: 0, and both it and the span
need min-width: 0 before an ellipsis can happen at all. */
.mobile-topbar .sb-logo {
flex-shrink: 1;
min-width: 0;
}
#topbar-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.content-wrap {
margin-left: 0;
}
.sb-is-collapsed .content-wrap {
margin-left: 0;
}
/* Constrain inline-sized inputs so they can't overflow narrow viewports */
input[style*='width:'],
select[style*='width:'] {
max-width: 100%;
}
/* Eight fields the rule above was written for and could not reach: their cap
was an inline `max-width`, which outranks every rule in this sheet. It is a
custom property now, so the cascade decides again — same device as
.fs-floor and --fs-own. Two of the eight declare no inline width at all, so
the attribute selector does not match them either; this is what covers them. */
.w-cap {
max-width: 100%;
}
/* WCAG-size touch targets for calendar toolbar on mobile */
.cal-nav-btn,
.cal-print-btn {
width: 44px;
height: 44px;
font-size: 1.125rem;
}
.cal-today-btn,
.cal-add-btn {
min-height: 44px;
}
.cal-vbtn {
min-height: 44px;
}
.scan-modal {
width: 95vw;
max-width: 95vw;
max-height: 90vh;
max-height: 90dvh;
height: 90vh;
height: 90dvh;
border-radius: 12px;
padding: 0;
}
.scan-overlay {
align-items: center;
}
.scan-modal-log {
max-height: 40vh;
max-height: 40dvh;
}
/* Narrower side padding so four tabs fit; the size stays where the token put
it. Shrinking type at a narrow breakpoint is the exact move the token layer
replaced — and this one was on the tab strip of the screen that is only
ever read at arm's length. */
.scan-tab {
padding: 10px 10px;
}
.g3,
.g4,
.g6 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.g2 {
grid-template-columns: minmax(0, 1fr);
}
th,
td {
padding: 7px 6px;
}
.met-v {
font-size: 1.375rem;
}
.cal-cell {
min-height: 72px;
}
.cal-toolbar {
flex-direction: column;
align-items: stretch;
}
.cal-toolbar-left,
.cal-toolbar-right {
justify-content: center;
}
.cal-title {
font-size: 1rem;
margin-left: 4px;
}
/* Bigger day cells on phone (audit Section 1.14: gloved hands can't
reliably hit 24x24 — adjacent dates get fat-fingered). 36x36 keeps the
7-column grid usable on 360px (7×36 = 252px) while leaving 100+px for
padding/events. */
.cal-day {
width: 36px;
height: 36px;
font-size: 0.8125rem;
}
.cal-event {
padding: 1px 5px;
}
.cal-week-hdr-cell .wk-day-num {
font-size: 1rem;
}
/* The drawer stops repeating the bottom bar. app.js marks these from the same
list that wires the bar's buttons, so the two cannot drift apart: 19 rows
and 868px of content in a 655px viewport becomes 14 rows that fit, and the
five destinations that were pushed below the fold were the five the bar
shows permanently anyway. A group heading whose entries are all in the bar
is marked too, or "Arbeiten" would sit alone over the gap. */
.sb-in-bottom-nav {
display: none;
}
/* Both of these describe the grids, and below this breakpoint there are no
grids. The toggle would offer three views that all render the agenda; the
legend is eight colour keys for dots whose meaning every agenda row spells
out in words next to the label. Hidden rather than shrunk — the eight
legend spans were the last sub-floor type in the app at 11px, and the
honest fix for a key nobody needs is not a bigger key. */
.cal-view-toggle,
.cal-legend {
display: none;
}
.dash-top-row {
flex-direction: column;
align-items: stretch;
}
.rack-grid.rack-grid-5col {
grid-template-columns: repeat(2, 1fr);
}
.tent-columns {
grid-template-columns: 1fr;
}
.modal {
width: 96%;
}
.scan-modal {
width: 95vw;
height: 90vh;
height: 90dvh;
max-height: 90vh;
max-height: 90dvh;
max-width: 95vw;
border-radius: 12px;
margin: 0;
}
.scan-overlay.open {
align-items: center;
}
.scan-modal-log {
min-height: 200px;
}
}
@media (max-width: 480px) {
.dash-search-inline {
width: 140px;
}
.card {
border-radius: var(--radius-sm);
}
.g2,
.g3,
.g4,
.g6 {
grid-template-columns: minmax(0, 1fr);
}
.cal-cell {
min-height: 60px;
padding: 4px;
}
.todo-row {
padding: 10px;
gap: 8px;
}
.scan-modal {
padding: 12px;
}
.scan-modal-chips {
gap: 4px;
}
.chip {
min-width: 44px;
padding: 4px 6px;
}
.scan-modal-btns {
flex-wrap: wrap;
justify-content: center;
}
}
/* ═══ LANDSCAPE (short viewports) ═══════════════════════════════ */
@media (max-height: 500px) and (orientation: landscape) {
.modal {
max-height: calc(100dvh - 20px);
padding: 16px 20px;
}
.modal h3 {
margin-bottom: 6px;
}
.scan-modal {
height: auto;
max-height: calc(100dvh - 20px);
}
.scan-modal-log {
max-height: 30vh;
}
.cal-toolbar {
flex-direction: row;
flex-wrap: wrap;
}
.content-wrap {
min-height: 100dvh;
}
}
/* ═══ PRINT ═════════════════════════════════════════════════════ */
@media print {
.sidebar,
.mobile-topbar,
.scan-toast-inline,
#harvest-panel,
.no-print,
.sb-overlay {
display: none !important;
}
.content-wrap {
margin-left: 0 !important;
min-height: 0 !important;
display: none !important;
}
.main {
padding: 0;
max-width: 100%;
}
.page,
.sp {
display: none !important;
}
#print-sheet,
#ref-print-sheet {
display: block !important;
padding: 10px;
}
body {
background: #fff;
}
.card {
border: none;
box-shadow: none;
padding: 0;
}
/* Calendar print */
.cal-print-page {
font-family: Arial, Helvetica, sans-serif;
}
.cal-print-header {
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 2px solid #333;
}
.cal-print-body .cal-grid {
border: 1px solid #999;
gap: 0;
}
.cal-print-body .cal-hdr {
background: #f0f0f0;
border-bottom: 1px solid #999;
padding: 6px 4px;
font-size: 10px;
}
.cal-print-body .cal-cell {
border: 0.5px solid #ccc;
min-height: 80px;
padding: 4px 5px;
}
.cal-print-body .cal-cell.today {
background: #f0faf5;
}
.cal-print-body .cal-cell.other {
background: #f8f8f8;
}
.cal-print-body .cal-day {
font-size: 11px;
width: auto;
height: auto;
}
.cal-print-body .cal-event {
font-size: 9px;
padding: 1px 5px;
margin-bottom: 2px;
border-radius: 3px;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.cal-print-body .cal-more {
font-size: 9px;
}
.cal-print-body .cal-week {
border: 1px solid #999;
}
.cal-print-body .cal-week-hdr {
border-bottom: 1px solid #999;
}
.cal-print-body .cal-week-hdr-cell {
padding: 6px 4px;
font-size: 10px;
}
.cal-print-body .cal-week-hdr-cell .wk-day-num {
font-size: 16px;
}
.cal-print-body .cal-week-body {
max-height: none;
overflow: visible;
}
.cal-print-body .cal-week-ev {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
font-size: 9px;
}
.cal-print-body .cal-week-slot {
height: 36px;
}
.cal-print-body .cal-week-time {
height: 36px;
font-size: 9px;
}
.cal-print-body .cal-day-view {
border: 1px solid #999;
}
.cal-print-body .cal-day-body {
max-height: none;
overflow: visible;
}
.cal-print-body .cal-day-ev {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
font-size: 10px;
}
.cal-print-body .cal-day-slot {
height: 36px;
}
.cal-print-body .cal-day-time {
height: 36px;
font-size: 9px;
}
/* Calendar print — task list */
.cal-print-tasklist .cal-print-header {
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid #333;
}
.cal-print-tasklist .cal-print-day {
margin-bottom: 10px;
page-break-inside: avoid;
border: 1px solid #ccc;
border-radius: 4px;
overflow: hidden;
}
.cal-print-tasklist .cal-print-day.today {
border-color: #16a34a;
}
.cal-print-tasklist .cal-print-day-hdr {
background: #f0f0f0;
padding: 6px 10px;
font-size: 12px;
font-weight: 700;
color: #111;
border-bottom: 1px solid #ccc;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.cal-print-tasklist .cal-print-day.today .cal-print-day-hdr {
background: #dcfce7;
}
.cal-print-tasklist .cal-print-empty {
padding: 6px 10px;
font-size: 10px;
color: #888;
font-style: italic;
}
.cal-print-tasklist .cal-print-list {
list-style: none;
margin: 0;
padding: 0;
}
.cal-print-tasklist .cal-print-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 5px 10px;
font-size: 11px;
color: #111;
border-bottom: 1px dashed #e5e5e5;
}
.cal-print-tasklist .cal-print-item:last-child {
border-bottom: none;
}
.cal-print-tasklist .cal-print-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 4px;
flex-shrink: 0;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.cal-print-tasklist .cal-print-time {
min-width: 82px;
font-variant-numeric: tabular-nums;
font-weight: 600;
color: #333;
flex-shrink: 0;
}
.cal-print-tasklist .cal-print-type {
min-width: 70px;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #666;
padding-top: 1px;
flex-shrink: 0;
}
.cal-print-tasklist .cal-print-label {
flex: 1;
font-weight: 500;
}
.cal-print-tasklist .cal-print-assignees {
font-weight: 400;
color: #555;
font-size: 10px;
}
.cal-print-tasklist .cal-print-desc {
font-weight: 400;
color: #555;
font-size: 10px;
margin-top: 2px;
}
}
#print-sheet,
#ref-print-sheet {
display: none;
}
/* Zone management */
.zone-row {
padding: 10px 12px;
border-radius: 8px;
background: var(--c-surface);
margin-bottom: 8px;
position: relative;
}
.zone-row-header {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.zone-row-name {
font-weight: 600;
font-size: 0.875rem;
}
.zone-row-racks {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.zone-rack-chip {
font-size: max(0.6875rem, var(--fs-min));
font-family: monospace;
padding: 2px 8px;
border-radius: 4px;
background: var(--c-border-light);
display: inline-flex;
align-items: center;
gap: 3px;
}
.zone-rack-del {
padding: 1px 4px !important;
font-size: max(0.5625rem, var(--fs-min)) !important;
margin-left: 4px;
line-height: 1;
}
.zone-group-header {
font-size: max(0.6875rem, var(--fs-min));
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--c-text-muted);
padding: 14px 4px 6px;
border-bottom: 1px solid var(--c-border-light);
margin-bottom: 6px;
}
.zone-group-header:first-child {
padding-top: 4px;
}
.zone-drag-handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 22px;
margin-right: 2px;
font-size: 0.875rem;
font-weight: 700;
letter-spacing: -2px;
color: var(--c-text-muted);
cursor: grab;
user-select: none;
border-radius: 3px;
}
.zone-drag-handle:hover {
background: var(--c-border-light);
color: var(--c-text);
}
.zone-drag-handle:active {
cursor: grabbing;
}
.zone-row.zone-dragging {
opacity: 0.4;
}
.zone-row.zone-drop-before {
box-shadow: inset 0 4px 0 0 var(--c-primary);
}
.zone-row.zone-drop-after {
box-shadow: inset 0 -4px 0 0 var(--c-primary);
}
.zone-direct-badge {
background: var(--c-red) !important;
color: #fff !important;
font-size: max(0.75rem, var(--fs-min)) !important;
font-weight: 700 !important;
padding: 4px 10px !important;
border-radius: 6px !important;
box-shadow: 0 0 0 2px var(--c-red-light);
letter-spacing: 0.3px;
}
.zone-qr-panel {
border-top: 1px solid var(--c-border-light);
margin-top: 6px;
padding-top: 8px;
}
.zone-qr-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.zone-qr-cell {
border: 1px solid var(--c-border);
border-radius: 6px;
padding: 8px;
text-align: center;
background: var(--c-surface);
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.zone-qr-label {
font-size: max(0.6875rem, var(--fs-min));
font-weight: 600;
font-family: monospace;
color: var(--c-text-sec);
}
/* ═══ NOTIFICATIONS (floating inbox top-right) ═══════════════════ */
.notif-fab {
position: fixed;
top: 12px;
right: 16px;
z-index: 150;
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
padding: 0;
border: 1px solid var(--c-border);
border-radius: 50%;
background: var(--c-surface);
color: var(--c-text-sec);
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition:
background 0.15s,
color 0.15s,
box-shadow 0.15s,
transform 0.08s;
}
.notif-fab svg {
width: 24px;
height: 24px;
}
.notif-fab:hover {
background: var(--c-border-light);
color: var(--c-text);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.notif-fab:active {
transform: translateY(1px);
}
/* When there are unread notifications, make the button prominent and pulse */
.notif-fab:has(.bell-badge:not([hidden])) {
background: var(--c-primary);
color: #fff;
border-color: var(--c-primary);
animation: notif-pulse 2s ease-in-out infinite;
}
.notif-fab:has(.bell-badge:not([hidden])):hover {
background: var(--c-primary);
color: #fff;
filter: brightness(1.05);
}
@keyframes notif-pulse {
0%,
100% {
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.08),
0 0 0 0 rgba(22, 163, 74, 0.55);
}
50% {
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.08),
0 0 0 10px rgba(22, 163, 74, 0);
}
}
@media (prefers-reduced-motion: reduce) {
/* Disable every named keyframe animation in this stylesheet. The standard
"*, *::before, *::after { animation-duration: 0.01ms !important }" trick
is too heavy — it would also kill open/close transitions which are
functional (they signal state change). Listing the animation-using
selectors explicitly leaves transitions intact. */
.notif-fab:has(.bell-badge:not([hidden])),
.chip-pulse,
.count-bump .chip-v,
.cam-chip.ch-pulse,
.batch-row-flash > td,
.gs-flash,
.gs-flash > td {
animation: none !important;
}
}
.bell-badge {
position: absolute;
top: -2px;
right: -2px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: #ef4444;
color: #fff;
font-size: max(0.6875rem, var(--fs-min));
font-weight: 700;
line-height: 18px;
text-align: center;
box-shadow: 0 0 0 2px var(--c-surface);
}
.bell-badge[hidden] {
display: none;
}
.mobile-bell {
position: relative;
display: none;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
background: transparent;
color: var(--c-text-sec);
border-radius: var(--radius-sm);
cursor: pointer;
margin-left: auto;
padding: 0;
/* 36px is a phone value in a rule the phone is the only reader of — the bell
is display:none until 768px. The floor lifts it to 48 there and leaves the
literal alone above, where nothing renders it anyway. The topbar's own
padding drops by the same 12px (see the 768px block), so the bar stays the
height it is and only the target grows. */
min-width: max(36px, var(--tap-sm-min));
min-height: max(36px, var(--tap-sm-min));
}
.mobile-bell:hover {
background: var(--c-border-light);
color: var(--c-text);
}
.mobile-bell .bell-badge {
top: 2px;
right: 2px;
box-shadow: 0 0 0 2px var(--c-surface);
}
.notif-dropdown {
position: fixed;
top: 60px;
right: 16px;
width: 360px;
max-width: calc(100vw - 24px);
max-height: 70vh;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18);
z-index: 200;
display: flex;
flex-direction: column;
overflow: hidden;
}
.notif-dropdown[hidden] {
display: none;
}
.notif-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid var(--c-border-light);
}
.notif-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--c-text);
}
.notif-mark-read {
font-size: max(0.6875rem, var(--fs-min));
color: var(--c-primary);
background: transparent;
border: none;
cursor: pointer;
font-family: inherit;
padding: 2px 4px;
}
.notif-mark-read:hover {
text-decoration: underline;
}
.notif-list {
flex: 1;
overflow-y: auto;
}
.notif-item {
display: block;
width: 100%;
padding: 10px 14px;
border: none;
background: transparent;
text-align: left;
font-family: inherit;
cursor: pointer;
border-bottom: 1px solid var(--c-border-light);
color: var(--c-text);
}
.notif-item:last-child {
border-bottom: none;
}
.notif-item:hover {
background: var(--c-border-light);
}
.notif-item.unread {
background: var(--c-primary-light);
}
.notif-item.unread:hover {
background: var(--c-primary-light);
filter: brightness(0.97);
}
.notif-item-title {
font-size: 0.8125rem;
font-weight: 600;
margin-bottom: 2px;
display: flex;
align-items: center;
gap: 6px;
}
.notif-item.unread .notif-item-title::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--c-primary);
flex-shrink: 0;
}
.notif-item-body {
font-size: max(0.75rem, var(--fs-min));
color: var(--c-text-sec);
margin-bottom: 3px;
}
.notif-item-time {
font-size: max(0.6875rem, var(--fs-min));
color: var(--c-text-muted);
}
.notif-empty {
padding: 24px 14px;
text-align: center;
color: var(--c-text-muted);
font-size: max(0.75rem, var(--fs-min));
}
@media (max-width: 768px) {
/* On mobile, the mobile-topbar has its own inbox icon — hide the floating FAB */
.notif-fab {
display: none;
}
.mobile-bell {
display: inline-flex;
}
/* Paid for out of the bar's own padding rather than the screen: 10px around a
36px bell and 4px around a 48px one are both 56px tall. */
.mobile-topbar {
padding-block: 4px;
padding-top: calc(4px + env(safe-area-inset-top, 0px));
}
.notif-dropdown {
left: 8px;
right: 8px;
/* Under the bar, not under the notch. The bar is 56px of content plus
whatever the display cutout takes — it says so itself, two rules up —
and this is `fixed`, so its 56px is measured from the physical top of
the screen, which with viewport-fit=cover is behind the status bar. On a
phone with no cutout the two agree and the panel sits flush; on one with
a notch or an island the panel opened 47-59px high and, at z-index 200
against the bar's 110, covered the bell that had just been tapped. */
top: calc(56px + env(safe-area-inset-top, 0px));
bottom: auto;
width: auto;
max-width: none;
/* Same correction at the other end: 80px was the bar plus a margin, and the
home indicator is not in it. Without the inset the last row of the list
sits in the strip iOS reserves for its own swipe. */
max-height: calc(100vh - 80px);
max-height: calc(100dvh - 80px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
}
}
/* ═══ MOBILE BOTTOM NAVIGATION ════════════════════════════════
Fixed tab bar with the four most-used pages — Dashboard, Batches,
Lab, Calendar. Hidden on desktop; shown on phones via the media
query below. Sidebar remains accessible via the hamburger for the
less-frequent pages (Inventory, Zones, Print, Strains). */
.bottom-nav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
background: var(--c-surface);
border-top: 1px solid var(--c-border);
padding-bottom: env(safe-area-inset-bottom, 0);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06);
}
.bottom-nav-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
min-height: 56px;
/* 2 px waagerecht und nicht 4, weil die fünf deutschen Beschriftungen bei
320 px genau daran scheiterten. Gemessen, nicht geschätzt: Die Texte
„Arbeitsgänge, Dashboard, Chargen, Labor, Kalender" sind 83 + 67 + 52 +
36 + 55 = 293 px breit, dazu 5×8 px Polsterung macht 333 — und „Kalender"
hing 14 px über den rechten Rand.
Kein `min-width: 0`, und das ist der Punkt, an dem der naheliegende Griff
falsch ist: `flex: 1` hat `flex-basis: 0`, also bekämen mit `min-width: 0`
ALLE fünf Knöpfe dieselben 64 px, und „Arbeitsgänge" (83 px) würde
abgeschnitten, obwohl die Summe passt. Ohne ihn nimmt jeder Knopf seine
Inhaltsbreite, und 293 + 5×4 = 313 passen in 320.
Der Rest ist sieben Pixel Luft, und die sind dünn. Sie sind aber gemessen
und bleiben gemessen: Eine längere Übersetzung meldet der Lauf als
OVERFLOW-Befund, statt sie stillschweigend abzuschneiden. */
padding: 8px 2px;
background: none;
border: none;
color: var(--c-text-muted);
font-size: max(0.6875rem, var(--fs-min));
font-weight: 500;
font-family: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition:
color 0.15s,
background 0.15s;
}
.bottom-nav-btn:active {
background: rgba(0, 0, 0, 0.05);
}
.bottom-nav-btn.active {
color: var(--c-primary);
}
.bottom-nav-btn svg {
flex-shrink: 0;
}
@media (max-width: 768px) {
.bottom-nav {
display: flex;
}
/* Reserve space so page content isn't hidden behind the fixed bar.
56px nav + safe-area inset for devices with home indicators. */
.content-wrap {
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0));
}
/* ── The touch floor, once, for everything that takes a tap ──
Every other touch rule in this file names a component: .btn-sm and
.btn-xs get one under (pointer: coarse), .sb-btn another, .sle-undo
a third. That works exactly as far as the list someone remembered,
and rendering the app against a seeded backend at 375px found five
shapes that were never on it — the column-sort buttons in the batch
table at 21px, the "Prüfen" buttons on the zone sections at 28px,
the collapsed-group toggles at 27px, and "Wochenrhythmus bearbeiten"
on the dashboard at 15px. All five carry one of the .fs-* utilities
and nothing else, which is what made them look styled.
So this stops enumerating components. 347 buttons render on a phone;
this moves 12 of them, and every one it moves was under the floor or
(three calendar buttons) at the universal 44px minimum rather than
the 48px §9 chose for Büro work. Nothing already above it changes.
A <div> with an onclick is a control the markup does not declare, so
no selector can find it in general. .location-section-header is the
one this app has. */
button,
[role='button'],
.location-section-header {
min-height: var(--tap-sm-min);
}
/* min-height alone makes a taller box with the label at the top of it
when the button sets its own display; the UA's centring only applies
to the default one. */
button,
[role='button'] {
align-content: center;
}
}
/* Die Benutzertabelle unter System wird in app.js gebaut, und ihre Zellen
trugen ihre Polsterung INLINE. Inline schlägt jede Regel im Blatt, auch die
92px Rinne des Kartenmodus: Beschriftung und Wert lagen übereinander,
„ROLE" quer durch „admin". Gemessen hat das nichts, weil nichts überlief und
nichts zu klein war — es stand nur falsch, und gesehen hat es erst ein Bild.
Darum steht die Polsterung hier, mit einer Spezifität (0,1,1), die der
Kartenmodus (0,3,3) überbieten kann. */
.us-tab th,
.us-tab td {
padding: 6px;
}
.us-tab th {
text-align: left;
border-bottom: 1px solid var(--c-border);
}
/* Die Spalten legen sich an ihren Inhalt. `table { width: 100% }` verteilt den
Rest gleichmäßig über die Spalten, und bei einer Zeile stand „probe" dadurch
in einer Spalte von 254 px. Im Kartenmodus gewinnt `.t-cards { width: 100% }`
weiter, weil es weiter unten im Blatt steht. */
.us-tab {
width: auto;
}
/* ⚠️ **Ein Ankreuzfeld ist kein Textfeld** — und für diese zwei Spalten auch
nicht die richtige Form. Die globale Regel (styles.css:2095) gibt jedem
`input` `width: 100%`, `padding: 9px 12px` und `min-height: 64px`, und ein
Ankreuzfeld erbt jedes Wort davon: hier 202 x 64 px um ein Häkchen von 13 px.
Siehe Befund T8, der auch sagt, warum kein Messlauf das je gemeldet hat: Die
TOUCH-Prüfung sucht Flächen, die zu KLEIN sind, zu groß ist keine Kategorie.
Ein Ankreuzfeld auf Fingergröße zu ziehen behebt das nicht, es macht einen
größeren Klotz: `appearance: auto` malt über den Polsterkasten, also ist die
Trefferfläche IMMER genau so groß wie das gezeichnete Kästchen. Wer 44 px
treffen will, sieht 44 px. Gemessen, nicht vermutet.
Julian hat sich am 25.08. den Schalter ausgesucht. Er ist weiterhin ein
echtes `input[type=checkbox]` — Tastatur, `:checked`, der bestehende
Klick-Auswerter in app.js bleiben, wie sie sind — nur gezeichnet wird er
selbst. Und selbst gezeichnet lassen sich Fläche und Bild trennen, was ein
natives Kästchen nicht kann:
Der Kasten ist `max(28px, --tap-sm-min)` hoch, also 48 px in der Hand und
28 px am Schreibtisch (Regel R1: die Eingabeachse entscheidet, nicht die
Breite). Die Pille darin ist immer 28 px hoch. Den Unterschied tragen
durchsichtige Ränder, und `background-clip: padding-box` sorgt dafür, dass
die Farbe nur im Polsterkasten liegt. Die Fläche hält damit die 44 px des
Fingerbodens (WCAG 2.5.5) und die 24 px der Maus (2.5.8, Stufe AA), ohne
dass das Bild mitwächst.
Der Knopf ist ein zweiter Hintergrund und keine `::before`. Ein `input` ist
ein ersetztes Element; dass Chrome dort Pseudoelemente zeichnet, sobald
`appearance: none` gesetzt ist, gilt für WebKit nicht verlässlich, und
Julian arbeitet in Safari. Ein Farbverlauf ist überall ein Hintergrund. Er
wandert über `background-position`, das lässt sich weich schalten. */
.us-tab input[type='checkbox'] {
appearance: none;
-webkit-appearance: none;
box-sizing: border-box;
width: 50px;
height: max(28px, var(--tap-sm-min));
min-height: 0;
padding: 0;
margin: 0;
border: 0 solid transparent;
border-top-width: calc((max(28px, var(--tap-sm-min)) - 28px) / 2);
border-bottom-width: calc((max(28px, var(--tap-sm-min)) - 28px) / 2);
border-radius: 999px;
background-color: var(--c-border);
background-image: radial-gradient(circle 11px at 14px 50%, #fff 98%, rgba(255, 255, 255, 0) 100%);
background-repeat: no-repeat;
background-clip: padding-box;
background-position: 0 0;
cursor: pointer;
}
.us-tab input[type='checkbox']:checked {
background-color: var(--c-primary);
background-position: 22px 0;
}
/* Ein Schalter, der ohne Bewegung umspringt, sagt nicht, dass er geschaltet
hat. Mit Bewegungsempfindlichkeit springt er trotzdem, das ist die Regel. */
@media (prefers-reduced-motion: no-preference) {
.us-tab input[type='checkbox'] {
transition:
background-color 0.15s,
background-position 0.15s;
}
}
/* Admins dürfen immer, das Feld ist gesperrt. Gesperrt heißt hier gedämpft und
nicht ausgegraut: der Zustand ist WAHR und soll ablesbar bleiben. */
.us-tab input[type='checkbox']:disabled {
opacity: 0.5;
cursor: default;
}
.us-tab input[type='checkbox']:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: 2px;
}
/* zwilling: kartenmodus */
@media (max-width: 768px) {
/* ── Tables as cards ────────────────────────────────────────
A 12-column table on a 360px viewport puts its Actions column
roughly 700px off-screen. Below this breakpoint the rows become
stacked cards: thead off, tr a card, td a label/value row, and the
label read from the data-mlabel the renderer already writes.
One mechanism, applied by class. Four tables had this spelled out
separately — #t-batches, #t-cultures, #t-log, #t-harvest — in four
blocks that were the same rules under different ids, 306 lines of
it. The cost of that shape was never the duplication: #t-grain is
filled by batchRowHtml(), the *same function* that fills
#t-batches, so every one of its cells already carried a
data-mlabel — and it still got a sideways-scrolling table, because
the fifth copy of the block was never written.
Two structural facts now do the work four ids used to:
- the card header is the first cell, in all four tables and by
construction — batch id, culture id, log time, harvest date;
- the actions cell is the one with no data-mlabel, because a row
of buttons has nothing to label. That is what distinguishes it
from a last cell that *is* data, which is what #t-harvest has
and why :last-child would have been wrong. */
.t-cards {
border: 0;
width: 100%;
}
.t-cards > thead {
display: none;
}
.t-cards > tbody > tr {
display: block;
border: 1px solid var(--c-border);
border-radius: 10px;
background: var(--c-surface);
margin-bottom: 10px;
padding: 12px 14px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
}
.t-cards > tbody > tr > td {
display: block;
padding: 4px 0;
border: 0;
font-size: 0.8125rem;
line-height: 1.4;
/* The card carries the background now, not the cell. Two table rules
still target these cells — `tr:nth-child(even) td` and `tr:hover td`
— and both outlive the layout they were written for: zebra striping
tints every second *card* grey instead of every second row, and a
tap on a coarse pointer leaves the hover tint stuck on. Neither was
introduced by the card mode; neither had been looked at since it
arrived. */
background: none;
}
/* Label in a gutter, value in normal flow.
This cell was `display: flex` with the label as its first item, which
reads correctly and is wrong: making the cell a flex container makes
*every* element child a flex item, so the layout silently depended on
each cell holding exactly one. #t-batches' Substrat cell holds three —
batchRowHtml() writes one .sub-tag per material — and they became three
competing items on a nowrap line, each shrunk until "HW 60% WB 20%"
wrapped to four lines inside a 99px-radius pill. Eight cards, every
batch, and no count could see it: the chips do not exist until the
renderer runs, so both the source census and measure-mobile.js (which
serves the page with the scripts stripped) looked straight past them.
Normal flow is also what these chips get from the desktop table cell
they were written for, so the two layouts now differ in where the label
sits and not in how the value is laid out. */
.t-cards > tbody > tr > td[data-mlabel]:not(:first-child) {
position: relative;
padding-left: 92px;
}
/* The label was 10–11px here, which is the size this whole project
exists to stop shipping. Uppercase, 600 and muted already say
"this is a label" without help from a size below the floor.
84px fits every label the renderers emit except "Chargen-ID" at 87px,
which is a first cell and hidden; the rest top out at 72px. */
.t-cards > tbody > tr > td[data-mlabel]:not(:first-child)::before {
content: attr(data-mlabel);
position: absolute;
left: 0;
top: 4px;
width: 84px;
color: var(--c-text-muted);
font-size: var(--fs-xs);
font-weight: 600;
line-height: 1.4;
text-transform: uppercase;
letter-spacing: 0.02em;
}
/* The other half of the same cell. Normal flow fixed the three chips
competing as flex items; it does not fix each chip being an inline box,
which splits its background and its 99px radius across two line boxes the
moment it wraps — "RH 63%" came out as a 155×39 smear. A pill has to be a
box. Scoped to the card because the desktop table's Substrat column is
~63px wide, where the same keyword wraps every chip into a padded stack
and takes #t-batches from 760px to 1093px tall. Same defect, different
fix: the desktop needs its column widths looked at. */
.t-cards .sub-tag {
display: inline-block;
}
/* First cell: the card's header. What it looks like is per table — an
id wants to be big, a timestamp wants to be a quiet subtitle — so
this sets only what they share. Its label is not hidden here but never
written: the :not(:first-child) above is what keeps 184 of the 189
first cells from indenting 92px around a label nobody wants to see. */
.t-cards > tbody > tr > td:first-child {
padding-bottom: 6px;
margin-bottom: 6px;
border-bottom: 1px solid var(--c-border-light);
}
/* Actions row — full width, buttons share it equally. The
(pointer: coarse) rule near .btn-sm already gives them 44px. */
.t-cards > tbody > tr > td:not([data-mlabel]):not(:first-child):not(.empty) {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--c-border-light);
white-space: normal !important;
}
.t-cards > tbody > tr > td:not([data-mlabel]):not(:first-child):empty {
display: none;
}
.t-cards > tbody > tr > td:not([data-mlabel]) .btn-sm,
.t-cards > tbody > tr > td:not([data-mlabel]) .btn-xs {
flex: 1 1 auto;
min-width: 0;
margin: 0 !important;
text-align: center;
}
/* Empty/no-matches row keeps a centred full-row look. */
.t-cards > tbody > tr > td.empty {
display: block;
text-align: center;
padding: 18px 0;
border-bottom: 0;
margin-bottom: 0;
}
/* ── Per table, only what is genuinely its own ───────────────── */
/* Batches and grain lead with an id, which is the card's name. */
#t-batches > tbody > tr > td.bt-id,
#t-grain > tbody > tr > td.bt-id {
font-size: 0.9375rem !important;
font-weight: 600;
}
/* The scan log and the harvest log lead with a time, which is not a
name — it dates the row rather than titling it. */
#t-log > tbody > tr > td.lg-time,
#t-harvest > tbody > tr > td.hv-date {
font-size: max(0.75rem, var(--fs-min)) !important;
color: var(--c-text-sec) !important;
padding-bottom: 4px;
margin-bottom: 4px;
}
/* Sub-row injected by toggleBatchBags() — keep its full-width layout
for the bag chips, just retint it as a connected detail panel. */
#t-batches > tbody > tr[id^='brow-'],
#t-grain > tbody > tr[id^='brow-'] {
border: 1px dashed var(--c-border);
padding: 10px 12px;
background: var(--c-bg);
}
/* Harvest cards: grams pinned top-right as a big amber number, the
most-scanned value when reading a harvest log on a phone. */
#t-harvest > tbody > tr {
position: relative;
}
#t-harvest > tbody > tr > td.hv-date {
padding-right: 96px;
}
#t-harvest > tbody > tr > td.hv-grams {
position: absolute;
top: 8px;
right: 12px;
font-size: 1.25rem !important;
font-weight: 600 !important;
line-height: 1.1;
padding: 0 !important;
}
#t-harvest > tbody > tr > td.hv-grams::before {
display: none;
}
}
/* zwilling: kartenmodus
── Regel R2: der Kasten misst, nicht das Fenster ───────────────────────
Der Kartenmodus hing an der Fensterbreite, und darin steckte die Klippe bei
769 px: dort kommt die Seitenleiste zurück und nimmt 240 px vom Inhalt weg,
die Tabelle bekommt aber weiter das Layout für 769 px statt für die 497 px,
die ihr geblieben sind. Gemessen war das der Grund für zwei Ratschenzeilen
(Labor 769-860 px verkleinert, Kontaminationsstreifen 769-800 px über den
Rand) — ein Fehler mit zwei Gesichtern, je nachdem ob die Meta-Ansicht gilt.
736 px ist keine neue Grenze, sondern dieselbe: Auf dem Telefon ist die
Seitenleiste weg und `.main` hat 2×16 px Polsterung, 768 − 32 = 736. Der
Kartenmodus setzt also auf dem Telefon exakt dort ein wie vorher, und auf
dem Schreibtisch zusätzlich, sobald die Inhaltsspalte schmal genug ist —
was auch heißt: Wird die Seitenleiste eingeklappt, folgt er von selbst.
Keine Medienabfrage der Welt kann das.
ZWEIMAL GESCHRIEBEN, mit Absicht. Die @media-Fassung ist die Rückfallebene
für einen Browser ohne Container-Abfragen (Chrome vor 105, Safari vor 16);
dort bleibt alles, wie es heute ist. Ein Test hält die beiden Fassungen
Zeichen für Zeichen gleich, weil zwei Kopien sonst genau so lange
übereinstimmen, bis jemand eine davon ändert. */
@container inhalt (max-width: 736px) {
/* ── Tables as cards ────────────────────────────────────────
A 12-column table on a 360px viewport puts its Actions column
roughly 700px off-screen. Below this breakpoint the rows become
stacked cards: thead off, tr a card, td a label/value row, and the
label read from the data-mlabel the renderer already writes.
One mechanism, applied by class. Four tables had this spelled out
separately — #t-batches, #t-cultures, #t-log, #t-harvest — in four
blocks that were the same rules under different ids, 306 lines of
it. The cost of that shape was never the duplication: #t-grain is
filled by batchRowHtml(), the *same function* that fills
#t-batches, so every one of its cells already carried a
data-mlabel — and it still got a sideways-scrolling table, because
the fifth copy of the block was never written.
Two structural facts now do the work four ids used to:
- the card header is the first cell, in all four tables and by
construction — batch id, culture id, log time, harvest date;
- the actions cell is the one with no data-mlabel, because a row
of buttons has nothing to label. That is what distinguishes it
from a last cell that *is* data, which is what #t-harvest has
and why :last-child would have been wrong. */
.t-cards {
border: 0;
width: 100%;
}
.t-cards > thead {
display: none;
}
.t-cards > tbody > tr {
display: block;
border: 1px solid var(--c-border);
border-radius: 10px;
background: var(--c-surface);
margin-bottom: 10px;
padding: 12px 14px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
}
.t-cards > tbody > tr > td {
display: block;
padding: 4px 0;
border: 0;
font-size: 0.8125rem;
line-height: 1.4;
/* The card carries the background now, not the cell. Two table rules
still target these cells — `tr:nth-child(even) td` and `tr:hover td`
— and both outlive the layout they were written for: zebra striping
tints every second *card* grey instead of every second row, and a
tap on a coarse pointer leaves the hover tint stuck on. Neither was
introduced by the card mode; neither had been looked at since it
arrived. */
background: none;
}
/* Label in a gutter, value in normal flow.
This cell was `display: flex` with the label as its first item, which
reads correctly and is wrong: making the cell a flex container makes
*every* element child a flex item, so the layout silently depended on
each cell holding exactly one. #t-batches' Substrat cell holds three —
batchRowHtml() writes one .sub-tag per material — and they became three
competing items on a nowrap line, each shrunk until "HW 60% WB 20%"
wrapped to four lines inside a 99px-radius pill. Eight cards, every
batch, and no count could see it: the chips do not exist until the
renderer runs, so both the source census and measure-mobile.js (which
serves the page with the scripts stripped) looked straight past them.
Normal flow is also what these chips get from the desktop table cell
they were written for, so the two layouts now differ in where the label
sits and not in how the value is laid out. */
.t-cards > tbody > tr > td[data-mlabel]:not(:first-child) {
position: relative;
padding-left: 92px;
}
/* The label was 10–11px here, which is the size this whole project
exists to stop shipping. Uppercase, 600 and muted already say
"this is a label" without help from a size below the floor.
84px fits every label the renderers emit except "Chargen-ID" at 87px,
which is a first cell and hidden; the rest top out at 72px. */
.t-cards > tbody > tr > td[data-mlabel]:not(:first-child)::before {
content: attr(data-mlabel);
position: absolute;
left: 0;
top: 4px;
width: 84px;
color: var(--c-text-muted);
font-size: var(--fs-xs);
font-weight: 600;
line-height: 1.4;
text-transform: uppercase;
letter-spacing: 0.02em;
}
/* The other half of the same cell. Normal flow fixed the three chips
competing as flex items; it does not fix each chip being an inline box,
which splits its background and its 99px radius across two line boxes the
moment it wraps — "RH 63%" came out as a 155×39 smear. A pill has to be a
box. Scoped to the card because the desktop table's Substrat column is
~63px wide, where the same keyword wraps every chip into a padded stack
and takes #t-batches from 760px to 1093px tall. Same defect, different
fix: the desktop needs its column widths looked at. */
.t-cards .sub-tag {
display: inline-block;
}
/* First cell: the card's header. What it looks like is per table — an
id wants to be big, a timestamp wants to be a quiet subtitle — so
this sets only what they share. Its label is not hidden here but never
written: the :not(:first-child) above is what keeps 184 of the 189
first cells from indenting 92px around a label nobody wants to see. */
.t-cards > tbody > tr > td:first-child {
padding-bottom: 6px;
margin-bottom: 6px;
border-bottom: 1px solid var(--c-border-light);
}
/* Actions row — full width, buttons share it equally. The
(pointer: coarse) rule near .btn-sm already gives them 44px. */
.t-cards > tbody > tr > td:not([data-mlabel]):not(:first-child):not(.empty) {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--c-border-light);
white-space: normal !important;
}
.t-cards > tbody > tr > td:not([data-mlabel]):not(:first-child):empty {
display: none;
}
.t-cards > tbody > tr > td:not([data-mlabel]) .btn-sm,
.t-cards > tbody > tr > td:not([data-mlabel]) .btn-xs {
flex: 1 1 auto;
min-width: 0;
margin: 0 !important;
text-align: center;
}
/* Empty/no-matches row keeps a centred full-row look. */
.t-cards > tbody > tr > td.empty {
display: block;
text-align: center;
padding: 18px 0;
border-bottom: 0;
margin-bottom: 0;
}
/* ── Per table, only what is genuinely its own ───────────────── */
/* Batches and grain lead with an id, which is the card's name. */
#t-batches > tbody > tr > td.bt-id,
#t-grain > tbody > tr > td.bt-id {
font-size: 0.9375rem !important;
font-weight: 600;
}
/* The scan log and the harvest log lead with a time, which is not a
name — it dates the row rather than titling it. */
#t-log > tbody > tr > td.lg-time,
#t-harvest > tbody > tr > td.hv-date {
font-size: max(0.75rem, var(--fs-min)) !important;
color: var(--c-text-sec) !important;
padding-bottom: 4px;
margin-bottom: 4px;
}
/* Sub-row injected by toggleBatchBags() — keep its full-width layout
for the bag chips, just retint it as a connected detail panel. */
#t-batches > tbody > tr[id^='brow-'],
#t-grain > tbody > tr[id^='brow-'] {
border: 1px dashed var(--c-border);
padding: 10px 12px;
background: var(--c-bg);
}
/* Harvest cards: grams pinned top-right as a big amber number, the
most-scanned value when reading a harvest log on a phone. */
#t-harvest > tbody > tr {
position: relative;
}
#t-harvest > tbody > tr > td.hv-date {
padding-right: 96px;
}
#t-harvest > tbody > tr > td.hv-grams {
position: absolute;
top: 8px;
right: 12px;
font-size: 1.25rem !important;
font-weight: 600 !important;
line-height: 1.1;
padding: 0 !important;
}
#t-harvest > tbody > tr > td.hv-grams::before {
display: none;
}
}
/* Clearance for the floating scan button. It is gated on the pointer, not on
width, so the 56 px reserved above for the bottom-nav misses it twice: on a
phone it reaches higher than the bar, and on a coarse-pointer tablet past
768 px there is no bar and no reserve at all. It sits --fab-bottom (72 px) up
and stands 56 px tall, so its top edge is 128 px above the bottom. Without
this the last stretch of any long page hides underneath;
Admin → Kanäle put its "Jetzt synchronisieren" button there.
Must stay after the bottom-nav rule: same specificity, later wins. */
@media (pointer: coarse) and (hover: none) {
.content-wrap {
padding-bottom: calc(140px + env(safe-area-inset-bottom, 0));
}
}
/* ═══ CONTAMINATION REPORT MODAL ═══════════════════════════════
Capture flow: type chips, severity row, 80x80 photo tiles. Photos
are compressed to ~200KB JPEG client-side before submit (canvas
resize at 1280px / quality 0.8). */
/* Foto und Notiz: das, was am Regal manchmal dazukommt. Eng gesetzt, damit
Senden am Telefon ohne Scrollen zu sehen ist. */
.cr-quick {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 10px;
}
.cr-quick .contam-photo-tile {
width: 64px;
height: 64px;
}
.cr-quick .contam-photo-tile.add {
width: auto;
padding: 0 14px;
font-size: max(0.875rem, var(--fs-min));
font-weight: 600;
}
.cr-quick #cr-photo-status:empty {
display: none;
}
/* Typ, Schwere, Verschieben: zugeklappt. Die Zeile ist eine Daumenbreite hoch,
damit sie sich am Telefon treffen lässt. */
.cr-more summary {
min-height: 44px;
display: flex;
align-items: center;
font-size: max(0.8125rem, var(--fs-min));
font-weight: 600;
color: var(--c-text-sec);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
list-style: none;
gap: 8px;
}
.cr-more summary::-webkit-details-marker {
display: none;
}
/* Der Pfeil sagt, dass die Zeile aufgeht; display:flex nimmt den eigenen weg. */
.cr-more summary::before {
content: '';
width: 7px;
height: 7px;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: rotate(-45deg);
transition: transform 0.15s;
flex-shrink: 0;
}
.cr-more[open] summary::before {
transform: rotate(45deg);
}
.cr-more-body {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 4px;
}
.cr-more-body .contam-severity-row {
margin-bottom: 6px;
}
.cr-auto-move-row {
display: flex;
align-items: center;
gap: 8px;
font-size: max(0.8125rem, var(--fs-min));
color: var(--c-text-sec);
cursor: pointer;
}
.cr-auto-move-row input {
width: 24px;
height: 24px;
cursor: pointer;
}
.cr-label {
display: block;
font-size: max(0.6875rem, var(--fs-min));
font-weight: 600;
color: var(--c-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 6px;
}
/* Die Beutel, über die der Bericht geht — einer je Plättchen, mit einem × zum
Streichen, dahinter der Knopf für weitere. Am Telefon bricht die Zeile um,
statt zu scrollen: eine Reihe können acht Beutel sein. */
.cr-target-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.cr-target-chip {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 4px 4px 4px 8px;
border-radius: 6px;
background: var(--c-surface);
border: 1px solid var(--c-border);
color: var(--c-text);
font-size: 0.8125rem;
}
.cr-target-x {
display: inline-flex;
align-items: center;
justify-content: center;
/* Dasselbe Maß wie jeder andere Knopf im Formular: das × steht am Regal
zwischen zwei Beuteln und wird mit dem Daumen getroffen, nicht mit der
Maus. */
width: 44px;
min-height: 44px;
border: none;
border-radius: 5px;
background: none;
color: var(--c-text-muted);
font-size: 1.125rem;
line-height: 1;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.cr-target-x:active {
background: rgba(0, 0, 0, 0.08);
color: var(--c-text);
}
.cr-target-add {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 4px 12px;
border: 1px dashed var(--c-border);
border-radius: 6px;
background: none;
color: var(--c-text-sec);
font-family: inherit;
font-size: 0.8125rem;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.cr-target-add:active {
background: rgba(0, 0, 0, 0.05);
}
/* Ohne gesammelte Beutel steht der Knopf allein hinter dem Text der Charge. */
.cr-target-text + .cr-target-add {
margin-left: 8px;
}
/* Melden-Knopf in der Scan-Leiste: rot, weil er das Formular über bereits
markierte Beutel öffnet, und das ist der Abschluss des Sammelns. */
.btn-contam-collect {
background: #dc2626;
color: #fff;
border-color: #dc2626;
}
.contam-type-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 6px;
}
.contam-type-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 12px;
border: 1px solid var(--c-border);
border-radius: 8px;
background: var(--c-surface);
font-size: 0.8125rem;
font-family: inherit;
text-align: left;
cursor: pointer;
min-height: 44px;
-webkit-tap-highlight-color: transparent;
transition:
border-color 0.15s,
background 0.15s;
}
.contam-type-btn:active {
background: rgba(0, 0, 0, 0.05);
}
.contam-type-btn.active {
border-color: currentColor;
background: rgba(0, 0, 0, 0.04);
}
.contam-type-dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.contam-severity-row {
display: flex;
gap: 6px;
}
.contam-sev-btn {
flex: 1;
min-height: 44px;
padding: 10px 12px;
border: 1px solid var(--c-border);
border-radius: 8px;
background: var(--c-surface);
font-family: inherit;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: all 0.15s;
}
.contam-sev-btn:active {
background: rgba(0, 0, 0, 0.05);
}
.contam-sev-btn.active {
background: var(--c-primary);
color: #fff;
border-color: var(--c-primary);
}
.contam-sev-btn[data-sev='major'].active {
background: #f59e0b;
border-color: #f59e0b;
}
.contam-sev-btn[data-sev='lost'].active {
background: #dc2626;
border-color: #dc2626;
}
.contam-photo-tiles {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.contam-photo-tile {
width: 80px;
height: 80px;
border-radius: 8px;
border: 1px solid var(--c-border);
background: var(--c-bg);
display: flex;
align-items: center;
justify-content: center;
font-size: max(0.75rem, var(--fs-min));
color: var(--c-text-muted);
cursor: pointer;
overflow: hidden;
position: relative;
-webkit-tap-highlight-color: transparent;
flex-shrink: 0;
}
.contam-photo-tile.add {
border-style: dashed;
font-size: 1.375rem;
}
.contam-photo-tile img {
width: 100%;
height: 100%;
object-fit: cover;
}
.contam-photo-tile .remove {
position: absolute;
top: 2px;
right: 2px;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
color: #fff;
border: none;
font-size: 0.875rem;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
/* Annotation hint chip — small "edit" pencil overlay on the bottom-left of
each photo tile so workers know they can tap to draw on it. */
.contam-photo-tile .annot-hint {
position: absolute;
bottom: 2px;
left: 2px;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
font-size: max(0.75rem, var(--fs-min));
}
/* ═══ PHOTO ANNOTATION MODAL ══════════════════════════════════
Full-screen-ish drawing surface. The canvas size matches the photo's
intrinsic dimensions so strokes composite at the same pixel scale on
submit. Toolbar header with Clear / Cancel / Done. */
.pa-shell {
background: var(--c-surface);
width: 96%;
max-width: 720px;
height: 92vh;
height: 92dvh;
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.3);
}
.pa-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--c-border-light);
flex-wrap: wrap;
}
.pa-title {
flex: 1;
font-size: 0.875rem;
font-weight: 600;
min-width: 100px;
}
.pa-canvas-wrap {
flex: 1;
background: #000;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
/* Allow vertical pan (some content may overflow on tiny landscape) but
not horizontal/zoom — drawing gestures are absorbed by the canvas. */
touch-action: pan-y;
}
#pa-canvas {
max-width: 100%;
max-height: 100%;
display: block;
/* Stroke gestures, not scroll. */
touch-action: none;
}
.pa-hint {
padding: 8px 12px;
font-size: max(0.6875rem, var(--fs-min));
color: var(--c-text-muted);
text-align: center;
border-top: 1px solid var(--c-border-light);
}
/* ═══ CONTAMINATION REPORT LIST + DRILL-DOWN ═══════════════════
The Lab → Kontaminationen sub-tab. Card-per-report layout works
on both phone and desktop without a JS branch. */
.contam-list-card {
display: flex;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--c-border);
border-radius: 10px;
background: var(--c-surface);
margin-bottom: 8px;
cursor: pointer;
transition: background 0.15s;
-webkit-tap-highlight-color: transparent;
}
.contam-list-card:hover {
background: rgba(0, 0, 0, 0.02);
}
.contam-list-card:active {
background: rgba(0, 0, 0, 0.05);
}
.contam-list-thumb {
width: 64px;
height: 64px;
flex-shrink: 0;
border-radius: 6px;
background: var(--c-bg);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: var(--c-text-muted);
font-size: 1.125rem;
}
.contam-list-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.contam-list-meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.contam-list-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.contam-list-bag {
font-family: monospace;
font-size: 0.8125rem;
font-weight: 600;
}
.contam-list-batch {
font-family: monospace;
font-size: max(0.6875rem, var(--fs-min));
color: var(--c-text-muted);
}
.contam-list-when {
font-size: max(0.6875rem, var(--fs-min));
color: var(--c-text-muted);
margin-left: auto;
}
.contam-type-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: max(0.6875rem, var(--fs-min));
font-weight: 600;
background: rgba(0, 0, 0, 0.04);
}
.contam-type-badge .dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.contam-sev-badge {
font-size: max(0.625rem, var(--fs-min));
font-weight: 600;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 4px;
letter-spacing: 0.04em;
}
.contam-sev-badge.sev-minor {
background: #f1f5f9;
color: var(--c-text-sec);
}
.contam-sev-badge.sev-major {
background: #fef3c7;
color: #92400e;
}
.contam-sev-badge.sev-lost {
background: #fee2e2;
color: #991b1b;
}
.contam-resolved-badge {
font-size: max(0.6875rem, var(--fs-min));
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
background: #dcfce7;
color: #14532d;
}
.contam-open-badge {
font-size: max(0.6875rem, var(--fs-min));
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
background: #fef3c7;
color: #92400e;
}
/* Resolved cards in the browse list — fade slightly so the eye lands on
open ones first; still tappable, still readable. */
.contam-list-card.is-resolved {
opacity: 0.65;
background: var(--c-bg);
}
.contam-list-card.is-resolved:hover {
opacity: 0.9;
background: rgba(0, 0, 0, 0.02);
}
/* Resolve action row in the drill-down modal */
.cd-resolve-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--c-border-light);
}
.contam-list-notes {
font-size: max(0.75rem, var(--fs-min));
color: var(--c-text-sec);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Drill-down modal: photo gallery + metadata grid */
.cd-meta-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 16px;
margin-bottom: 14px;
font-size: 0.8125rem;
}
.cd-meta-grid .label {
color: var(--c-text-muted);
font-size: max(0.6875rem, var(--fs-min));
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.cd-photos {
display: flex;
gap: 8px;
overflow-x: auto;
scroll-snap-type: x mandatory;
margin: 0 -20px 14px;
padding: 0 20px 4px;
-webkit-overflow-scrolling: touch;
}
.cd-photo {
flex: 0 0 auto;
width: 240px;
height: 240px;
border-radius: 8px;
overflow: hidden;
background: var(--c-bg);
scroll-snap-align: start;
cursor: zoom-in;
}
.cd-photo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cd-notes {
padding: 10px 12px;
background: var(--c-bg);
border-radius: 6px;
font-size: 0.8125rem;
line-height: 1.5;
white-space: pre-wrap;
}
@media (max-width: 480px) {
.cd-meta-grid {
grid-template-columns: 1fr;
}
.cd-photo {
width: 80vw;
height: 80vw;
max-width: 320px;
max-height: 320px;
}
}
/* ═══ ARBEITSGÄNGE ══════════════════════════════════════════════════════════
The launcher page and the shared flow modal. Tiles are deliberately large:
this is the screen a new worker meets first, and on the tablet it is used
standing up. Line icons, not emoji — same visual language as the sidebar. */
/* Wieder ganz oben, also ohne Abstand nach oben: die Kacheln stehen seit dem
11.09.2026 über der Tagesliste, damit das Telefon auf ihnen öffnet. Den
Abstand zwischen den beiden Hälften trägt jetzt .dash-top-row. */
.wk-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 4px;
}
.wk-sub {
font-size: var(--fs-sm);
color: var(--c-text-muted);
margin-top: 3px;
}
/* Aufs Handy legen: dieselbe Bauart wie der Hinweis oben, aber ruhiger — das
ist ein Angebot, keine Aufgabe. Die eigene [hidden]-Regel ist nötig, weil
display:flex eine Autoren-Regel ist und sonst die des Browsers schlägt;
derselbe Fehler stand bis vor Kurzem auf dem Zurück-Knopf. */
.wk-install {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
background: var(--c-blue-light);
border: 1px solid var(--c-blue-border);
color: var(--c-blue-dark);
border-radius: var(--radius-sm);
padding: 9px 12px;
font-size: var(--fs-sm);
font-weight: 600;
margin: 14px 0 0;
}
.wk-install[hidden] {
display: none;
}
.wk-install .btn {
margin-left: auto;
min-height: var(--tap);
border-color: var(--c-blue-dark);
color: var(--c-blue-dark);
}
.wk-tiles {
display: grid;
/* Fixed four across, so the launcher reads as four things that make
something and three that are done to it — an auto-fill grid put six on a
wide screen and left the seventh stranded on its own line. The second row
keeps the same four-column track, so every tile is the same width. */
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
/* Wo steht was: one chip per zone, and under the chosen one a tap-to-unfold row
per species and move-in day. The date line is the reason the card exists, so
it is set in the secondary text colour rather than muted. */
.wk-tents {
margin-top: 12px;
padding: 12px 16px;
}
.wk-zchips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.wk-zchip {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 44px;
max-width: 100%;
padding: 6px 12px;
border: 1px solid var(--c-border);
border-radius: 22px;
background: var(--c-surface);
color: var(--c-text);
font: inherit;
font-size: 0.875rem;
cursor: pointer;
}
.wk-zchip[aria-pressed='true'] {
border-color: var(--c-accent);
box-shadow: inset 0 0 0 1px var(--c-accent);
font-weight: 600;
}
.wk-zchip .zone-dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex: none;
}
.wk-zchip-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wk-zchip-n {
color: var(--c-text-muted);
font-weight: 600;
}
.wk-zone-rows {
margin-top: 8px;
border-top: 1px solid var(--c-border);
}
.wk-tent-row summary {
display: grid;
grid-template-columns: 1fr auto;
column-gap: 8px;
padding: 6px 0;
min-height: 44px;
align-content: center;
cursor: pointer;
list-style: none;
}
.wk-tent-row summary::-webkit-details-marker {
display: none;
}
.wk-tent-row + .wk-tent-row summary {
border-top: 1px dashed var(--c-border);
}
.wk-tent-sp {
font-weight: 500;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wk-tent-n {
font-weight: 600;
white-space: nowrap;
}
.wk-tent-when {
grid-column: 1 / -1;
font-size: 0.875rem;
color: var(--c-text-sec);
}
.wk-tent-detail {
padding: 0 0 8px;
font-size: 0.8125rem;
color: var(--c-text-sec);
}
.wk-tent-batch {
font-family: monospace;
color: var(--c-text-muted);
}
.wk-tent-empty {
padding: 10px 0 4px;
font-size: 0.875rem;
color: var(--c-text-muted);
}
/* Only there when something is wrong. Sized to its text, not stretched. */
.wk-alert {
display: flex;
width: fit-content;
max-width: 100%;
align-items: center;
min-height: 44px;
margin: 0 0 12px;
padding: 8px 14px;
border: 1px solid var(--c-amber-border);
border-radius: var(--radius);
background: var(--c-amber-light);
color: var(--c-amber-dark);
font: inherit;
font-weight: 600;
text-align: left;
cursor: pointer;
}
.wk-alert[hidden] {
display: none;
}
/* Nachproduzieren on the work page (renderWorkNachproduzieren): the alert's
shape, red while something is urgent, amber while it is only short. */
.wk-alert.wk-np-now {
border-color: var(--c-red-border);
background: var(--c-red-light);
color: var(--c-red-dark);
}
/* Today's USB stick backup, for admins: the alert's shape, coloured by what
it asks for. Blue while a backup runs, amber when one is due or nothing is
set up, green when the stick can come out, red when it failed. */
.wk-usb {
display: flex;
width: fit-content;
max-width: 100%;
align-items: center;
min-height: 44px;
margin: 0 0 12px;
padding: 8px 14px;
border: 1px solid var(--c-blue-border);
border-radius: var(--radius);
background: var(--c-blue-light);
color: var(--c-blue-dark);
font: inherit;
font-weight: 600;
text-align: left;
cursor: pointer;
}
.wk-usb[hidden] {
display: none;
}
.wk-usb-due,
.wk-usb-setup,
.wk-usb-key,
.wk-usb-gaps,
.wk-usb-first {
border-color: var(--c-amber-border);
background: var(--c-amber-light);
color: var(--c-amber-dark);
}
.wk-usb-remove {
border-color: var(--c-green-border);
background: var(--c-green-light);
color: var(--c-green-dark);
}
.wk-usb-error,
.wk-usb-state {
border-color: var(--c-red-border);
background: var(--c-red-light);
color: var(--c-red-dark);
}
/* The task card's heading is the day in one line and the switch for the list. */
.wk-today {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 44px;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.wk-today-sum {
flex: 1;
min-width: 0;
font-size: 0.875rem;
color: var(--c-text-sec);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wk-today-chev {
color: var(--c-text-muted);
transition: transform 0.15s;
}
.wk-today[aria-expanded='true'] .wk-today-chev {
transform: rotate(180deg);
}
.wk-today[aria-expanded='true'] {
margin-bottom: 8px;
}
#dash-batch-tasks-card.wk-tasks-closed #dash-batch-tasks {
display: none;
}
/* Two columns from 900px down, phones included. One column put seven full-width
tiles above everything else on the start screen. Two fit since the titles
became one or two words ("Labor", "Körnerbrut") and the phone drops the
when-line (below). */
@media (max-width: 900px) {
.wk-tiles {
grid-template-columns: repeat(2, 1fr);
}
}
.wk-tile {
position: relative;
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr;
align-content: start;
gap: 7px;
text-align: left;
background: linear-gradient(150deg, var(--wk-cl) 0%, var(--c-surface) 62%);
border: 1px solid var(--c-border);
border-radius: var(--radius);
padding: var(--pad-tile);
min-height: 156px;
cursor: pointer;
font: inherit;
color: var(--c-text);
box-shadow: var(--shadow);
transition:
border-color 0.15s,
box-shadow 0.15s,
transform 0.1s;
}
.wk-tile:hover {
border-color: var(--wk-c);
box-shadow: var(--shadow-md);
}
.wk-tile:active {
transform: scale(0.985);
}
/* The drawing sits behind the words and bleeds off the corner, so the tile reads
as a picture of the job rather than a labelled box. It is drawn, not
photographed, because this app runs offline in the lab: nothing to load, sharp
at any size, a few hundred bytes each. Every shape takes the tile's own colour
through currentColor, so the seven read as one family. */
/* A veil between the drawing and the words. The artwork may bleed as far across
the tile as it likes; the text side stays calm regardless of what is drawn
underneath, so legibility never depends on the shape of the picture. */
.wk-tile::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(102deg, var(--c-surface) 24%, rgba(255, 255, 255, 0) 72%);
opacity: 0.85;
pointer-events: none;
z-index: 1;
}
.wk-tile-art {
z-index: 0;
position: absolute;
right: -10px;
bottom: -8px;
width: 134px;
color: var(--wk-c);
pointer-events: none;
}
.wk-tile-art svg {
width: 100%;
height: auto;
display: block;
}
.wk-tile-t {
position: relative;
z-index: 2;
font-size: var(--fs-tile);
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1.25;
max-width: 90%;
}
.wk-tile-when {
position: relative;
z-index: 2;
font-size: max(0.71875rem, var(--fs-min));
color: var(--c-text-muted);
line-height: 1.4;
max-width: 64%;
}
/* The phone tile, after the four rules above rather than before them: a media
query adds no specificity, so between two `.wk-tile` rules the later one wins
and the base min-height would simply have stood. */
@media (max-width: 768px) {
/* Two tiles side by side, each about 165px wide at 375px. There the
when-line would wrap to four or five lines of 13px, so the phone shows
the title alone. The drawing does not bleed here: at this size a
corner of a picture reads as a smudge, so it stands whole on the right
at one fixed size on every tile, and the title takes the rest. No veil
is needed with nothing underneath the words. */
.wk-tiles {
gap: 10px;
}
.wk-tile {
grid-template-columns: minmax(0, 1fr) 52px;
grid-template-rows: auto;
align-items: center;
column-gap: 8px;
min-height: 72px;
padding: 10px 10px 10px 14px;
}
.wk-tile::after {
display: none;
}
.wk-tile-art {
position: static;
grid-column: 2;
grid-row: 1;
width: 52px;
}
.wk-tile-t {
grid-column: 1;
grid-row: 1;
max-width: none;
overflow-wrap: anywhere;
}
.wk-tile-when {
display: none;
}
}
/* Reihenfolge ändern: a quiet link under the tiles, and a numbered list with
up/down buttons that moves the tiles as you tap. */
.wk-order-bar {
display: flex;
justify-content: flex-end;
margin-top: 6px;
}
.wk-order-link {
min-height: 44px;
padding: 0 4px;
border: 0;
background: none;
color: var(--c-text-sec);
font: inherit;
font-size: 0.875rem;
text-decoration: underline;
cursor: pointer;
}
.wk-order {
margin-top: 4px;
padding: 10px 14px;
}
.wk-order[hidden] {
display: none;
}
.wk-order-list {
margin: 0;
padding-left: 1.5em;
}
.wk-order-row {
padding: 2px 0;
}
.wk-order-row > * {
vertical-align: middle;
}
.wk-order-name {
display: inline-block;
min-width: 9em;
margin-right: 8px;
}
.wk-order-row .btn {
min-width: 44px;
margin-left: 4px;
}
.wk-order-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
/* ── flow modal ─────────────────────────────────────────────────────────── */
.wkf-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.wkf-head h3 {
margin: 0;
flex: 1;
font-size: 1.0625rem;
}
.wkf-chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.wkf-chip {
font-size: max(0.6875rem, var(--fs-min));
font-weight: 700;
padding: 3px 10px;
border-radius: 999px;
border: 1px solid var(--c-border);
color: var(--c-text-muted);
background: var(--c-surface);
}
.wkf-chip.done {
color: var(--c-green-dark);
background: var(--c-primary-light);
border-color: var(--c-green-border);
}
.wkf-chip.now {
color: #fff;
background: var(--c-primary);
border-color: var(--c-primary);
}
.wkf-empty {
font-size: 0.8125rem;
color: var(--c-text-muted);
padding: 22px 0;
text-align: center;
}
.wkf-foot {
display: flex;
gap: 8px;
justify-content: flex-end;
border-top: 1px solid var(--c-border);
margin-top: 14px;
padding-top: 14px;
}
/* batch list */
.wkf-list {
display: flex;
flex-direction: column;
gap: 6px;
/* dvh, not vh: with the on-screen keyboard up, vh still measures the window
as if it were not there, so a list sized in vh keeps its full height and
pushes the footer buttons off the bottom. This list is step 1 of every
flow and the field below it is what opens the keyboard. */
max-height: 46dvh;
overflow-y: auto;
}
.wkf-row {
display: grid;
grid-template-columns: minmax(120px, auto) 1fr auto;
gap: 10px;
align-items: center;
text-align: left;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
padding: 10px 12px;
cursor: pointer;
font: inherit;
color: var(--c-text);
min-height: max(46px, var(--tap-min));
}
.wkf-row:hover {
border-color: var(--c-primary);
background: var(--c-primary-light);
}
.wkf-row-id {
font-family: ui-monospace, 'Cascadia Code', monospace;
font-size: max(0.78125rem, var(--fs-min));
font-weight: 700;
}
.wkf-row-meta {
font-size: max(0.75rem, var(--fs-min));
color: var(--c-text-muted);
}
.wkf-row-zone {
font-size: max(0.6875rem, var(--fs-min));
font-weight: 700;
color: var(--c-green-dark);
background: var(--c-green-light);
border-radius: 999px;
padding: 2px 9px;
white-space: nowrap;
}
/* harvest list */
.wkf-bar {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 10px;
}
.wkf-bar-sp {
font-size: max(0.75rem, var(--fs-min));
color: var(--c-text-muted);
margin-left: auto;
}
.wkf-check {
display: flex;
align-items: center;
gap: 8px;
min-height: var(--tap);
font-size: max(0.78125rem, var(--fs-min));
color: var(--c-text-sec);
margin-bottom: 10px;
}
.wkf-check input {
width: 20px;
height: 20px;
}
.wkf-bags {
display: flex;
flex-direction: column;
max-height: 42dvh;
overflow-y: auto;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
}
.wkf-bag {
display: grid;
/* minmax(0, 1fr), not 1fr: a grid track's default minimum is min-content, so
a long bag id widens the track instead of ellipsing inside it, and the row
pushes the modal sideways. */
grid-template-columns: minmax(0, 1fr) auto 92px auto;
gap: 9px;
align-items: center;
padding: 7px 11px;
border-top: 1px solid var(--c-border-light);
}
.wkf-bag:first-child {
border-top: 0;
}
.wkf-bag-id {
font-family: ui-monospace, 'Cascadia Code', monospace;
font-size: max(0.78125rem, var(--fs-min));
font-weight: 650;
}
.wkf-prev {
font-size: max(0.6875rem, var(--fs-min));
color: var(--c-text-muted);
white-space: nowrap;
}
.wkf-g {
text-align: right;
font-weight: 700;
}
.wkf-unit {
font-size: max(0.6875rem, var(--fs-min));
color: var(--c-text-muted);
}
.wkf-sum {
display: flex;
align-items: baseline;
gap: 9px;
margin-top: 12px;
padding-top: 10px;
border-top: 2px solid var(--c-border);
}
.wkf-sum b {
font-size: 1.25rem;
font-weight: 800;
}
.wkf-sum span {
font-size: max(0.75rem, var(--fs-min));
color: var(--c-text-muted);
}
/* move targets */
.wkf-zones {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 8px;
}
.wkf-zone {
display: flex;
align-items: center;
gap: 9px;
text-align: left;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
padding: 12px;
cursor: pointer;
font: inherit;
color: var(--c-text);
min-height: max(48px, var(--tap-min));
}
.wkf-zone.sel {
border-color: var(--c-primary);
background: var(--c-primary-light);
box-shadow: 0 0 0 1px var(--c-primary);
}
.wkf-zone-dot {
width: 11px;
height: 11px;
border-radius: 50%;
flex: none;
}
.wkf-zone-n {
font-size: 0.8125rem;
font-weight: 650;
}
/* receipt */
.wkf-receipt {
display: flex;
flex-direction: column;
gap: 12px;
border: 2px solid var(--c-primary);
border-radius: var(--radius);
padding: 16px 18px;
}
.wkf-r-head {
display: flex;
align-items: center;
gap: 10px;
font-size: 1rem;
}
.wkf-r-tick {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--c-primary);
color: #fff;
display: grid;
place-items: center;
font-size: 0.8125rem;
font-weight: 800;
flex: none;
}
.wkf-r-lines {
display: flex;
flex-direction: column;
gap: 6px;
border-top: 1px solid var(--c-border);
padding-top: 12px;
}
.wkf-r-lines div {
display: flex;
gap: 10px;
font-size: 0.8125rem;
align-items: baseline;
}
.wkf-r-k {
color: var(--c-text-muted);
min-width: 116px;
flex: none;
}
/* The flow on a phone. It used to answer three different questions at three
invented widths — 460 for the two-up field pairs, 520 for the launcher tiles,
560 for the list rows — none of which is where anything actually stops
fitting. One breakpoint, at the 768 the rest of the app already uses. */
@media (max-width: 768px) {
.wkf-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.wkf-row-meta {
grid-column: 1 / -1;
}
.wkf-two,
.wkf-zones {
grid-template-columns: 1fr;
}
/* Three columns and four children, placed rather than left to auto-flow: id,
field and unit on one line, and what the bag already gave on the next.
The 560px rule hid that line instead — which is the number a worker needs
to decide whether this bag is worth weighing at all. */
.wkf-bag {
grid-template-columns: minmax(0, 1fr) 84px auto;
}
.wkf-bag-id {
grid-area: 1 / 1 / 2 / 2;
}
.wkf-g {
grid-area: 1 / 2 / 2 / 3;
}
.wkf-unit {
grid-area: 1 / 3 / 2 / 4;
}
.wkf-prev {
grid-area: 2 / 1 / 3 / 4;
}
/* Label above value instead of beside it — a 116px label column leaves 200px
for a zone name on a 375px screen. */
.wkf-r-lines div {
flex-wrap: wrap;
}
.wkf-r-k {
min-width: 100%;
}
/* §5.2: one primary action, full width, in the bottom third. The primary is
the last child, so a column puts it closest to the thumb; a row put it in
the far corner at whatever width its label happened to be. */
.wkf-foot {
flex-direction: column;
}
.wkf-foot .btn {
width: 100%;
}
}
/* Knopfreihe des Etiketten-Dialogs: die Hauptsache zuerst und breit, damit sie
am Tablet mit dem Daumen zu treffen ist, ohne die Beschriftung umzubrechen. */
.nbp-btns {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 16px;
}
.nbp-btns > .btn {
justify-content: center;
padding: 12px 16px;
}
.nbp-sub {
display: flex;
gap: 8px;
}
.nbp-sub .btn {
flex: 1;
justify-content: center;
}
/* The bag chip in an expanded batch row is the click path into the bag sheet. */
.bag-open-chip:hover {
border-color: var(--c-primary) !important;
background: var(--c-primary-light) !important;
}
/* Substrat ansetzen und Charge anlegen stehen nebeneinander: der Ansatz wird
gemischt und die Charge zieht sofort daraus, also gehören sie in einen Blick.
Der Substrat-Block ist schmaler, weil er weniger zu fragen hat. Unter 1000 px
fällt beides untereinander — im Labor wird am Handy gearbeitet. */
/* Der geführte Weg zum Anlegen einer Charge: dieselbe Hülle wie Ernte und Umzug,
nur mit eigenen Bausteinen. Alle Bedienelemente sind fingerhoch, weil der
Ablauf im Labor am Tablet läuft. */
.wkf-q {
font-size: 1rem;
font-weight: 800;
letter-spacing: -0.01em;
margin-bottom: 2px;
}
.wkf-field {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.wkf-field > label {
font-size: max(0.71875rem, var(--fs-min));
color: var(--c-text-sec);
font-weight: 600;
margin: 0;
}
/* Outranks the app-wide `input, select, textarea { min-height: 64px }` on
specificity, so before the floor this rule made the flow's fields SMALLER
than every other field in the app — 44px against 64px — on the one screen
that is filled in wearing gloves. */
.wkf-field input,
.wkf-field select {
min-height: max(44px, var(--tap-min));
}
.wkf-two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
/* "Mischung" mit dem Rückweg zum Rezept daneben, im Substrat-Arbeitsgang. Die
Beschriftung sieht aus wie die der anderen Felder; der Knopf erscheint erst,
wenn eine Zahl vom Rezept abweicht. */
.wkm-blend-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.wkm-blend-head label {
font-size: max(0.71875rem, var(--fs-min));
color: var(--c-text-sec);
font-weight: 600;
margin: 0;
}
.wkm-blend-head [hidden] {
display: none;
}
/* Vier Anteile, zwei je Zeile: am Telefon passen nicht mehr nebeneinander, und
ein auto-fit-Raster bricht dort drei plus eins um. */
.wkm-blend label {
color: var(--c-text-sec);
}
.wkf-chipsrow {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.wkf-w {
border: 1px solid var(--c-border);
background: var(--c-surface);
border-radius: 8px;
padding: 0 12px;
min-height: max(44px, var(--tap-min));
font: inherit;
font-size: 0.8125rem;
font-weight: 650;
color: var(--c-text-sec);
cursor: pointer;
}
.wkf-w.on {
background: var(--c-primary);
border-color: var(--c-primary);
color: #fff;
}
.wkf-note {
background: var(--c-primary-light);
border: 1px solid var(--c-green-border);
color: var(--c-green-dark);
border-radius: 9px;
padding: 10px 12px;
font-size: max(0.78125rem, var(--fs-min));
line-height: 1.6;
margin-bottom: 10px;
}
.wkf-note.bad {
background: var(--c-red-light);
border-color: var(--c-red-border);
color: var(--c-red-dark);
}
.wkf-note b {
display: block;
}
.wkf-link {
background: none;
border: 0;
padding: 0;
font: inherit;
color: inherit;
text-decoration: underline;
font-weight: 700;
cursor: pointer;
}
.wkf-sum-card {
border: 1px solid var(--c-border);
border-radius: 9px;
padding: 12px 14px;
margin-bottom: 10px;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.8125rem;
}
.wkf-sum-card > div {
display: flex;
gap: 8px;
align-items: baseline;
flex-wrap: wrap;
}
.wkf-hint {
font-size: max(0.6875rem, var(--fs-min));
color: var(--c-text-muted);
margin-bottom: 10px;
}
/* Stamm und "Angesetzt am": derselbe Baustein im Schnelldialog und im
Chargen-Assistenten. Auswahl oder Feld nimmt die Breite, der Knopf daneben
nur seine eigene — am Telefon bleibt beides in einer Zeile, ohne dass der
Knopf zum Balken wird. */
.stamm-row {
display: flex;
gap: 6px;
align-items: stretch;
}
.stamm-row > input,
.stamm-row > select {
flex: 1 1 auto;
min-width: 0;
}
/* Was als Beimpfmaterial gescannt oder gewählt ist, je ein Kärtchen mit ✕.
Leer nimmt die Reihe keinen Platz. */
.inoc-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.inoc-chips:empty {
display: none;
}
.stamm-row > .btn {
flex: none;
}
/* Im Schnelldialog stehen die Felder mit 8px Abstand untereinander; im
Assistenten sorgt .wkf-field selbst dafür. Leer — ohne Sorte — nimmt der Platz
gar keinen Raum. */
#ms-quick-modal .stamm-wrap:not(:empty),
#ms-quick-modal .anlage-wrap:not(:empty) {
margin-top: 8px;
}
#wkf-body .anlage-wrap:not(:empty) {
margin-bottom: 10px;
}
.anlage-zu {
display: flex;
gap: 6px;
align-items: baseline;
flex-wrap: wrap;
}
.anlage-k {
color: var(--c-text-muted);
}
/* Die Karte "Stämme" auf der Sortenseite: je Sorte ihre Stämme, und darunter,
was an Chargen und Kulturen steht, ohne in der Liste zu sein. */
.stamm-hint {
color: var(--c-text-muted);
margin-bottom: 10px;
}
.stamm-add {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: stretch;
margin-bottom: 12px;
}
.stamm-add > select {
flex: 1 1 180px;
min-width: 0;
}
.stamm-add > input {
flex: 2 1 200px;
min-width: 0;
}
.stamm-add > .btn {
flex: none;
}
.stamm-gruppe {
border-top: 1px solid var(--c-border);
padding: 10px 0;
}
.stamm-sorte {
font-weight: 600;
margin-bottom: 4px;
}
.stamm-kz {
font-family: monospace;
color: var(--c-text-muted);
}
/* Name und Verwendung links übereinander, die Knöpfe rechts: so bleibt eine
Zeile eine Zeile, auch wenn der Name lang ist — als Flex-Reihe brach am
Telefon das Kreuz allein in die nächste. */
.stamm-zeile {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px;
padding: 3px 0;
}
.stamm-name {
min-width: 0;
overflow-wrap: anywhere;
}
.stamm-use {
display: block;
}
.stamm-use,
.stamm-fremd,
.stamm-leer {
color: var(--c-text-muted);
}
.stamm-fremd {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-top: 6px;
}
/* Kartenkopf mit Motiv: derselbe Strich wie auf den Arbeitsgang-Kacheln, damit
Substrat ansetzen und Growbags Batch auf einen Blick auseinandergehen. Die
Zeichnung sitzt hinter der Überschrift und läuft aus der Ecke heraus. */
.card-head {
position: relative;
overflow: hidden;
border-radius: var(--radius-sm);
background: linear-gradient(120deg, var(--wk-cl) 0%, transparent 70%);
padding: 10px 12px;
margin: -4px -4px 10px;
}
.card-head-art {
position: absolute;
right: -6px;
bottom: -14px;
width: 96px;
color: var(--wk-c);
pointer-events: none;
}
.card-head-art svg {
width: 100%;
height: auto;
display: block;
}
.card-head .sec {
position: relative;
}
/* Die Fußknöpfe der Abläufe sind die Hauptaktion und werden im Labor mit dem
Finger getroffen — 34 px reichten dafür nicht. 44 reichen für die Maus, für
den Handschuh nicht; max() hebt sie am Gerät an und lässt sie am Schreibtisch
stehen. */
.wkf-foot .btn {
min-height: max(44px, var(--tap-min));
}
/* ── Vorhaben (Forschung · Produkte · Bau) ───────────────────
Drei Unterlagen zum Lesen, nicht zum Ausfüllen: eine Recherche, ein paar
Rezepte und ein Bauplan. Das ist die einzige Stelle dieser Anwendung, an der
längerer Fließtext steht, und Fließtext braucht anderes als eine Tabelle —
eine Zeilenhöhe, mit der man einen Absatz zu Ende liest, und eine Zeile, die
nicht quer über einen 27-Zoll-Schirm läuft.
Die Tabellen bringen ihr Telefonverhalten selbst mit: sie tragen .t-cards und
werden unter 769px zu Karten wie die zwölf anderen. Deshalb steht hier keine
einzige Tabellenregel für das Telefon. */
.pj-head {
border-left: 3px solid var(--c-primary);
}
.pj-title {
font-size: max(1.0625rem, var(--fs-min));
font-weight: 700;
line-height: 1.25;
margin-bottom: 4px;
}
.pj-meta {
font-size: var(--fs-xs);
color: var(--c-text-muted);
margin-bottom: 10px;
}
.pj-lead {
font-size: max(0.875rem, var(--fs-min));
line-height: 1.6;
margin: 0;
max-width: 68ch;
}
/* Die Zwischenüberschrift eines Abschnitts. .sec ist die Überschrift der Karte
und schon vergeben; diese hier steht darunter und ist deshalb leiser gesetzt,
aber nicht kleiner — unter dem Mindestmaß fängt das Suchen an. */
.pj-h {
font-size: max(0.8125rem, var(--fs-min));
font-weight: 700;
color: var(--c-text);
margin: 18px 0 6px;
}
.pj-sec > .pj-h:first-of-type {
margin-top: 0;
}
.pj-p {
font-size: max(0.8125rem, var(--fs-min));
line-height: 1.65;
color: var(--c-text);
margin: 0 0 10px;
max-width: 72ch;
}
.pj-ul {
font-size: max(0.8125rem, var(--fs-min));
line-height: 1.6;
color: var(--c-text);
margin: 0 0 10px;
padding-left: 20px;
max-width: 72ch;
}
.pj-ul li {
margin-bottom: 6px;
}
/* Der Einschub. Zwei Tonlagen: bernstein für „das ist der Haken an der Sache",
rot für „das geht schief, wenn du es überliest". Mehr Abstufungen wären eine
Farbenlehre, die niemand liest. */
.pj-note {
background: var(--c-amber-light);
border-left: 3px solid var(--c-amber);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
padding: 10px 12px;
font-size: max(0.8125rem, var(--fs-min));
line-height: 1.55;
color: var(--c-text);
margin: 0 0 10px;
max-width: 72ch;
}
.pj-note-warn {
background: var(--c-red-light);
border-left-color: var(--c-red);
}
.pj-t {
margin-bottom: 12px;
}
/* Prosa in einer Zelle: oben ausgerichtet, sonst schwimmt eine einzeilige
Spalte in der Mitte einer vierzeiligen. */
.pj-t td {
vertical-align: top;
line-height: 1.5;
/* Die Artentabelle hat sechs Spalten. Ohne das schiebt ein langes Wort wie
„Verbundwerkstoff" die Tabelle am schmalsten Schreibtisch über ihre Karte
hinaus — anywhere zählt beim Spaltenrechnen mit, break-word nicht. */
overflow-wrap: anywhere;
}
/* Rezeptkarten. auto-fit mit 232px heißt: drei nebeneinander am Schreibtisch,
eine untereinander am Telefon, ohne eine einzige Abfrage nach der Fensterbreite. */
.pj-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(232px, 100%), 1fr));
gap: 10px;
margin-bottom: 12px;
}
.pj-rx {
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
background: var(--c-bg);
padding: 12px 14px;
}
.pj-rx-t {
font-weight: 700;
font-size: max(0.8125rem, var(--fs-min));
margin-bottom: 2px;
}
.pj-rx-sub {
font-size: var(--fs-xs);
color: var(--c-text-muted);
margin-bottom: 8px;
}
.pj-ing {
list-style: none;
margin: 0;
padding: 0;
font-size: max(0.8125rem, var(--fs-min));
}
.pj-ing li {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 3px 0;
border-bottom: 1px solid var(--c-border-light);
line-height: 1.4;
}
.pj-ing li:last-child {
border-bottom: 0;
}
.pj-v {
font-variant-numeric: tabular-nums;
font-weight: 600;
white-space: nowrap;
}
.pj-rx-foot {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--c-border);
font-size: var(--fs-xs);
color: var(--c-text-muted);
line-height: 1.45;
}
/* Der Ablauf über der Zeit: links wann, rechts was. Unter 769px stapelt sich
das, weil „Tag 12–13" und ein Satz nebeneinander auf einem Telefon zwei
Spalten von je elf Zeichen wären. */
.pj-steps {
margin-bottom: 12px;
border-top: 1px solid var(--c-border-light);
}
.pj-step {
display: grid;
grid-template-columns: 104px 1fr;
gap: 14px;
padding: 9px 0;
border-bottom: 1px solid var(--c-border-light);
font-size: max(0.8125rem, var(--fs-min));
line-height: 1.55;
}
.pj-step-k {
font-weight: 700;
color: var(--c-primary);
}
/* Ein Schritt mit einem Knopf daneben. Der Knopf bleibt so breit wie sein Wort
— eine Zeile, die zur Aufgabe werden kann, ist kein Formular-Absenden. */
.pj-tasks {
list-style: none;
margin: 0;
padding: 0;
font-size: max(0.8125rem, var(--fs-min));
}
.pj-tasks li {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--c-border-light);
line-height: 1.5;
}
.pj-tasks li:last-child {
border-bottom: 0;
}
.pj-tasks li > span {
flex: 1 1 200px;
min-width: 0;
}
.pj-add {
flex: 0 0 auto;
}
/* Der Anfragetext, so wie er in die Mail gehört. pre-wrap, damit die Zeilen
umbrechen statt die Seite breiter zu machen: ein <pre> mit einem deutschen
Absatz darin ist sonst 900px breit und das Telefon 360. */
.pj-pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
background: var(--c-bg);
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
padding: 12px;
font-family: inherit;
font-size: max(0.8125rem, var(--fs-min));
line-height: 1.55;
color: var(--c-text);
margin: 0 0 10px;
}
@media (max-width: 768px) {
.pj-step {
grid-template-columns: 1fr;
gap: 2px;
}
}
/* Der Tagesplan. Eine Zeile ist ein Handgriff, und abgehakt heisst weg: am
Tisch zeigt die Liste, was noch zu tun ist. Durchgestrichene Zeilen kosten
genau dort Platz, wo der nächste Schritt stehen müsste. */
.pj-chk {
display: flex;
gap: 11px;
align-items: flex-start;
padding: 10px 0;
border-bottom: 1px solid var(--c-border-light);
}
.pj-chk input {
margin-top: 3px;
width: 18px;
height: 18px;
flex: none;
accent-color: var(--c-primary);
cursor: pointer;
}
.pj-chk label {
cursor: pointer;
flex: 1;
min-width: 0;
font-size: max(0.8125rem, var(--fs-min));
line-height: 1.5;
}
.pj-body {
flex: 1;
min-width: 0;
}
.pj-chk input:checked + .pj-body > label {
opacity: 0.45;
text-decoration: line-through;
}
/* Das Detail zur Zeile. Zu, bis jemand danach fragt — die Mengen stehen dort,
die Begruendung auch, und beides braucht man einmal und dann nie wieder. */
.pj-more {
border: 0;
background: none;
color: var(--c-text-muted);
cursor: pointer;
font: inherit;
line-height: 1;
padding: 0 6px;
min-width: max(24px, var(--tap-min));
min-height: max(24px, var(--tap-min));
}
.pj-more:hover {
color: var(--c-text);
}
.pj-det {
margin-top: 4px;
font-size: var(--fs-xs);
line-height: 1.5;
color: var(--c-text-muted);
}
.pj-chk.is-done {
display: none;
}
.pj-plan.pj-show-done .pj-chk.is-done {
display: flex;
}
/* Wo der Handgriff passiert: Einkauf, Bank, Autoklav, Flow. Eine eigene Zeile
über dem Text, weil sie am Telefon sonst die halbe Breite nimmt. */
.pj-tag {
display: block;
font-size: var(--fs-xs);
font-weight: 700;
color: var(--c-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 2px;
}
.pj-empty {
margin: 0;
padding: 14px 0 2px;
font-size: max(0.8125rem, var(--fs-min));
color: var(--c-text-muted);
}
.pj-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.pj-count {
font-size: var(--fs-xs);
color: var(--c-text-muted);
}
/* Nachschlagen, nicht lesen. Die Recherche steht unter dem Tagesplan und ist
zu: aufgeklappt ist sie ein Bericht, zugeklappt eine Zeile, und während der
Autoklav läuft will niemand einen Bericht scrollen. */
.pj-fold > summary {
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
gap: 8px;
margin: 0;
min-height: max(24px, var(--tap-min));
}
.pj-fold > summary::-webkit-details-marker {
display: none;
}
.pj-fold > summary::before {
content: '▸';
flex: none;
font-size: 0.75rem;
color: var(--c-text-muted);
}
.pj-fold[open] > summary::before {
content: '▾';
}
.pj-fold[open] > summary {
margin-bottom: 10px;
}
.pj-fold > summary:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: 2px;
border-radius: 4px;
}
/* ── Versuch und Ansätze ─────────────────────────────────────
Eine Zeile je Ansatz: Code, was es ist, Ergebnis, Ausgang, Etikett. Flex mit
Umbruch statt Rasterspalten — dieselbe Zeile liegt am Schreibtisch
nebeneinander und am Telefon untereinander, ohne eine zweite Beschreibung
desselben Layouts in einer Medienabfrage. */
.pj-v-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.pj-v-code {
font-weight: 700;
font-size: max(0.875rem, var(--fs-min));
}
.pj-v-title {
font-size: max(0.875rem, var(--fs-min));
}
.pj-v-meta {
font-size: var(--fs-xs);
color: var(--c-text-muted);
margin-top: 2px;
}
/* Drucken und Abschliessen stehen zusammen rechts im Kopf und brechen am
Telefon gemeinsam unter den Titel. */
.pj-v-knoepfe {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
/* Der Zeitraum fuers Laborbuch: zwei Datumsfelder, die Beschriftung darueber,
der Knopf auf Hoehe der Felder. */
.pj-lb-bar {
align-items: flex-end;
}
.pj-lb-feld {
flex: 1 1 130px;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
font-size: var(--fs-xs);
color: var(--c-text-muted);
}
.pj-lb-feld input,
.pj-lb-feld select {
width: 100%;
}
/* Das Arbeitspaket des Versuchs, unter dem Kopf: Beschriftung und Auswahl in
einer Zeile, am Telefon untereinander. */
.pj-ap {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
margin-bottom: 6px;
font-size: var(--fs-xs);
color: var(--c-text-muted);
}
.pj-ap select {
flex: 1 1 200px;
min-width: 0;
}
/* Ein Paket je Zeile: der Code schmal, der Titel breit, der Knopf rechts.
Die Zeile zum Anlegen steht genauso, deshalb gegen .pj-run-new input. */
.pj-sec .pj-ap-code {
flex: 0 1 90px;
min-width: 0;
}
.pj-sec .pj-ap-titel {
flex: 1 1 180px;
min-width: 0;
}
.pj-ap-weg input {
color: var(--c-text-muted);
}
.pj-run {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--c-border-light);
}
.pj-run-kopf {
flex: 1 1 100%;
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.pj-run-tools {
flex: 1 1 100%;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
}
.pj-run-code {
flex: 0 0 auto;
font-weight: 700;
font-size: max(0.8125rem, var(--fs-min));
/* Ein langer Code bricht um, statt die Zeile ueber den Rand zu schieben: neben
ihm stehen noch das Etikett und das Kreuz zum Entfernen. */
max-width: 40%;
overflow-wrap: anywhere;
}
.pj-run-what {
flex: 1 1 auto;
min-width: 0;
font-size: var(--fs-xs);
color: var(--c-text-muted);
}
/* Das Etikett steht oben neben dem Code und nicht unten neben der Auswahl:
unten nahm es dem Ergebnisfeld die Breite, und getippt wird ins Feld. */
.pj-run-etikett {
flex: 0 0 auto;
margin-left: auto;
}
.pj-run-res {
/* Klein starten, dann waechst es in den Rest. 150 px Basis hiessen am Telefon:
Feld, Auswahl und Etikett brauchen zusammen 346 von 330 verfuegbaren, und
der Knopf brach in eine dritte Zeile um. */
flex: 1 1 90px;
min-width: 0;
}
.pj-run-new {
border-bottom: 0;
padding-top: 12px;
}
.pj-run-new input {
flex: 1 1 130px;
min-width: 0;
}
.pj-out {
flex: 0 0 auto;
width: auto;
}
.pj-run-ro .pj-run-res {
flex: 2 1 150px;
font-size: max(0.8125rem, var(--fs-min));
}
.pj-out-good {
color: var(--c-green-dark);
}
.pj-out-contam {
color: var(--c-red-dark);
}
.pj-out-discarded,
.pj-out-open {
color: var(--c-text-muted);
}
/* ── Fotos am Versuch ────────────────────────────────────────
Quadratische Kacheln in einem Raster, das sich selbst fuellt: am Telefon
drei nebeneinander, am Schreibtisch acht, ohne eine zweite Beschreibung
desselben Rasters. Darunter steht, wann das Bild entstanden ist — bei einem
Lauf ueber vierzehn Tage ist das die halbe Information. */
.pj-fotos {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(96px, 100%), 1fr));
gap: 8px;
margin-bottom: 12px;
}
.pj-foto {
position: relative;
margin: 0;
}
.pj-foto img {
display: block;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid var(--c-border);
background: var(--c-bg);
}
.pj-foto figcaption {
font-size: var(--fs-xs);
color: var(--c-text-muted);
margin-top: 3px;
line-height: 1.3;
}
/* The full-resolution original, as a link in the caption. The padding makes
it something a thumb can hit; the negative margin keeps the line where it
was. Where there is none yet, the same place offers to add it: a button,
since it opens a file picker and not a page, dressed like the link. */
.pj-foto-original,
.pj-foto-original-neu {
display: inline-block;
padding: 6px 4px;
margin: -6px 0;
color: var(--c-green-dark);
font-weight: 600;
}
.pj-foto-original-neu {
border: 0;
background: none;
font-family: inherit;
font-size: inherit;
text-decoration: underline;
cursor: pointer;
}
.pj-foto-weg {
position: absolute;
top: 3px;
right: 3px;
min-width: max(24px, var(--tap-min));
min-height: max(24px, var(--tap-min));
border: 0;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 1rem;
line-height: 1;
cursor: pointer;
}
/* ── Die Liste der Experimente ───────────────────────────────
Eine Zeile je Ansatz, und die ganze Zeile ist der Knopf: am Telefon trifft
man einen Bereich ueber die volle Breite, nicht ein Pfeilchen am Rand. */
.pj-liste-zeile {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
text-align: left;
border: 0;
border-bottom: 1px solid var(--c-border-light);
background: none;
font: inherit;
color: inherit;
padding: 11px 0;
cursor: pointer;
min-height: max(44px, var(--tap-min));
}
.pj-liste-zeile:last-child {
border-bottom: 0;
}
.pj-liste-zeile:hover {
background: var(--c-bg);
}
.pj-liste-code {
flex: 0 0 auto;
font-weight: 700;
font-size: max(0.8125rem, var(--fs-min));
min-width: 34px;
}
.pj-liste-mitte {
flex: 1 1 auto;
min-width: 0;
}
.pj-liste-titel {
display: block;
font-size: max(0.8125rem, var(--fs-min));
line-height: 1.35;
}
.pj-liste-meta {
display: block;
font-size: var(--fs-xs);
color: var(--c-text-muted);
line-height: 1.35;
}
.pj-liste-rechts {
flex: 0 0 auto;
text-align: right;
}
.pj-liste-pfeil {
flex: 0 0 auto;
color: var(--c-text-muted);
}
/* Der Weg zurueck. Steht ueber dem Titel, weil dort die Hand ohnehin ist,
wenn sie gerade hineingetippt hat. */
.pj-zurueck {
border: 0;
background: none;
color: var(--c-text-muted);
font: inherit;
font-size: var(--fs-xs);
cursor: pointer;
padding: 0 8px 4px 0;
min-height: max(24px, var(--tap-min));
}
.pj-zurueck:hover {
color: var(--c-text);
}
/* ── Notizen ─────────────────────────────────────────────────
Ein Zettel neben dem Laborbuch. Ein Ansatz traegt sein Ergebnis in einer
Zeile, damit die Tabelle zu ueberfliegen bleibt; alles, was laenger ist,
steht hier. Hinter derselben Tuer liegt die Recherche, weil beide dasselbe
sind: nachschlagen, nicht lesen. */
.pj-notebox textarea {
width: 100%;
margin-top: 8px;
font: inherit;
font-size: max(0.8125rem, var(--fs-min));
line-height: 1.5;
padding: 10px 12px;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
background: var(--c-bg);
color: var(--c-text);
resize: vertical;
}
/* Der Knopf bleibt so breit wie sein Wort. */
.pj-note-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.pj-notes {
list-style: none;
margin: 16px 0 0;
padding: 0;
}
.pj-note {
padding: 12px 0;
border-top: 1px solid var(--c-border-light);
}
.pj-note-top {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 6px;
}
.pj-note-date {
font-size: var(--fs-xs);
color: var(--c-text-muted);
}
.pj-note-btns {
display: flex;
gap: 8px;
}
/* Zeilenumbrueche bleiben Zeilenumbrueche: eine Notiz wird getippt, nicht
gesetzt. Der Text geht durch pjText, Markup entsteht daraus keins. */
.pj-note-body {
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: max(0.8125rem, var(--fs-min));
line-height: 1.55;
}
.pj-note .pj-note-edit,
.pj-note .pj-note-bar {
display: none;
}
.pj-note.is-edit .pj-note-body {
display: none;
}
.pj-note.is-edit .pj-note-edit {
display: block;
}
.pj-note.is-edit .pj-note-bar {
display: flex;
}
/* Die Recherche sitzt jetzt hinter den Notizen statt als dreizehn eigene
Karten darunter: eine Tuer, dahinter die Schubladen. */
.pj-refs {
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid var(--c-border);
}
.pj-ref {
margin-top: 10px;
padding-left: 14px;
border-left: 2px solid var(--c-border-light);
}
/* ── Verlauf ─────────────────────────────────────────────────
Wer wann was an einem Experiment geaendert hat, das Neueste oben. Der Kopf
klein und grau wie das Datum einer Notiz. Der alte Wert durchgestrichen, der
neue schlicht, beide mit ihren Zeilenumbruechen: ein Fazit hat Absaetze, und
ein Verlauf, der sie zusammenzieht, zeigt einen Text, den es nie gab. */
.pj-hist-liste {
list-style: none;
margin: 0;
padding: 0;
}
.pj-hist {
padding: 10px 0;
border-top: 1px solid var(--c-border-light);
}
.pj-hist:first-child {
border-top: 0;
padding-top: 0;
}
.pj-hist-kopf {
font-size: var(--fs-xs);
color: var(--c-text-muted);
}
.pj-hist-was {
font-size: max(0.8125rem, var(--fs-min));
font-weight: 600;
margin-top: 2px;
}
.pj-hist-wert {
font-size: max(0.8125rem, var(--fs-min));
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
margin-top: 2px;
}
.pj-hist-wert del {
color: var(--c-text-muted);
}
.pj-hist-wert ins {
text-decoration: none;
}
/* ── Entfernen und Zurueckholen ──────────────────────────────
Das Kreuz am Gefaess steht ganz rechts neben dem Etikett, so leise wie das
am Foto: entfernt wird selten, und eine Rueckfrage kommt ohnehin. Was
entfernt ist, liegt zugeklappt unter der Liste. Ein entferntes Gefaess ist
nur noch zum Lesen da (.pj-run-ro), ein entferntes Foto blass, und beide
tragen statt des Kreuzes einen Knopf zum Zurueckholen. */
.pj-run-weg {
flex: 0 0 auto;
min-width: max(24px, var(--tap-min));
min-height: max(24px, var(--tap-min));
border: 0;
background: none;
color: var(--c-text-muted);
font-size: 1.125rem;
line-height: 1;
cursor: pointer;
}
.pj-run-weg:hover {
color: var(--c-red-dark);
}
.pj-run-zurueck {
flex: 0 0 auto;
margin-left: auto;
}
.pj-weg {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--c-border-light);
}
.pj-foto-entfernt img {
opacity: 0.55;
}
/* Das Foto, von dem eine Zeile im Verlauf spricht, klein davor. */
.pj-hist-foto {
width: 28px;
height: 28px;
object-fit: cover;
border-radius: 4px;
vertical-align: middle;
margin-right: 6px;
}
/* Ein endgueltig geloeschtes Foto: der Platz, an dem es war. */
.pj-foto-leer {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
padding: 6px;
text-align: center;
font-size: var(--fs-xs);
color: var(--c-text-muted);
border: 1px dashed var(--c-border);
border-radius: var(--radius-sm);
}
.pj-foto-zurueck {
display: block;
width: 100%;
margin-top: 4px;
}
/* Settings → Backup → USB stick. The key box is the one thing on the page that
must not be overlooked, so it is the only one with a coloured frame. */
.usb-intro {
font-size: 0.8125rem;
color: var(--c-text-sec);
margin-bottom: 12px;
line-height: 1.6;
}
.usb-status {
font-weight: 600;
margin-bottom: 6px;
}
.usb-msg {
font-size: 0.8125rem;
line-height: 1.5;
margin: 6px 0;
}
.usb-msg-err {
color: var(--c-red-dark);
}
.usb-msg-ok {
color: var(--c-green-dark);
}
.usb-progress {
margin: 8px 0;
}
.usb-bar {
display: block;
width: 100%;
height: 10px;
margin-bottom: 4px;
}
.usb-sticks {
list-style: none;
margin: 10px 0 0;
padding: 0;
}
.usb-stick {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 0;
border-top: 1px solid var(--c-border);
}
.usb-stick-main {
min-width: 0;
}
.usb-stick-name {
font-weight: 600;
}
.usb-badge {
display: inline-block;
margin-left: 6px;
padding: 1px 8px;
border-radius: 999px;
background: var(--c-green-light);
color: var(--c-green-dark);
font-size: 0.75rem;
font-weight: 600;
}
.usb-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-top: 10px;
}
.usb-row input[type='text'] {
flex: 1;
min-width: 160px;
}
.usb-sub {
font-weight: 700;
margin-bottom: 4px;
}
#usb-setup {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--c-border);
}
#usb-setup[hidden],
#usb-key[hidden] {
display: none;
}
.usb-drives {
display: grid;
gap: 6px;
margin: 8px 0;
}
.usb-drive {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-height: 44px;
padding: 6px 10px;
border: 1px solid var(--c-border);
border-radius: var(--radius);
cursor: pointer;
}
.usb-drive.is-off {
opacity: 0.6;
cursor: default;
}
.usb-drive-root {
font-weight: 600;
}
.usb-key {
margin: 0 0 14px;
padding: 12px;
border: 2px solid var(--c-amber-border);
border-radius: var(--radius);
background: var(--c-amber-light);
}
.usb-key-code {
display: block;
margin: 8px 0;
padding: 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 1.125rem;
letter-spacing: 0.04em;
word-break: break-all;
background: var(--c-surface);
color: var(--c-text);
border-radius: 6px;
user-select: all;
}
.usb-key-saved {
display: flex;
gap: 9px;
align-items: center;
margin-top: 10px;
font-size: 0.8125rem;
cursor: pointer;
}
/* The general input rule (full width, 64px tall, padded) is for text fields;
a radio or a checkbox under it is a 64px column with a small circle in it. */
.usb-drive input,
.usb-key-saved input {
width: 22px;
height: 22px;
min-height: 0;
margin: 0;
padding: 0;
flex: none;
}