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
+199
View File
@@ -0,0 +1,199 @@
<!doctype html>
<html lang="en" data-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Foodster — C · Fridge board</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=Kalam:wght@400;700&family=Figtree:wght@400;500;700;800&display=swap" rel="stylesheet">
<style>
:root{
color-scheme: light dark;
--board: light-dark(#E3E7E3, #1A1D1B);
--card: light-dark(#FCFCFA, #24272A);
--ink: light-dark(#1D2724, #E6E9E5);
--muted: light-dark(#6F7A75, #8E9994);
--line: light-dark(#D2D8D3, #33383A);
--marker:light-dark(#16403C, #6FD3C4);
--tape: light-dark(#E8D48A, #6E6134);
--meat: light-dark(#AF4230, #E07561);
--chicken: light-dark(#BE8410, #DFA83B);
--fish: light-dark(#25688F, #63AAD8);
--vegetarian:light-dark(#457A3C, #82BE7A);
}
html[data-theme="light"]{ color-scheme: only light; }
html[data-theme="dark"] { color-scheme: only dark; }
*{box-sizing:border-box;}
body{margin:0; background:var(--board); color:var(--ink);
font-family:Figtree,system-ui,sans-serif; font-size:16px; line-height:1.5;
-webkit-font-smoothing:antialiased;}
.wrap{max-width:1100px; margin:0 auto; padding:0 20px 80px;}
.top{display:flex; align-items:center; gap:18px; flex-wrap:wrap; padding:22px 0 0;}
.logo{font-family:Kalam,cursive; font-weight:700; font-size:26px; color:var(--marker);}
nav{display:flex; gap:6px; margin-left:auto;}
nav a{font-size:14px; font-weight:500; color:var(--muted); text-decoration:none; padding:6px 2px;
border-bottom:2px solid transparent;}
nav a[aria-current]{color:var(--ink); border-color:var(--marker);}
nav a:hover{color:var(--ink);}
.themebtn{font-family:Figtree,sans-serif; font-size:13px; background:none;
border:1px solid var(--line); color:var(--muted); padding:6px 12px;
border-radius:6px; cursor:pointer;}
.themebtn:hover{color:var(--ink);}
header.lede{padding:38px 0 8px; max-width:46ch;}
h1{font-family:Figtree,sans-serif; font-weight:800; font-size:clamp(34px,5.5vw,52px);
line-height:1.05; letter-spacing:-.03em; margin:0 0 10px;}
.lede p{margin:0; color:var(--muted);}
.handnote{font-family:Kalam,cursive; font-size:19px; color:var(--marker);
transform:rotate(-1.1deg); margin:18px 0 0;}
.actions{display:flex; gap:10px; margin:24px 0 26px;}
.regen{font-family:Figtree,sans-serif; font-weight:700; font-size:15px;
background:var(--marker); color:var(--board); border:0; padding:12px 22px;
border-radius:7px; cursor:pointer;}
.regen:hover{filter:brightness(1.12);}
/* ---- cards on the board ---- */
.cards{display:grid; gap:22px; grid-template-columns:repeat(auto-fill,minmax(238px,1fr));}
.card{position:relative; background:var(--card); border-radius:3px; padding:26px 18px 16px;
box-shadow:0 1px 2px rgba(0,0,0,.10), 0 6px 14px -8px rgba(0,0,0,.22);}
.card:nth-child(3n+1){transform:rotate(-.55deg);}
.card:nth-child(3n+2){transform:rotate(.4deg);}
.card:nth-child(3n+3){transform:rotate(-.2deg);}
.card::before{content:""; position:absolute; top:-7px; left:50%; margin-left:-7px;
width:14px; height:14px; border-radius:50%; background:var(--mag,var(--muted));
box-shadow:inset 0 -2px 3px rgba(0,0,0,.28);}
.m-meat{--mag:var(--meat);} .m-chicken{--mag:var(--chicken);}
.m-fish{--mag:var(--fish);} .m-vegetarian{--mag:var(--vegetarian);}
.card.wild::before{background:conic-gradient(var(--meat) 0 25%, var(--chicken) 25% 50%,
var(--fish) 50% 75%, var(--vegetarian) 75% 100%);}
.tag{font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase;
color:var(--muted); margin:0;}
.dish{font-family:Figtree,sans-serif; font-weight:700; font-size:25px; line-height:1.08;
letter-spacing:-.025em; margin:8px 0 8px;}
.sides{margin:0 0 10px; font-size:14px; color:var(--ink);}
.sides.none{color:var(--muted); font-style:italic;}
.last{font-size:12.5px; color:var(--muted); margin:0 0 14px;}
.swaps{display:flex; gap:6px; border-top:1px solid var(--line); padding-top:12px;}
.swaps button{flex:1; font-family:Figtree,sans-serif; font-size:12.5px; font-weight:600;
background:none; color:var(--muted); border:1px solid var(--line);
border-radius:6px; padding:8px 5px; cursor:pointer;}
.swaps button:hover:not(:disabled){color:var(--marker); border-color:var(--marker);}
.swaps button:disabled{opacity:.4; cursor:not-allowed;}
/* ---- the split: handwriting = what we actually ate ---- */
.logstrip{margin-top:56px; border-top:2px dashed var(--line); padding-top:24px;}
.logstrip h2{font-family:Figtree,sans-serif; font-weight:800; font-size:18px;
letter-spacing:-.02em; margin:0 0 4px;}
.logstrip .sub{color:var(--muted); font-size:14px; margin:0 0 18px;}
.logrow{display:flex; gap:14px; align-items:baseline; padding:10px 0;
border-bottom:1px solid var(--line);}
.logrow time{font-size:13px; color:var(--muted); min-width:80px;}
.logrow .hand{font-family:Kalam,cursive; font-size:22px; color:var(--marker); line-height:1.2;}
.logrow em{font-family:Kalam,cursive; font-style:normal; font-size:17px;
color:var(--muted); margin-left:auto;}
:focus-visible{outline:2.5px solid var(--marker); outline-offset:3px;}
@media (max-width:560px){ .card{transform:none!important;} }
@media (prefers-reduced-motion:reduce){ *{transition:none!important;} }
</style>
</head>
<body>
<div class="wrap">
<div class="top">
<div class="logo">Foodster</div>
<nav><a href="#" aria-current="page">Plan</a><a href="#">Log</a><a href="#">Catalog</a></nav>
<button class="themebtn" id="t">Theme: auto</button>
</div>
<header class="lede">
<h1>Seven dinners on the door.</h1>
<p>Take them in any order. Swap what nobody's in the mood for.</p>
<p class="handnote">— last regenerated Sunday</p>
</header>
<div class="actions"><button class="regen">New seven</button></div>
<div class="cards">
<article class="card m-meat">
<p class="tag">Meat</p>
<h3 class="dish">Jauhelihakastike</h3>
<p class="sides">Perunamuusi · Vihersalaatti</p>
<p class="last">Last eaten 23 days ago</p>
<div class="swaps"><button>Swap similar</button><button>Swap anything</button></div>
</article>
<article class="card m-chicken">
<p class="tag">Chicken</p>
<h3 class="dish">Broilerikastike</h3>
<p class="sides">Riisi</p>
<p class="last">Last eaten 31 days ago</p>
<div class="swaps"><button>Swap similar</button><button>Swap anything</button></div>
</article>
<article class="card wild">
<p class="tag">Meat · Chicken · Fish · Veg</p>
<h3 class="dish">Tortillat</h3>
<p class="sides none">Served on its own</p>
<p class="last">Last eaten 40 days ago</p>
<div class="swaps">
<button disabled title="Nothing else covers all four">Swap similar</button>
<button>Swap anything</button>
</div>
</article>
<article class="card m-fish">
<p class="tag">Fish</p>
<h3 class="dish">Uunilohi</h3>
<p class="sides">Riisi · Vihersalaatti</p>
<p class="last">Last eaten 34 days ago</p>
<div class="swaps"><button>Swap similar</button><button>Swap anything</button></div>
</article>
<article class="card m-vegetarian">
<p class="tag">Vegetarian</p>
<h3 class="dish">Kasvispyörykät</h3>
<p class="sides">Perunamuusi · Höyrytetyt Porkkanat</p>
<p class="last">Last eaten 52 days ago</p>
<div class="swaps"><button>Swap similar</button><button>Swap anything</button></div>
</article>
<article class="card m-meat">
<p class="tag">Meat</p>
<h3 class="dish">Makaronilaatikko</h3>
<p class="sides none">Served on its own</p>
<p class="last">Last eaten 19 days ago</p>
<div class="swaps"><button>Swap similar</button><button>Swap anything</button></div>
</article>
<article class="card m-vegetarian">
<p class="tag">Vegetarian</p>
<h3 class="dish">Hernekeitto</h3>
<p class="sides none">Served on its own</p>
<p class="last">Last eaten 27 days ago</p>
<div class="swaps"><button>Swap similar</button><button>Swap anything</button></div>
</article>
</div>
<section class="logstrip">
<h2>What we actually ate</h2>
<p class="sub">Printed above is the app guessing. Below is the house, on the record.</p>
<div class="logrow"><time>Thu 3 Sep</time><span class="hand">Lohikeitto</span><em>ruisleipä</em></div>
<div class="logrow"><time>Wed 2 Sep</time><span class="hand">Kanacurry</span><em>riisi</em></div>
<div class="logrow"><time>Tue 1 Sep</time><span class="hand">Uunimakkara</span><em>perunamuusi, vihersalaatti</em></div>
</section>
</div>
<script>
const t=document.getElementById('t'), h=document.documentElement, m=['auto','light','dark'];
t.onclick=()=>{const n=m[(m.indexOf(h.dataset.theme)+1)%3]; h.dataset.theme=n; t.textContent='Theme: '+n;};
</script>
</body>
</html>