Add Phase 1 Pocket Pascal UI: home, pantry, week plan, and plate.
Replace the hello-world shell with the elevated design system, bilingual screens, recipe/extras data, and the interactive Mein Teller view. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
43
handover/CLAUDE.md
Normal file
43
handover/CLAUDE.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Pocket Pascal
|
||||
|
||||
Nutrition coach app for Steffi + David, built on Pascal Wirth's Ernährungsbaukasten (building-block nutrition system). Decision aid for "what do I eat right now" — not a tracker, not chat-first. See `/docs/CONCEPT.md` for the full spec.
|
||||
|
||||
## Before touching this repo
|
||||
|
||||
Read `/docs/CONCEPT.md` in full. It is the source of truth for:
|
||||
- Vision, non-goals, and the "app should make itself obsolete" success criterion
|
||||
- The plate/building-block rules (protein, fat, carbs, veg/fruit) and portion sizes
|
||||
- The module list and which phase each belongs to
|
||||
- The decision log (section 8) — settled calls, do not re-litigate without a documented reason
|
||||
- Open questions (section 7) — several are marked as UX calls Steffi owns; don't decide those unilaterally
|
||||
|
||||
## Platform
|
||||
|
||||
Web/PWA. **Not** a native iOS app. Decided 2026-08-03 (see decision log) — only 2 users, no App Store needed, and the existing vanilla JS data model/logic carries over directly.
|
||||
|
||||
Target stack: React + shadcn/ui + Tailwind, hosted on Vercel or GitHub Pages (exact host still an open question — see concept doc §7).
|
||||
|
||||
## Data model
|
||||
|
||||
`/data/foods.json` and `/data/steps.json` are extracted from the working prototype. Structured records, not display strings:
|
||||
|
||||
```json
|
||||
{ "id": "chicken_breast", "cat": "protein",
|
||||
"name": { "de": "Hühnerbrust", "en": "Chicken breast" },
|
||||
"v": { "de": "111 kcal · 24 g", "en": "111 kcal · 24 g" },
|
||||
"f": "pick" }
|
||||
```
|
||||
|
||||
`f` is `"pick"` (especially recommended), `"caution"` (enjoy mindfully), or absent. `v` is currently a display string per item — **not yet normalized to a common basis**. Pantry items are mostly per-100g; snack items are per-serving. Normalizing everything to per-100g is a prerequisite before quantities/calorie math can be added (see concept doc, Teller-Builder — Analyse-Layer module).
|
||||
|
||||
DE is the primary working language (Steffi edits it directly), EN is the general template — every user-facing string needs both. UI chrome strings (not food data) live in a separate dictionary, not duplicated HTML.
|
||||
|
||||
## What's already built (reference only — not authoritative, rebuild in the new stack)
|
||||
|
||||
`/reference/kueche_app.html` — working single-file prototype (Claude Artifact). Has: plate rule view, pantry/knowledge base with search, Teller-Builder (multi-select plate builder with pick/caution scoring), settings with language switch. Useful for behavior reference, not for copy-pasting into the new stack as-is.
|
||||
|
||||
## Working conventions
|
||||
|
||||
- Bilingual: every content change ships both `de` and `en` in the same commit.
|
||||
- Curated, not exhaustive — see non-goals in the concept doc. Don't add a food-database API integration without checking §7 first.
|
||||
- UX/UI decisions are Steffi's call (she's the UX designer on this project). Flag them, don't just decide.
|
||||
665
handover/CONCEPT.html
Normal file
665
handover/CONCEPT.html
Normal file
@@ -0,0 +1,665 @@
|
||||
<title>Pocket Pascal — Konzeptdokument</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#fbfbfa; --surface:#ffffff; --ink:#14150f; --ink-soft:#5f6055; --ink-faint:#8b8c80;
|
||||
--line:#e4e4de; --line-soft:#efefe9;
|
||||
--green:#2F5233; --green-soft:#e3ece4;
|
||||
--sage:#4A6FA5; --sage-soft:#e7edf5;
|
||||
--coral:#B33D22; --coral-soft:#f9e7e1;
|
||||
--leaf:#6B7A22; --leaf-soft:#eff2df;
|
||||
--gold:#8A6A1A; --gold-soft:#fdf1d9;
|
||||
--plum:#6B4C9A; --plum-soft:#efe9f5;
|
||||
}
|
||||
@media (prefers-color-scheme: dark){
|
||||
:root{
|
||||
--bg:#16170f; --surface:#1e1f18; --ink:#f2f1e6; --ink-soft:#b6b6a8; --ink-faint:#84857a;
|
||||
--line:#33342b; --line-soft:#27281f;
|
||||
--green:#8FB583; --green-soft:#242e23;
|
||||
--sage:#93B3D8; --sage-soft:#1f2836;
|
||||
--coral:#E58B67; --coral-soft:#331f18;
|
||||
--leaf:#B5C963; --leaf-soft:#262b17;
|
||||
--gold:#D7B565; --gold-soft:#302713;
|
||||
--plum:#B197DA; --plum-soft:#26203a;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"]{
|
||||
--bg:#16170f; --surface:#1e1f18; --ink:#f2f1e6; --ink-soft:#b6b6a8; --ink-faint:#84857a;
|
||||
--line:#33342b; --line-soft:#27281f;
|
||||
--green:#8FB583; --green-soft:#242e23; --sage:#93B3D8; --sage-soft:#1f2836;
|
||||
--coral:#E58B67; --coral-soft:#331f18; --leaf:#B5C963; --leaf-soft:#262b17;
|
||||
--gold:#D7B565; --gold-soft:#302713; --plum:#B197DA; --plum-soft:#26203a;
|
||||
}
|
||||
:root[data-theme="light"]{
|
||||
--bg:#fbfbfa; --surface:#ffffff; --ink:#14150f; --ink-soft:#5f6055; --ink-faint:#8b8c80;
|
||||
--line:#e4e4de; --line-soft:#efefe9;
|
||||
--green:#2F5233; --green-soft:#e3ece4; --sage:#4A6FA5; --sage-soft:#e7edf5;
|
||||
--coral:#B33D22; --coral-soft:#f9e7e1; --leaf:#6B7A22; --leaf-soft:#eff2df;
|
||||
--gold:#8A6A1A; --gold-soft:#fdf1d9; --plum:#6B4C9A; --plum-soft:#efe9f5;
|
||||
}
|
||||
|
||||
*{ box-sizing:border-box; }
|
||||
body{
|
||||
background:var(--bg); color:var(--ink); margin:0;
|
||||
font-family: ui-sans-serif, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
font-size:16px; line-height:1.6;
|
||||
padding:40px 20px 80px;
|
||||
}
|
||||
body:not([data-lang="en"]) .en{ display:none; }
|
||||
body[data-lang="en"] .de{ display:none; }
|
||||
|
||||
.display{ font-family:"Futura","Century Gothic","Avenir Next","Avenir",ui-sans-serif,sans-serif; font-weight:800; letter-spacing:0.01em; text-wrap:balance; }
|
||||
|
||||
.wrap{ max-width:760px; margin:0 auto; display:flex; flex-direction:column; gap:0; }
|
||||
|
||||
.langbar{
|
||||
position:sticky; top:12px; z-index:20; align-self:flex-end;
|
||||
display:flex; gap:2px; background:var(--surface); border:1px solid var(--line);
|
||||
border-radius:999px; padding:3px; box-shadow:0 2px 10px rgba(0,0,0,0.07);
|
||||
}
|
||||
.langbar button{
|
||||
border:none; background:transparent; padding:6px 15px; border-radius:999px; cursor:pointer;
|
||||
font-size:0.74rem; font-weight:800; letter-spacing:0.08em; color:var(--ink-soft);
|
||||
font-family:inherit;
|
||||
}
|
||||
.langbar button:focus-visible{ outline:2px solid var(--sage); outline-offset:2px; }
|
||||
body:not([data-lang="en"]) .langbar button.de,
|
||||
body[data-lang="en"] .langbar button.en{ background:var(--green); color:var(--bg); }
|
||||
|
||||
header.doc{ display:flex; flex-direction:column; gap:10px; padding-bottom:26px; border-bottom:2px solid var(--ink); margin-bottom:34px; }
|
||||
.kicker{ text-transform:uppercase; letter-spacing:0.16em; font-size:0.68rem; font-weight:800; color:var(--coral); }
|
||||
header.doc h1{ font-size:clamp(2rem,5vw,2.7rem); margin:0; text-transform:uppercase; line-height:1.05; }
|
||||
.meta{ display:flex; flex-wrap:wrap; gap:8px 18px; font-size:0.74rem; color:var(--ink-soft); margin-top:4px; }
|
||||
.meta b{ color:var(--ink); font-weight:700; }
|
||||
|
||||
section{ margin-bottom:38px; }
|
||||
h2{ font-family:"Futura","Century Gothic","Avenir Next",ui-sans-serif,sans-serif; font-size:1.05rem; text-transform:uppercase; letter-spacing:0.07em; font-weight:800; margin:0 0 4px; display:flex; align-items:baseline; gap:10px; }
|
||||
h2 .idx{ font-size:0.72rem; color:var(--ink-faint); font-weight:700; letter-spacing:0.05em; }
|
||||
.lede{ color:var(--ink-soft); font-size:0.92rem; margin:0 0 18px; padding-bottom:14px; border-bottom:1px solid var(--line); }
|
||||
h3{ font-size:0.88rem; font-weight:800; margin:22px 0 6px; letter-spacing:0.01em; }
|
||||
p{ margin:0 0 12px; }
|
||||
ul{ margin:0 0 12px; padding-left:20px; }
|
||||
li{ margin-bottom:5px; }
|
||||
li::marker{ color:var(--ink-faint); }
|
||||
strong{ font-weight:700; }
|
||||
|
||||
.callout{ background:var(--surface); border:1px solid var(--line); border-left:3px solid var(--coral); border-radius:0 8px 8px 0; padding:14px 16px; margin:0 0 16px; font-size:0.9rem; }
|
||||
.callout.ok{ border-left-color:var(--green); }
|
||||
.callout.info{ border-left-color:var(--sage); }
|
||||
.callout b{ display:block; font-size:0.7rem; text-transform:uppercase; letter-spacing:0.08em; margin-bottom:4px; color:var(--coral); }
|
||||
.callout.ok b{ color:var(--green); }
|
||||
.callout.info b{ color:var(--sage); }
|
||||
|
||||
pre{ background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:14px 16px; overflow-x:auto; margin:0 0 16px; }
|
||||
code{ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size:0.8rem; line-height:1.55; }
|
||||
p code, li code, td code{ background:var(--line-soft); padding:1px 5px; border-radius:4px; font-size:0.82em; }
|
||||
|
||||
.modules{ display:flex; flex-direction:column; gap:10px; }
|
||||
.mod{ background:var(--surface); border:1px solid var(--line); border-radius:10px; padding:14px 16px; display:flex; flex-direction:column; gap:5px; }
|
||||
.mod-top{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
.mod-name{ font-weight:800; font-size:0.92rem; flex:1; min-width:160px; }
|
||||
.mod p{ margin:0; font-size:0.85rem; color:var(--ink-soft); }
|
||||
.badge{ font-size:0.62rem; font-weight:800; text-transform:uppercase; letter-spacing:0.08em; padding:3px 9px; border-radius:999px; flex:none; }
|
||||
.badge.p1{ background:var(--green-soft); color:var(--green); }
|
||||
.badge.p2{ background:var(--gold-soft); color:var(--gold); }
|
||||
.badge.p3{ background:var(--plum-soft); color:var(--plum); }
|
||||
.badge.done{ background:var(--sage-soft); color:var(--sage); }
|
||||
|
||||
.tablewrap{ overflow-x:auto; border:1px solid var(--line); border-radius:8px; margin-bottom:16px; }
|
||||
table{ width:100%; border-collapse:collapse; font-size:0.84rem; font-variant-numeric:tabular-nums; }
|
||||
th{ text-align:left; background:var(--surface); font-size:0.66rem; text-transform:uppercase; letter-spacing:0.07em; color:var(--ink-soft); padding:9px 12px; border-bottom:1px solid var(--line); white-space:nowrap; }
|
||||
td{ padding:9px 12px; border-bottom:1px solid var(--line-soft); vertical-align:top; }
|
||||
tbody tr:last-child td{ border-bottom:none; }
|
||||
|
||||
.qlist{ counter-reset:q; list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:9px; }
|
||||
.qlist li{ counter-increment:q; background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:12px 14px 12px 44px; position:relative; font-size:0.88rem; margin:0; }
|
||||
.qlist li::before{ content:"Q" counter(q); position:absolute; left:13px; top:12px; font-size:0.66rem; font-weight:800; color:var(--coral); letter-spacing:0.04em; }
|
||||
.qlist .who{ display:block; font-size:0.7rem; color:var(--ink-faint); margin-top:3px; }
|
||||
|
||||
footer.doc{ border-top:1px solid var(--line); padding-top:18px; font-size:0.74rem; color:var(--ink-faint); }
|
||||
|
||||
.loop{ margin:16px 0 14px; }
|
||||
.loop-row{ display:flex; align-items:stretch; flex-wrap:wrap; gap:0; }
|
||||
.loop-node{
|
||||
flex:1 1 130px; background:var(--surface); border:1px solid var(--line); border-radius:10px;
|
||||
padding:12px 13px; font-size:0.8rem; font-weight:700; text-align:center; line-height:1.3;
|
||||
display:flex; align-items:center; justify-content:center; min-height:56px;
|
||||
}
|
||||
.loop-node .sub{ display:block; font-weight:400; font-size:0.72rem; color:var(--ink-soft); margin-top:3px; }
|
||||
.loop-arrow{ flex:0 0 auto; display:flex; align-items:center; justify-content:center; padding:0 8px; font-size:1.15rem; color:var(--ink-faint); }
|
||||
@media (max-width:620px){ .loop-arrow{ width:100%; padding:4px 0; transform:rotate(90deg); } }
|
||||
.loop-back{
|
||||
display:flex; align-items:center; gap:10px; margin-top:2px; padding:10px 14px;
|
||||
border:1px dashed var(--coral); border-radius:10px; background:var(--coral-soft);
|
||||
font-size:0.8rem; color:var(--ink);
|
||||
}
|
||||
.loop-back .icon{ font-size:1.2rem; color:var(--coral); flex:none; }
|
||||
.loop-base{
|
||||
display:flex; align-items:center; gap:10px; margin-top:10px; padding:10px 14px;
|
||||
border:1px solid var(--line); border-radius:10px; background:var(--sage-soft);
|
||||
font-size:0.8rem; color:var(--ink);
|
||||
}
|
||||
.loop-base .icon{ font-size:1.2rem; color:var(--sage); flex:none; }
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<div class="langbar">
|
||||
<button class="de" onclick="document.body.setAttribute('data-lang','de')">DE</button>
|
||||
<button class="en" onclick="document.body.setAttribute('data-lang','en')">EN</button>
|
||||
</div>
|
||||
|
||||
<header class="doc">
|
||||
<span class="kicker"><span class="de">Arbeitsdokument · intern</span><span class="en">Working document · internal</span></span>
|
||||
<h1 class="display">Pocket Pascal</h1>
|
||||
<p style="margin:0; color:var(--ink-soft); font-size:1rem;">
|
||||
<span class="de">Ein Ernährungs-Coach für die Hosentasche. Verhindert schlechte Entscheidungen im Moment — statt Gewohnheiten zu überwachen.</span>
|
||||
<span class="en">A nutrition coach for your pocket. Prevents poor decisions in the moment — instead of monitoring habits.</span>
|
||||
</p>
|
||||
<div class="meta">
|
||||
<span><b><span class="de">Stand</span><span class="en">Version</span></b> 2026-08-03</span>
|
||||
<span><b><span class="de">Autoren</span><span class="en">Authors</span></b> Steffi + Claude</span>
|
||||
<span><b>Status</b> <span class="de">Entwurf, Phase 1 offen</span><span class="en">Draft, Phase 1 open</span></span>
|
||||
<span><b><span class="de">Sprachen</span><span class="en">Languages</span></b> DE (primär) / EN</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 1 VISION -->
|
||||
<section>
|
||||
<h2><span class="idx">01</span><span class="de">Vision</span><span class="en">Vision</span></h2>
|
||||
<p class="lede">
|
||||
<span class="de">Was die App ist — und vor allem, was sie nicht ist.</span>
|
||||
<span class="en">What the app is — and more importantly, what it is not.</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span class="de">Pocket Pascal ist eine Entscheidungshilfe für den Moment: „Es ist 16 Uhr, ich habe Hunger, was jetzt?" Die App beantwortet das in wenigen Taps, gestützt auf Pascals Ernährungsbaukasten und den vorhandenen Vorrat.</span>
|
||||
<span class="en">Pocket Pascal is a decision aid for the moment: "It's 4pm, I'm hungry, what now?" The app answers that in a few taps, based on Pascal's building-block system and what's actually in the kitchen.</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="de">Das Erfolgskriterium ist ungewöhnlich: Die App soll sich <strong>überflüssig machen</strong>. Wenn die Daumenregeln verinnerlicht sind, wird sie seltener gebraucht. Das ist gewollt, kein Fehler.</span>
|
||||
<span class="en">The success criterion is unusual: the app should <strong>make itself obsolete</strong>. Once the rules of thumb are internalised, it gets used less. That's intended, not a flaw.</span>
|
||||
</p>
|
||||
|
||||
<h3><span class="de">Nicht-Ziele</span><span class="en">Non-goals</span></h3>
|
||||
<ul>
|
||||
<li><span class="de"><strong>Kein Tracker.</strong> Kein Kalorienzählen, keine Streaks, keine Schuldgefühl-Mechanik. Protokolliert wird im Coaching-Tool (Nutrilize), nicht hier.</span><span class="en"><strong>Not a tracker.</strong> No calorie counting, no streaks, no guilt mechanics. Logging happens in the coaching tool (Nutrilize), not here.</span></li>
|
||||
<li><span class="de"><strong>Kein Chat-first-Interface.</strong> Buttons und klare UI schlagen offene Konversation. Ausnahme: tiefe Manager-Module (Kühlschrank, Wochenplanung) in Phase 3.</span><span class="en"><strong>Not chat-first.</strong> Buttons and clear UI beat open-ended conversation. Exception: deeper manager modules (fridge, weekly planning) in Phase 3.</span></li>
|
||||
<li><span class="de"><strong>Kein Ersatz für den Coach.</strong> Die App ist Nachschlagewerk und Anwendungshilfe für Pascals Vorgaben, keine eigene Ernährungsberatung.</span><span class="en"><strong>Not a replacement for the coach.</strong> The app applies Pascal's guidance; it does not give its own nutritional advice.</span></li>
|
||||
<li><span class="de"><strong>Keine Vollständigkeit.</strong> Lieber 40 gute Rezepte als 4.000 mittelmäßige. Der Datensatz bleibt kuratiert.</span><span class="en"><strong>Not exhaustive.</strong> Better 40 good recipes than 4,000 mediocre ones. The dataset stays curated.</span></li>
|
||||
</ul>
|
||||
|
||||
<div class="callout">
|
||||
<b><span class="de">Kernspannung</span><span class="en">Core tension</span></b>
|
||||
<span class="de">Flexibilität gegen Struktur. Zu starr → wird umgangen. Zu offen → gibt keine Antwort und wird nicht geöffnet. Jede Funktion muss sich an dieser Achse messen lassen.</span>
|
||||
<span class="en">Flexibility versus structure. Too rigid → gets circumvented. Too open → gives no answer and doesn't get opened. Every feature has to be measured against this axis.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2 NUTZER -->
|
||||
<section>
|
||||
<h2><span class="idx">02</span><span class="de">Nutzer & Bedürfnisse</span><span class="en">Users & needs</span></h2>
|
||||
<p class="lede">
|
||||
<span class="de">Zwei Personen, zwei sehr unterschiedliche Einstiegspunkte in dieselbe Logik.</span>
|
||||
<span class="en">Two people, two very different entry points into the same logic.</span>
|
||||
</p>
|
||||
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><span class="de">Nutzer</span><span class="en">User</span></th>
|
||||
<th><span class="de">Hauptbedürfnis</span><span class="en">Primary need</span></th>
|
||||
<th><span class="de">Bedient durch</span><span class="en">Served by</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Steffi</strong></td>
|
||||
<td><span class="de">Repertoire erweitern. Weiß bei manchen Zutaten nicht, was sie damit anfangen soll (z. B. Proteinpulver). Will Neues ausprobieren und eine mentale Rezeptbibliothek aufbauen.</span><span class="en">Broaden her repertoire. Doesn't know what to do with certain ingredients (e.g. protein powder). Wants to try new things and build a mental recipe library.</span></td>
|
||||
<td><span class="de">Rezeptbibliothek, Vorschlagsmaschine</span><span class="en">Recipe library, suggestion engine</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>David</strong></td>
|
||||
<td><span class="de">Einkaufen mit richtiger Kategorie-Balance. Braucht (a) ein Gefühl für Lebensmittelkategorien im Laden und (b) Ideen für das, was schon zu Hause ist.</span><span class="en">Shopping with the right category balance. Needs (a) an intuitive sense for food categories while shopping and (b) ideas for what's already at home.</span></td>
|
||||
<td><span class="de">Einkaufshilfe, Knowledge Base, Kühlschrank</span><span class="en">Shopping aid, knowledge base, fridge</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<span class="de">Beide Bedürfnisse laufen auf dieselbe Datenschicht hinaus: Lebensmittel mit Kategorie, Rezepte als Kombinationen daraus. Der Unterschied liegt nur in der Richtung der Abfrage — „was koche ich aus X?" gegen „was muss ich für die Woche kaufen?".</span>
|
||||
<span class="en">Both needs resolve to the same data layer: foods with a category, recipes as combinations of them. The only difference is the direction of the query — "what do I cook from X?" versus "what do I need to buy for the week?".</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 3 PLATE LOGIC -->
|
||||
<section>
|
||||
<h2><span class="idx">03</span><span class="de">Plate Logic — das Regelwerk</span><span class="en">Plate logic — the rule set</span></h2>
|
||||
<p class="lede">
|
||||
<span class="de">Der inhaltliche Kern. Stammt aus Pascals Ernährungsbaukasten und Einkaufsguide.</span>
|
||||
<span class="en">The substantive core. Sourced from Pascal's building-block system and shopping guide.</span>
|
||||
</p>
|
||||
|
||||
<h3><span class="de">Baukasten-Reihenfolge</span><span class="en">Building-block sequence</span></h3>
|
||||
<ul>
|
||||
<li><span class="de"><strong>1. Protein</strong> — 30 g pro Mahlzeit, 150 g am Tag</span><span class="en"><strong>1. Protein</strong> — 30 g per meal, 150 g per day</span></li>
|
||||
<li><span class="de"><strong>2. Obst oder Gemüse</strong> — eine Portion (Obst 100 g, Gemüse 200 g)</span><span class="en"><strong>2. Fruit or vegetables</strong> — one portion (fruit 100 g, veg 200 g)</span></li>
|
||||
<li><span class="de"><strong>3. Fett</strong> — 15 g Kerne/Nüsse, ¼ Avocado oder 1 EL Öl</span><span class="en"><strong>3. Fat</strong> — 15 g seeds/nuts, ¼ avocado or 1 tbsp oil</span></li>
|
||||
<li><span class="de"><strong>4. Kohlenhydrate</strong> — 75 g trocken, 250 g Kartoffeln oder 50 g Haferflocken</span><span class="en"><strong>4. Carbohydrates</strong> — 75 g dry, 250 g potatoes or 50 g oats</span></li>
|
||||
</ul>
|
||||
|
||||
<h3><span class="de">Tellerverteilung</span><span class="en">Plate distribution</span></h3>
|
||||
<ul>
|
||||
<li><span class="de"><strong>Ganze Mahlzeit</strong> — ⅓ Protein, ⅓ Gemüse/Obst, ⅓ Fett + Kohlenhydrate</span><span class="en"><strong>Full meal</strong> — ⅓ protein, ⅓ veg/fruit, ⅓ fat + carbs</span></li>
|
||||
<li><span class="de"><strong>Halbe Mahlzeit (Snack)</strong> — Protein + Gemüse/Obst, ohne Kohlenhydrate</span><span class="en"><strong>Half meal (snack)</strong> — protein + veg/fruit, no carbs</span></li>
|
||||
</ul>
|
||||
|
||||
<h3><span class="de">Tagesstruktur</span><span class="en">Day structure</span></h3>
|
||||
<p>
|
||||
<span class="de">4 Mahlzeiten, ca. alle 3–4 Std. Morgens Protein + Obst · mittags ganzer Teller · nachmittags Proteinsnack + Obst/Gemüse · abends ganzer Teller. 2 L Wasser täglich.</span>
|
||||
<span class="en">4 meals, roughly every 3–4 hours. Morning protein + fruit · midday full plate · afternoon protein snack + fruit/veg · evening full plate. 2 L water daily.</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="de">Diese Struktur ist zugleich die <strong>Vorschlagslogik</strong>: Aus der Uhrzeit folgt der Mahlzeitentyp, aus dem Typ folgt die Zusammensetzung. Kein Rätselraten nötig.</span>
|
||||
<span class="en">This structure doubles as the <strong>suggestion logic</strong>: time of day determines the meal type, meal type determines the composition. No guesswork needed.</span>
|
||||
</p>
|
||||
|
||||
<h3><span class="de">Einkaufsregel</span><span class="en">Shopping rule</span></h3>
|
||||
<p>
|
||||
<span class="de">Pro Einkauf mindestens 5 Proteinquellen, 5 Obst-/Gemüsesorten, 4 Fettquellen, 4 Kohlenhydratquellen. Bedarf = Tage bis zum nächsten Einkauf × Mahlzeiten pro Tag. Diese Formel ist direkt rechenbar und wird zur Einkaufshilfe.</span>
|
||||
<span class="en">Per shopping trip: at least 5 protein sources, 5 fruit/veg varieties, 4 fat sources, 4 carb sources. Demand = days until next trip × meals per day. This formula is directly computable and becomes the shopping aid.</span>
|
||||
</p>
|
||||
|
||||
<h3><span class="de">Daumenregeln (Etikett-Check)</span><span class="en">Rules of thumb (label check)</span></h3>
|
||||
<ul>
|
||||
<li><span class="de"><strong>Protein</strong> — mindestens 15–20 g pro 100 g, wenig Zucker & Fett, kurze Zutatenliste</span><span class="en"><strong>Protein</strong> — at least 15–20 g per 100 g, low sugar & fat, short ingredient list</span></li>
|
||||
<li><span class="de"><strong>Kohlenhydrate</strong> — Vollkorn als erste Zutat; Faustregel 10:1 (pro 10 g KH mind. 1 g Ballaststoffe)</span><span class="en"><strong>Carbs</strong> — whole grain as first ingredient; 10:1 rule (at least 1 g fibre per 10 g carbs)</span></li>
|
||||
<li><span class="de"><strong>Fett</strong> — native/kaltgepresste Öle, ungesalzene Nüsse, Zutatenliste = nur die Zutat</span><span class="en"><strong>Fat</strong> — native/cold-pressed oils, unsalted nuts, ingredient list = just the ingredient</span></li>
|
||||
<li><span class="de"><strong>Zucker</strong> — Tagesbudget = kcal × 10 % ÷ 4,1. Versteckt hinter „-ose", „…zucker", „…sirup"</span><span class="en"><strong>Sugar</strong> — daily budget = kcal × 10% ÷ 4.1. Hidden behind "-ose", "…sugar", "…syrup"</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 4 MODULE -->
|
||||
<section>
|
||||
<h2><span class="idx">04</span><span class="de">Module</span><span class="en">Modules</span></h2>
|
||||
<p class="lede">
|
||||
<span class="de">Sieben Bausteine, nach Phase sortiert. Jeder für sich nutzbar.</span>
|
||||
<span class="en">Seven building blocks, sorted by phase. Each usable on its own.</span>
|
||||
</p>
|
||||
|
||||
<div class="modules">
|
||||
<div class="mod">
|
||||
<div class="mod-top">
|
||||
<span class="mod-name"><span class="de">Knowledge Base</span><span class="en">Knowledge base</span></span>
|
||||
<span class="badge done"><span class="de">Steht</span><span class="en">Built</span></span>
|
||||
<span class="badge p1">Phase 1</span>
|
||||
</div>
|
||||
<p><span class="de">Teller-Regel und Vorratskammer als Nachschlagewerk: Kategorien aufklappbar, Suche, Punkte-Markierung (empfehlenswert / bewusst genießen), Daumenregeln pro Kategorie. Das Fundament, auf das alle anderen Module zugreifen. Muss auf das strukturierte Datenmodell umgestellt werden.</span><span class="en">Plate rule and pantry as a reference: collapsible categories, search, dot markers (recommended / enjoy mindfully), rules of thumb per category. The foundation all other modules read from. Needs migrating to the structured data model.</span></p>
|
||||
</div>
|
||||
|
||||
<div class="mod">
|
||||
<div class="mod-top">
|
||||
<span class="mod-name"><span class="de">Rezeptbibliothek</span><span class="en">Recipe library</span></span>
|
||||
<span class="badge p1">Phase 1</span>
|
||||
</div>
|
||||
<p><span class="de">Kuratierte Sammlung von Mahlzeiten als Kombinationen aus Baukasten-Zutaten. Jedes Rezept kennt seine Zutaten-IDs, den Mahlzeitentyp und die Tageszeit. Startbestand: die 13 Beispielmahlzeiten aus der Ernährungscheckliste, danach schrittweise erweitert. Ohne diese Schicht kann die Vorschlagsmaschine nur die Tellerverteilung wiederholen.</span><span class="en">A curated collection of meals as combinations of building-block ingredients. Each recipe knows its ingredient IDs, meal type and time of day. Seed: the 13 example meals from the nutrition checklist, expanded gradually. Without this layer the suggestion engine can only repeat the plate ratio.</span></p>
|
||||
</div>
|
||||
|
||||
<div class="mod">
|
||||
<div class="mod-top">
|
||||
<span class="mod-name"><span class="de">Vorschlagsmaschine</span><span class="en">Suggestion engine</span></span>
|
||||
<span class="badge p1">Phase 1</span>
|
||||
</div>
|
||||
<p><span class="de">„Was esse ich jetzt?" Ein Tap. Uhrzeit bestimmt den Vorschlag (halbe oder ganze Mahlzeit), manuelle Umschaltung bleibt möglich. Zieht passende Rezepte aus der Bibliothek. Rein regelbasiert, keine KI nötig. Später filterbar nach Kühlschrankinhalt.</span><span class="en">"What do I eat now?" One tap. Time of day drives the suggestion (half or full meal), with manual override. Pulls matching recipes from the library. Purely rule-based, no AI needed. Later filterable by fridge contents.</span></p>
|
||||
</div>
|
||||
|
||||
<div class="mod">
|
||||
<div class="mod-top">
|
||||
<span class="mod-name"><span class="de">Teller-Builder</span><span class="en">Plate builder</span></span>
|
||||
<span class="badge done"><span class="de">Steht</span><span class="en">Built</span></span>
|
||||
<span class="badge p1">Phase 1</span>
|
||||
</div>
|
||||
<p><span class="de">Pro Kategorie (Protein, Fett, Kohlenhydrat, Obst/Gemüse) beliebig viele Lebensmittel als Chips antippen — die App bewertet die Kombination gegen die Baukasten-Regeln und die Pick/Caution-Markierungen: guter Teller, guter Snack, oder was fehlt. Kein Backend nötig, reine Client-Logik auf dem bestehenden Datenmodell. Übersetzt die Vorratskammer direkt in gefühltes Wissen — trifft die Vision „App macht sich überflüssig" am direktesten.</span><span class="en">Tap any number of foods per category (protein, fat, carb, veg/fruit) as chips — the app evaluates the combination against the building-block rules and the pick/caution tags: good plate, good snack, or what's missing. No backend needed, pure client-side logic on the existing data model. Translates the pantry straight into felt intuition — hits the "app makes itself obsolete" vision most directly.</span></p>
|
||||
</div>
|
||||
|
||||
<div class="mod">
|
||||
<div class="mod-top">
|
||||
<span class="mod-name"><span class="de">Teller-Builder — Analyse-Layer</span><span class="en">Plate builder — analysis layer</span></span>
|
||||
<span class="badge p2">Phase 2</span>
|
||||
</div>
|
||||
<p><span class="de">Baut auf dem Teller-Builder auf, sobald Mengen dazukommen. Vier Parameter: <strong>Kalorien</strong> (Summe aus Mengen × Nährwert), <strong>Makros</strong> (Protein/Fett/KH gegen Baukasten-Zielwerte), <strong>Vielfalt</strong> (abgedeckte Vitamine/Mineralien je Teller, später auch über die Woche), <strong>Zucker</strong> (Tagesbudget-Regel aus dem Zucker-Guide). Mengen bekommen einen Vorschlagswert aus den Baukasten-Standardmengen (30 g Protein, 15 g Fett, 75 g KH, 100 g Obst/200 g Gemüse), editierbar. Ausgewählte Kombination als Favorit speichern — landet direkt in der Rezeptbibliothek, keine zweite Datenstruktur nötig. Voraussetzung: alle Lebensmittel auf eine einheitliche Basis (pro 100 g) normalisieren — aktuell sind Snacks pro Portion, der Rest pro 100 g angegeben.</span><span class="en">Builds on the plate builder once quantities are added. Four parameters: <strong>calories</strong> (sum of quantity × nutrition value), <strong>macros</strong> (protein/fat/carbs against building-block targets), <strong>variety</strong> (vitamins/minerals covered per plate, later across the week too), <strong>sugar</strong> (daily-budget rule from the sugar guide). Quantities get a suggested value from the building-block defaults (30 g protein, 15 g fat, 75 g carbs, 100 g fruit/200 g veg), editable. Save a combination as a favourite — it lands straight in the recipe library, no second data structure needed. Prerequisite: normalise all foods onto one basis (per 100 g) — currently snacks are per serving, everything else per 100 g.</span></p>
|
||||
</div>
|
||||
|
||||
<div class="mod">
|
||||
<div class="mod-top">
|
||||
<span class="mod-name">Settings</span>
|
||||
<span class="badge p1">Phase 1</span>
|
||||
</div>
|
||||
<p><span class="de">Sprachumschaltung DE/EN, gespeichert. Später: Mahlzeiten pro Tag, Einkaufsrhythmus, Kalorienrichtwert für das Zuckerbudget.</span><span class="en">Language switch DE/EN, persisted. Later: meals per day, shopping rhythm, calorie reference for the sugar budget.</span></p>
|
||||
</div>
|
||||
|
||||
<div class="mod">
|
||||
<div class="mod-top">
|
||||
<span class="mod-name"><span class="de">Einkaufshilfe</span><span class="en">Shopping aid</span></span>
|
||||
<span class="badge p2">Phase 2</span>
|
||||
</div>
|
||||
<p><span class="de">Rechnet aus Einkaufsrhythmus und Mahlzeitenzahl den Bedarf je Kategorie und schlägt eine ausgewogene Liste vor. Abhakbar im Laden. Bedient primär David.</span><span class="en">Calculates per-category demand from shopping rhythm and meal count, then proposes a balanced list. Checkable in the shop. Primarily serves David.</span></p>
|
||||
</div>
|
||||
|
||||
<div class="mod">
|
||||
<div class="mod-top">
|
||||
<span class="mod-name"><span class="de">Virtueller Kühlschrank</span><span class="en">Virtual fridge</span></span>
|
||||
<span class="badge p2">Phase 2</span>
|
||||
</div>
|
||||
<p><span class="de">Erfasst, was da ist; bucht Zutaten ab, wenn gekocht wurde. Ermöglicht „was kann ich jetzt kochen?" und hilft, angebrochene Packungen über mehrere Tage aufzubrauchen (z. B. eine Dose Bohnen). Braucht Persistenz — technisch die erste echte Hürde. Später: speist die Chip-Auswahl im Teller-Builder direkt aus dem Bestand statt aus der ganzen Vorratskammer — dieselbe Bewertungslogik, andere Datenquelle.</span><span class="en">Records what's available; deducts ingredients once a meal is cooked. Enables "what can I make right now?" and helps use up opened items across several days (e.g. a can of beans). Requires persistence — the first real technical hurdle. Later: feeds the plate builder's chip choices straight from stock instead of the whole pantry — same scoring logic, different data source.</span></p>
|
||||
</div>
|
||||
|
||||
<div class="mod">
|
||||
<div class="mod-top">
|
||||
<span class="mod-name"><span class="de">Wunschliste</span><span class="en">Wishlist</span></span>
|
||||
<span class="badge p2">Phase 2</span>
|
||||
</div>
|
||||
<p><span class="de">Merkliste für Dinge, die in die Vorratskammer sollen, aber gerade fehlen — Bindeglied zwischen Kühlschrank und Einkaufsliste. Ohne eigene Logik: Einträge landen automatisch auf der nächsten Einkaufsliste.</span><span class="en">A running list of things that should be in the pantry but currently aren't — the link between the fridge and the shopping list. No logic of its own: entries land on the next shopping list automatically.</span></p>
|
||||
</div>
|
||||
|
||||
<div class="mod">
|
||||
<div class="mod-top">
|
||||
<span class="mod-name"><span class="de">Konversationelle Manager</span><span class="en">Conversational managers</span></span>
|
||||
<span class="badge p3">Phase 3</span>
|
||||
</div>
|
||||
<p><span class="de">Kühlschrank-Manager und Wochenplaner mit natürlicher Sprache („hab noch Spinat und Feta, was geht?"). Die einzige Stelle, an der Chat sinnvoll ist — weil die Eingabe zu vielfältig für Buttons wird. Braucht Backend.</span><span class="en">Fridge manager and weekly planner with natural language ("got spinach and feta left, what works?"). The only place chat makes sense — because input becomes too varied for buttons. Requires a backend.</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3><span class="de">Zusammenspiel der Module — der Kreislauf</span><span class="en">How the modules interlock — the loop</span></h3>
|
||||
<p>
|
||||
<span class="de">Aus dem FigJam-Board: Die Module sind kein loses Set, sie bilden einen geschlossenen Kreislauf. Jeder Schritt füttert den nächsten, der letzte Schritt füttert wieder den ersten.</span>
|
||||
<span class="en">From the FigJam board: the modules aren't a loose set — they form a closed loop. Each step feeds the next, and the last step feeds back into the first.</span>
|
||||
</p>
|
||||
|
||||
<div class="loop">
|
||||
<div class="loop-row">
|
||||
<div class="loop-node"><span class="de">Kühlschrank<span class="sub">kennt Bestand</span></span><span class="en">Fridge<span class="sub">knows stock</span></span></div>
|
||||
<div class="loop-arrow">→</div>
|
||||
<div class="loop-node"><span class="de">Einkaufsliste</span><span class="en">Shopping list</span></div>
|
||||
<div class="loop-arrow">→</div>
|
||||
<div class="loop-node"><span class="de">Wochenplan<span class="sub">mit Einkaufsliste verknüpft</span></span><span class="en">Meal plan<span class="sub">linked to shopping list</span></span></div>
|
||||
<div class="loop-arrow">→</div>
|
||||
<div class="loop-node"><span class="de">Rezeptvorschläge</span><span class="en">Recipe suggestions</span></div>
|
||||
<div class="loop-arrow">→</div>
|
||||
<div class="loop-node"><span class="de">„Hab Hunger, was jetzt?"</span><span class="en">"Hungry now, what's next?"</span></div>
|
||||
</div>
|
||||
<div class="loop-back">
|
||||
<span class="icon">↺</span>
|
||||
<span><span class="de">Die Antwort verbraucht Zutaten aus dem Bestand — und schreibt zurück in den Kühlschrank. Der Kreis schließt sich.</span><span class="en">The answer consumes ingredients from stock — and writes back into the fridge. The loop closes.</span></span>
|
||||
</div>
|
||||
<div class="loop-base">
|
||||
<span class="icon">◆</span>
|
||||
<span><span class="de">Die Knowledge Base (Teller-Regel, Vorratskammer, Daumenregeln) liegt unter jedem einzelnen Schritt — jedes Modul liest daraus, statt eigene Regeln mitzubringen.</span><span class="en">The knowledge base (plate rule, pantry, rules of thumb) underlies every single step — each module reads from it instead of carrying its own rules.</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callout info">
|
||||
<b><span class="de">Konsequenz für die Roadmap</span><span class="en">Consequence for the roadmap</span></b>
|
||||
<span class="de">Kühlschrank (Phase 2) ist der Dreh- und Angelpunkt des Kreislaufs, nicht nur ein Feature unter anderen. Einkaufshilfe und Vorschlagsmaschine bekommen ihren vollen Wert erst, wenn er steht. Phase 1 liefert die Module isoliert nutzbar vor — der Kreislauf selbst ist Phase 2.</span>
|
||||
<span class="en">The fridge (Phase 2) is the pivot of the loop, not just one feature among others. The shopping aid and suggestion engine only reach their full value once it exists. Phase 1 ships the modules usable in isolation — the loop itself is Phase 2.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 5 ARCHITEKTUR -->
|
||||
<section>
|
||||
<h2><span class="idx">05</span><span class="de">Architektur</span><span class="en">Architecture</span></h2>
|
||||
<p class="lede">
|
||||
<span class="de">Entscheidungen, die jetzt getroffen werden müssen, weil sie später teuer sind.</span>
|
||||
<span class="en">Decisions that must be made now because they get expensive later.</span>
|
||||
</p>
|
||||
|
||||
<h3><span class="de">Datenmodell: strukturiert statt Anzeige-Strings</span><span class="en">Data model: structured, not display strings</span></h3>
|
||||
<p>
|
||||
<span class="de">Der aktuelle Prototyp speichert fertige Anzeigetexte. Das blockiert sowohl Zweisprachigkeit als auch jede Rechenfunktion. Neue Form:</span>
|
||||
<span class="en">The current prototype stores ready-made display text. That blocks both bilingualism and any computation. New shape:</span>
|
||||
</p>
|
||||
<pre><code>// Lebensmittel / food item
|
||||
{ id: 'chicken_breast',
|
||||
cat: 'protein',
|
||||
name: { de: 'Hühnerbrust', en: 'Chicken breast' },
|
||||
kcal: 111,
|
||||
protein: 24,
|
||||
tag: 'pick' } // 'pick' | 'caution' | null
|
||||
|
||||
// Rezept / recipe
|
||||
{ id: 'eggs_cottage_cucumber',
|
||||
name: { de: '3 Eier + Hüttenkäse + Gurke', en: '3 eggs + cottage cheese + cucumber' },
|
||||
type: 'full', // 'full' | 'half'
|
||||
times: ['midday','evening'],
|
||||
uses: ['egg','cottage_cheese','cucumber','lentil_waffle'] }</code></pre>
|
||||
<p>
|
||||
<span class="de">Damit wird alles möglich: Übersetzen (<code>name[lang]</code>), Rechnen (Einkaufsmengen), Filtern (welches Rezept passt zum Vorrat), Verknüpfen (Rezept ↔ Zutat ↔ Kategorie).</span>
|
||||
<span class="en">This unlocks everything: translation (<code>name[lang]</code>), computation (shopping quantities), filtering (which recipe fits the stock), linking (recipe ↔ ingredient ↔ category).</span>
|
||||
</p>
|
||||
|
||||
<div class="callout ok">
|
||||
<b><span class="de">Jetzt umstellen</span><span class="en">Migrate now</span></b>
|
||||
<span class="de">Der Datensatz umfasst aktuell rund 70 Einträge. Bei 300 wird der Umbau zur Tagesaufgabe. Der Refactor gehört an den Anfang von Phase 1, nicht ans Ende.</span>
|
||||
<span class="en">The dataset currently holds around 70 entries. At 300 the migration becomes a full day's work. The refactor belongs at the start of Phase 1, not the end.</span>
|
||||
</div>
|
||||
|
||||
<h3><span class="de">Portabilität</span><span class="en">Portability</span></h3>
|
||||
<p>
|
||||
<span class="de">Ziel ist der spätere Umzug auf echtes Hosting. Damit das kein Neubau wird, gilt ab sofort: <strong>Daten, Logik und Darstellung strikt getrennt</strong>. Die Datenschicht bleibt reines JavaScript ohne DOM-Bezug — sie lässt sich unverändert in ein Repo übernehmen. Keine Abhängigkeit von Artifact-spezifischen APIs im Kern.</span>
|
||||
<span class="en">The goal is an eventual move to real hosting. To keep that from becoming a rebuild, from now on: <strong>strictly separate data, logic and presentation</strong>. The data layer stays plain JavaScript with no DOM references — it can move into a repo untouched. No dependency on Artifact-specific APIs in the core.</span>
|
||||
</p>
|
||||
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><span class="de">Fähigkeit</span><span class="en">Capability</span></th>
|
||||
<th>Artifact</th>
|
||||
<th><span class="de">Eigenes Hosting</span><span class="en">Own hosting</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><span class="de">Nachschlagen, Rechnen, Vorschläge</span><span class="en">Reference, computation, suggestions</span></td><td>✓</td><td>✓</td></tr>
|
||||
<tr><td><span class="de">Home-Bildschirm-Icon</span><span class="en">Home screen icon</span></td><td><span class="de">über Safari „Teilen"</span><span class="en">via Safari "Share"</span></td><td>✓</td></tr>
|
||||
<tr><td><span class="de">Zustand speichern (Kühlschrank, Sprache)</span><span class="en">Persisted state (fridge, language)</span></td><td><span class="de">ungeprüft</span><span class="en">unverified</span></td><td>✓</td></tr>
|
||||
<tr><td><span class="de">Offline-Nutzung</span><span class="en">Offline use</span></td><td>—</td><td>✓</td></tr>
|
||||
<tr><td><span class="de">Konversationelle Module</span><span class="en">Conversational modules</span></td><td>—</td><td>✓</td></tr>
|
||||
<tr><td><span class="de">Sync zwischen zwei Nutzern</span><span class="en">Sync between two users</span></td><td>—</td><td>✓</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3><span class="de">Zweisprachigkeit</span><span class="en">Bilingualism</span></h3>
|
||||
<p>
|
||||
<span class="de">DE ist die primäre Arbeitssprache, EN die allgemeine Vorlage. Umsetzung nicht mehr über doppelte HTML-Blöcke wie bisher, sondern über ein <code>t()</code>-Lookup gegen das Datenmodell — sonst wächst jede Seite doppelt. UI-Texte kommen in ein eigenes Wörterbuch.</span>
|
||||
<span class="en">DE is the primary working language, EN the general template. No longer implemented via duplicated HTML blocks, but through a <code>t()</code> lookup against the data model — otherwise every page doubles in size. UI strings move into their own dictionary.</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 6 ROADMAP -->
|
||||
<section>
|
||||
<h2><span class="idx">06</span>Roadmap</h2>
|
||||
<p class="lede">
|
||||
<span class="de">Phase 1 muss allein stehen können. Erst wenn sie im Alltag benutzt wird, kommt Phase 2.</span>
|
||||
<span class="en">Phase 1 has to stand on its own. Phase 2 only follows once Phase 1 is actually used day to day.</span>
|
||||
</p>
|
||||
|
||||
<h3><span class="de">Phase 1 — Fundament</span><span class="en">Phase 1 — foundation</span></h3>
|
||||
<ul>
|
||||
<li><span class="de">Datenmodell-Refactor: Anzeige-Strings → strukturierte Records mit IDs und <code>de</code>/<code>en</code></span><span class="en">Data model refactor: display strings → structured records with IDs and <code>de</code>/<code>en</code></span></li>
|
||||
<li><span class="de">Settings-Seite mit Sprachumschaltung; <code>localStorage</code> im Artifact prüfen</span><span class="en">Settings page with language switch; verify <code>localStorage</code> inside the Artifact</span></li>
|
||||
<li><span class="de">Knowledge Base auf das neue Modell umstellen (Teller-Regel + Vorratskammer + Daumenregeln)</span><span class="en">Migrate the knowledge base to the new model (plate rule + pantry + rules of thumb)</span></li>
|
||||
<li><span class="de">Rezeptbibliothek aufbauen, Start mit den 13 Beispielmahlzeiten</span><span class="en">Build the recipe library, starting from the 13 example meals</span></li>
|
||||
<li><span class="de">Vorschlagsmaschine: „Was esse ich jetzt?" nach Uhrzeit und Mahlzeitentyp</span><span class="en">Suggestion engine: "What do I eat now?" by time of day and meal type</span></li>
|
||||
<li><span class="de">Teller-Builder: Lebensmittel pro Kategorie wählen, Kombination bewerten lassen</span><span class="en">Plate builder: pick foods per category, get the combination evaluated</span></li>
|
||||
</ul>
|
||||
|
||||
<h3><span class="de">Phase 2 — Zustand & Einkauf</span><span class="en">Phase 2 — state & shopping</span></h3>
|
||||
<ul>
|
||||
<li><span class="de">Umzug auf eigenes Hosting, echtes PWA-Manifest, Offline-Fähigkeit</span><span class="en">Move to own hosting, real PWA manifest, offline capability</span></li>
|
||||
<li><span class="de">Einkaufshilfe mit Kategorie-Rechner und abhakbarer Liste</span><span class="en">Shopping aid with category calculator and checkable list</span></li>
|
||||
<li><span class="de">Virtueller Kühlschrank mit Abbuchung nach dem Kochen</span><span class="en">Virtual fridge with deduction after cooking</span></li>
|
||||
<li><span class="de">Wunschliste als Bindeglied zwischen Kühlschrank und Einkaufsliste</span><span class="en">Wishlist as the link between fridge and shopping list</span></li>
|
||||
<li><span class="de">Vorschläge nach Vorrat filtern</span><span class="en">Filter suggestions by available stock</span></li>
|
||||
<li><span class="de">Datenmodell auf Basis „pro 100 g" normalisieren, Mengen in den Teller-Builder</span><span class="en">Normalise the data model to a "per 100 g" basis, add quantities to the plate builder</span></li>
|
||||
<li><span class="de">Teller-Builder-Analyse: Kalorien, Makros, Vielfalt, Zucker; Favoriten speichern → Rezeptbibliothek</span><span class="en">Plate builder analysis: calories, macros, variety, sugar; save favourites → recipe library</span></li>
|
||||
<li><span class="de">UI-Politur: von funktionalem Prototyp zu hochwertig wirkender App — mit dir als UX-Designerin</span><span class="en">UI polish: from functional prototype to an app that feels high-quality — with you leading as UX designer</span></li>
|
||||
</ul>
|
||||
|
||||
<h3><span class="de">Phase 3 — Agentische Module</span><span class="en">Phase 3 — agentic modules</span></h3>
|
||||
<ul>
|
||||
<li><span class="de">Kühlschrank-Manager und Wochenplaner mit natürlicher Sprache</span><span class="en">Fridge manager and weekly planner with natural language</span></li>
|
||||
<li><span class="de">Rezeptvorschläge, die über den kuratierten Bestand hinausgehen</span><span class="en">Recipe suggestions beyond the curated set</span></li>
|
||||
<li><span class="de">Mehrbenutzer-Betrieb mit geteiltem Haushalt</span><span class="en">Multi-user operation with a shared household</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 7 OFFENE FRAGEN -->
|
||||
<section>
|
||||
<h2><span class="idx">07</span><span class="de">Offene Fragen</span><span class="en">Open questions</span></h2>
|
||||
<p class="lede">
|
||||
<span class="de">Zu klären, bevor die jeweilige Phase startet.</span>
|
||||
<span class="en">To be resolved before the respective phase starts.</span>
|
||||
</p>
|
||||
|
||||
<ol class="qlist">
|
||||
<li>
|
||||
<span class="de">Speichert <code>localStorage</code> im Artifact-iframe zuverlässig? Im normalen Browser bestätigt (Sprache übersteht Reload), im claude.ai-iframe selbst noch nicht getestet. App zeigt in den Settings ehrlich an, ob's geklappt hat.</span>
|
||||
<span class="en">Does <code>localStorage</code> persist reliably inside the Artifact iframe? Confirmed in a normal browser (language survives reload), not yet verified inside the claude.ai iframe itself. The app's settings screen honestly reports whether it worked.</span>
|
||||
<span class="who"><span class="de">Steffi · auf dem iPhone prüfen</span><span class="en">Steffi · verify on iPhone</span></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="de">Woher kommen neue Rezepte? Von Claude vorgeschlagen und von dir freigegeben, oder sammelst du selbst? Empfehlung: Claude schlägt vor, du kuratierst — sonst wächst die Bibliothek nicht.</span>
|
||||
<span class="en">Where do new recipes come from? Proposed by Claude and approved by you, or collected yourself? Recommendation: Claude proposes, you curate — otherwise the library won't grow.</span>
|
||||
<span class="who">Steffi</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="de">Wie viele Rezepte braucht ein sinnvoller Start? Vorschlag: 30–40, verteilt über die vier Tageszeiten.</span>
|
||||
<span class="en">How many recipes make a viable start? Proposal: 30–40, spread across the four times of day.</span>
|
||||
<span class="who">Steffi</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="de">Bekommt David eine eigene Instanz oder eine geteilte? Betrifft die Datenhaltung ab Phase 2.</span>
|
||||
<span class="en">Does David get his own instance or a shared one? Affects data handling from Phase 2 onwards.</span>
|
||||
<span class="who">Steffi</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="de">Wohin beim Hosting — GitHub Pages, Vercel, eigene Domain? Entscheidet über Aufwand und Sync-Möglichkeiten.</span>
|
||||
<span class="en">Where to host — GitHub Pages, Vercel, own domain? Determines effort and sync options.</span>
|
||||
<span class="who"><span class="de">Steffi · vor Phase 2</span><span class="en">Steffi · before Phase 2</span></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="de">Genügen gerundete Nährwerte? Die App ist keine medizinische Anwendung; PCOS und Prädiabetes gehören ins Coaching, nicht in eine App-Logik.</span>
|
||||
<span class="en">Are rounded nutritional values sufficient? The app is not a medical tool; PCOS and prediabetes belong in the coaching, not in app logic.</span>
|
||||
<span class="who">Steffi</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="de">Lohnt sich eine Food-Database-API statt manueller Pflege? Open Food Facts (kostenlos, kein Key, gut für verpackte Produkte/Barcodes) vs. USDA FoodData Central (kostenlos, Key nötig, sehr genau für Grundzutaten, aber Englisch/US-zentriert) vs. Edamam (bester Rezept-Nährwert-Abgleich, aber Freemium mit Kontingent). Widerspricht evtl. dem Nicht-Ziel „kuratiert statt vollständig" — Integration würde eher die Vorratskammer ergänzen (Nährwerte nachschlagen), nicht ersetzen.</span>
|
||||
<span class="en">Does a food-database API pay off over manual upkeep? Open Food Facts (free, no key, good for packaged products/barcodes) vs. USDA FoodData Central (free, needs a key, very accurate for base ingredients, but English/US-centric) vs. Edamam (best recipe-nutrition matching, but freemium with quota). May cut against the non-goal "curated, not exhaustive" — integration would more likely supplement the pantry (nutrition lookup) than replace it.</span>
|
||||
<span class="who"><span class="de">Steffi · vor Phase 2</span><span class="en">Steffi · before Phase 2</span></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="de">Wer treibt das UI-Redesign und wann im Prozess? Du bist UX-Designerin — heißt vermutlich: du lieferst Richtung/Mockups, Claude setzt um. Zeitpunkt: jetzt parallel zu Phase 1, oder erst wenn Funktionsumfang steht?</span>
|
||||
<span class="en">Who drives the UI redesign and at what point in the process? You're the UX designer — likely means: you supply direction/mockups, Claude implements. Timing: now, in parallel with Phase 1, or only once the feature set is settled?</span>
|
||||
<span class="who">Steffi</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="de">Teller-Builder: Was genau macht eine Kombination „gut"? Vorschlag: alle 4 Kategorien besetzt = vollständig; enthält „pick"-Markierung = Bonus; enthält „caution" = Hinweis statt Fehler (nichts ist verboten). Genaue Bewertungsstufen und Formulierung sind UX-Entscheidung.</span>
|
||||
<span class="en">Plate builder: what exactly makes a combination "good"? Proposal: all 4 categories filled = complete; contains a "pick" tag = bonus; contains a "caution" tag = a note, not an error (nothing is forbidden). Exact scoring tiers and wording are a UX call.</span>
|
||||
<span class="who">Steffi</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="de">Wie werden Mengen eingegeben — Stepper, Slider oder Zahlenfeld pro Chip? Vorschlag: Baukasten-Standardmenge vorausgefüllt, direkt editierbar. UX-Entscheidung.</span>
|
||||
<span class="en">How are quantities entered — stepper, slider, or number field per chip? Proposal: pre-filled with the building-block default, directly editable. A UX call.</span>
|
||||
<span class="who">Steffi</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="de">Vielfalt über die Woche messen — braucht Verlauf gespeicherter Teller, nicht nur den aktuellen. Erst sinnvoll, sobald Favoriten/Historie existieren (Phase 2).</span>
|
||||
<span class="en">Measuring variety across the week needs a history of saved plates, not just the current one. Only makes sense once favourites/history exist (Phase 2).</span>
|
||||
<span class="who"><span class="de">Steffi · vor Umsetzung</span><span class="en">Steffi · before implementation</span></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="de">Magic Button „Was esse ich jetzt?" — Konzept noch nicht ausgearbeitet. Braucht: Eingaben (Zeit, evtl. Kühlschrankinhalt, evtl. Stimmung/Zeitdruck), Ausgabelogik (ein Vorschlag vs. 3 Optionen), Verhalten bei „passt nicht" (neu würfeln? Filter?). Gehört zur Vorschlagsmaschine (Modul, Abschnitt 4), aber die Interaktion selbst ist UX-Arbeit.</span>
|
||||
<span class="en">Magic Button "What can I eat now?" — concept not yet worked out. Needs: inputs (time, maybe fridge contents, maybe mood/time pressure), output logic (one suggestion vs. 3 options), behaviour on "doesn't fit" (reroll? filter?). Belongs to the suggestion engine (module, section 4), but the interaction itself is UX work.</span>
|
||||
<span class="who"><span class="de">Steffi + Claude gemeinsam</span><span class="en">Steffi + Claude together</span></span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="callout">
|
||||
<b><span class="de">Rechtlicher Hinweis</span><span class="en">Legal note</span></b>
|
||||
<span class="de">Zucker-Guide und Einkaufsguide tragen den Vermerk „Weitergabe ohne Genehmigung ist nicht gestattet". Die App baut inhaltlich auf diesem Material auf. Für den privaten Gebrauch unproblematisch — vor jeder Weitergabe, Veröffentlichung oder Nutzung durch David mit Pascal klären. Gleiches gilt für den Namen „Pocket Pascal".</span>
|
||||
<span class="en">The sugar guide and shopping guide carry the notice "distribution without permission is not permitted". The app builds on this material. Unproblematic for private use — clear it with Pascal before any sharing, publication, or use by David. The same applies to the name "Pocket Pascal".</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 8 ENTSCHEIDUNGSLOG -->
|
||||
<section>
|
||||
<h2><span class="idx">08</span><span class="de">Entscheidungslog</span><span class="en">Decision log</span></h2>
|
||||
<p class="lede">
|
||||
<span class="de">Getroffene Festlegungen, damit sie nicht neu verhandelt werden.</span>
|
||||
<span class="en">Settled decisions, so they don't get re-litigated.</span>
|
||||
</p>
|
||||
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><span class="de">Datum</span><span class="en">Date</span></th>
|
||||
<th><span class="de">Entscheidung</span><span class="en">Decision</span></th>
|
||||
<th><span class="de">Begründung</span><span class="en">Rationale</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>2026-08-03</td>
|
||||
<td><span class="de">Alle Artefakte zweisprachig, DE primär</span><span class="en">All artefacts bilingual, DE primary</span></td>
|
||||
<td><span class="de">DE zum eigenen Anpassen, EN als allgemeine Vorlage</span><span class="en">DE for personal editing, EN as the general template</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2026-08-03</td>
|
||||
<td><span class="de">Buttons statt Chat als Standard</span><span class="en">Buttons over chat by default</span></td>
|
||||
<td><span class="de">Offene Konversation kostet im Hunger-Moment zu viel Zeit</span><span class="en">Open conversation costs too much time in a moment of hunger</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2026-08-03</td>
|
||||
<td><span class="de">Später eigenes Hosting, portable Architektur ab jetzt</span><span class="en">Own hosting later, portable architecture from now</span></td>
|
||||
<td><span class="de">Kühlschrank und AI-Module sind im Artifact nicht umsetzbar</span><span class="en">Fridge and AI modules aren't feasible inside an Artifact</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2026-08-03</td>
|
||||
<td><span class="de">Datenmodell strukturiert statt Anzeige-Strings</span><span class="en">Structured data model instead of display strings</span></td>
|
||||
<td><span class="de">Voraussetzung für Übersetzung, Rechnen und Filtern</span><span class="en">Prerequisite for translation, computation and filtering</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2026-08-03</td>
|
||||
<td><span class="de">App nur hell, Dokumente hell/dunkel</span><span class="en">App light only, documents light/dark</span></td>
|
||||
<td><span class="de">Küchennutzung bei Tageslicht, Druckvorlagen brauchen Weiß</span><span class="en">Kitchen use in daylight, print masters need white</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2026-08-03</td>
|
||||
<td><span class="de">Phase 1 = Knowledge Base + Rezeptbibliothek + Vorschlag</span><span class="en">Phase 1 = knowledge base + recipe library + suggestion</span></td>
|
||||
<td><span class="de">Bedient Steffis Kernbedürfnis, ohne Persistenz auszukommen</span><span class="en">Serves Steffi's core need without requiring persistence</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2026-08-03</td>
|
||||
<td><span class="de">Testen/Aktualisieren: Artifact-Link direkt auf dem iPhone öffnen, kein Neu-Hinzufügen zum Home-Bildschirm nötig</span><span class="en">Testing/updating: open the Artifact link directly on the iPhone, no need to re-add to the home screen</span></td>
|
||||
<td><span class="de">Ohne Service Worker cacht iOS nichts offline — jeder Aufruf lädt die zuletzt veröffentlichte Version live nach; das Home-Bildschirm-Icon zeigt also automatisch den neuesten Stand</span><span class="en">Without a service worker, iOS caches nothing offline — every open fetches the latest published version live, so the home-screen icon automatically shows the newest state</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2026-08-03</td>
|
||||
<td><span class="de">Web/PWA statt native iOS-App (kein Swift/Cursor)</span><span class="en">Web/PWA instead of a native iOS app (no Swift/Cursor)</span></td>
|
||||
<td><span class="de">Nur 2 Nutzer (Steffi + David); bestehendes Datenmodell/Logik direkt weiterverwendbar statt Neustart der Codebasis; kein App-Store-Aufwand nötig</span><span class="en">Only 2 users (Steffi + David); existing data model/logic carries over directly instead of a codebase restart; no App Store overhead needed</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="doc">
|
||||
<span class="de">Pocket Pascal · Arbeitsdokument, Stand 2026-08-03 · Inhaltliche Grundlage: Ernährungsbaukasten, Einkaufsguide und Zucker-Guide von Pascal Wirth</span>
|
||||
<span class="en">Pocket Pascal · working document, as of 2026-08-03 · Content basis: building-block system, shopping guide and sugar guide by Pascal Wirth</span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
112
handover/component-library/ComponentShowcase.tsx
Normal file
112
handover/component-library/ComponentShowcase.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useState } from "react";
|
||||
import { GlassCard } from "./components/GlassCard";
|
||||
import { Button } from "./components/Button";
|
||||
import { BottomNav, type NavItem } from "./components/BottomNav";
|
||||
import { PlateChart } from "./components/PlateChart";
|
||||
|
||||
/**
|
||||
* Demo page — every component rendered over a few different background
|
||||
* gradients so the backdrop-blur actually has something to refract.
|
||||
* Drop this on a route in Cursor (e.g. /showcase) to sanity-check the
|
||||
* system before wiring it into real screens.
|
||||
*/
|
||||
export default function ComponentShowcase() {
|
||||
const [dark, setDark] = useState(false);
|
||||
const [active, setActive] = useState("plate");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ key: "plate", icon: "🍽️", label: "Teller", active: active === "plate", onClick: () => setActive("plate") },
|
||||
{ key: "pantry", icon: "🧺", label: "Vorrat", active: active === "pantry", onClick: () => setActive("pantry") },
|
||||
{ key: "builder", icon: "🧩", label: "Builder", active: active === "builder", onClick: () => setActive("builder") },
|
||||
{ key: "settings", icon: "⚙️", label: "Mehr", active: active === "settings", onClick: () => setActive("settings") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={dark ? "dark" : ""}>
|
||||
<div
|
||||
className="min-h-screen font-body"
|
||||
style={{ background: "var(--page-gradient)", color: "var(--ink)" }}
|
||||
>
|
||||
<div className="mx-auto max-w-md space-y-10 px-5 pb-32 pt-10">
|
||||
<header className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-[var(--ink-faint)]">
|
||||
Pocket Pascal · Component Showcase
|
||||
</p>
|
||||
<h1 className="font-display text-3xl font-black">Elevated Surfaces</h1>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => setDark((d) => !d)}>
|
||||
{dark ? "☀️ Light" : "🌙 Dark"}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{/* GlassCard variants */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-display text-sm font-bold uppercase tracking-wide text-[var(--ink-faint)]">
|
||||
Card
|
||||
</h2>
|
||||
<GlassCard className="p-5">
|
||||
<p className="font-display text-lg font-bold">Default</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Static container — low-transparency surface, real elevation shadow, raised bevel edge.
|
||||
</p>
|
||||
</GlassCard>
|
||||
<GlassCard variant="interactive" className="p-5" onClick={() => {}}>
|
||||
<p className="font-display text-lg font-bold">Interactive</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">Tap me — settles down 1px, shadow softens.</p>
|
||||
</GlassCard>
|
||||
</section>
|
||||
|
||||
{/* Dining Plate chart inside a GlassCard */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-display text-sm font-bold uppercase tracking-wide text-[var(--ink-faint)]">
|
||||
Dining Plate Chart
|
||||
</h2>
|
||||
<GlassCard className="flex flex-col items-center gap-4 p-6">
|
||||
<PlateChart
|
||||
size={220}
|
||||
centerValue="420"
|
||||
centerLabel="kcal"
|
||||
slices={[
|
||||
{ id: "protein", label: "Protein", value: 30, colors: ["#D9F227", "#B8CE1E"] },
|
||||
{ id: "obstgem", label: "Obst/Gemüse", value: 30, colors: ["#FF6A3D", "#FF8F6B"] },
|
||||
{ id: "fett", label: "Fett", value: 15, colors: ["#FFB300", "#FFC94A"] },
|
||||
{ id: "kh", label: "KH", value: 25, colors: ["#1FD9C4", "#5FE0C4"] },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs font-bold text-[var(--ink-soft)]">
|
||||
<span>🟡 Protein 30%</span>
|
||||
<span>🟠 Obst/Gemüse 30%</span>
|
||||
<span>🟨 Fett 15%</span>
|
||||
<span>🟢 KH 25%</span>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</section>
|
||||
|
||||
{/* Buttons */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-display text-sm font-bold uppercase tracking-wide text-[var(--ink-faint)]">
|
||||
Buttons
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="primary">Guter Teller!</Button>
|
||||
<Button variant="secondary">Details</Button>
|
||||
<Button variant="fab" icon="+" aria-label="Hinzufügen" />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="primary" loading={loading} onClick={() => setLoading((l) => !l)}>
|
||||
{loading ? "Speichert…" : "Speichern (Loading toggle)"}
|
||||
</Button>
|
||||
<Button variant="secondary" disabled>
|
||||
Disabled
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<BottomNav items={navItems} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
handover/component-library/README.md
Normal file
58
handover/component-library/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Pocket Pascal — Elevated Surface Component Library
|
||||
|
||||
Drop this folder's contents into your Cursor React + TypeScript + Tailwind project. It's framework-agnostic (no Next.js-specific imports), works in Vite or Next.
|
||||
|
||||
> **Pivoted from glassmorphism to elevation (2026-08-03).** The first pass used real see-through glass (12% white fill, 16px blur) — over a plain white/dark background it barely reads as anything, which is exactly the feedback that came back: "I don't see the glass effect." Rather than fight a page background that has nothing distinct behind it to refract, the system now uses **low-transparency solid surfaces (~90% opaque) with visible elevation shadows**, and buttons that visibly **press inward** on tap (inset shadow + 1px downward shift + slight darkening) instead of just scaling down. The `--glass-*` variable names in `theme.css` were kept to minimize the diff across components, but they now mean "surface fill / surface blur," not transparent glass — see the comment block at the top of `theme.css`.
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Copy files**
|
||||
- `styles/theme.css` → your global stylesheet, imported once in your app root (e.g. `main.tsx` or `app/layout.tsx`), **after** `@tailwind base/components/utilities`.
|
||||
- `lib/cx.ts`, `components/*.tsx` → into your `src/` at matching paths, or adjust the relative imports.
|
||||
- `public/fonts/*.woff2` → your project's `public/fonts/` (already the correct static latin-subset files, no need to re-fetch from Google).
|
||||
- `ComponentShowcase.tsx` → mount on a scratch route to sanity-check everything renders before wiring into real screens.
|
||||
|
||||
2. **Tailwind config** — enable class-based dark mode so the `.dark` class in `theme.css` works:
|
||||
|
||||
```ts
|
||||
// tailwind.config.ts
|
||||
export default {
|
||||
darkMode: "class",
|
||||
content: ["./src/**/*.{ts,tsx}"],
|
||||
theme: { extend: {} },
|
||||
};
|
||||
```
|
||||
|
||||
No plugin needed — `backdrop-filter`, `active:`, and arbitrary values (`bg-[image:var(--x)]`) are all core Tailwind v3+. This library leans on CSS variables + inline `style` for the gradient/blur values rather than baking them into the Tailwind theme, so it works the same whether or not you extend the config further.
|
||||
|
||||
3. **Toggle dark mode** by adding/removing the `dark` class on `<html>` (or any ancestor — see `ComponentShowcase.tsx` for a self-contained example that wraps in a `div` instead, for demo purposes only; in a real app put it on `<html>`).
|
||||
|
||||
## What's in here
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `styles/theme.css` | All design tokens: surface fill/blur/bevel-border, three-state shadows (resting/elevated/pressed), brand colors, surface + action gradients, `@font-face`, the `.glass-border` masked-bevel utility, PWA polish resets. |
|
||||
| `components/GlassCard.tsx` | Primary container. `variant="default" \| "interactive"`. Real elevation shadow, settles down 1px on tap. |
|
||||
| `components/BottomNav.tsx` | Floating pill nav, safe-area aware, gradient-on-active-icon, items press inward on tap. |
|
||||
| `components/Button.tsx` | `variant="primary" \| "secondary" \| "fab"`, plus `loading`/`disabled` states. Press state = inset shadow + 1px shift + darken, not just scale. |
|
||||
| `components/PlateChart.tsx` | SVG donut styled as a ceramic plate (rim + recessed well + gapped gradient slices + center label). |
|
||||
| `ComponentShowcase.tsx` | Demo page rendering all of the above over the ambient page gradient, with a light/dark toggle. |
|
||||
|
||||
## Deliberate deviations from the original brief — and why
|
||||
|
||||
**Text color on gradient buttons/badges is `--ink` (near-black), not white.**
|
||||
The brand's action gradients (lime → teal, coral → amber) are light/high-luminance by design — they're the same tokens already WCAG-audited for the rest of the Pocket Pascal system (see `/docs/CONCEPT.html`, decision log). White text on them fails AA contrast outright; a text-shadow doesn't fix a 2:1 ratio. Dark `--ink` text on these gradients measures 5.6–10.6:1 in both themes. If you introduce a new, genuinely dark gradient (e.g. for a destructive/caution CTA), white text + `text-shadow: 0 1px 2px rgba(0,0,0,0.25)` is fine there — just check the ratio first.
|
||||
|
||||
**`--gradient-caution` was added** (coral → deep red) for destructive actions, since the brief's two named gradients (`primary`, `accent`) didn't cover that case and the brand already has a `--color-caution` token.
|
||||
|
||||
**Fonts are loaded via local `@font-face` + static `.woff2` files, not `next/font` or a Google Fonts `<link>`.** Keeps `theme.css` portable across Vite/Next, and avoids a runtime dependency on Google's CDN. The five files in `public/fonts/` are the exact latin-subset statics used everywhere else in this project.
|
||||
|
||||
**`.glass-border` uses a masked pseudo-gradient, not `border-image`.** `border-image` ignores `border-radius` — it would square off every rounded corner. The mask-composite technique in `theme.css` respects `border-radius: inherit`, so it works on the 3xl card, the pill nav, and the 2xl buttons without extra per-component CSS.
|
||||
|
||||
**Pressed state is inset-shadow + 1px shift + darken, applied via `active:` Tailwind classes referencing CSS vars (`active:shadow-[var(--shadow-pressed)]`), not JS state or `active:scale`.** Scale alone reads as "shrinking," not "pushed in" — the eye needs the shadow to flip from outside (elevated) to inside (pressed) to sell the depth illusion. `Button` and the `BottomNav` items use this; `GlassCard`'s interactive variant deliberately uses a milder version (settle 1px + soften shadow, no inset) since a card is a container you tap *into*, not a switch you press.
|
||||
|
||||
## Known follow-ups (not done here)
|
||||
|
||||
- `PlateChart` gap is computed from a **physical pixel gap → angular gap** at a fixed average radius; if you resize the chart a lot at runtime, re-check that 2px still reads as a visible gap at very small sizes (below ~120px it may visually disappear).
|
||||
- No unit/visual regression tests included — this is a first-pass scaffold for Cursor, not a hardened library.
|
||||
- `BottomNav` active-icon gradient uses `background-clip: text`, which needs `-webkit-background-clip: text` too in some older WebKit builds; not added here since Tailwind's `bg-clip-text` utility already handles the prefix.
|
||||
88
handover/component-library/components/BottomNav.tsx
Normal file
88
handover/component-library/components/BottomNav.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cx } from "../lib/cx";
|
||||
|
||||
export interface NavItem {
|
||||
key: string;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export interface BottomNavProps {
|
||||
items: NavItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating Glass Pill bottom navigation.
|
||||
*
|
||||
* - Fixed to the bottom, centered, floats above content (not edge-to-edge).
|
||||
* - Respects `env(safe-area-inset-bottom)` so it clears the iOS home indicator.
|
||||
* - Active item gets the accent gradient applied directly to icon + label
|
||||
* (bg-clip-text) rather than a pill behind it, so it reads against the
|
||||
* glass background instead of competing with it.
|
||||
*/
|
||||
export function BottomNav({ items, className }: BottomNavProps) {
|
||||
return (
|
||||
<nav
|
||||
className={cx("fixed inset-x-0 bottom-0 z-50 flex justify-center pointer-events-none", className)}
|
||||
style={{ paddingBottom: "calc(env(safe-area-inset-bottom) + 12px)" }}
|
||||
>
|
||||
<div
|
||||
className="relative flex items-center gap-1 rounded-full px-2 py-2 pointer-events-auto"
|
||||
style={{ boxShadow: "var(--shadow-ambient)" }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{
|
||||
background: "var(--glass-fill)",
|
||||
backdropFilter: "blur(var(--glass-blur))",
|
||||
WebkitBackdropFilter: "blur(var(--glass-blur))",
|
||||
}}
|
||||
/>
|
||||
<div className="glass-border pointer-events-none absolute inset-0 rounded-full" />
|
||||
|
||||
{items.map((item) => {
|
||||
const content = (
|
||||
<>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cx(
|
||||
"text-xl leading-none",
|
||||
item.active ? "bg-clip-text text-transparent" : "text-[var(--ink-soft)]"
|
||||
)}
|
||||
style={item.active ? { backgroundImage: "var(--gradient-accent)" } : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span
|
||||
className={cx(
|
||||
"font-body text-[10px] font-bold leading-none",
|
||||
item.active ? "bg-clip-text text-transparent" : "text-[var(--ink-soft)]"
|
||||
)}
|
||||
style={item.active ? { backgroundImage: "var(--gradient-accent)" } : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
const itemClass = cx(
|
||||
"relative z-10 flex min-w-[48px] min-h-[48px] flex-col items-center justify-center gap-0.5",
|
||||
"elevation-transition rounded-full px-3 no-select active:translate-y-px active:brightness-95"
|
||||
);
|
||||
return item.href ? (
|
||||
<a key={item.key} href={item.href} className={itemClass}>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<button key={item.key} type="button" onClick={item.onClick} className={itemClass}>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
112
handover/component-library/components/Button.tsx
Normal file
112
handover/component-library/components/Button.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
import { cx } from "../lib/cx";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "fab";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
loading?: boolean;
|
||||
/** Required for `variant="fab"` — icon-only, no visible label. */
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
function Spinner({ dark = false }: { dark?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cx(
|
||||
"inline-block h-4 w-4 animate-spin rounded-full border-2",
|
||||
dark ? "border-[var(--ink)]/25 border-t-[var(--ink)]" : "border-white/40 border-t-white"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared press behavior: 1px downward shift + swap from elevated to an
|
||||
* inset shadow + slight darkening. This is what actually reads as
|
||||
* "physically pushed in" — scale alone doesn't sell it, the inset shadow
|
||||
* does the work.
|
||||
*/
|
||||
const pressable = cx(
|
||||
"elevation-transition active:translate-y-px active:brightness-95",
|
||||
"shadow-[var(--shadow-elevated)] active:shadow-[var(--shadow-pressed)]"
|
||||
);
|
||||
|
||||
const base = cx(
|
||||
"relative inline-flex items-center justify-center gap-2",
|
||||
"font-body font-bold no-select [-webkit-tap-highlight-color:transparent]",
|
||||
"disabled:opacity-40 disabled:pointer-events-none disabled:active:translate-y-0 disabled:active:shadow-[var(--shadow-elevated)]"
|
||||
);
|
||||
|
||||
/**
|
||||
* Touch-optimized button set. All variants meet the 48px minimum touch target.
|
||||
*
|
||||
* Text color rule: our brand action gradients (lime→teal, coral→amber) are
|
||||
* light/high-luminance by design — see theme.css. Buttons use --ink text on
|
||||
* them, not white, to stay at WCAG AA without a text-shadow crutch. The
|
||||
* `secondary` variant uses --ink for the same reason (low-transparency
|
||||
* surface fill, not a dark background).
|
||||
*/
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ variant = "primary", loading = false, disabled, icon, className, children, ...props }, ref) => {
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
if (variant === "fab") {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={isDisabled}
|
||||
className={cx(base, pressable, "h-14 w-14 rounded-full text-[var(--ink)]", className)}
|
||||
style={{ backgroundImage: "var(--gradient-accent)" }}
|
||||
{...props}
|
||||
>
|
||||
{loading ? <Spinner dark /> : icon ?? children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "secondary") {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={isDisabled}
|
||||
className={cx(
|
||||
base,
|
||||
pressable,
|
||||
"min-h-[48px] overflow-hidden rounded-2xl px-6 text-[var(--ink)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="absolute inset-0 rounded-2xl"
|
||||
style={{
|
||||
background: "var(--glass-fill)",
|
||||
backdropFilter: "blur(var(--glass-blur))",
|
||||
WebkitBackdropFilter: "blur(var(--glass-blur))",
|
||||
}}
|
||||
/>
|
||||
<span className="glass-border pointer-events-none absolute inset-0 rounded-2xl" />
|
||||
<span className="relative z-10 flex items-center gap-2">
|
||||
{loading ? <Spinner dark /> : children}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// primary
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={isDisabled}
|
||||
className={cx(base, pressable, "min-h-[48px] rounded-2xl px-6 text-[var(--ink)]", className)}
|
||||
style={{ backgroundImage: "var(--gradient-primary)" }}
|
||||
{...props}
|
||||
>
|
||||
{loading ? <Spinner dark /> : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
62
handover/component-library/components/GlassCard.tsx
Normal file
62
handover/component-library/components/GlassCard.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { forwardRef, type HTMLAttributes } from "react";
|
||||
import { cx } from "../lib/cx";
|
||||
|
||||
export interface GlassCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* `default` — static, sits at rest elevation.
|
||||
* `interactive` — lifts on hover-capable devices, settles down 1px + shadow
|
||||
* softens on press (not a full inward "pressed" look — that's reserved
|
||||
* for `Button`; a card is a container you tap into, not a switch).
|
||||
*/
|
||||
variant?: "default" | "interactive";
|
||||
}
|
||||
|
||||
/**
|
||||
* Elevated Surface Card — the primary container of the design system.
|
||||
*
|
||||
* Low-transparency (not see-through glass): a solid-reading surface fill
|
||||
* (~90% opaque) with a visible drop shadow for real elevation, plus a faint
|
||||
* top-light/bottom-shadow bevel on the edge so it reads as raised off the
|
||||
* page rather than flat or floating-glass.
|
||||
*
|
||||
* Structure (three stacked layers, all inside one rounded-3xl shell):
|
||||
* 1. Base: subtle surface gradient (--surface-gradient) for texture.
|
||||
* 2. Surface fill: ~90% opaque fill + 8px blur (softens whatever's behind
|
||||
* it without reading as transparent) + masked bevel border.
|
||||
* 3. Content: rendered above both, in normal flow.
|
||||
*/
|
||||
export const GlassCard = forwardRef<HTMLDivElement, GlassCardProps>(
|
||||
({ variant = "default", className, children, ...props }, ref) => {
|
||||
const interactive = variant === "interactive";
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role={interactive ? "button" : undefined}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
className={cx(
|
||||
"elevation-transition relative rounded-3xl no-select",
|
||||
"shadow-[var(--shadow-elevated)]",
|
||||
interactive && "cursor-pointer active:translate-y-px active:shadow-[var(--shadow-resting)]",
|
||||
className
|
||||
)}
|
||||
style={{ backgroundImage: "var(--surface-gradient)" }}
|
||||
{...props}
|
||||
>
|
||||
{/* surface fill + blur */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-3xl"
|
||||
style={{
|
||||
background: "var(--glass-fill)",
|
||||
backdropFilter: "blur(var(--glass-blur))",
|
||||
WebkitBackdropFilter: "blur(var(--glass-blur))",
|
||||
}}
|
||||
/>
|
||||
{/* bevel border, 1px, radius-safe */}
|
||||
<div className="glass-border pointer-events-none absolute inset-0 rounded-3xl" />
|
||||
{/* content */}
|
||||
<div className="relative z-10">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
GlassCard.displayName = "GlassCard";
|
||||
121
handover/component-library/components/PlateChart.tsx
Normal file
121
handover/component-library/components/PlateChart.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useId } from "react";
|
||||
|
||||
export interface PlateSlice {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number;
|
||||
/** 2-stop gradient for this slice, e.g. ["#D9F227", "#1FD9C4"]. */
|
||||
colors: [string, string];
|
||||
}
|
||||
|
||||
export interface PlateChartProps {
|
||||
slices: PlateSlice[];
|
||||
size?: number;
|
||||
centerLabel?: string;
|
||||
centerValue?: string;
|
||||
/** Physical gap between slices in px, converted to an angular gap at render time. */
|
||||
gapPx?: number;
|
||||
}
|
||||
|
||||
function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) {
|
||||
const rad = ((angleDeg - 90) * Math.PI) / 180;
|
||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
||||
}
|
||||
|
||||
/** Donut-segment path between two radii, from startAngle to endAngle (degrees, 0 = top). */
|
||||
function arcPath(cx: number, cy: number, rOuter: number, rInner: number, startAngle: number, endAngle: number) {
|
||||
const startOuter = polarToCartesian(cx, cy, rOuter, endAngle);
|
||||
const endOuter = polarToCartesian(cx, cy, rOuter, startAngle);
|
||||
const startInner = polarToCartesian(cx, cy, rInner, endAngle);
|
||||
const endInner = polarToCartesian(cx, cy, rInner, startAngle);
|
||||
const largeArc = endAngle - startAngle <= 180 ? 0 : 1;
|
||||
return [
|
||||
"M", startOuter.x, startOuter.y,
|
||||
"A", rOuter, rOuter, 0, largeArc, 0, endOuter.x, endOuter.y,
|
||||
"L", endInner.x, endInner.y,
|
||||
"A", rInner, rInner, 0, largeArc, 1, startInner.x, startInner.y,
|
||||
"Z",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* "Dining Plate" chart — a donut styled to read as a ceramic plate: outer
|
||||
* rim (gradient stroke), recessed well (radial gradient), slices as
|
||||
* gradient-filled donut segments with a small gap between each, and a
|
||||
* center overlay for the headline metric.
|
||||
*
|
||||
* Meant to live inside a `GlassCard`. All visuals are gradients — no images.
|
||||
*/
|
||||
export function PlateChart({ slices, size = 240, centerLabel, centerValue, gapPx = 2 }: PlateChartProps) {
|
||||
const uid = useId().replace(/:/g, "");
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const rimR = size / 2 - 4;
|
||||
const outerR = rimR - 14;
|
||||
const innerR = size * 0.32;
|
||||
|
||||
const total = slices.reduce((sum, s) => sum + s.value, 0) || 1;
|
||||
const avgR = (outerR + innerR) / 2;
|
||||
const gapDeg = avgR > 0 ? (gapPx / (avgR * 2 * Math.PI)) * 360 : 0;
|
||||
|
||||
let angle = 0;
|
||||
const segments = slices
|
||||
.filter((s) => s.value > 0)
|
||||
.map((slice) => {
|
||||
const sweep = (slice.value / total) * 360;
|
||||
const start = angle + gapDeg / 2;
|
||||
const end = angle + sweep - gapDeg / 2;
|
||||
angle += sweep;
|
||||
return { ...slice, start, end, d: end > start ? arcPath(cx, cy, outerR, innerR, start, end) : null };
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative select-none" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} role="img" aria-label="Plate breakdown">
|
||||
<defs>
|
||||
<radialGradient id={`${uid}-well`} cx="50%" cy="50%" r="65%">
|
||||
<stop offset="0%" stopColor="var(--plate-well-center)" />
|
||||
<stop offset="100%" stopColor="var(--plate-well-edge)" />
|
||||
</radialGradient>
|
||||
<linearGradient id={`${uid}-rim`} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="var(--plate-rim-light)" />
|
||||
<stop offset="100%" stopColor="var(--plate-rim-shadow)" />
|
||||
</linearGradient>
|
||||
{segments.map((s) => (
|
||||
<linearGradient key={s.id} id={`${uid}-slice-${s.id}`} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor={s.colors[0]} />
|
||||
<stop offset="100%" stopColor={s.colors[1]} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
|
||||
{/* ceramic rim */}
|
||||
<circle cx={cx} cy={cy} r={rimR} fill="none" stroke={`url(#${uid}-rim)`} strokeWidth={6} />
|
||||
{/* recessed well */}
|
||||
<circle cx={cx} cy={cy} r={rimR - 8} fill={`url(#${uid}-well)`} />
|
||||
|
||||
{segments.map(
|
||||
(s) => s.d && <path key={s.id} d={s.d} fill={`url(#${uid}-slice-${s.id})`} />
|
||||
)}
|
||||
|
||||
{/* center hole, matches well so the label sits on clean ground */}
|
||||
<circle cx={cx} cy={cy} r={innerR - 6} fill={`url(#${uid}-well)`} />
|
||||
</svg>
|
||||
|
||||
{(centerLabel || centerValue) && (
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
||||
{centerValue && (
|
||||
<span className="font-display text-2xl font-black leading-none text-[var(--ink)]">
|
||||
{centerValue}
|
||||
</span>
|
||||
)}
|
||||
{centerLabel && (
|
||||
<span className="font-body mt-1 text-[11px] font-bold uppercase tracking-wide text-[var(--ink-soft)]">
|
||||
{centerLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
handover/component-library/lib/cx.ts
Normal file
4
handover/component-library/lib/cx.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Tiny className joiner — avoids pulling in clsx/tailwind-merge as a dependency. */
|
||||
export function cx(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
BIN
handover/component-library/public/fonts/albert-sans-400.woff2
Normal file
BIN
handover/component-library/public/fonts/albert-sans-400.woff2
Normal file
Binary file not shown.
BIN
handover/component-library/public/fonts/albert-sans-500.woff2
Normal file
BIN
handover/component-library/public/fonts/albert-sans-500.woff2
Normal file
Binary file not shown.
BIN
handover/component-library/public/fonts/albert-sans-700.woff2
Normal file
BIN
handover/component-library/public/fonts/albert-sans-700.woff2
Normal file
Binary file not shown.
BIN
handover/component-library/public/fonts/unbounded-700.woff2
Normal file
BIN
handover/component-library/public/fonts/unbounded-700.woff2
Normal file
Binary file not shown.
BIN
handover/component-library/public/fonts/unbounded-900.woff2
Normal file
BIN
handover/component-library/public/fonts/unbounded-900.woff2
Normal file
Binary file not shown.
192
handover/component-library/styles/theme.css
Normal file
192
handover/component-library/styles/theme.css
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Pocket Pascal — Elevated Surface Design Tokens
|
||||
*
|
||||
* Pivoted from heavy glassmorphism to a low-transparency, tactile system:
|
||||
* cards read as solid raised surfaces (elevation shadow + faint top/bottom
|
||||
* bevel), buttons visibly depress on press (inset shadow + 1px downward
|
||||
* shift) instead of just scaling. `--glass-*` names kept for minimal diff
|
||||
* across components — they now mean "surface fill / surface blur", not
|
||||
* "see-through glass". Blur is low (8px) and opacity high (~88%): it softens
|
||||
* whatever sits behind a card without reading as transparent.
|
||||
*
|
||||
* Toggle dark mode by adding/removing the `dark` class on <html>
|
||||
* (matches Tailwind `darkMode: 'class'` — see README).
|
||||
*
|
||||
* Brand hues (protein/obstgem/fett/kh + ink) come from the WCAG-audited
|
||||
* Pocket Pascal palette, not generic placeholders — see /docs/CONCEPT.html
|
||||
* section 05/08 for the full rationale and contrast numbers.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ---------- Elevated surface (formerly "glass") ---------- */
|
||||
--glass-fill: rgba(255, 255, 255, 0.9);
|
||||
--glass-blur: 8px;
|
||||
/* top-light / bottom-shadow bevel instead of an even glass edge — reads as raised, not see-through */
|
||||
--glass-border-from: rgba(255, 255, 255, 0.9);
|
||||
--glass-border-to: rgba(0, 0, 0, 0.06);
|
||||
|
||||
/* ---------- Shadows (three states: resting, elevated/lifted, pressed-in) ---------- */
|
||||
--shadow-resting: 0 1px 2px rgba(0, 0, 0, 0.06), 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||
--shadow-elevated: 0 4px 10px rgba(0, 0, 0, 0.10), 0 14px 28px rgba(0, 0, 0, 0.10);
|
||||
--shadow-pressed: inset 0 2px 5px rgba(0, 0, 0, 0.28), inset 0 1px 1px rgba(0, 0, 0, 0.15);
|
||||
/* kept for anything still referencing the old name */
|
||||
--shadow-ambient: var(--shadow-elevated);
|
||||
|
||||
/* ---------- Brand ink / neutrals ---------- */
|
||||
--ink: #191800;
|
||||
--ink-soft: #5c5b48;
|
||||
--ink-faint: #6b6a56;
|
||||
--border: #e2e8f0;
|
||||
|
||||
/* ---------- Brand category colors (bright primary + dark accent pairs) ---------- */
|
||||
--color-protein: #d9f227;
|
||||
--color-protein-dk: #2e3200;
|
||||
--color-obstgem: #ff6a3d;
|
||||
--color-obstgem-dk: #3d1508;
|
||||
--color-fett: #ffb300;
|
||||
--color-fett-dk: #3d2900;
|
||||
--color-kh: #1fd9c4;
|
||||
--color-kh-dk: #04302b;
|
||||
--color-pick: #0f7a37;
|
||||
--color-caution: #c81e1e;
|
||||
|
||||
/* ---------- Surface gradients (subtle, sit UNDER glass layers) ---------- */
|
||||
--surface-gradient: linear-gradient(160deg, #fafaf7 0%, #f0f0ea 100%);
|
||||
--page-gradient: radial-gradient(120% 100% at 0% 0%, #fff9e0 0%, #ffffff 45%),
|
||||
radial-gradient(120% 100% at 100% 100%, #e2fbf6 0%, #ffffff 45%);
|
||||
|
||||
/* ---------- Action gradients (vibrant, multi-stop) ---------- */
|
||||
/* NOTE: brand hues are light/high-luminance by design (see contrast audit),
|
||||
so action gradients pair with --ink text, not white — this keeps every
|
||||
button at AA contrast without text-shadow hacks. Do not swap to white
|
||||
text on these without re-checking contrast. */
|
||||
--gradient-primary: linear-gradient(135deg, #d9f227 0%, #1fd9c4 100%);
|
||||
--gradient-accent: linear-gradient(135deg, #ff6a3d 0%, #ffb300 100%);
|
||||
--gradient-caution: linear-gradient(135deg, #ff6a3d 0%, #c81e1e 100%);
|
||||
|
||||
/* ---------- Dining Plate chart tokens ---------- */
|
||||
--plate-rim-light: #ffffff;
|
||||
--plate-rim-shadow: #d8d8d0;
|
||||
--plate-well-center: #f7f7f2;
|
||||
--plate-well-edge: #e6e6dd;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--glass-fill: rgba(28, 28, 21, 0.88);
|
||||
--glass-blur: 8px;
|
||||
--glass-border-from: rgba(255, 255, 255, 0.10);
|
||||
--glass-border-to: rgba(0, 0, 0, 0.35);
|
||||
|
||||
--shadow-resting: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
--shadow-elevated: 0 4px 10px rgba(0, 0, 0, 0.45), 0 16px 32px rgba(0, 0, 0, 0.4);
|
||||
--shadow-pressed: inset 0 2px 5px rgba(0, 0, 0, 0.6), inset 0 1px 1px rgba(0, 0, 0, 0.3);
|
||||
--shadow-ambient: var(--shadow-elevated);
|
||||
|
||||
--ink: #f3f2e6;
|
||||
--ink-soft: #c2c1ac;
|
||||
--ink-faint: #8f8e7a;
|
||||
--border: #33342a;
|
||||
|
||||
/* category primaries stay identical (they're the brand, not a theme) */
|
||||
--color-protein: #d9f227;
|
||||
--color-protein-dk: #2e3200;
|
||||
--color-obstgem: #ff6a3d;
|
||||
--color-obstgem-dk: #3d1508;
|
||||
--color-fett: #ffb300;
|
||||
--color-fett-dk: #3d2900;
|
||||
--color-kh: #1fd9c4;
|
||||
--color-kh-dk: #04302b;
|
||||
--color-pick: #4ade80;
|
||||
--color-caution: #ff6b60;
|
||||
|
||||
--surface-gradient: linear-gradient(160deg, #1c1c15 0%, #14140f 100%);
|
||||
--page-gradient: radial-gradient(120% 100% at 0% 0%, #2a2a12 0%, #14140f 45%),
|
||||
radial-gradient(120% 100% at 100% 100%, #0f2a26 0%, #14140f 45%);
|
||||
|
||||
--gradient-primary: linear-gradient(135deg, #d9f227 0%, #1fd9c4 100%);
|
||||
--gradient-accent: linear-gradient(135deg, #ff6a3d 0%, #ffb300 100%);
|
||||
--gradient-caution: linear-gradient(135deg, #ff6a3d 0%, #ff6b60 100%);
|
||||
|
||||
--plate-rim-light: #2a2a22;
|
||||
--plate-rim-shadow: #0c0c09;
|
||||
--plate-well-center: #1c1c15;
|
||||
--plate-well-edge: #111109;
|
||||
}
|
||||
|
||||
/* ---------- Fonts ---------- */
|
||||
/* Download the two static woff2 files listed in README into /public/fonts,
|
||||
then this block just works. Using local @font-face (not next/font) keeps
|
||||
this file framework-agnostic for Cursor. */
|
||||
@font-face {
|
||||
font-family: "Unbounded";
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url("/fonts/unbounded-700.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Unbounded";
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url("/fonts/unbounded-900.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Albert Sans";
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url("/fonts/albert-sans-400.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Albert Sans";
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url("/fonts/albert-sans-500.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Albert Sans";
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url("/fonts/albert-sans-700.woff2") format("woff2");
|
||||
}
|
||||
|
||||
.font-display {
|
||||
font-family: "Unbounded", ui-sans-serif, sans-serif;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.font-body {
|
||||
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* ---------- Glass border (gradient 1px border that respects border-radius) ---------- */
|
||||
/* border-image ignores border-radius, so we use a masked pseudo-element instead.
|
||||
Apply `.glass-border` to a `relative` container with `overflow-hidden` off
|
||||
(the pseudo-element is inset:0 and radius:inherit). */
|
||||
.glass-border {
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
/* top-light, bottom-shadow — reads as a raised bevel, not a glass sheen */
|
||||
background: linear-gradient(180deg, var(--glass-border-from), var(--glass-border-to));
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
}
|
||||
|
||||
/* Applied on top of --shadow-elevated/--shadow-resting to animate between them. */
|
||||
.elevation-transition {
|
||||
transition: box-shadow 120ms ease-out, transform 120ms ease-out, filter 120ms ease-out;
|
||||
}
|
||||
|
||||
/* ---------- PWA polish ---------- */
|
||||
html,
|
||||
body {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
.no-select {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
979
handover/data/foods.json
Normal file
979
handover/data/foods.json
Normal file
@@ -0,0 +1,979 @@
|
||||
[
|
||||
{
|
||||
"id": "protein",
|
||||
"cls": "protein",
|
||||
"ic": "🥚",
|
||||
"title": {
|
||||
"de": "Protein",
|
||||
"en": "Protein"
|
||||
},
|
||||
"thumb": {
|
||||
"de": "Mindestens 15–20 g Protein pro 100 g, wenig Zucker & wenig Fett, kurze Zutatenliste.",
|
||||
"en": "At least 15–20 g protein per 100 g, low sugar & low fat, short ingredient list."
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": "edamame",
|
||||
"name": {
|
||||
"de": "Edamame",
|
||||
"en": "Edamame"
|
||||
},
|
||||
"v": {
|
||||
"de": "125 kcal · 14 g",
|
||||
"en": "125 kcal · 14 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "egg",
|
||||
"name": {
|
||||
"de": "Ei (Größe M)",
|
||||
"en": "Egg (size M)"
|
||||
},
|
||||
"v": {
|
||||
"de": "137 kcal · 13 g",
|
||||
"en": "137 kcal · 13 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pea_protein_strips",
|
||||
"name": {
|
||||
"de": "Erbsenproteinschnetzel",
|
||||
"en": "Pea-protein strips"
|
||||
},
|
||||
"v": {
|
||||
"de": "367 kcal · 61 g",
|
||||
"en": "367 kcal · 61 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "feta",
|
||||
"name": {
|
||||
"de": "Feta (leicht)",
|
||||
"en": "Feta (light)"
|
||||
},
|
||||
"v": {
|
||||
"de": "161 kcal · 19 g",
|
||||
"en": "161 kcal · 19 g"
|
||||
},
|
||||
"f": "caution"
|
||||
},
|
||||
{
|
||||
"id": "chicken_breast",
|
||||
"name": {
|
||||
"de": "Hühnerbrust",
|
||||
"en": "Chicken breast"
|
||||
},
|
||||
"v": {
|
||||
"de": "111 kcal · 24 g",
|
||||
"en": "111 kcal · 24 g"
|
||||
},
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "cottage_cheese",
|
||||
"name": {
|
||||
"de": "Hüttenkäse",
|
||||
"en": "Cottage cheese"
|
||||
},
|
||||
"v": {
|
||||
"de": "87 kcal · 12,3 g",
|
||||
"en": "87 kcal · 12.3 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "like_chicken",
|
||||
"name": {
|
||||
"de": "„Like Chicken\" (pflanzlich)",
|
||||
"en": "\"Like Chicken\" (plant-based)"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈140 kcal · 18 g",
|
||||
"en": "≈140 kcal · 18 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "low_fat_quark",
|
||||
"name": {
|
||||
"de": "Magerquark",
|
||||
"en": "Low-fat quark"
|
||||
},
|
||||
"v": {
|
||||
"de": "67 kcal · 12 g",
|
||||
"en": "67 kcal · 12 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "beef_fillet",
|
||||
"name": {
|
||||
"de": "Rinderfilet / Tatar",
|
||||
"en": "Beef fillet / tartare"
|
||||
},
|
||||
"v": {
|
||||
"de": "112 kcal · 22 g",
|
||||
"en": "112 kcal · 22 g"
|
||||
},
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "silken_tofu",
|
||||
"name": {
|
||||
"de": "Seidentofu",
|
||||
"en": "Silken tofu"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈55 kcal · 5 g",
|
||||
"en": "≈55 kcal · 5 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "skyr",
|
||||
"name": {
|
||||
"de": "Skyr",
|
||||
"en": "Skyr"
|
||||
},
|
||||
"v": {
|
||||
"de": "54 kcal · 10,3 g",
|
||||
"en": "54 kcal · 10.3 g"
|
||||
},
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "tempeh",
|
||||
"name": {
|
||||
"de": "Tempeh",
|
||||
"en": "Tempeh"
|
||||
},
|
||||
"v": {
|
||||
"de": "184 kcal · 19 g",
|
||||
"en": "184 kcal · 19 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tofu",
|
||||
"name": {
|
||||
"de": "Tofu",
|
||||
"en": "Tofu"
|
||||
},
|
||||
"v": {
|
||||
"de": "129 kcal · 13 g",
|
||||
"en": "129 kcal · 13 g"
|
||||
}
|
||||
}
|
||||
],
|
||||
"sub": {
|
||||
"title": {
|
||||
"de": "Milch & Joghurt",
|
||||
"en": "Milk & yoghurt"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": "greek_yogurt_10",
|
||||
"name": {
|
||||
"de": "Griech. Joghurt 10 %",
|
||||
"en": "Greek yoghurt 10%"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈115 kcal · 5 g",
|
||||
"en": "≈115 kcal · 5 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "natural_yogurt",
|
||||
"name": {
|
||||
"de": "Naturjoghurt",
|
||||
"en": "Natural yoghurt"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈61 kcal · 3,5 g",
|
||||
"en": "≈61 kcal · 3.5 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "oatly_barista",
|
||||
"name": {
|
||||
"de": "Oatly Barista",
|
||||
"en": "Oatly Barista"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈68 kcal · 1 g (kaum Protein)",
|
||||
"en": "≈68 kcal · 1 g (barely protein)"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "caution"
|
||||
},
|
||||
{
|
||||
"id": "whole_milk",
|
||||
"name": {
|
||||
"de": "Vollmilch 3,5 %",
|
||||
"en": "Whole milk 3.5%"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈64 kcal · 3,4 g",
|
||||
"en": "≈64 kcal · 3.4 g"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "caution"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "snack",
|
||||
"cls": "snack",
|
||||
"ic": "🥤",
|
||||
"title": {
|
||||
"de": "Proteinsnacks für Steffi",
|
||||
"en": "Protein snacks for Steffi"
|
||||
},
|
||||
"thumb": {
|
||||
"de": "Mindestens 15 g Protein pro Portion, unter 5 g Zucker, Fett unter 8 g — Zutatenliste beginnt nicht mit Zucker/Sirup/Schokolade.",
|
||||
"en": "At least 15 g protein per serving, under 5 g sugar, fat under 8 g — ingredient list shouldn’t open with sugar/syrup/chocolate."
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": "three_eggs",
|
||||
"name": {
|
||||
"de": "3 Eier",
|
||||
"en": "3 eggs"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈205 kcal · 19 g",
|
||||
"en": "≈205 kcal · 19 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "cottage_berries",
|
||||
"name": {
|
||||
"de": "Hüttenkäse + Beeren",
|
||||
"en": "Cottage cheese + berries"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈210 kcal · 25 g",
|
||||
"en": "≈210 kcal · 25 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "chickpea_waffles",
|
||||
"name": {
|
||||
"de": "Kichererbsenwaffeln 90g",
|
||||
"en": "Chickpea waffles 90g"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈300 kcal · 17 g",
|
||||
"en": "≈300 kcal · 17 g"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "caution"
|
||||
},
|
||||
{
|
||||
"id": "pumpkin_seeds_30",
|
||||
"name": {
|
||||
"de": "Kürbiskerne 30g",
|
||||
"en": "Pumpkin seeds 30g"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈171 kcal · 9 g",
|
||||
"en": "≈171 kcal · 9 g"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "caution"
|
||||
},
|
||||
{
|
||||
"id": "lentil_waffles",
|
||||
"name": {
|
||||
"de": "Linsenwaffeln 90g",
|
||||
"en": "Lentil waffles 90g"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈300 kcal · 18 g",
|
||||
"en": "≈300 kcal · 18 g"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "caution"
|
||||
},
|
||||
{
|
||||
"id": "protein_bar",
|
||||
"name": {
|
||||
"de": "Magerer Proteinriegel",
|
||||
"en": "Lean protein bar"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈180 kcal · 20 g",
|
||||
"en": "≈180 kcal · 20 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "protein_rice_pudding",
|
||||
"name": {
|
||||
"de": "Proteinmilchreis (2x)",
|
||||
"en": "Protein rice pudding (2x)"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈340 kcal · 40 g",
|
||||
"en": "≈340 kcal · 40 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "protein_pudding",
|
||||
"name": {
|
||||
"de": "Proteinpudding",
|
||||
"en": "Protein pudding"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈90 kcal · 20 g",
|
||||
"en": "≈90 kcal · 20 g"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "protein_shake",
|
||||
"name": {
|
||||
"de": "Proteinshake 40g",
|
||||
"en": "Protein shake 40g"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈150 kcal · 30 g",
|
||||
"en": "≈150 kcal · 30 g"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "turkey_breast_pack",
|
||||
"name": {
|
||||
"de": "Putenbrust, 1 Packung",
|
||||
"en": "Turkey breast, 1 pack"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈130 kcal · 28 g",
|
||||
"en": "≈130 kcal · 28 g"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "smoked_tofu_cubes",
|
||||
"name": {
|
||||
"de": "Räuchertofu-Würfel",
|
||||
"en": "Smoked tofu cubes"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈225 kcal · 25 g",
|
||||
"en": "≈225 kcal · 25 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "skyr_honey",
|
||||
"name": {
|
||||
"de": "Skyr/Griech. Joghurt 10% + Honig, 250g+",
|
||||
"en": "Skyr/Greek yoghurt 10% + honey, 250g+"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈180 kcal · 25 g",
|
||||
"en": "≈180 kcal · 25 g"
|
||||
},
|
||||
"approx": true
|
||||
}
|
||||
],
|
||||
"recipe": {
|
||||
"title": {
|
||||
"de": "Rezept: Whey-Waffeln",
|
||||
"en": "Recipe: Whey waffles"
|
||||
},
|
||||
"text": {
|
||||
"de": "40 g Whey-Protein + 1 Ei + 40 g Haferflocken (fein gemahlen) + 60 ml Milch/Wasser verquirlen. ≈25 g Protein.",
|
||||
"en": "Whisk 40 g whey protein + 1 egg + 40 g oats (finely ground) + 60 ml milk/water. ≈25 g protein."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "obst",
|
||||
"cls": "obst",
|
||||
"ic": "🍎",
|
||||
"title": {
|
||||
"de": "Obst",
|
||||
"en": "Fruit"
|
||||
},
|
||||
"thumb": {
|
||||
"de": "Frisch oder tiefgekühlt ohne Zuckerzusatz, Saison bevorzugen.",
|
||||
"en": "Fresh or frozen without added sugar, prefer what’s in season."
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": "apple",
|
||||
"name": {
|
||||
"de": "Apfel",
|
||||
"en": "Apple"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, Kalium, Kupfer",
|
||||
"en": "Vit. C, potassium, copper"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "banana",
|
||||
"name": {
|
||||
"de": "Banane",
|
||||
"en": "Banana"
|
||||
},
|
||||
"v": {
|
||||
"de": "Kalium, B6, C, Magnesium",
|
||||
"en": "Potassium, B6, C, magnesium"
|
||||
},
|
||||
"f": "caution"
|
||||
},
|
||||
{
|
||||
"id": "blueberry",
|
||||
"name": {
|
||||
"de": "Blaubeere",
|
||||
"en": "Blueberry"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. K, C, Mangan",
|
||||
"en": "Vit. K, C, manganese"
|
||||
},
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "strawberry",
|
||||
"name": {
|
||||
"de": "Erdbeere",
|
||||
"en": "Strawberry"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, Mangan, Folsäure, Kalium",
|
||||
"en": "Vit. C, manganese, folate, potassium"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "granadilla",
|
||||
"name": {
|
||||
"de": "Granadilla (≈ Passionsfrucht)",
|
||||
"en": "Granadilla (≈ passion fruit)"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, A, Kalium, B2",
|
||||
"en": "Vit. C, A, potassium, B2"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "pomegranate",
|
||||
"name": {
|
||||
"de": "Granatapfel",
|
||||
"en": "Pomegranate"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, K, Folsäure, Kalium, B6",
|
||||
"en": "Vit. C, K, folate, potassium, B6"
|
||||
},
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "mango",
|
||||
"name": {
|
||||
"de": "Mango",
|
||||
"en": "Mango"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, A, B6, Folsäure, Kupfer",
|
||||
"en": "Vit. C, A, B6, folate, copper"
|
||||
},
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "nectarine",
|
||||
"name": {
|
||||
"de": "Nektarine",
|
||||
"en": "Nectarine"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, A, Kalium, E, Niacin",
|
||||
"en": "Vit. C, A, potassium, E, niacin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "orange",
|
||||
"name": {
|
||||
"de": "Orange",
|
||||
"en": "Orange"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, Folsäure, Kalium, Calcium",
|
||||
"en": "Vit. C, folate, potassium, calcium"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "passion_fruit",
|
||||
"name": {
|
||||
"de": "Passionsfrucht",
|
||||
"en": "Passion fruit"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, A, Kalium, Eisen",
|
||||
"en": "Vit. C, A, potassium, iron"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "plum",
|
||||
"name": {
|
||||
"de": "Pflaume",
|
||||
"en": "Plum"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, K, Kalium, A, Kupfer",
|
||||
"en": "Vit. C, K, potassium, A, copper"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "grapes",
|
||||
"name": {
|
||||
"de": "Trauben",
|
||||
"en": "Grapes"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. K, C, Kalium, Kupfer, Mangan",
|
||||
"en": "Vit. K, C, potassium, copper, manganese"
|
||||
},
|
||||
"f": "caution"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gemuese",
|
||||
"cls": "gemuese",
|
||||
"ic": "🥦",
|
||||
"title": {
|
||||
"de": "Gemüse",
|
||||
"en": "Vegetables"
|
||||
},
|
||||
"thumbLabel": {
|
||||
"de": "Tipp",
|
||||
"en": "Tip"
|
||||
},
|
||||
"thumb": {
|
||||
"de": "TK-Gemüse ist völlig okay, wenn’s schnell gehen muss — genauso nährstoffreich wie frisch, einfach ohne Salz-/Fettzusatz wählen.",
|
||||
"en": "Frozen veg is perfectly fine when time is short — just as nutrient-rich as fresh, just pick it without added salt/fat."
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": "broccoli",
|
||||
"name": {
|
||||
"de": "Brokkoli",
|
||||
"en": "Broccoli"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, K, Folsäure, Eisen",
|
||||
"en": "Vit. C, K, folate, iron"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mushrooms",
|
||||
"name": {
|
||||
"de": "Champignons",
|
||||
"en": "Mushrooms"
|
||||
},
|
||||
"v": {
|
||||
"de": "Kalium, Selen, Kupfer, B2",
|
||||
"en": "Potassium, selenium, copper, B2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "iceberg_lettuce",
|
||||
"name": {
|
||||
"de": "Eisbergsalat",
|
||||
"en": "Iceberg lettuce"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. K, A, Folsäure, Kalium — nährstoffarm",
|
||||
"en": "Vit. K, A, folate, potassium — low nutrient density"
|
||||
},
|
||||
"f": "caution"
|
||||
},
|
||||
{
|
||||
"id": "lambs_lettuce",
|
||||
"name": {
|
||||
"de": "Feldsalat",
|
||||
"en": "Lamb’s lettuce"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. A, C, Folsäure, Eisen",
|
||||
"en": "Vit. A, C, folate, iron"
|
||||
},
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "cucumber",
|
||||
"name": {
|
||||
"de": "Gurke",
|
||||
"en": "Cucumber"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. K, Kalium, Mangan",
|
||||
"en": "Vit. K, potassium, manganese"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "carrot",
|
||||
"name": {
|
||||
"de": "Karotte",
|
||||
"en": "Carrot"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. A, K, Kalium, B6",
|
||||
"en": "Vit. A, K, potassium, B6"
|
||||
},
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "bell_pepper",
|
||||
"name": {
|
||||
"de": "Paprika",
|
||||
"en": "Bell pepper"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, A, B6, E",
|
||||
"en": "Vit. C, A, B6, E"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "spinach",
|
||||
"name": {
|
||||
"de": "Spinat",
|
||||
"en": "Spinach"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. K, A, Folsäure, Eisen",
|
||||
"en": "Vit. K, A, folate, iron"
|
||||
},
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "tomato",
|
||||
"name": {
|
||||
"de": "Tomate",
|
||||
"en": "Tomato"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, K, Kalium, Folsäure",
|
||||
"en": "Vit. C, K, potassium, folate"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "zucchini",
|
||||
"name": {
|
||||
"de": "Zucchini",
|
||||
"en": "Zucchini"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, Kalium, B6, Mangan",
|
||||
"en": "Vit. C, potassium, B6, manganese"
|
||||
}
|
||||
}
|
||||
],
|
||||
"note": {
|
||||
"de": "Hinweis: Süßkartoffel, Kochbanane und Erbsen zählen hier als Kohlenhydratquelle, Avocado als Fettquelle — siehe dort.",
|
||||
"en": "Note: sweet potato, plantain and peas count here as a carb source, avocado as a fat source — see those cards."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fett",
|
||||
"cls": "fett",
|
||||
"ic": "🥑",
|
||||
"title": {
|
||||
"de": "Fett",
|
||||
"en": "Fat"
|
||||
},
|
||||
"thumb": {
|
||||
"de": "Native/kaltgepresste Öle, ungesalzene & ungeröstete Nüsse/Kerne, Zutatenliste = nur die Zutat selbst.",
|
||||
"en": "Native/cold-pressed oils, unsalted & unroasted nuts/seeds, ingredient list = just the ingredient itself."
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": "avocado",
|
||||
"name": {
|
||||
"de": "Avocado",
|
||||
"en": "Avocado"
|
||||
},
|
||||
"v": {
|
||||
"de": "217 kcal · 12,5 g",
|
||||
"en": "217 kcal · 12.5 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "chia_seeds",
|
||||
"name": {
|
||||
"de": "Chiasamen",
|
||||
"en": "Chia seeds"
|
||||
},
|
||||
"v": {
|
||||
"de": "486 kcal · 31 g",
|
||||
"en": "486 kcal · 31 g"
|
||||
},
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "pumpkin_seeds",
|
||||
"name": {
|
||||
"de": "Kürbiskerne",
|
||||
"en": "Pumpkin seeds"
|
||||
},
|
||||
"v": {
|
||||
"de": "570 kcal · 44 g",
|
||||
"en": "570 kcal · 44 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "linseed_oil",
|
||||
"name": {
|
||||
"de": "Leinöl",
|
||||
"en": "Linseed oil"
|
||||
},
|
||||
"v": {
|
||||
"de": "837 kcal · 93 g",
|
||||
"en": "837 kcal · 93 g"
|
||||
},
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "almonds",
|
||||
"name": {
|
||||
"de": "Mandeln",
|
||||
"en": "Almonds"
|
||||
},
|
||||
"v": {
|
||||
"de": "612 kcal · 55 g",
|
||||
"en": "612 kcal · 55 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "olive_oil",
|
||||
"name": {
|
||||
"de": "Olivenöl",
|
||||
"en": "Olive oil"
|
||||
},
|
||||
"v": {
|
||||
"de": "857 kcal · 91,5 g",
|
||||
"en": "857 kcal · 91.5 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "walnuts",
|
||||
"name": {
|
||||
"de": "Walnüsse",
|
||||
"en": "Walnuts"
|
||||
},
|
||||
"v": {
|
||||
"de": "674 kcal · 62,5 g",
|
||||
"en": "674 kcal · 62.5 g"
|
||||
},
|
||||
"f": "pick"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "kh",
|
||||
"cls": "kh",
|
||||
"ic": "🌾",
|
||||
"title": {
|
||||
"de": "Kohlenhydrate",
|
||||
"en": "Carbohydrates"
|
||||
},
|
||||
"thumb": {
|
||||
"de": "Vollkorn als erste Zutat, wenig zugesetzter Zucker. Faustregel 10:1 — pro 10 g Kohlenhydrate mind. 1 g Ballaststoffe (z. B. 30 g KH → mind. 3 g Ballaststoffe).",
|
||||
"en": "Whole grain as the first ingredient, low added sugar. 10:1 rule of thumb — at least 1 g fibre per 10 g carbs (e.g. 30 g carbs → at least 3 g fibre)."
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": "brown_lentils",
|
||||
"name": {
|
||||
"de": "Berglinsen",
|
||||
"en": "Brown lentils"
|
||||
},
|
||||
"v": {
|
||||
"de": "319 kcal · ≈46 g",
|
||||
"en": "319 kcal · ≈46 g"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "buckwheat",
|
||||
"name": {
|
||||
"de": "Buchweizen",
|
||||
"en": "Buckwheat"
|
||||
},
|
||||
"v": {
|
||||
"de": "343 kcal · 71,5 g",
|
||||
"en": "343 kcal · 71.5 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "puffed_spelt",
|
||||
"name": {
|
||||
"de": "Dinkel gepufft",
|
||||
"en": "Puffed spelt"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈338 kcal · 70 g",
|
||||
"en": "≈338 kcal · 70 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "spelt_pasta",
|
||||
"name": {
|
||||
"de": "Dinkel-Vollkorn-Nudeln",
|
||||
"en": "Whole-grain spelt pasta"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈338 kcal · 70 g",
|
||||
"en": "≈338 kcal · 70 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "peas",
|
||||
"name": {
|
||||
"de": "Erbsen",
|
||||
"en": "Peas"
|
||||
},
|
||||
"v": {
|
||||
"de": "93 kcal · 10,4 g",
|
||||
"en": "93 kcal · 10.4 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "oats",
|
||||
"name": {
|
||||
"de": "Haferflocken",
|
||||
"en": "Oats"
|
||||
},
|
||||
"v": {
|
||||
"de": "372 kcal · 66,3 g",
|
||||
"en": "372 kcal · 66.3 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "millet",
|
||||
"name": {
|
||||
"de": "Hirse",
|
||||
"en": "Millet"
|
||||
},
|
||||
"v": {
|
||||
"de": "363 kcal · 69 g",
|
||||
"en": "363 kcal · 69 g"
|
||||
},
|
||||
"f": "caution"
|
||||
},
|
||||
{
|
||||
"id": "chickpeas",
|
||||
"name": {
|
||||
"de": "Kichererbsen",
|
||||
"en": "Chickpeas"
|
||||
},
|
||||
"v": {
|
||||
"de": "108 kcal · 14,2 g",
|
||||
"en": "108 kcal · 14.2 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "chickpea_pasta",
|
||||
"name": {
|
||||
"de": "Kichererbsennudeln / -couscous",
|
||||
"en": "Chickpea pasta / couscous"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈335 kcal · 50 g",
|
||||
"en": "≈335 kcal · 50 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "plantain",
|
||||
"name": {
|
||||
"de": "Kochbanane",
|
||||
"en": "Plantain"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈122 kcal · 32 g",
|
||||
"en": "≈122 kcal · 32 g"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "caution"
|
||||
},
|
||||
{
|
||||
"id": "lentil_pasta",
|
||||
"name": {
|
||||
"de": "Linsennudeln",
|
||||
"en": "Lentil pasta"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈335 kcal · 52 g",
|
||||
"en": "≈335 kcal · 52 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "quinoa",
|
||||
"name": {
|
||||
"de": "Quinoa",
|
||||
"en": "Quinoa"
|
||||
},
|
||||
"v": {
|
||||
"de": "368 kcal · 64,2 g",
|
||||
"en": "368 kcal · 64.2 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "red_lentils",
|
||||
"name": {
|
||||
"de": "Rote Linsen",
|
||||
"en": "Red lentils"
|
||||
},
|
||||
"v": {
|
||||
"de": "326 kcal · ≈52 g",
|
||||
"en": "326 kcal · ≈52 g"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "sweet_potato",
|
||||
"name": {
|
||||
"de": "Süßkartoffel",
|
||||
"en": "Sweet potato"
|
||||
},
|
||||
"v": {
|
||||
"de": "86 kcal · 20,1 g",
|
||||
"en": "86 kcal · 20.1 g"
|
||||
},
|
||||
"f": "caution"
|
||||
},
|
||||
{
|
||||
"id": "french_lentils",
|
||||
"name": {
|
||||
"de": "Tellerlinsen",
|
||||
"en": "French lentils"
|
||||
},
|
||||
"v": {
|
||||
"de": "234 kcal · ≈34 g",
|
||||
"en": "234 kcal · ≈34 g"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "pick"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
66
handover/data/steps.json
Normal file
66
handover/data/steps.json
Normal file
@@ -0,0 +1,66 @@
|
||||
[
|
||||
{
|
||||
"n": 1,
|
||||
"cls": "g",
|
||||
"title": {
|
||||
"de": "Protein",
|
||||
"en": "Protein"
|
||||
},
|
||||
"qty": {
|
||||
"de": "30 g pro Mahlzeit",
|
||||
"en": "30 g per meal"
|
||||
},
|
||||
"text": {
|
||||
"de": "Bspw. 125 g Rinderfilet, 300 g Skyr, 40 g Proteinpulver, 50 g Erbsenproteingeschnetzeltes oder 5 Eier. 150 g am Tag insgesamt.",
|
||||
"en": "E.g. 125 g beef fillet, 300 g Skyr, 40 g protein powder, 50 g pea-protein strips, or 5 eggs. 150 g total per day."
|
||||
}
|
||||
},
|
||||
{
|
||||
"n": 2,
|
||||
"cls": "t",
|
||||
"title": {
|
||||
"de": "Obst ODER Gemüse",
|
||||
"en": "Fruit OR Vegetables"
|
||||
},
|
||||
"qty": {
|
||||
"de": "eine Portion",
|
||||
"en": "one portion"
|
||||
},
|
||||
"text": {
|
||||
"de": "Obst = 100 g oder eine geballte Faust. Gemüse = 200 g oder 2 Handvoll.",
|
||||
"en": "Fruit = 100 g or one clenched fist. Vegetables = 200 g or 2 handfuls."
|
||||
}
|
||||
},
|
||||
{
|
||||
"n": 3,
|
||||
"cls": "o",
|
||||
"title": {
|
||||
"de": "Fett",
|
||||
"en": "Fat"
|
||||
},
|
||||
"qty": {
|
||||
"de": "",
|
||||
"en": ""
|
||||
},
|
||||
"text": {
|
||||
"de": "15 g Samen/Kerne/Nüsse (≈20 Stück, 1 EL) · ¼ Avocado · 1 EL Öl (15 g) — Nussöl außer Erdnuss, Olivenöl, Leinsamenöl.",
|
||||
"en": "15 g seeds/nuts (≈20 pieces, 1 tbsp) · ¼ avocado · 1 tbsp oil (15 g) — nut oil except peanut, olive oil, linseed oil."
|
||||
}
|
||||
},
|
||||
{
|
||||
"n": 4,
|
||||
"cls": "s",
|
||||
"title": {
|
||||
"de": "Kohlenhydrate",
|
||||
"en": "Carbohydrates"
|
||||
},
|
||||
"qty": {
|
||||
"de": "",
|
||||
"en": ""
|
||||
},
|
||||
"text": {
|
||||
"de": "75 g Reis/Nudeln/Brot (trocken) · ca. 250 g rohe Kartoffeln (≈3 mittelgroße) · 50 g Haferflocken (≈5 gehäufte EL).",
|
||||
"en": "75 g rice/pasta/bread (dry) · approx. 250 g raw potatoes (≈3 medium) · 50 g oats (≈5 heaped tbsp)."
|
||||
}
|
||||
}
|
||||
]
|
||||
836
handover/reference-kueche_app.html
Normal file
836
handover/reference-kueche_app.html
Normal file
@@ -0,0 +1,836 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Baukasten">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<title>Pocket Pascal</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#ffffff; --surface:#ffffff; --ink:#111111; --ink-soft:#5c5c5c; --line:#e6e6e6;
|
||||
--green:#2F5233; --green-soft:#e3ece4;
|
||||
--sage:#4A6FA5; --sage-soft:#e7edf5;
|
||||
--terracotta:#B33D22; --terracotta-soft:#f7e0d8; --terracotta-text:#A83B22;
|
||||
--leaf:#A8C23E; --leaf-soft:#f2f5df; --leaf-text:#5C6B1E;
|
||||
--gold:#E8B93A; --gold-soft:#fdf1d9; --gold-text:#8A6A1A;
|
||||
--slate:#6B4C9A; --slate-soft:#efe9f5;
|
||||
--pick:#16A34A; --caution:#DC2626;
|
||||
}
|
||||
*{box-sizing:border-box; -webkit-tap-highlight-color:transparent;}
|
||||
html,body{ height:100%; }
|
||||
body{
|
||||
margin:0; background:var(--bg); color:var(--ink);
|
||||
font-family: ui-sans-serif, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding-top:env(safe-area-inset-top); padding-bottom:env(safe-area-inset-bottom);
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
.display{ font-family:"Futura","Century Gothic","Avenir Next","Avenir",ui-sans-serif,sans-serif; font-weight:800; letter-spacing:-0.01em; }
|
||||
|
||||
/* ---------- top bar ---------- */
|
||||
.topbar{
|
||||
position:sticky; top:0; z-index:10; background:var(--surface);
|
||||
border-bottom:1px solid var(--line);
|
||||
display:flex; align-items:center; gap:8px;
|
||||
padding:14px 16px calc(14px + env(safe-area-inset-top)) 16px;
|
||||
margin-top:calc(-1 * env(safe-area-inset-top));
|
||||
padding-top:calc(14px + env(safe-area-inset-top));
|
||||
}
|
||||
.backbtn{
|
||||
display:none; border:none; background:var(--bg); color:var(--ink);
|
||||
width:34px; height:34px; border-radius:50%; font-size:1.1rem;
|
||||
align-items:center; justify-content:center; flex:none;
|
||||
}
|
||||
.backbtn.show{ display:flex; }
|
||||
.tb-title{ flex:1; min-width:0; }
|
||||
.topbar h1{ font-size:1.05rem; margin:0; font-weight:900; text-transform:uppercase; letter-spacing:0.02em; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.topbar .eyebrow{ font-size:0.62rem; text-transform:uppercase; letter-spacing:0.1em; color:var(--ink-soft); font-weight:700; display:block; }
|
||||
.tb-actions{ flex:none; display:flex; align-items:center; gap:8px; }
|
||||
.langpill{
|
||||
display:flex; gap:2px; background:var(--bg); border:1px solid var(--line);
|
||||
border-radius:999px; padding:2px;
|
||||
}
|
||||
.langpill button{
|
||||
border:none; background:transparent; padding:5px 9px; border-radius:999px;
|
||||
font-size:0.66rem; font-weight:800; letter-spacing:0.04em; color:var(--ink-soft);
|
||||
}
|
||||
.langpill button.active{ background:var(--green); color:#fff; }
|
||||
.gearbtn{
|
||||
border:none; background:var(--bg); width:30px; height:30px; border-radius:50%;
|
||||
font-size:0.95rem; display:flex; align-items:center; justify-content:center; flex:none;
|
||||
}
|
||||
|
||||
main{ padding:16px; padding-bottom:calc(32px + env(safe-area-inset-bottom)); max-width:640px; margin:0 auto; }
|
||||
.view{ display:none; }
|
||||
.view.active{ display:block; }
|
||||
|
||||
/* ---------- home grid ---------- */
|
||||
.homegrid{
|
||||
display:grid; grid-template-columns:repeat(2,1fr); gap:12px;
|
||||
grid-auto-rows:minmax(120px,auto);
|
||||
}
|
||||
.tile{
|
||||
border:1px solid var(--line); border-radius:14px; overflow:hidden;
|
||||
display:flex; flex-direction:column; justify-content:space-between;
|
||||
padding:16px; text-align:left; background:var(--surface);
|
||||
-webkit-user-select:none; user-select:none;
|
||||
}
|
||||
.tile .ic{ font-size:2rem; line-height:1; }
|
||||
.tile .t{ font-size:0.95rem; font-weight:900; text-transform:uppercase; letter-spacing:0.01em; margin-top:10px; }
|
||||
.tile .d{ font-size:0.7rem; color:var(--ink-soft); margin-top:2px; }
|
||||
.tile.soon{ opacity:0.4; }
|
||||
.tile.teller{ border-top:4px solid var(--green); }
|
||||
.tile.vorrat{ border-top:4px solid var(--terracotta); }
|
||||
.tile.settings{ border-top:4px solid var(--slate); }
|
||||
.tile.builder{ border-top:4px solid var(--pick); }
|
||||
.tile.soon{ border-top:4px solid var(--line); }
|
||||
|
||||
/* ---------- plate toggle ---------- */
|
||||
.segctl{
|
||||
display:flex; background:var(--green-soft); border-radius:999px; padding:3px; margin-bottom:16px;
|
||||
}
|
||||
.segctl button{
|
||||
flex:1; border:none; background:transparent; padding:9px 4px; border-radius:999px;
|
||||
font-size:0.78rem; font-weight:700; color:var(--ink-soft);
|
||||
}
|
||||
.segctl button.active{ background:var(--surface); color:var(--ink); box-shadow:0 1px 3px rgba(0,0,0,0.12); }
|
||||
|
||||
.plate-wrap{ display:flex; flex-direction:column; align-items:center; gap:10px; margin-bottom:18px; }
|
||||
.plate{ width:150px; height:150px; border-radius:50%; position:relative; box-shadow:inset 0 0 0 1px var(--line); }
|
||||
.plate::after{ content:""; position:absolute; inset:22%; background:var(--surface); border-radius:50%; box-shadow:inset 0 0 0 1px var(--line); }
|
||||
.legend{ display:flex; flex-direction:column; gap:4px; font-size:0.78rem; color:var(--ink-soft); }
|
||||
.legend span{ display:flex; align-items:center; gap:6px; justify-content:center; }
|
||||
.dot{ width:8px; height:8px; border-radius:50%; flex:none; }
|
||||
.dot.g{background:var(--green);} .dot.t{background:var(--terracotta);} .dot.o{background:var(--gold);} .dot.s{background:var(--slate);}
|
||||
|
||||
/* ---------- accordion (steps + categories) ---------- */
|
||||
.acc{ border:1px solid var(--line); border-radius:10px; overflow:hidden; margin-bottom:10px; }
|
||||
.acc-head{
|
||||
display:flex; align-items:center; gap:10px; padding:13px 14px;
|
||||
background:var(--surface); border:none; width:100%; text-align:left;
|
||||
}
|
||||
.acc-head .num{ flex:none; width:26px; height:26px; border-radius:50%; color:#fff; font-weight:700; font-size:0.8rem; display:flex; align-items:center; justify-content:center; }
|
||||
.acc-head .num.g{background:var(--green);} .acc-head .num.t{background:var(--terracotta);} .acc-head .num.o{background:var(--gold); color:#111;} .acc-head .num.s{background:var(--slate);}
|
||||
.acc-head .ic{ flex:none; font-size:1.3rem; width:26px; text-align:center; }
|
||||
.acc-head .title{ flex:1; font-weight:800; font-size:0.86rem; text-transform:uppercase; letter-spacing:0.02em; }
|
||||
.acc-head .qty{ font-size:0.72rem; color:var(--ink-soft); font-weight:600; }
|
||||
.acc-head .count{ font-size:0.7rem; color:var(--ink-soft); background:var(--bg); border-radius:20px; padding:2px 9px; flex:none; }
|
||||
.acc-head .chev{ flex:none; font-size:0.9rem; color:var(--ink-soft); transition:transform 0.15s ease; }
|
||||
.acc.open .chev{ transform:rotate(90deg); }
|
||||
.acc-body{ display:none; padding:0 14px 14px; border-top:1px solid var(--line); }
|
||||
.acc.open .acc-body{ display:block; padding-top:10px; }
|
||||
.acc-body p{ margin:0; font-size:0.82rem; color:var(--ink-soft); line-height:1.5; }
|
||||
|
||||
.cat-head{ border-left-width:4px; border-left-style:solid; }
|
||||
.acc.protein .cat-head{ border-left-color:var(--green); }
|
||||
.acc.snack .cat-head{ border-left-color:var(--sage); }
|
||||
.acc.obst .cat-head{ border-left-color:var(--terracotta); }
|
||||
.acc.gemuese .cat-head{ border-left-color:var(--leaf); }
|
||||
.acc.fett .cat-head{ border-left-color:var(--gold); }
|
||||
.acc.kh .cat-head{ border-left-color:var(--slate); }
|
||||
|
||||
table{ width:100%; border-collapse:collapse; font-size:0.82rem; margin-top:6px; }
|
||||
table td{ padding:6px 0; border-bottom:1px dashed var(--line); vertical-align:top; }
|
||||
table td:last-child{ text-align:right; color:var(--ink-soft); white-space:nowrap; padding-left:8px; }
|
||||
tr:last-child td{ border-bottom:none; }
|
||||
.approx{ font-style:italic; }
|
||||
.dotmark{ font-size:0.7em; margin-right:3px; }
|
||||
.dotmark.pick{ color:var(--pick); }
|
||||
.dotmark.caution{ color:var(--caution); }
|
||||
.subhead{ font-size:0.7rem; font-weight:700; text-transform:uppercase; letter-spacing:0.05em; color:var(--ink-soft); margin:10px 0 0; }
|
||||
.rot{ margin-top:10px; padding-top:10px; border-top:1px dashed var(--line); font-size:0.78rem; line-height:1.45; }
|
||||
.rot b{ text-transform:uppercase; letter-spacing:0.03em; font-size:0.7rem; }
|
||||
.acc.protein .rot b{ color:var(--green); } .acc.snack .rot b{ color:var(--sage); }
|
||||
.acc.obst .rot b{ color:var(--terracotta-text); } .acc.gemuese .rot b{ color:var(--leaf-text); }
|
||||
.acc.fett .rot b{ color:var(--gold-text); } .acc.kh .rot b{ color:var(--slate); }
|
||||
|
||||
.searchbar{
|
||||
display:flex; align-items:center; gap:8px; background:var(--surface); border:1px solid var(--line);
|
||||
border-radius:10px; padding:10px 12px; margin-bottom:14px;
|
||||
}
|
||||
.searchbar input{ border:none; outline:none; font-size:0.9rem; flex:1; background:transparent; color:var(--ink); }
|
||||
.searchbar .clear{ border:none; background:var(--bg); color:var(--ink-soft); border-radius:50%; width:22px; height:22px; font-size:0.75rem; display:none; }
|
||||
|
||||
.unitnote{ font-size:0.74rem; color:var(--ink-soft); background:var(--green-soft); border-radius:8px; padding:9px 12px; margin-bottom:14px; }
|
||||
.recipe{ background:var(--sage-soft); border:1px solid var(--sage); border-radius:8px; padding:10px 12px; font-size:0.8rem; line-height:1.5; margin-top:8px; }
|
||||
.recipe b{ color:var(--sage); }
|
||||
.rules{ display:flex; flex-direction:column; gap:8px; border-top:1px solid var(--line); padding-top:14px; margin-top:16px; }
|
||||
.rule{ display:flex; gap:8px; font-size:0.82rem; line-height:1.4; }
|
||||
.rule::before{ content:"—"; color:var(--terracotta-text); flex:none; }
|
||||
|
||||
/* ---------- settings ---------- */
|
||||
.setgroup{ border:1px solid var(--line); border-radius:10px; padding:14px 16px; margin-bottom:12px; }
|
||||
.setgroup h3{ font-size:0.72rem; text-transform:uppercase; letter-spacing:0.06em; color:var(--ink-soft); margin:0 0 10px; font-weight:700; }
|
||||
.setrow{ display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.setrow .label{ font-size:0.88rem; font-weight:600; }
|
||||
.setnote{ font-size:0.72rem; color:var(--ink-soft); margin-top:8px; line-height:1.4; }
|
||||
.setnote.ok{ color:var(--pick); }
|
||||
.setnote.warn{ color:var(--caution); }
|
||||
.setgroup.disabled{ opacity:0.45; }
|
||||
|
||||
/* ---------- builder ---------- */
|
||||
.builder-row{ margin-bottom:16px; }
|
||||
.builder-row .rowhead{ display:flex; align-items:baseline; justify-content:space-between; margin-bottom:8px; }
|
||||
.builder-row label{ font-size:0.7rem; font-weight:700; text-transform:uppercase; letter-spacing:0.05em; color:var(--ink-soft); }
|
||||
.builder-row .rcount{ font-size:0.68rem; color:var(--ink-soft); }
|
||||
.chip-grid{ display:flex; flex-wrap:wrap; gap:7px; }
|
||||
.chip{
|
||||
border:1px solid var(--line); background:var(--surface); color:var(--ink);
|
||||
padding:8px 13px; border-radius:999px; font-size:0.82rem; font-weight:600;
|
||||
display:flex; align-items:center; gap:5px; -webkit-user-select:none; user-select:none;
|
||||
}
|
||||
.chip .dotmark{ margin-right:0; }
|
||||
.builder-row.protein .chip.on{ background:var(--green); border-color:var(--green); color:#fff; }
|
||||
.builder-row.obstgem .chip.on{ background:var(--terracotta); border-color:var(--terracotta); color:#fff; }
|
||||
.builder-row.fett .chip.on{ background:var(--gold); border-color:var(--gold); color:#111; }
|
||||
.builder-row.kh .chip.on{ background:var(--slate); border-color:var(--slate); color:#fff; }
|
||||
.chip.on .dotmark.pick, .chip.on .dotmark.caution{ filter:brightness(1.4) saturate(1.4); }
|
||||
|
||||
.builder-result{ margin-top:6px; padding:14px 16px; border-radius:10px; border:1px solid var(--line); font-size:0.85rem; line-height:1.5; }
|
||||
.builder-result .bh{ font-weight:900; font-size:0.95rem; text-transform:uppercase; letter-spacing:0.01em; margin-bottom:4px; display:flex; align-items:center; gap:8px; }
|
||||
.builder-result.neutral{ background:var(--surface); }
|
||||
.builder-result.solid{ background:var(--green-soft); }
|
||||
.builder-result.solid .bh{ color:var(--green); }
|
||||
.builder-result.pick{ background:var(--green-soft); }
|
||||
.builder-result.pick .bh{ color:var(--green); }
|
||||
.builder-result.caution{ background:#fdf1ec; }
|
||||
.builder-result.caution .bh{ color:var(--terracotta-text); }
|
||||
.builder-result ul{ margin:6px 0 0; padding-left:18px; }
|
||||
.builder-result li{ margin-bottom:2px; }
|
||||
</style>
|
||||
|
||||
<div class="topbar">
|
||||
<button class="backbtn" id="backBtn" onclick="goHome()">‹</button>
|
||||
<div class="tb-title">
|
||||
<span class="eyebrow" id="topEyebrow">Body Comeback</span>
|
||||
<h1 id="topTitle">Pocket Pascal</h1>
|
||||
</div>
|
||||
<div class="tb-actions">
|
||||
<div class="langpill">
|
||||
<button id="pillDe" onclick="setLang('de')">DE</button>
|
||||
<button id="pillEn" onclick="setLang('en')">EN</button>
|
||||
</div>
|
||||
<button class="gearbtn" onclick="showView('settings')">⚙️</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
|
||||
<!-- HOME -->
|
||||
<section class="view active" id="view-home">
|
||||
<div class="homegrid" id="homegrid"></div>
|
||||
</section>
|
||||
|
||||
<!-- TELLER -->
|
||||
<section class="view" id="view-teller">
|
||||
<div class="segctl">
|
||||
<button id="segFull" class="active" onclick="setMeal('full')"></button>
|
||||
<button id="segHalf" onclick="setMeal('half')"></button>
|
||||
</div>
|
||||
|
||||
<div class="plate-wrap">
|
||||
<div class="plate" id="plateFull" style="background:conic-gradient(var(--green) 0deg 120deg, var(--terracotta) 120deg 240deg, var(--gold) 240deg 300deg, var(--slate) 300deg 360deg);"></div>
|
||||
<div class="plate" id="plateHalf" style="display:none; background:conic-gradient(var(--green) 0deg 200deg, var(--terracotta) 200deg 360deg);"></div>
|
||||
<div class="legend" id="legendFull"></div>
|
||||
<div class="legend" id="legendHalf" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div id="stepsList"></div>
|
||||
|
||||
<div class="rules" id="tellerRules"></div>
|
||||
</section>
|
||||
|
||||
<!-- VORRATSKAMMER -->
|
||||
<section class="view" id="view-vorrat">
|
||||
<div class="searchbar">
|
||||
<span>🔍</span>
|
||||
<input id="searchInput" oninput="onSearch()">
|
||||
<button class="clear" id="clearBtn" onclick="clearSearch()">✕</button>
|
||||
</div>
|
||||
<div class="unitnote" id="unitnote"></div>
|
||||
<div id="catList"></div>
|
||||
</section>
|
||||
|
||||
<!-- BUILDER -->
|
||||
<section class="view" id="view-builder">
|
||||
<div class="segctl">
|
||||
<button id="bSegFull" class="active" onclick="setBuilderMeal('full')"></button>
|
||||
<button id="bSegHalf" onclick="setBuilderMeal('half')"></button>
|
||||
</div>
|
||||
<div id="builderPickers"></div>
|
||||
<div class="builder-result neutral" id="builderResult"></div>
|
||||
</section>
|
||||
|
||||
<!-- SETTINGS -->
|
||||
<section class="view" id="view-settings">
|
||||
<div class="setgroup">
|
||||
<h3 id="setLangLabel">Sprache</h3>
|
||||
<div class="setrow">
|
||||
<span class="label">Deutsch / English</span>
|
||||
<div class="langpill">
|
||||
<button id="pillDe2" onclick="setLang('de')">DE</button>
|
||||
<button id="pillEn2" onclick="setLang('en')">EN</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setnote" id="persistNote"></div>
|
||||
</div>
|
||||
<div class="setgroup disabled">
|
||||
<h3 id="setMoreLabel">Weitere Einstellungen</h3>
|
||||
<div class="setnote" id="setMoreNote"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// ---------------------------------------------------------------------------
|
||||
// i18n dictionary — all static UI strings
|
||||
// ---------------------------------------------------------------------------
|
||||
const UI = {
|
||||
de: {
|
||||
body:'Body Comeback', appTitle:'Pocket Pascal',
|
||||
tileTellerT:'Dein Teller', tileTellerD:'Baukasten & Reihenfolge',
|
||||
tileVorratT:'Vorratskammer', tileVorratD:'Einkauf & Kühlschrank',
|
||||
tileSettingsT:'Einstellungen', tileSettingsD:'Sprache & mehr',
|
||||
tileBuilderT:'Teller-Builder', tileBuilderD:'Kombi checken',
|
||||
tileSugarT:'Zucker-Guide', tileSugarD:'Bald verfügbar',
|
||||
tileCheckT:'Checkliste', tileCheckD:'Bald verfügbar',
|
||||
card1:'Karte 1', card2:'Karte 2', cardSettings:'Einstellungen', cardBuilder:'Teller-Builder',
|
||||
pickProtein:'Protein', pickObstGem:'Obst / Gemüse', pickFett:'Fett', pickKh:'Kohlenhydrate',
|
||||
pickPlaceholder:'— auswählen —',
|
||||
bResultMissingH:'Noch unvollständig',
|
||||
bResultMissingList:'Fehlt noch:',
|
||||
bResultSolidH:'Solider Teller',
|
||||
bResultSolidHSnack:'Solider Snack',
|
||||
bResultPickH:'Guter Teller!',
|
||||
bResultPickHSnack:'Guter Snack!',
|
||||
bResultPickList:'Besonders gut gewählt:',
|
||||
bResultCautionH:'Mit Hinweis',
|
||||
bResultCautionList:'Bewusst genießen:',
|
||||
bResultCautionNote:'Kein Fehler — nur ein Punkt, den du im Blick behalten kannst.',
|
||||
segFull:'Ganze Mahlzeit', segHalf:'Halbe Mahlzeit',
|
||||
legFullP:'⅓ Protein', legFullVO:'⅓ Gemüse / Obst', legFullFK:'⅓ Fett + Kohlenhydrate',
|
||||
legHalfP:'Protein', legHalfVO:'Gemüse / Obst',
|
||||
rule1:'Jede Mahlzeit beruht auf Protein — egal ob süß, herzhaft, Snack oder Hauptmahlzeit.',
|
||||
rule2:'Jede Mahlzeit enthält eine Portion Gemüse oder Obst.',
|
||||
rule3:'4 Mahlzeiten am Tag, ca. alle 3–4 Std.',
|
||||
rule4:'2 L Wasser täglich.',
|
||||
searchPlaceholder:'Lebensmittel suchen…',
|
||||
unitnote:'Werte je 100 g, sofern nicht anders angegeben.',
|
||||
pickLabel:'besonders empfehlenswert', cautionLabel:'bewusst genießen',
|
||||
noResults:'Keine Treffer.',
|
||||
thumbDefault:'Daumenregel',
|
||||
setLangLabel:'Sprache', setMoreLabel:'Weitere Einstellungen',
|
||||
setMoreNote:'Mahlzeiten/Tag, Einkaufsrhythmus u.a. folgen in Phase 2.',
|
||||
persistOk:'Spracheinstellung wird auf diesem Gerät gespeichert.',
|
||||
persistWarn:'Spracheinstellung wird in dieser Ansicht nicht gespeichert — nach Neuladen wieder Deutsch.'
|
||||
},
|
||||
en: {
|
||||
body:'Body Comeback', appTitle:'Pocket Pascal',
|
||||
tileTellerT:'Your Plate', tileTellerD:'Building blocks & sequence',
|
||||
tileVorratT:'The Pantry', tileVorratD:'Shopping & fridge',
|
||||
tileSettingsT:'Settings', tileSettingsD:'Language & more',
|
||||
tileBuilderT:'Plate Builder', tileBuilderD:'Check a combo',
|
||||
tileSugarT:'Sugar Guide', tileSugarD:'Coming soon',
|
||||
tileCheckT:'Checklist', tileCheckD:'Coming soon',
|
||||
card1:'Card 1', card2:'Card 2', cardSettings:'Settings', cardBuilder:'Plate Builder',
|
||||
pickProtein:'Protein', pickObstGem:'Veg / Fruit', pickFett:'Fat', pickKh:'Carbohydrate',
|
||||
pickPlaceholder:'— choose —',
|
||||
bResultMissingH:'Still incomplete',
|
||||
bResultMissingList:'Still missing:',
|
||||
bResultSolidH:'Solid plate',
|
||||
bResultSolidHSnack:'Solid snack',
|
||||
bResultPickH:'Good plate!',
|
||||
bResultPickHSnack:'Good snack!',
|
||||
bResultPickList:'Especially well chosen:',
|
||||
bResultCautionH:'With a note',
|
||||
bResultCautionList:'Enjoy mindfully:',
|
||||
bResultCautionNote:'Not a mistake — just something to keep an eye on.',
|
||||
segFull:'Full meal', segHalf:'Half meal',
|
||||
legFullP:'⅓ Protein', legFullVO:'⅓ Veg / Fruit', legFullFK:'⅓ Fat + Carbs',
|
||||
legHalfP:'Protein', legHalfVO:'Veg / Fruit',
|
||||
rule1:'Every meal is built on protein — sweet, savoury, snack, or main.',
|
||||
rule2:'Every meal includes a portion of vegetables or fruit.',
|
||||
rule3:'4 meals a day, roughly every 3–4 hours.',
|
||||
rule4:'2 L water daily.',
|
||||
searchPlaceholder:'Search foods…',
|
||||
unitnote:'Values per 100 g unless noted otherwise.',
|
||||
pickLabel:'especially recommended', cautionLabel:'enjoy mindfully',
|
||||
noResults:'No matches.',
|
||||
thumbDefault:'Rule of thumb',
|
||||
setLangLabel:'Language', setMoreLabel:'More settings',
|
||||
setMoreNote:'Meals/day, shopping rhythm and more arrive in Phase 2.',
|
||||
persistOk:'Language choice is saved on this device.',
|
||||
persistWarn:'Language choice isn’t saved in this view — reloads back to German.'
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// data model — structured records, name/text as {de,en}
|
||||
// ---------------------------------------------------------------------------
|
||||
const STEPS = [
|
||||
{n:1, cls:'g',
|
||||
title:{de:'Protein', en:'Protein'},
|
||||
qty:{de:'30 g pro Mahlzeit', en:'30 g per meal'},
|
||||
text:{de:'Bspw. 125 g Rinderfilet, 300 g Skyr, 40 g Proteinpulver, 50 g Erbsenproteingeschnetzeltes oder 5 Eier. 150 g am Tag insgesamt.',
|
||||
en:'E.g. 125 g beef fillet, 300 g Skyr, 40 g protein powder, 50 g pea-protein strips, or 5 eggs. 150 g total per day.'}},
|
||||
{n:2, cls:'t',
|
||||
title:{de:'Obst ODER Gemüse', en:'Fruit OR Vegetables'},
|
||||
qty:{de:'eine Portion', en:'one portion'},
|
||||
text:{de:'Obst = 100 g oder eine geballte Faust. Gemüse = 200 g oder 2 Handvoll.',
|
||||
en:'Fruit = 100 g or one clenched fist. Vegetables = 200 g or 2 handfuls.'}},
|
||||
{n:3, cls:'o',
|
||||
title:{de:'Fett', en:'Fat'},
|
||||
qty:{de:'', en:''},
|
||||
text:{de:'15 g Samen/Kerne/Nüsse (≈20 Stück, 1 EL) · ¼ Avocado · 1 EL Öl (15 g) — Nussöl außer Erdnuss, Olivenöl, Leinsamenöl.',
|
||||
en:'15 g seeds/nuts (≈20 pieces, 1 tbsp) · ¼ avocado · 1 tbsp oil (15 g) — nut oil except peanut, olive oil, linseed oil.'}},
|
||||
{n:4, cls:'s',
|
||||
title:{de:'Kohlenhydrate', en:'Carbohydrates'},
|
||||
qty:{de:'', en:''},
|
||||
text:{de:'75 g Reis/Nudeln/Brot (trocken) · ca. 250 g rohe Kartoffeln (≈3 mittelgroße) · 50 g Haferflocken (≈5 gehäufte EL).',
|
||||
en:'75 g rice/pasta/bread (dry) · approx. 250 g raw potatoes (≈3 medium) · 50 g oats (≈5 heaped tbsp).'}}
|
||||
];
|
||||
|
||||
// f: 'pick' | 'caution' | undefined
|
||||
const CATS = [
|
||||
{id:'protein', cls:'protein', ic:'🥚', title:{de:'Protein', en:'Protein'},
|
||||
thumb:{de:'Mindestens 15–20 g Protein pro 100 g, wenig Zucker & wenig Fett, kurze Zutatenliste.',
|
||||
en:'At least 15–20 g protein per 100 g, low sugar & low fat, short ingredient list.'},
|
||||
items:[
|
||||
{id:'edamame', name:{de:'Edamame', en:'Edamame'}, v:{de:'125 kcal · 14 g', en:'125 kcal · 14 g'}},
|
||||
{id:'egg', name:{de:'Ei (Größe M)', en:'Egg (size M)'}, v:{de:'137 kcal · 13 g', en:'137 kcal · 13 g'}},
|
||||
{id:'pea_protein_strips', name:{de:'Erbsenproteinschnetzel', en:'Pea-protein strips'}, v:{de:'367 kcal · 61 g', en:'367 kcal · 61 g'}},
|
||||
{id:'feta', name:{de:'Feta (leicht)', en:'Feta (light)'}, v:{de:'161 kcal · 19 g', en:'161 kcal · 19 g'}, f:'caution'},
|
||||
{id:'chicken_breast', name:{de:'Hühnerbrust', en:'Chicken breast'}, v:{de:'111 kcal · 24 g', en:'111 kcal · 24 g'}, f:'pick'},
|
||||
{id:'cottage_cheese', name:{de:'Hüttenkäse', en:'Cottage cheese'}, v:{de:'87 kcal · 12,3 g', en:'87 kcal · 12.3 g'}},
|
||||
{id:'like_chicken', name:{de:'„Like Chicken" (pflanzlich)', en:'"Like Chicken" (plant-based)'}, v:{de:'≈140 kcal · 18 g', en:'≈140 kcal · 18 g'}, approx:true},
|
||||
{id:'low_fat_quark', name:{de:'Magerquark', en:'Low-fat quark'}, v:{de:'67 kcal · 12 g', en:'67 kcal · 12 g'}},
|
||||
{id:'beef_fillet', name:{de:'Rinderfilet / Tatar', en:'Beef fillet / tartare'}, v:{de:'112 kcal · 22 g', en:'112 kcal · 22 g'}, f:'pick'},
|
||||
{id:'silken_tofu', name:{de:'Seidentofu', en:'Silken tofu'}, v:{de:'≈55 kcal · 5 g', en:'≈55 kcal · 5 g'}, approx:true},
|
||||
{id:'skyr', name:{de:'Skyr', en:'Skyr'}, v:{de:'54 kcal · 10,3 g', en:'54 kcal · 10.3 g'}, f:'pick'},
|
||||
{id:'tempeh', name:{de:'Tempeh', en:'Tempeh'}, v:{de:'184 kcal · 19 g', en:'184 kcal · 19 g'}},
|
||||
{id:'tofu', name:{de:'Tofu', en:'Tofu'}, v:{de:'129 kcal · 13 g', en:'129 kcal · 13 g'}}
|
||||
],
|
||||
sub:{title:{de:'Milch & Joghurt', en:'Milk & yoghurt'}, items:[
|
||||
{id:'greek_yogurt_10', name:{de:'Griech. Joghurt 10 %', en:'Greek yoghurt 10%'}, v:{de:'≈115 kcal · 5 g', en:'≈115 kcal · 5 g'}, approx:true},
|
||||
{id:'natural_yogurt', name:{de:'Naturjoghurt', en:'Natural yoghurt'}, v:{de:'≈61 kcal · 3,5 g', en:'≈61 kcal · 3.5 g'}, approx:true},
|
||||
{id:'oatly_barista', name:{de:'Oatly Barista', en:'Oatly Barista'}, v:{de:'≈68 kcal · 1 g (kaum Protein)', en:'≈68 kcal · 1 g (barely protein)'}, approx:true, f:'caution'},
|
||||
{id:'whole_milk', name:{de:'Vollmilch 3,5 %', en:'Whole milk 3.5%'}, v:{de:'≈64 kcal · 3,4 g', en:'≈64 kcal · 3.4 g'}, approx:true, f:'caution'}
|
||||
]}},
|
||||
{id:'snack', cls:'snack', ic:'🥤', title:{de:'Proteinsnacks für Steffi', en:'Protein snacks for Steffi'},
|
||||
thumb:{de:'Mindestens 15 g Protein pro Portion, unter 5 g Zucker, Fett unter 8 g — Zutatenliste beginnt nicht mit Zucker/Sirup/Schokolade.',
|
||||
en:'At least 15 g protein per serving, under 5 g sugar, fat under 8 g — ingredient list shouldn’t open with sugar/syrup/chocolate.'},
|
||||
items:[
|
||||
{id:'three_eggs', name:{de:'3 Eier', en:'3 eggs'}, v:{de:'≈205 kcal · 19 g', en:'≈205 kcal · 19 g'}, approx:true},
|
||||
{id:'cottage_berries', name:{de:'Hüttenkäse + Beeren', en:'Cottage cheese + berries'}, v:{de:'≈210 kcal · 25 g', en:'≈210 kcal · 25 g'}, approx:true},
|
||||
{id:'chickpea_waffles', name:{de:'Kichererbsenwaffeln 90g', en:'Chickpea waffles 90g'}, v:{de:'≈300 kcal · 17 g', en:'≈300 kcal · 17 g'}, approx:true, f:'caution'},
|
||||
{id:'pumpkin_seeds_30', name:{de:'Kürbiskerne 30g', en:'Pumpkin seeds 30g'}, v:{de:'≈171 kcal · 9 g', en:'≈171 kcal · 9 g'}, approx:true, f:'caution'},
|
||||
{id:'lentil_waffles', name:{de:'Linsenwaffeln 90g', en:'Lentil waffles 90g'}, v:{de:'≈300 kcal · 18 g', en:'≈300 kcal · 18 g'}, approx:true, f:'caution'},
|
||||
{id:'protein_bar', name:{de:'Magerer Proteinriegel', en:'Lean protein bar'}, v:{de:'≈180 kcal · 20 g', en:'≈180 kcal · 20 g'}, approx:true},
|
||||
{id:'protein_rice_pudding', name:{de:'Proteinmilchreis (2x)', en:'Protein rice pudding (2x)'}, v:{de:'≈340 kcal · 40 g', en:'≈340 kcal · 40 g'}, approx:true},
|
||||
{id:'protein_pudding', name:{de:'Proteinpudding', en:'Protein pudding'}, v:{de:'≈90 kcal · 20 g', en:'≈90 kcal · 20 g'}, approx:true, f:'pick'},
|
||||
{id:'protein_shake', name:{de:'Proteinshake 40g', en:'Protein shake 40g'}, v:{de:'≈150 kcal · 30 g', en:'≈150 kcal · 30 g'}, approx:true, f:'pick'},
|
||||
{id:'turkey_breast_pack', name:{de:'Putenbrust, 1 Packung', en:'Turkey breast, 1 pack'}, v:{de:'≈130 kcal · 28 g', en:'≈130 kcal · 28 g'}, approx:true, f:'pick'},
|
||||
{id:'smoked_tofu_cubes', name:{de:'Räuchertofu-Würfel', en:'Smoked tofu cubes'}, v:{de:'≈225 kcal · 25 g', en:'≈225 kcal · 25 g'}, approx:true},
|
||||
{id:'skyr_honey', name:{de:'Skyr/Griech. Joghurt 10% + Honig, 250g+', en:'Skyr/Greek yoghurt 10% + honey, 250g+'}, v:{de:'≈180 kcal · 25 g', en:'≈180 kcal · 25 g'}, approx:true}
|
||||
],
|
||||
recipe:{title:{de:'Rezept: Whey-Waffeln', en:'Recipe: Whey waffles'},
|
||||
text:{de:'40 g Whey-Protein + 1 Ei + 40 g Haferflocken (fein gemahlen) + 60 ml Milch/Wasser verquirlen. ≈25 g Protein.',
|
||||
en:'Whisk 40 g whey protein + 1 egg + 40 g oats (finely ground) + 60 ml milk/water. ≈25 g protein.'}}},
|
||||
{id:'obst', cls:'obst', ic:'🍎', title:{de:'Obst', en:'Fruit'},
|
||||
thumb:{de:'Frisch oder tiefgekühlt ohne Zuckerzusatz, Saison bevorzugen.',
|
||||
en:'Fresh or frozen without added sugar, prefer what’s in season.'},
|
||||
items:[
|
||||
{id:'apple', name:{de:'Apfel', en:'Apple'}, v:{de:'Vit. C, Kalium, Kupfer', en:'Vit. C, potassium, copper'}},
|
||||
{id:'banana', name:{de:'Banane', en:'Banana'}, v:{de:'Kalium, B6, C, Magnesium', en:'Potassium, B6, C, magnesium'}, f:'caution'},
|
||||
{id:'blueberry', name:{de:'Blaubeere', en:'Blueberry'}, v:{de:'Vit. K, C, Mangan', en:'Vit. K, C, manganese'}, f:'pick'},
|
||||
{id:'strawberry', name:{de:'Erdbeere', en:'Strawberry'}, v:{de:'Vit. C, Mangan, Folsäure, Kalium', en:'Vit. C, manganese, folate, potassium'}},
|
||||
{id:'granadilla', name:{de:'Granadilla (≈ Passionsfrucht)', en:'Granadilla (≈ passion fruit)'}, v:{de:'Vit. C, A, Kalium, B2', en:'Vit. C, A, potassium, B2'}, approx:true},
|
||||
{id:'pomegranate', name:{de:'Granatapfel', en:'Pomegranate'}, v:{de:'Vit. C, K, Folsäure, Kalium, B6', en:'Vit. C, K, folate, potassium, B6'}, f:'pick'},
|
||||
{id:'mango', name:{de:'Mango', en:'Mango'}, v:{de:'Vit. C, A, B6, Folsäure, Kupfer', en:'Vit. C, A, B6, folate, copper'}, f:'pick'},
|
||||
{id:'nectarine', name:{de:'Nektarine', en:'Nectarine'}, v:{de:'Vit. C, A, Kalium, E, Niacin', en:'Vit. C, A, potassium, E, niacin'}},
|
||||
{id:'orange', name:{de:'Orange', en:'Orange'}, v:{de:'Vit. C, Folsäure, Kalium, Calcium', en:'Vit. C, folate, potassium, calcium'}},
|
||||
{id:'passion_fruit', name:{de:'Passionsfrucht', en:'Passion fruit'}, v:{de:'Vit. C, A, Kalium, Eisen', en:'Vit. C, A, potassium, iron'}, approx:true},
|
||||
{id:'plum', name:{de:'Pflaume', en:'Plum'}, v:{de:'Vit. C, K, Kalium, A, Kupfer', en:'Vit. C, K, potassium, A, copper'}},
|
||||
{id:'grapes', name:{de:'Trauben', en:'Grapes'}, v:{de:'Vit. K, C, Kalium, Kupfer, Mangan', en:'Vit. K, C, potassium, copper, manganese'}, f:'caution'}
|
||||
]},
|
||||
{id:'gemuese', cls:'gemuese', ic:'🥦', title:{de:'Gemüse', en:'Vegetables'},
|
||||
thumbLabel:{de:'Tipp', en:'Tip'},
|
||||
thumb:{de:'TK-Gemüse ist völlig okay, wenn’s schnell gehen muss — genauso nährstoffreich wie frisch, einfach ohne Salz-/Fettzusatz wählen.',
|
||||
en:'Frozen veg is perfectly fine when time is short — just as nutrient-rich as fresh, just pick it without added salt/fat.'},
|
||||
items:[
|
||||
{id:'broccoli', name:{de:'Brokkoli', en:'Broccoli'}, v:{de:'Vit. C, K, Folsäure, Eisen', en:'Vit. C, K, folate, iron'}},
|
||||
{id:'mushrooms', name:{de:'Champignons', en:'Mushrooms'}, v:{de:'Kalium, Selen, Kupfer, B2', en:'Potassium, selenium, copper, B2'}},
|
||||
{id:'iceberg_lettuce', name:{de:'Eisbergsalat', en:'Iceberg lettuce'}, v:{de:'Vit. K, A, Folsäure, Kalium — nährstoffarm', en:'Vit. K, A, folate, potassium — low nutrient density'}, f:'caution'},
|
||||
{id:'lambs_lettuce', name:{de:'Feldsalat', en:'Lamb’s lettuce'}, v:{de:'Vit. A, C, Folsäure, Eisen', en:'Vit. A, C, folate, iron'}, f:'pick'},
|
||||
{id:'cucumber', name:{de:'Gurke', en:'Cucumber'}, v:{de:'Vit. K, Kalium, Mangan', en:'Vit. K, potassium, manganese'}},
|
||||
{id:'carrot', name:{de:'Karotte', en:'Carrot'}, v:{de:'Vit. A, K, Kalium, B6', en:'Vit. A, K, potassium, B6'}, f:'pick'},
|
||||
{id:'bell_pepper', name:{de:'Paprika', en:'Bell pepper'}, v:{de:'Vit. C, A, B6, E', en:'Vit. C, A, B6, E'}},
|
||||
{id:'spinach', name:{de:'Spinat', en:'Spinach'}, v:{de:'Vit. K, A, Folsäure, Eisen', en:'Vit. K, A, folate, iron'}, f:'pick'},
|
||||
{id:'tomato', name:{de:'Tomate', en:'Tomato'}, v:{de:'Vit. C, K, Kalium, Folsäure', en:'Vit. C, K, potassium, folate'}},
|
||||
{id:'zucchini', name:{de:'Zucchini', en:'Zucchini'}, v:{de:'Vit. C, Kalium, B6, Mangan', en:'Vit. C, potassium, B6, manganese'}}
|
||||
],
|
||||
note:{de:'Hinweis: Süßkartoffel, Kochbanane und Erbsen zählen hier als Kohlenhydratquelle, Avocado als Fettquelle — siehe dort.',
|
||||
en:'Note: sweet potato, plantain and peas count here as a carb source, avocado as a fat source — see those cards.'}},
|
||||
{id:'fett', cls:'fett', ic:'🥑', title:{de:'Fett', en:'Fat'},
|
||||
thumb:{de:'Native/kaltgepresste Öle, ungesalzene & ungeröstete Nüsse/Kerne, Zutatenliste = nur die Zutat selbst.',
|
||||
en:'Native/cold-pressed oils, unsalted & unroasted nuts/seeds, ingredient list = just the ingredient itself.'},
|
||||
items:[
|
||||
{id:'avocado', name:{de:'Avocado', en:'Avocado'}, v:{de:'217 kcal · 12,5 g', en:'217 kcal · 12.5 g'}},
|
||||
{id:'chia_seeds', name:{de:'Chiasamen', en:'Chia seeds'}, v:{de:'486 kcal · 31 g', en:'486 kcal · 31 g'}, f:'pick'},
|
||||
{id:'pumpkin_seeds', name:{de:'Kürbiskerne', en:'Pumpkin seeds'}, v:{de:'570 kcal · 44 g', en:'570 kcal · 44 g'}},
|
||||
{id:'linseed_oil', name:{de:'Leinöl', en:'Linseed oil'}, v:{de:'837 kcal · 93 g', en:'837 kcal · 93 g'}, f:'pick'},
|
||||
{id:'almonds', name:{de:'Mandeln', en:'Almonds'}, v:{de:'612 kcal · 55 g', en:'612 kcal · 55 g'}},
|
||||
{id:'olive_oil', name:{de:'Olivenöl', en:'Olive oil'}, v:{de:'857 kcal · 91,5 g', en:'857 kcal · 91.5 g'}},
|
||||
{id:'walnuts', name:{de:'Walnüsse', en:'Walnuts'}, v:{de:'674 kcal · 62,5 g', en:'674 kcal · 62.5 g'}, f:'pick'}
|
||||
]},
|
||||
{id:'kh', cls:'kh', ic:'🌾', title:{de:'Kohlenhydrate', en:'Carbohydrates'},
|
||||
thumb:{de:'Vollkorn als erste Zutat, wenig zugesetzter Zucker. Faustregel 10:1 — pro 10 g Kohlenhydrate mind. 1 g Ballaststoffe (z. B. 30 g KH → mind. 3 g Ballaststoffe).',
|
||||
en:'Whole grain as the first ingredient, low added sugar. 10:1 rule of thumb — at least 1 g fibre per 10 g carbs (e.g. 30 g carbs → at least 3 g fibre).'},
|
||||
items:[
|
||||
{id:'brown_lentils', name:{de:'Berglinsen', en:'Brown lentils'}, v:{de:'319 kcal · ≈46 g', en:'319 kcal · ≈46 g'}, approx:true, f:'pick'},
|
||||
{id:'buckwheat', name:{de:'Buchweizen', en:'Buckwheat'}, v:{de:'343 kcal · 71,5 g', en:'343 kcal · 71.5 g'}},
|
||||
{id:'puffed_spelt', name:{de:'Dinkel gepufft', en:'Puffed spelt'}, v:{de:'≈338 kcal · 70 g', en:'≈338 kcal · 70 g'}, approx:true},
|
||||
{id:'spelt_pasta', name:{de:'Dinkel-Vollkorn-Nudeln', en:'Whole-grain spelt pasta'}, v:{de:'≈338 kcal · 70 g', en:'≈338 kcal · 70 g'}, approx:true},
|
||||
{id:'peas', name:{de:'Erbsen', en:'Peas'}, v:{de:'93 kcal · 10,4 g', en:'93 kcal · 10.4 g'}},
|
||||
{id:'oats', name:{de:'Haferflocken', en:'Oats'}, v:{de:'372 kcal · 66,3 g', en:'372 kcal · 66.3 g'}},
|
||||
{id:'millet', name:{de:'Hirse', en:'Millet'}, v:{de:'363 kcal · 69 g', en:'363 kcal · 69 g'}, f:'caution'},
|
||||
{id:'chickpeas', name:{de:'Kichererbsen', en:'Chickpeas'}, v:{de:'108 kcal · 14,2 g', en:'108 kcal · 14.2 g'}},
|
||||
{id:'chickpea_pasta', name:{de:'Kichererbsennudeln / -couscous', en:'Chickpea pasta / couscous'}, v:{de:'≈335 kcal · 50 g', en:'≈335 kcal · 50 g'}, approx:true},
|
||||
{id:'plantain', name:{de:'Kochbanane', en:'Plantain'}, v:{de:'≈122 kcal · 32 g', en:'≈122 kcal · 32 g'}, approx:true, f:'caution'},
|
||||
{id:'lentil_pasta', name:{de:'Linsennudeln', en:'Lentil pasta'}, v:{de:'≈335 kcal · 52 g', en:'≈335 kcal · 52 g'}, approx:true},
|
||||
{id:'quinoa', name:{de:'Quinoa', en:'Quinoa'}, v:{de:'368 kcal · 64,2 g', en:'368 kcal · 64.2 g'}},
|
||||
{id:'red_lentils', name:{de:'Rote Linsen', en:'Red lentils'}, v:{de:'326 kcal · ≈52 g', en:'326 kcal · ≈52 g'}, approx:true, f:'pick'},
|
||||
{id:'sweet_potato', name:{de:'Süßkartoffel', en:'Sweet potato'}, v:{de:'86 kcal · 20,1 g', en:'86 kcal · 20.1 g'}, f:'caution'},
|
||||
{id:'french_lentils', name:{de:'Tellerlinsen', en:'French lentils'}, v:{de:'234 kcal · ≈34 g', en:'234 kcal · ≈34 g'}, approx:true, f:'pick'}
|
||||
]}
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// state
|
||||
// ---------------------------------------------------------------------------
|
||||
let persistWorks = true;
|
||||
function loadLang(){
|
||||
try{
|
||||
const v = localStorage.getItem('pp_lang');
|
||||
return v === 'en' ? 'en' : 'de';
|
||||
}catch(e){ persistWorks = false; return 'de'; }
|
||||
}
|
||||
function saveLang(l){
|
||||
try{ localStorage.setItem('pp_lang', l); }catch(e){ persistWorks = false; }
|
||||
}
|
||||
const state = { lang: loadLang() };
|
||||
const builderState = { meal:'full', sel:{ protein:[], obstgem:[], fett:[], kh:[] } };
|
||||
function t(key){ return UI[state.lang][key]; }
|
||||
function tr(obj){ return obj[state.lang]; }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// render helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function dot(f){
|
||||
if(f==='pick') return `<span class="dotmark pick">●</span>`;
|
||||
if(f==='caution') return `<span class="dotmark caution">●</span>`;
|
||||
return '';
|
||||
}
|
||||
function rowsHtml(items){
|
||||
return items.map(it=>`<tr><td>${tr(it.name)}</td><td class="${it.approx?'approx':''}">${dot(it.f)}${tr(it.v)}</td></tr>`).join('');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// builder helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function catItems(id){
|
||||
const c = CATS.find(x=>x.id===id);
|
||||
return c.sub ? c.items.concat(c.sub.items) : c.items;
|
||||
}
|
||||
function obstGemItems(){
|
||||
return catItems('obst').concat(catItems('gemuese'));
|
||||
}
|
||||
function findItem(slot, id){
|
||||
const list = slot==='protein' ? catItems('protein')
|
||||
: slot==='obstgem' ? obstGemItems()
|
||||
: slot==='fett' ? catItems('fett')
|
||||
: catItems('kh');
|
||||
return list.find(it=>it.id===id);
|
||||
}
|
||||
|
||||
function renderHome(){
|
||||
document.getElementById('homegrid').innerHTML = `
|
||||
<button class="tile teller" onclick="showView('teller')">
|
||||
<span class="ic">🍽️</span>
|
||||
<div><div class="t">${t('tileTellerT')}</div><div class="d">${t('tileTellerD')}</div></div>
|
||||
</button>
|
||||
<button class="tile vorrat" onclick="showView('vorrat')">
|
||||
<span class="ic">🧺</span>
|
||||
<div><div class="t">${t('tileVorratT')}</div><div class="d">${t('tileVorratD')}</div></div>
|
||||
</button>
|
||||
<button class="tile builder" onclick="showView('builder')">
|
||||
<span class="ic">🧩</span>
|
||||
<div><div class="t">${t('tileBuilderT')}</div><div class="d">${t('tileBuilderD')}</div></div>
|
||||
</button>
|
||||
<button class="tile settings" onclick="showView('settings')">
|
||||
<span class="ic">⚙️</span>
|
||||
<div><div class="t">${t('tileSettingsT')}</div><div class="d">${t('tileSettingsD')}</div></div>
|
||||
</button>
|
||||
<button class="tile soon" disabled>
|
||||
<span class="ic">🍬</span>
|
||||
<div><div class="t">${t('tileSugarT')}</div><div class="d">${t('tileSugarD')}</div></div>
|
||||
</button>
|
||||
<button class="tile soon" disabled>
|
||||
<span class="ic">✅</span>
|
||||
<div><div class="t">${t('tileCheckT')}</div><div class="d">${t('tileCheckD')}</div></div>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSteps(){
|
||||
document.getElementById('stepsList').innerHTML = STEPS.map(s=>`
|
||||
<div class="acc" id="step-${s.n}">
|
||||
<button class="acc-head" onclick="toggleAcc('step-${s.n}')">
|
||||
<span class="num ${s.cls}">${s.n}</span>
|
||||
<span class="title">${tr(s.title)}</span>
|
||||
${tr(s.qty) ? `<span class="qty">${tr(s.qty)}</span>` : ''}
|
||||
<span class="chev">›</span>
|
||||
</button>
|
||||
<div class="acc-body"><p>${tr(s.text)}</p></div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderTellerStatic(){
|
||||
document.getElementById('segFull').textContent = t('segFull');
|
||||
document.getElementById('segHalf').textContent = t('segHalf');
|
||||
document.getElementById('legendFull').innerHTML = `
|
||||
<span><i class="dot g"></i>${t('legFullP')}</span>
|
||||
<span><i class="dot t"></i>${t('legFullVO')}</span>
|
||||
<span><i class="dot o"></i><i class="dot s"></i>${t('legFullFK')}</span>
|
||||
`;
|
||||
document.getElementById('legendHalf').innerHTML = `
|
||||
<span><i class="dot g"></i>${t('legHalfP')}</span>
|
||||
<span><i class="dot t"></i>${t('legHalfVO')}</span>
|
||||
`;
|
||||
document.getElementById('tellerRules').innerHTML = ['rule1','rule2','rule3','rule4']
|
||||
.map(k=>`<div class="rule">${t(k)}</div>`).join('');
|
||||
}
|
||||
|
||||
function renderCats(filter){
|
||||
const f = (filter||'').trim().toLowerCase();
|
||||
const html = CATS.map(c=>{
|
||||
let items = c.items;
|
||||
let subitems = c.sub ? c.sub.items : null;
|
||||
let matchCount = items.length + (subitems?subitems.length:0);
|
||||
let forceOpen = false;
|
||||
if(f){
|
||||
items = c.items.filter(it=>tr(it.name).toLowerCase().includes(f));
|
||||
subitems = subitems ? subitems.filter(it=>tr(it.name).toLowerCase().includes(f)) : null;
|
||||
matchCount = items.length + (subitems?subitems.length:0);
|
||||
forceOpen = matchCount>0;
|
||||
if(matchCount===0) return '';
|
||||
}
|
||||
return `
|
||||
<div class="acc ${c.cls} ${forceOpen?'open':''}" id="cat-${c.id}">
|
||||
<button class="acc-head cat-head" onclick="toggleAcc('cat-${c.id}')">
|
||||
<span class="ic">${c.ic}</span>
|
||||
<span class="title">${tr(c.title)}</span>
|
||||
<span class="count">${matchCount}</span>
|
||||
<span class="chev">›</span>
|
||||
</button>
|
||||
<div class="acc-body">
|
||||
<table>${rowsHtml(items)}</table>
|
||||
${subitems && subitems.length ? `<p class="subhead">${tr(c.sub.title)}</p><table>${rowsHtml(subitems)}</table>` : ''}
|
||||
${c.recipe ? `<div class="recipe"><b>${tr(c.recipe.title)}</b><br>${tr(c.recipe.text)}</div>` : ''}
|
||||
${c.note ? `<p class="subhead" style="text-transform:none;font-weight:400;">${tr(c.note)}</p>` : ''}
|
||||
<div class="rot"><b>${c.thumbLabel ? tr(c.thumbLabel) : t('thumbDefault')}</b><br>${tr(c.thumb)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
document.getElementById('catList').innerHTML = html || `<p style="color:var(--ink-soft); font-size:0.85rem;">${t('noResults')}</p>`;
|
||||
}
|
||||
|
||||
function renderVorratStatic(){
|
||||
document.getElementById('searchInput').placeholder = t('searchPlaceholder');
|
||||
document.getElementById('unitnote').innerHTML =
|
||||
`${t('unitnote')} <span class="dotmark pick">●</span> ${t('pickLabel')} · <span class="dotmark caution">●</span> ${t('cautionLabel')}`;
|
||||
}
|
||||
|
||||
function renderBuilder(){
|
||||
document.getElementById('bSegFull').textContent = t('segFull');
|
||||
document.getElementById('bSegHalf').textContent = t('segHalf');
|
||||
|
||||
const slots = builderState.meal==='full'
|
||||
? [['protein','pickProtein'],['obstgem','pickObstGem'],['fett','pickFett'],['kh','pickKh']]
|
||||
: [['protein','pickProtein'],['obstgem','pickObstGem']];
|
||||
|
||||
document.getElementById('builderPickers').innerHTML = slots.map(([slot,labelKey])=>{
|
||||
const list = slot==='protein' ? catItems('protein')
|
||||
: slot==='obstgem' ? obstGemItems()
|
||||
: slot==='fett' ? catItems('fett')
|
||||
: catItems('kh');
|
||||
const chips = list.map(it=>{
|
||||
const on = builderState.sel[slot].includes(it.id);
|
||||
return `<button class="chip ${on?'on':''}" onclick="toggleBuilderSel('${slot}','${it.id}')">${dot(it.f)}${tr(it.name)}</button>`;
|
||||
}).join('');
|
||||
const count = builderState.sel[slot].length;
|
||||
return `
|
||||
<div class="builder-row ${slot}">
|
||||
<div class="rowhead">
|
||||
<label>${t(labelKey)}</label>
|
||||
${count ? `<span class="rcount">${count}×</span>` : ''}
|
||||
</div>
|
||||
<div class="chip-grid">${chips}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
evaluateBuilder();
|
||||
}
|
||||
|
||||
function toggleBuilderSel(slot, id){
|
||||
const arr = builderState.sel[slot];
|
||||
const idx = arr.indexOf(id);
|
||||
if(idx===-1) arr.push(id); else arr.splice(idx,1);
|
||||
renderBuilder();
|
||||
}
|
||||
|
||||
function setBuilderMeal(which){
|
||||
builderState.meal = which;
|
||||
document.getElementById('bSegFull').classList.toggle('active', which==='full');
|
||||
document.getElementById('bSegHalf').classList.toggle('active', which==='half');
|
||||
renderBuilder();
|
||||
}
|
||||
|
||||
function evaluateBuilder(){
|
||||
const required = builderState.meal==='full' ? ['protein','obstgem','fett','kh'] : ['protein','obstgem'];
|
||||
const missing = required.filter(k=>builderState.sel[k].length===0);
|
||||
const box = document.getElementById('builderResult');
|
||||
const labelKeys = {protein:'pickProtein', obstgem:'pickObstGem', fett:'pickFett', kh:'pickKh'};
|
||||
|
||||
if(missing.length){
|
||||
box.className = 'builder-result neutral';
|
||||
box.innerHTML = `<div class="bh">${t('bResultMissingH')}</div>
|
||||
<div>${t('bResultMissingList')} ${missing.map(k=>t(labelKeys[k])).join(', ')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const items = [];
|
||||
required.forEach(k=>builderState.sel[k].forEach(id=>items.push({slot:k, item:findItem(k, id)})));
|
||||
const cautions = items.filter(x=>x.item.f==='caution');
|
||||
const picks = items.filter(x=>x.item.f==='pick');
|
||||
const isSnack = builderState.meal==='half';
|
||||
|
||||
if(cautions.length){
|
||||
box.className = 'builder-result caution';
|
||||
box.innerHTML = `<div class="bh">🧡 ${t('bResultCautionH')}</div>
|
||||
<div>${t('bResultCautionList')}</div>
|
||||
<ul>${cautions.map(x=>`<li>${tr(x.item.name)}</li>`).join('')}</ul>
|
||||
<div style="margin-top:6px;">${t('bResultCautionNote')}</div>`;
|
||||
} else if(picks.length){
|
||||
box.className = 'builder-result pick';
|
||||
box.innerHTML = `<div class="bh">✅ ${isSnack ? t('bResultPickHSnack') : t('bResultPickH')}</div>
|
||||
<div>${t('bResultPickList')}</div>
|
||||
<ul>${picks.map(x=>`<li>${tr(x.item.name)}</li>`).join('')}</ul>`;
|
||||
} else {
|
||||
box.className = 'builder-result solid';
|
||||
box.innerHTML = `<div class="bh">👍 ${isSnack ? t('bResultSolidHSnack') : t('bResultSolidH')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSettings(){
|
||||
document.getElementById('setLangLabel').textContent = t('setLangLabel');
|
||||
document.getElementById('setMoreLabel').textContent = t('setMoreLabel');
|
||||
document.getElementById('setMoreNote').textContent = t('setMoreNote');
|
||||
const note = document.getElementById('persistNote');
|
||||
note.textContent = persistWorks ? t('persistOk') : t('persistWarn');
|
||||
note.className = 'setnote ' + (persistWorks ? 'ok' : 'warn');
|
||||
}
|
||||
|
||||
function renderLangPills(){
|
||||
const de = state.lang==='de';
|
||||
['pillDe','pillDe2'].forEach(id=>document.getElementById(id).classList.toggle('active', de));
|
||||
['pillEn','pillEn2'].forEach(id=>document.getElementById(id).classList.toggle('active', !de));
|
||||
}
|
||||
|
||||
function renderAll(){
|
||||
renderLangPills();
|
||||
renderHome();
|
||||
renderSteps();
|
||||
renderTellerStatic();
|
||||
renderVorratStatic();
|
||||
renderCats(document.getElementById('searchInput').value);
|
||||
renderBuilder();
|
||||
renderSettings();
|
||||
refreshTopTitle();
|
||||
}
|
||||
|
||||
function setLang(l){
|
||||
if(l===state.lang) return;
|
||||
state.lang = l;
|
||||
saveLang(l);
|
||||
renderAll();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
function toggleAcc(id){
|
||||
document.getElementById(id).classList.toggle('open');
|
||||
}
|
||||
|
||||
function setMeal(which){
|
||||
const isFull = which==='full';
|
||||
document.getElementById('segFull').classList.toggle('active', isFull);
|
||||
document.getElementById('segHalf').classList.toggle('active', !isFull);
|
||||
document.getElementById('plateFull').style.display = isFull?'block':'none';
|
||||
document.getElementById('plateHalf').style.display = isFull?'none':'block';
|
||||
document.getElementById('legendFull').style.display = isFull?'flex':'none';
|
||||
document.getElementById('legendHalf').style.display = isFull?'none':'flex';
|
||||
}
|
||||
|
||||
let currentView = 'home';
|
||||
function refreshTopTitle(){
|
||||
const back = document.getElementById('backBtn');
|
||||
const eyebrow = document.getElementById('topEyebrow');
|
||||
const title = document.getElementById('topTitle');
|
||||
if(currentView==='home'){
|
||||
back.classList.remove('show');
|
||||
eyebrow.textContent = t('body');
|
||||
title.textContent = t('appTitle');
|
||||
} else {
|
||||
back.classList.add('show');
|
||||
if(currentView==='teller'){ eyebrow.textContent = t('card1'); title.textContent = t('tileTellerT'); }
|
||||
else if(currentView==='vorrat'){ eyebrow.textContent = t('card2'); title.textContent = t('tileVorratT'); }
|
||||
else if(currentView==='builder'){ eyebrow.textContent = t('appTitle'); title.textContent = t('cardBuilder'); }
|
||||
else if(currentView==='settings'){ eyebrow.textContent = t('appTitle'); title.textContent = t('cardSettings'); }
|
||||
}
|
||||
}
|
||||
|
||||
function showView(name){
|
||||
currentView = name;
|
||||
document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));
|
||||
document.getElementById('view-'+name).classList.add('active');
|
||||
refreshTopTitle();
|
||||
window.scrollTo(0,0);
|
||||
history.pushState({view:name}, '', '#'+name);
|
||||
}
|
||||
function goHome(){ showView('home'); }
|
||||
|
||||
function onSearch(){
|
||||
const val = document.getElementById('searchInput').value;
|
||||
document.getElementById('clearBtn').style.display = val ? 'block' : 'none';
|
||||
renderCats(val);
|
||||
}
|
||||
function clearSearch(){
|
||||
document.getElementById('searchInput').value='';
|
||||
document.getElementById('clearBtn').style.display='none';
|
||||
renderCats('');
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', (e)=>{
|
||||
const v = (e.state && e.state.view) || 'home';
|
||||
currentView = v;
|
||||
document.querySelectorAll('.view').forEach(el=>el.classList.remove('active'));
|
||||
document.getElementById('view-'+v).classList.add('active');
|
||||
refreshTopTitle();
|
||||
});
|
||||
|
||||
renderAll();
|
||||
</script>
|
||||
Reference in New Issue
Block a user