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
+345
View File
@@ -0,0 +1,345 @@
<!doctype html>
<html lang="fi" data-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Foodster — kirjaus ja historia</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>
: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);
--warn: light-dark(#AF4230, #DE7561);
--liha: light-dark(#AF4230, #DE7561);
--kana: light-dark(#B57E10, #DFA83B);
--kala: light-dark(#25688F, #63AAD8);
--kasvis:light-dark(#457A3C, #82BE7A);
--tap: 48px;
}
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-liha{background:var(--liha);} .d-kana{background:var(--kana);}
.d-kala{background:var(--kala);} .d-kasvis{background:var(--kasvis);}
.d-sek{background:conic-gradient(var(--liha) 0 25%,var(--kana) 25% 50%,
var(--kala) 50% 75%,var(--kasvis) 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;}
.switch button{font-size:14px; font-weight:600; background:var(--card);
border:1px solid var(--line); color:var(--muted); padding:10px 12px;
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;}
/* ---- app chrome ---- */
.screen{flex:1; overflow-y:auto; overscroll-behavior:contain; position:relative;}
.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;}
.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;}
/* ---- 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;}
.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);}
/* ---- already-logged state ---- */
.logged{background:var(--card); border:1px solid var(--line); border-radius:14px; padding:18px;}
.logged .k{font-family:"Martian Mono",monospace; font-size:10px; letter-spacing:.09em;
text-transform:uppercase; color:var(--accent); margin:0 0 10px;}
.logged .nm{font-size:26px; font-weight:700; letter-spacing:-.035em; margin:0 0 3px;
display:flex; align-items:center; gap:10px;}
.logged .sd{font-size:14.5px; color:var(--muted); margin:0 0 16px;}
.logged .pair{display:flex; gap:8px;}
.logged .pair button{flex:1; min-height:var(--tap); font-size:15px; font-weight:600;
background:var(--sunk); border:1px solid var(--line); color:var(--ink);
border-radius:10px; cursor:pointer;}
.logged .pair button.del{color:var(--warn);}
.orlog{margin:22px 0 0; font-size:14px; color:var(--muted); text-align:center;}
.orlog button{background:none; border:0; color:var(--accent); font:inherit;
font-weight:600; text-decoration:underline; cursor:pointer; min-height:38px;}
/* ---- journal ---- */
.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:54px; 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:54px; 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;}
@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 · vaihe 1</h1>
<p>390&nbsp;px · Kirjaa, Historia, ja jo kirjattu tila</p>
<button class="themebtn" id="t">Teema: auto</button>
</div>
<div class="switch">
<button aria-pressed="true" data-v="f1">Kirjaa</button>
<button aria-pressed="false" data-v="f2">Kirjattu</button>
<button aria-pressed="false" data-v="f3">Historia</button>
</div>
<div class="frames">
<!-- ============ 1 · KIRJAA ============ -->
<div class="framewrap on" id="f1">
<p class="framelbl">1 · Kirjaa <span>— napauta ruoka, valitse lisukkeet</span></p>
<div class="phone">
<div class="screen">
<div class="appbar">
<h2>Mitä syötiin?</h2>
<div class="dayseg">
<button aria-pressed="true">Tänään</button>
<button aria-pressed="false">Eilen</button>
<input type="date" value="2026-09-05" aria-label="Muu päivä">
</div>
</div>
<div class="pad" style="padding-bottom:280px">
<input class="filter" type="search" placeholder="Etsi" aria-label="Etsi">
<div class="board">
<button class="pill xl"><i class="dot d-liha"></i>Lihapullat <span class="n">12</span></button>
<button class="pill xl"><i class="dot d-liha"></i>Jauhelihakastike <span class="n">11</span></button>
<button class="pill lg picked"><i class="dot d-kala"></i>Lohikeitto <span class="n">9</span></button>
<button class="pill lg"><i class="dot d-kana"></i>Kanacurry <span class="n">8</span></button>
<button class="pill lg"><i class="dot d-liha"></i>Makaronilaatikko <span class="n">7</span></button>
<button class="pill lg"><i class="dot d-kana"></i>Broilerikastike <span class="n">6</span></button>
<button class="pill md"><i class="dot d-kala"></i>Uunilohi <span class="n">5</span></button>
<button class="pill md"><i class="dot d-kasvis"></i>Kasvispyörykät <span class="n">5</span></button>
<button class="pill md"><i class="dot d-sek"></i>Tortillat <span class="n">4</span></button>
<button class="pill md"><i class="dot d-kasvis"></i>Hernekeitto <span class="n">4</span></button>
<button class="pill md"><i class="dot d-liha"></i>Uunimakkara <span class="n">3</span></button>
<button class="pill sm"><i class="dot d-liha"></i>Kaalilaatikko <span class="n">2</span></button>
<button class="pill sm"><i class="dot d-sek"></i>Kotipizza <span class="n">2</span></button>
<button class="pill sm"><i class="dot d-kala"></i>Kalapuikot <span class="n">1</span></button>
</div>
</div>
<div class="sheet">
<div class="grab"></div>
<h3>Lohikeitto</h3>
<p class="q">Lisukkeita?</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">Tallenna</button>
<button class="ghost">Peruuta</button>
</div>
</div>
<nav class="tabbar">
<a href="#" aria-current="page"><i></i>Kirjaa</a>
<a href="#"><i></i>Historia</a>
<a href="#"><i></i>Ruoat</a>
</nav>
</div>
</div>
<!-- ============ 2 · JO KIRJATTU ============ -->
<div class="framewrap" id="f2">
<p class="framelbl">2 · Päivä jo kirjattu <span>— yksi merkintä per päivä</span></p>
<div class="phone">
<div class="screen">
<div class="appbar">
<h2>Mitä syötiin?</h2>
<div class="dayseg">
<button aria-pressed="true">Tänään</button>
<button aria-pressed="false">Eilen</button>
<input type="date" value="2026-09-05" aria-label="Muu päivä">
</div>
</div>
<div class="pad">
<div class="logged">
<p class="k">Tänään kirjattu</p>
<p class="nm"><i class="dot d-kala"></i>Lohikeitto</p>
<p class="sd">Ruisleipä</p>
<div class="pair">
<button>Muokkaa</button>
<button class="del">Poista</button>
</div>
</div>
<p class="orlog">Väärä päivä? <button>Kirjaa toinen päivä</button></p>
</div>
</div>
<nav class="tabbar">
<a href="#" aria-current="page"><i></i>Kirjaa</a>
<a href="#"><i></i>Historia</a>
<a href="#"><i></i>Ruoat</a>
</nav>
</div>
</div>
<!-- ============ 3 · HISTORIA ============ -->
<div class="framewrap" id="f3">
<p class="framelbl">3 · Historia <span>— lista, ei kalenteria</span></p>
<div class="phone">
<div class="screen">
<div class="appbar">
<h2>Historia</h2>
</div>
<div class="pad">
<div class="monthrule">Syyskuu</div>
<div class="gapline"><time>pe 4.9.</time><span>Ei merkintää</span><button>Merkitse</button></div>
<div class="entry">
<time>to 3.9.</time>
<div><div class="nm"><i class="dot d-liha"></i>Lihapullat</div><div class="sd">Vihersalaatti</div></div>
<span class="chev"></span>
</div>
<div class="entry">
<time>ke 2.9.</time>
<div><div class="nm"><i class="dot d-kala"></i>Lohikeitto</div><div class="sd">Ruisleipä</div></div>
<span class="chev"></span>
</div>
<div class="entry">
<time>ti 1.9.</time>
<div><div class="nm"><i class="dot d-kana"></i>Kanacurry</div><div class="sd">Riisi</div></div>
<span class="chev"></span>
</div>
<div class="monthrule">Elokuu</div>
<div class="entry">
<time>ma 31.8.</time>
<div><div class="nm"><i class="dot d-liha"></i>Uunimakkara</div><div class="sd">Perunamuusi</div></div>
<span class="chev"></span>
</div>
<div class="entry">
<time>su 30.8.</time>
<div><div class="nm"><i class="dot d-kasvis"></i>Kasvispyörykät</div><div class="sd">Ei lisukkeita</div></div>
<span class="chev"></span>
</div>
<div class="entry">
<time>la 29.8.</time>
<div><div class="nm"><i class="dot d-liha"></i>Lihapullat</div><div class="sd">Perunamuusi</div></div>
<span class="chev"></span>
</div>
<div class="gapline"><time>pe 28.8.</time><span>Ei merkintää</span><button>Merkitse</button></div>
<div class="entry">
<time>to 27.8.</time>
<div><div class="nm"><i class="dot d-kana"></i>Kanacurry</div><div class="sd">Riisi</div></div>
<span class="chev"></span>
</div>
<div class="entry">
<time>ke 26.8.</time>
<div><div class="nm"><i class="dot d-liha"></i>Jauhelihakastike</div><div class="sd">Perunamuusi, vihersalaatti</div></div>
<span class="chev"></span>
</div>
</div>
</div>
<nav class="tabbar">
<a href="#"><i></i>Kirjaa</a>
<a href="#" aria-current="page"><i></i>Historia</a>
<a href="#"><i></i>Ruoat</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='Teema: '+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>