Initial commit: PRD, build tooling, and design mockups

Foodster is a self-hosted dinner log and meal suggester for one household.
Stage 1 (eating history) is in development; stage 2 (the suggester) follows
once there is enough history to weight against.

Replace the PRD's original stack (Nuxt, Postgres, Drizzle, Pico CSS) with
Go 1.27, net/http, templ, Datastar and SQLite — one static binary, no
Node.js in the build. Sections 3, 4, 5, 9, 10 and 11 are rewritten to match.

Record the decisions made while reviewing mockups:

- the UI is written in Finnish; PRD, code and comments stay English
- access is one shared password over HTTP Basic rather than open on the LAN
- images are CalVer vYYYYMMDD-N, built with Podman, run under Docker Compose
- registry coordinates live in .env, so no infrastructure detail is
  committed and the MIT publication option stays open

mockups/ holds standalone HTML design studies. log-fi.html is the current
one; the others are superseded exploration kept for reference.
This commit is contained in:
Esa Kataja
2026-09-05 17:35:37 +03:00
commit 6ed047aafd
13 changed files with 2067 additions and 0 deletions
+440
View File
@@ -0,0 +1,440 @@
<!doctype html>
<html lang="en" data-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Foodster — stage 1 log · mobile layouts</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700&family=Martian+Mono:wght@300;500&display=swap" rel="stylesheet">
<style>
/* ============ shared skin ============ */
:root{
color-scheme: light dark;
--paper: light-dark(#ECEDE8, #121316);
--card: light-dark(#FFFFFF, #1C1E22);
--sunk: light-dark(#E3E4DE, #17181B);
--ink: light-dark(#14161A, #E9EAE5);
--muted: light-dark(#6B6F6A, #8B8F89);
--line: light-dark(#D5D6D0, #2C2F34);
--accent:light-dark(#15616D, #58C6D2);
--onacc: light-dark(#FFFFFF, #0C1417);
--meat: light-dark(#AF4230, #DE7561);
--chicken: light-dark(#B57E10, #DFA83B);
--fish: light-dark(#25688F, #63AAD8);
--vegetarian:light-dark(#457A3C, #82BE7A);
--tap: 48px; /* minimum touch target */
}
html[data-theme="light"]{color-scheme:only light;}
html[data-theme="dark"] {color-scheme:only dark;}
*{box-sizing:border-box; -webkit-tap-highlight-color:transparent;}
body{margin:0; background:var(--sunk); color:var(--ink);
font-family:Archivo,system-ui,sans-serif; font-size:16px; line-height:1.45;
-webkit-font-smoothing:antialiased;}
button,input{font-family:inherit;}
:focus-visible{outline:2.5px solid var(--accent); outline-offset:2px;}
@media (prefers-reduced-motion:reduce){*{transition:none!important;}}
.dot{width:9px; height:9px; border-radius:2px; flex:none; display:inline-block;}
.d-meat{background:var(--meat);} .d-chicken{background:var(--chicken);}
.d-fish{background:var(--fish);} .d-vegetarian{background:var(--vegetarian);}
.d-wild{background:conic-gradient(var(--meat) 0 25%,var(--chicken) 25% 50%,
var(--fish) 50% 75%,var(--vegetarian) 75% 100%);}
/* ============ comparison rig (desktop only) ============ */
.rig{padding:20px; max-width:1400px; margin:0 auto;}
.rigtop{display:flex; align-items:center; gap:14px; flex-wrap:wrap; margin-bottom:18px;}
.rigtop h1{font-size:17px; font-weight:700; letter-spacing:-.02em; margin:0;}
.rigtop p{margin:0; font-size:13px; color:var(--muted);}
.themebtn{font-size:12px; background:var(--card); border:1px solid var(--line);
color:var(--muted); padding:7px 12px; border-radius:7px; cursor:pointer; margin-left:auto;}
.switch{display:none; gap:6px; margin-bottom:14px; flex-wrap:wrap;}
.switch button{font-size:14px; font-weight:600; background:var(--card);
border:1px solid var(--line); color:var(--muted); padding:10px 14px;
border-radius:8px; cursor:pointer; flex:1;}
.switch button[aria-pressed="true"]{background:var(--accent); border-color:var(--accent); color:var(--onacc);}
.frames{display:flex; gap:22px; align-items:flex-start; overflow-x:auto; padding-bottom:14px;}
.framewrap{flex:none;}
.framelbl{font-size:13px; font-weight:600; margin:0 0 9px; letter-spacing:-.01em;}
.framelbl span{font-weight:400; color:var(--muted);}
.phone{width:390px; height:788px; background:var(--paper); border:1px solid var(--line);
border-radius:26px; overflow:hidden; display:flex; flex-direction:column;}
/* ============ in-app chrome ============ */
.screen{flex:1; overflow-y:auto; overscroll-behavior:contain;}
.appbar{position:sticky; top:0; z-index:5; background:var(--paper);
border-bottom:1px solid var(--line); padding:14px 16px 12px;}
.appbar h2{margin:0; font-size:22px; font-weight:700; letter-spacing:-.03em;}
.appbar .meta{font-size:12.5px; color:var(--muted); margin:2px 0 0;}
.pad{padding:16px;}
.tabbar{flex:none; display:flex; background:var(--card); border-top:1px solid var(--line);
padding-bottom:env(safe-area-inset-bottom);}
.tabbar a{flex:1; text-align:center; text-decoration:none; color:var(--muted);
font-size:11.5px; font-weight:600; padding:11px 0 13px; letter-spacing:.01em;}
.tabbar a[aria-current]{color:var(--accent);}
.tabbar i{display:block; font-style:normal; font-size:18px; line-height:1.3;}
.primary{display:block; width:100%; min-height:var(--tap); background:var(--accent);
color:var(--onacc); border:0; border-radius:11px; font-size:16.5px; font-weight:700;
cursor:pointer; letter-spacing:-.01em;}
.ghost{display:block; width:100%; min-height:var(--tap); background:none; color:var(--muted);
border:0; font-size:15px; cursor:pointer;}
/* ============ 1 · CALENDAR-LED ============ */
.mini{display:grid; grid-template-columns:repeat(7,1fr); gap:4px;}
.mini .wn{font-size:9.5px; letter-spacing:.06em; text-transform:uppercase;
color:var(--muted); text-align:center; padding-bottom:3px;}
.mini button{aspect-ratio:1; background:var(--card); border:1px solid var(--line);
border-radius:8px; cursor:pointer; display:flex; flex-direction:column;
align-items:center; justify-content:center; gap:4px; padding:0; color:var(--muted);
font-size:11px;}
.mini button .dot{width:11px; height:11px; border-radius:3px;}
.mini button.empty{background:none; border-style:dashed;}
.mini button.empty::after{content:"+"; font-size:14px; opacity:.45;}
.mini button.today{border:2px solid var(--accent); color:var(--accent); font-weight:700;}
.mini button.future{opacity:.3; border-color:transparent; background:none;}
.tally{display:flex; align-items:baseline; gap:8px; margin:14px 0 0; font-size:12.5px;
color:var(--muted);}
.tally b{color:var(--ink); font-size:14px;}
.bar{flex:1; height:5px; border-radius:3px; background:var(--line); overflow:hidden;}
.bar i{display:block; height:100%; background:var(--accent);}
.tonight{background:var(--card); border:1px solid var(--line); border-radius:13px;
padding:16px; margin:18px 0 0;}
.tonight .k{font-family:"Martian Mono",monospace; font-size:10px; letter-spacing:.08em;
text-transform:uppercase; color:var(--muted); margin:0 0 9px;}
.dayrow{display:flex; gap:12px; align-items:center; padding:13px 0;
border-bottom:1px solid var(--line); min-height:var(--tap);}
.dayrow time{font-family:"Martian Mono",monospace; font-size:11px; color:var(--muted);
min-width:56px; flex:none;}
.dayrow .nm{font-size:16px; font-weight:600; letter-spacing:-.02em;}
.dayrow .sd{font-size:12.5px; color:var(--muted);}
.dayrow .chev{margin-left:auto; color:var(--muted); font-size:18px;}
.sechead{font-family:"Martian Mono",monospace; font-size:10px; letter-spacing:.09em;
text-transform:uppercase; color:var(--muted); margin:24px 0 4px;}
/* ============ 2 · TAP BOARD ============ */
.dayseg{display:flex; gap:6px; margin-top:11px;}
.dayseg button{flex:1; min-height:42px; font-size:14px; font-weight:600; background:var(--card);
border:1px solid var(--line); color:var(--ink); border-radius:9px; cursor:pointer;}
.dayseg button[aria-pressed="true"]{background:var(--ink); border-color:var(--ink); color:var(--paper);}
.dayseg input{flex:none; width:52px; font-size:16px; background:var(--card); color:var(--ink);
border:1px solid var(--line); border-radius:9px; padding:0 8px;}
.filter{width:100%; min-height:var(--tap); font-size:16px; background:var(--card);
color:var(--ink); border:1px solid var(--line); border-radius:10px;
padding:0 14px; margin-bottom:14px;}
.board{display:flex; flex-wrap:wrap; gap:8px;}
.pill{display:flex; align-items:center; gap:8px; background:var(--card);
border:1px solid var(--line); border-radius:11px; cursor:pointer; color:var(--ink);
font-weight:600; letter-spacing:-.022em; min-height:var(--tap);}
.pill .n{font-family:"Martian Mono",monospace; font-weight:300; color:var(--muted); font-size:10px;}
.pill.xl{font-size:22px; padding:14px 18px; flex:1 1 100%;}
.pill.lg{font-size:18px; padding:12px 16px;}
.pill.md{font-size:15.5px; padding:11px 14px;}
.pill.sm{font-size:14px; padding:10px 13px; color:var(--muted);}
.pill.picked{background:var(--accent); border-color:var(--accent); color:var(--onacc);}
.pill.picked .n{color:var(--onacc); opacity:.65;}
.sheetwrap{position:relative;}
.sheet{position:absolute; left:0; right:0; bottom:0; z-index:9; background:var(--card);
border-top:1px solid var(--line); border-radius:18px 18px 0 0; padding:8px 16px 16px;
box-shadow:0 -10px 30px -14px rgba(0,0,0,.4);}
.grab{width:38px; height:4px; border-radius:3px; background:var(--line); margin:0 auto 13px;}
.sheet h3{margin:0 0 2px; font-size:19px; letter-spacing:-.025em;}
.sheet .q{margin:0 0 12px; font-size:13px; color:var(--muted);}
.chips{display:flex; gap:7px; flex-wrap:wrap; margin-bottom:14px;}
.chips button{font-size:14.5px; min-height:42px; background:var(--sunk);
border:1px solid var(--line); color:var(--ink); padding:0 14px;
border-radius:999px; cursor:pointer;}
.chips button[aria-pressed="true"]{background:var(--accent); border-color:var(--accent); color:var(--onacc);}
/* ============ 3 · JOURNAL ============ */
.jtonight{background:var(--accent); color:var(--onacc); border-radius:14px;
padding:16px; margin-bottom:20px;}
.jtonight .k{font-family:"Martian Mono",monospace; font-size:10px; letter-spacing:.09em;
text-transform:uppercase; opacity:.75; margin:0 0 5px;}
.jtonight p{margin:0 0 13px; font-size:19px; font-weight:600; letter-spacing:-.025em;}
.jtonight button{width:100%; min-height:var(--tap); background:var(--onacc); color:var(--accent);
border:0; border-radius:10px; font-size:16px; font-weight:700; cursor:pointer;}
.entry{display:flex; gap:12px; align-items:center; padding:14px 0;
border-bottom:1px solid var(--line); min-height:var(--tap);}
.entry time{font-family:"Martian Mono",monospace; font-size:11px; color:var(--muted);
min-width:52px; flex:none;}
.entry .nm{font-size:17px; font-weight:600; letter-spacing:-.025em;
display:flex; align-items:center; gap:8px;}
.entry .sd{font-size:12.5px; color:var(--muted); margin-top:1px;}
.entry .chev{margin-left:auto; color:var(--muted); font-size:19px; flex:none;}
.gapline{display:flex; gap:12px; align-items:center; padding:12px 0;
border-bottom:1px dashed var(--line); font-size:13.5px; color:var(--muted);
min-height:var(--tap);}
.gapline time{font-family:"Martian Mono",monospace; font-size:11px; min-width:52px; flex:none;}
.gapline button{margin-left:auto; font-size:13.5px; background:none; border:0;
color:var(--accent); font-weight:600; cursor:pointer; min-height:38px; padding:0 4px;}
.monthrule{font-family:"Martian Mono",monospace; font-size:10px; letter-spacing:.12em;
text-transform:uppercase; color:var(--muted); padding:22px 0 5px;}
/* ============ real phone: drop the rig, go full bleed ============ */
@media (max-width:900px){
body{background:var(--paper);}
.rig{padding:0;}
.rigtop{padding:12px 16px 0; margin-bottom:10px;}
.switch{display:flex; padding:0 16px;}
.frames{display:block; overflow:visible; padding:0;}
.framewrap{display:none;}
.framewrap.on{display:block;}
.framelbl{display:none;}
.phone{width:auto; height:calc(100svh - 118px); border:0; border-radius:0;}
}
</style>
</head>
<body>
<div class="rig">
<div class="rigtop">
<h1>Foodster · stage 1 log</h1>
<p>390&nbsp;px. Three structures, one skin.</p>
<button class="themebtn" id="t">Theme: auto</button>
</div>
<div class="switch">
<button aria-pressed="true" data-v="f1">Calendar</button>
<button aria-pressed="false" data-v="f2">Tap board</button>
<button aria-pressed="false" data-v="f3">Journal</button>
</div>
<div class="frames">
<!-- ================= 1 · CALENDAR-LED ================= -->
<div class="framewrap on" id="f1">
<p class="framelbl">1 · Calendar-led <span>— history first, calendar navigates</span></p>
<div class="phone">
<div class="screen">
<div class="appbar">
<h2>What we ate</h2>
<p class="meta">Last five weeks · 3 Aug 6 Sep</p>
</div>
<div class="pad">
<div class="mini" aria-hidden="true">
<div class="wn">M</div><div class="wn">T</div><div class="wn">W</div><div class="wn">T</div>
<div class="wn">F</div><div class="wn">S</div><div class="wn">S</div>
</div>
<div class="mini">
<button>3<i class="dot d-meat"></i></button>
<button>4<i class="dot d-fish"></i></button>
<button class="empty">5</button>
<button>6<i class="dot d-meat"></i></button>
<button>7<i class="dot d-wild"></i></button>
<button>8<i class="dot d-vegetarian"></i></button>
<button>9<i class="dot d-fish"></i></button>
<button>10<i class="dot d-chicken"></i></button>
<button>11<i class="dot d-vegetarian"></i></button>
<button>12<i class="dot d-meat"></i></button>
<button class="empty">13</button>
<button>14<i class="dot d-chicken"></i></button>
<button>15<i class="dot d-meat"></i></button>
<button>16<i class="dot d-fish"></i></button>
<button>17<i class="dot d-meat"></i></button>
<button>18<i class="dot d-vegetarian"></i></button>
<button>19<i class="dot d-meat"></i></button>
<button class="empty">20</button>
<button>21<i class="dot d-wild"></i></button>
<button>22<i class="dot d-chicken"></i></button>
<button>23<i class="dot d-meat"></i></button>
<button>24<i class="dot d-fish"></i></button>
<button>25<i class="dot d-vegetarian"></i></button>
<button>26<i class="dot d-meat"></i></button>
<button>27<i class="dot d-chicken"></i></button>
<button class="empty">28</button>
<button>29<i class="dot d-meat"></i></button>
<button>30<i class="dot d-vegetarian"></i></button>
<button>31<i class="dot d-meat"></i></button>
<button>1<i class="dot d-chicken"></i></button>
<button>2<i class="dot d-fish"></i></button>
<button>3<i class="dot d-meat"></i></button>
<button class="empty">4</button>
<button class="today">5</button>
<button class="future">6</button>
</div>
<div class="tally">
<b>30</b><span>of 35 logged</span>
<span class="bar"><i style="width:86%"></i></span>
</div>
<div class="tonight">
<p class="k">Tonight · Sat 5 Sep</p>
<button class="primary">Log tonight's dinner</button>
</div>
<p class="sechead">Recent</p>
<div class="dayrow">
<time>Thu 3</time>
<div><div class="nm">Lihapullat</div><div class="sd">Vihersalaatti</div></div>
<span class="chev"></span>
</div>
<div class="dayrow">
<time>Wed 2</time>
<div><div class="nm">Lohikeitto</div><div class="sd">Ruisleipä</div></div>
<span class="chev"></span>
</div>
<div class="dayrow">
<time>Tue 1</time>
<div><div class="nm">Kanacurry</div><div class="sd">Riisi</div></div>
<span class="chev"></span>
</div>
</div>
</div>
<nav class="tabbar">
<a href="#" aria-current="page"><i></i>Log</a>
<a href="#"><i></i>History</a>
<a href="#"><i></i>Catalog</a>
</nav>
</div>
</div>
<!-- ================= 2 · TAP BOARD ================= -->
<div class="framewrap" id="f2">
<p class="framelbl">2 · Tap board <span>— fastest entry, sheet in the thumb zone</span></p>
<div class="phone">
<div class="screen sheetwrap">
<div class="appbar">
<h2>Tap what you ate</h2>
<div class="dayseg">
<button aria-pressed="true">Today</button>
<button aria-pressed="false">Yesterday</button>
<input type="date" value="2026-09-05" aria-label="Another date">
</div>
</div>
<div class="pad" style="padding-bottom:270px">
<input class="filter" type="search" placeholder="Filter dishes…" aria-label="Filter dishes">
<div class="board">
<button class="pill xl"><i class="dot d-meat"></i>Lihapullat <span class="n">12</span></button>
<button class="pill xl"><i class="dot d-meat"></i>Jauhelihakastike <span class="n">11</span></button>
<button class="pill lg picked"><i class="dot d-fish"></i>Lohikeitto <span class="n">9</span></button>
<button class="pill lg"><i class="dot d-chicken"></i>Kanacurry <span class="n">8</span></button>
<button class="pill lg"><i class="dot d-meat"></i>Makaronilaatikko <span class="n">7</span></button>
<button class="pill lg"><i class="dot d-chicken"></i>Broilerikastike <span class="n">6</span></button>
<button class="pill md"><i class="dot d-fish"></i>Uunilohi <span class="n">5</span></button>
<button class="pill md"><i class="dot d-vegetarian"></i>Kasvispyörykät <span class="n">5</span></button>
<button class="pill md"><i class="dot d-wild"></i>Tortillat <span class="n">4</span></button>
<button class="pill md"><i class="dot d-vegetarian"></i>Hernekeitto <span class="n">4</span></button>
<button class="pill md"><i class="dot d-meat"></i>Uunimakkara <span class="n">3</span></button>
<button class="pill sm"><i class="dot d-meat"></i>Kaalilaatikko <span class="n">2</span></button>
<button class="pill sm"><i class="dot d-wild"></i>Kotipizza <span class="n">2</span></button>
<button class="pill sm"><i class="dot d-fish"></i>Kalapuikot <span class="n">1</span></button>
</div>
</div>
<div class="sheet">
<div class="grab"></div>
<h3>Lohikeitto</h3>
<p class="q">Anything on the side?</p>
<div class="chips">
<button aria-pressed="true">Ruisleipä</button>
<button aria-pressed="false">Perunamuusi</button>
<button aria-pressed="false">Riisi</button>
<button aria-pressed="false">Vihersalaatti</button>
</div>
<button class="primary">Save Saturday</button>
<button class="ghost">Cancel</button>
</div>
</div>
<nav class="tabbar">
<a href="#" aria-current="page"><i></i>Log</a>
<a href="#"><i></i>History</a>
<a href="#"><i></i>Catalog</a>
</nav>
</div>
</div>
<!-- ================= 3 · JOURNAL ================= -->
<div class="framewrap" id="f3">
<p class="framelbl">3 · Journal <span>— one column, gaps called out inline</span></p>
<div class="phone">
<div class="screen">
<div class="appbar">
<h2>Dinner journal</h2>
<p class="meta">One line per night</p>
</div>
<div class="pad">
<div class="jtonight">
<p class="k">Tonight · Sat 5 Sep</p>
<p>Nothing written down yet.</p>
<button>What did we eat?</button>
</div>
<div class="monthrule">September</div>
<div class="gapline"><time>Fri 4</time><span>Not logged</span><button>Fill in</button></div>
<div class="entry">
<time>Thu 3</time>
<div><div class="nm"><i class="dot d-meat"></i>Lihapullat</div><div class="sd">Vihersalaatti</div></div>
<span class="chev"></span>
</div>
<div class="entry">
<time>Wed 2</time>
<div><div class="nm"><i class="dot d-fish"></i>Lohikeitto</div><div class="sd">Ruisleipä</div></div>
<span class="chev"></span>
</div>
<div class="entry">
<time>Tue 1</time>
<div><div class="nm"><i class="dot d-chicken"></i>Kanacurry</div><div class="sd">Riisi</div></div>
<span class="chev"></span>
</div>
<div class="monthrule">August</div>
<div class="entry">
<time>Mon 31</time>
<div><div class="nm"><i class="dot d-meat"></i>Uunimakkara</div><div class="sd">Perunamuusi</div></div>
<span class="chev"></span>
</div>
<div class="entry">
<time>Sun 30</time>
<div><div class="nm"><i class="dot d-vegetarian"></i>Kasvispyörykät</div><div class="sd">Served on its own</div></div>
<span class="chev"></span>
</div>
<div class="entry">
<time>Sat 29</time>
<div><div class="nm"><i class="dot d-meat"></i>Lihapullat</div><div class="sd">Perunamuusi</div></div>
<span class="chev"></span>
</div>
<div class="gapline"><time>Fri 28</time><span>Not logged</span><button>Fill in</button></div>
<div class="entry">
<time>Thu 27</time>
<div><div class="nm"><i class="dot d-chicken"></i>Kanacurry</div><div class="sd">Riisi</div></div>
<span class="chev"></span>
</div>
</div>
</div>
<nav class="tabbar">
<a href="#" aria-current="page"><i></i>Log</a>
<a href="#"><i></i>History</a>
<a href="#"><i></i>Catalog</a>
</nav>
</div>
</div>
</div>
</div>
<script>
const h=document.documentElement, tb=document.getElementById('t'), m=['auto','light','dark'];
tb.onclick=()=>{const n=m[(m.indexOf(h.dataset.theme)+1)%3];h.dataset.theme=n;tb.textContent='Theme: '+n;};
document.querySelectorAll('.switch button').forEach(b=>b.onclick=()=>{
document.querySelectorAll('.switch button').forEach(x=>x.setAttribute('aria-pressed',x===b));
document.querySelectorAll('.framewrap').forEach(f=>f.classList.toggle('on',f.id===b.dataset.v));
});
document.querySelectorAll('.chips button,.dayseg button').forEach(b=>b.onclick=()=>
b.setAttribute('aria-pressed', b.getAttribute('aria-pressed')!=='true'));
</script>
</body>
</html>