/* ═══════════════════════════════════════════════════════════════ 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 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 `` 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 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
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 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
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
 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;
}