How this got built

Built log by log,
commit by commit.

This site's own history, pulled straight from git — every change, grouped by the day it happened.


01

Dev Log

How this site got built, day by day — read straight from the repository's own history at build time.

68
Commits
11
Days worked
2026.07.29
Started
2026.08.14
Last change
What the work has been made of
Frontend Backend & data Database Framework & build Deploy & infra Docs & spec Design
Activity, day by day
2026.08.14 1 commit
c7df348 Stop the daily record repeating two days running
Frontend

Hashing the date to pick a record looked right and wasn't. The strings differ only in their last characters, and no amount of avalanche in a 32-bit mix made that scatter reliably across a pool of thirteen: 8th and 9th both gave Depression Cherry, 10th and 11th both OVER LIFE, 12th and 13th both Flowers Powers. Three collisions in nine days.

The signal was there when I built it — a seven-day check printed 5 and 5 for two adjacent dates — and I read it as noise rather than as the distribution failing.

The pool is shuffled once by a hash of each entry's id, which is stable, and then walked by day number. Consecutive days are consecutive positions, so they cannot collide, and every record has its turn before any repeats. The shuffle is what keeps that from reading as a march through the shelf in logging order.

A pool that changes size overnight shifts every index and could still repeat, so yesterday's pick is recomputed against yesterday's pool and a collision steps forward one place. Simulated across 28 days against the live data: no consecutive repeats, 21 distinct records.

2026.08.11 2 commits
73dac44 Read through PostgREST, and load the auth client only on demand
Frontend Backend & data Framework & build Deploy & infra

The Supabase client was 180KB of a 305KB bundle, and a visitor uses none of what makes it big: sessions, token refresh, realtime, storage. Reading this log is a GET with two headers, so that is what it does now — the same PostgREST calls the Cloudflare Functions have always made directly.

Authentication keeps the library. Token refresh and the cross-tab session dance are exactly the kind of thing that looks simple and isn't, and getting them subtly wrong would lock the owner out of their own site. It loads dynamically instead: only when a stored session exists, or when someone clicks sign in. Whether a session exists is answered by looking for Supabase's own localStorage key, which needs no library to read.

A visitor now downloads 33KB of JavaScript where they downloaded 305KB, and never fetches the auth chunk at all. Verified on the home page and on /log: the cover walls, treemap, daily record, timeline, filters and form all work, the console is clean, and the auth chunk is absent from both.

One thing I cannot verify from here: the owner's sign-in path. It needs a password I won't type. The flow is unchanged in shape — same prompts, same library, same calls — but it is worth signing in once to confirm before trusting it.

b406d2b Split the tabs into four real pages
Frontend Design Deploy & infra Docs & spec

The four sections were panels inside one document, switched by JavaScript, so every visitor downloaded all four to read one. The Dev Log alone — a full commit history with bodies — was 127KB of the home page's 213KB: more than half the markup, for a tab most people never open. The home page is now 24KB, and each section has an address that can be linked to and indexed on its own.

Cross-page filters travel as query strings. This fixes something the panels made worse: clicking a feeling on the Mood Map filtered a timeline on a screen you weren't looking at, so the count dropped with nothing to explain it. As `/log?feeling=moved` the filter is visible, shareable, and undone by the back button.

Most of the work was making app.ts tolerant of its own absence. It assumed every element existed, because they always had; `el()` throws by design, and that is worth keeping for real typos, so the render functions guard their own root instead and the page-specific listeners bind optionally. Each page was loaded and its console read: four passes, four missing-element errors, each fixed at the source rather than by weakening `el()`.

The timeline's "locate on map" is gone. It highlighted an entry in the legend beside the radar — but the radar became a treemap, which has no per-entry mark, and the legend now lives on another page. It had been pointing at nothing for a while.

2026.08.09 4 commits
cbfbc00 Draw an entry as a Story card, and hand it to the share sheet
Frontend Design Backend & data Deploy & infra

Instagram will not accept a post from a web page — its Stories API is open only to registered native apps. The honest ceiling is to draw the image and hand it to the operating system's share sheet, where Instagram appears alongside everything else. Two taps. Promising more would be a lie about what a website is allowed to do, so the button says "Share to a Story" and the fallback on desktop says the file was saved.

What made it possible at all is that every image source here — Apple, Wikipedia, AniList — sends permissive CORS headers, checked before anything was built. A cover from a host that refused would taint the canvas and toBlob would throw.

The card is dark where the site is pastel. A Story is watched on a phone, between other people's high-contrast posts, and a pale card is skipped before it is read; the pastels come back as the feeling pills, accents against a ground rather than the ground itself.

JPEG at 0.92 rather than PNG. The card is a photograph on a gradient — nothing needs lossless encoding or transparency — and PNG weighed 1.9MB against 164KB, which is a lot to hand a share sheet over mobile data.

Wrapping measures rendered width instead of counting characters, since a Latin title and a CJK title of the same length are nowhere near the same width, and the display stack falls back past Fraunces, which carries no CJK. Checked against the widest cases in the log: the longest title wraps to two of its three allowed lines and the widest feeling row is 552px inside a 960px safe area.

ed5bbc1 Recommend records from the shelf, not from a genre word
Backend & data Docs & spec

Music was recommended by putting the most-used genre into an iTunes search. This file's own comment already called that broken — iTunes matches titles, so it cannot answer "an album in this genre" — which is why films moved to Wikidata. Music was left behind, and the damage fell hardest on the most-used tag: searching "Alternative", seven of these twenty-two records, returned Fleetwood Mac's Rumours, dvsn and Harry Styles. It matched the word, not the genre.

Two alternatives were measured before choosing. MusicBrainz tag search returns genuinely tagged releases but reaches for Lowlife and Jesper Lundager; Apple's genre charts are correct and well known but identical for every visitor, and would offer Imagine Dragons.

Seeding from the artists actually logged answers a better question. A genre name is a label someone else chose; "people who listen to Beach House also listen to…" comes from listening. ListenBrainz publishes that free and without a key, and it lands where it should — Beach House returns Grizzly Bear, Deerhunter and The xx; Slowdive returns My Bloody Valentine, Cocteau Twins and Ride.

Scores sum across the five seeds so an artist adjacent to several records outranks one close to only one. MBIDs and neighbour lists are cached, since MusicBrainz asks for a request a second and a cold run costs ten seconds; the other media types paint first and every later visit is instant.

The cards had to stop lying about themselves — an artist-derived pick was still captioned "because you log Rock". The reason is now a kind plus a value rather than a finished sentence, which keeps it translatable: the Japanese dictionary is keyed on whole sentences and would never match one carrying a band's name.

a24a790 Make the daily pick a record, and pull it from its sleeve
Frontend Design Docs & spec

Vinyl only now. The rest of the log is things watched, and a record is the thing you actually put on — which is also what makes the treatment possible: a sleeve with the disc pulled out of it means nothing for a film.

The age rule had to move. Every record here was logged inside the last month, the oldest eleven days ago, so "older than six months" selected nothing at all and the feature would have shown an empty space. Seven days still keeps this week's listening out of it and grows more meaningful as the shelf does. Artwork is a requirement rather than a preference, since the sleeve is half the picture; 11 of the 22 records carry artwork, a feeling and a note.

The disc is drawn in CSS. Concentric circles are exactly what a repeating radial gradient describes, so an image would be another request for something already stated precisely — and the sleeve art becomes its label, behind a circular mask so the square artwork can't square off the record.

It spins up and eases to a stop, once, three turns over 3.4 seconds: put on, spun up, left to settle. Something turning forever would sit in the corner of the eye for as long as the page is open.

Two geometry mistakes on the way. `inset: 0` with a `left` override leaves the right edge pinned, which squashed the record into an ellipse hidden entirely behind the sleeve; it needs its own square. And at a 24% overhang the disc came within four pixels of the text, which reads as a collision rather than a gap — 19% leaves 19px on a laptop and 12px on a phone.

845b13c Add a daily pick from the back of the log, and handle the shelf like records
Database Framework & build Docs & spec

The pick sits at the top of Summary and comes from the date, not from Math.random: something that changes on every reload is a shuffle, and "today's" has to mean today's. Seeding from the date also means a rebuild during the day — a push, the nightly run — lands on the same entry rather than quietly swapping it. It is built server-side, so it is in the HTML for a crawler too.

Entries newer than 180 days are excluded, since something logged last week is not rediscovered, and ones carrying both a feeling and a note are preferred — those have something to come back to. 55 of the 158 entries qualify.

The date hash needed mixing. A plain rolling hash of "2026-08-05" and "2026-08-06" lands one apart, so consecutive days walked the list in order: correct, but reading as a queue rather than a rediscovery. FNV-1a with a final avalanche scatters neighbouring dates across the pool — checked over seven consecutive days.

The shelf animation is the gesture of flicking through a crate rather than a generic card hover. A sleeve lifts and tips forward, pivoting on its bottom edge the way one does when tilted up out of a row, and its neighbours settle back so the row reads as one object being handled instead of twelve buttons. Arrival is a single left-to-right pass. The existing reduced-motion block already neutralises all of it.

2026.08.05 5 commits
dc1034d Deploy once per push, from the workflow only
Framework & build Deploy & infra

The pre-push hook built and deployed from my machine, which was the right answer while that was the only way anything shipped. Since the GitHub Actions workflow took that over, every push has deployed twice — a minute of waiting and a build of quota for a result already in hand.

The file goes rather than just the config, because leaving it would leave a trap: a fresh clone that ran `git config core.hooksPath .githooks` would quietly be back to deploying twice with nothing to explain why. `npm run ship` goes with it, since a plain `git push` now does the whole thing.

af82c21 Put the log in the HTML, and give every entry a page
Frontend Backend & data Database Framework & build Deploy & infra

Fetched with Googlebot's user agent, the home page carried 9,254 indexable words of which 8,521 — 92% — were commit messages from the Dev Log. Not one of the 156 entries appeared, because everything but the Dev Log was fetched in the browser from Supabase. The only album title in the markup was one I had happened to mention in a commit. Search engines had this filed as a blog about building a website, which is the opposite of what it is.

Entries are now read at build time and rendered server-side, and each logging gets its own page: title, creator, feelings, genres, notes, the cover as its OG image, and Review JSON-LD. Nobody searches for "Mood Log" — they search for 五月雪 觀後感 — so one URL becoming 157 is the whole point. Slugs keep CJK, since `/log/五月雪` is what a person would recognise, and the two repeated works take a date suffix.

A logging rather than a work gets the page: the same record heard twice is two entries, because the feelings differ and that difference is why this log exists. Each links to the other.

sitemap.xml, robots.txt and a 404 page are real now. All three used to answer 200 with the home page — Cloudflare's SPA fallback made every wrong URL a soft 404. Verified: entry pages 200, unknown paths 404, sitemap lists 157 URLs, and the home page carries 156 links into them.

Rebuilds come from a workflow rather than a Cloudflare deploy hook, which does nothing on a direct-upload project — there is no repository for it to build. It runs on push, nightly, and on a repository_dispatch for a Supabase webhook to fire later. The checkout is unshallow because the Dev Log reads git history.

2ca6df8 Say which filters are on, and give the suggestion dots a real target
Frontend Framework & build

Clicking a feeling on the Mood Map filters the timeline, but the map is on the Home tab and the timeline is on Log — so the effect landed on a screen the cause was not on. The count dropped from ten to five, the search box was empty, the count label was empty, and the type chips still read "All · 151". Nothing said why. A filter you cannot see is indistinguishable from data that went missing, and this was my own doing when the treemap gained that behaviour.

Active filters now appear above the timeline as chips — feeling, search, genre — each dismissable, with a clear-all beside them. Verified end to end: the feeling chip appears with the count at five, its ✕ returns it to ten, clicking a cover shows a search chip at one result, and clear-all restores everything.

The suggestion dots were 10px square, which is a coin-flip to hit on a phone. The button around them is 28 while the dot stays 10: the row is a position indicator, and drawing it bigger would make it shout. Shipped and present in the build, but not measured in a live page — the preview pane reports a zero-width viewport, so that one wants an eye on it.

d2ad12c Add a second cover row for what was watched, and drop the time band
Backend & data Database

Films, series and anime share one row rather than getting three. They are all "what I watched", and split three ways each row would be short enough to say less than two full ones do.

Only real artwork appears, as with the records. Generated art stands in well enough beside a title in a list, but a row half real and half generated reads as broken rather than varied — and 70 of the watched entries have real covers, so filling twelve is not close.

The time band is gone at the owner's request, and the renderer with it rather than left behind unused.

ff70c51 Prefer Taiwanese titles, and never the generic Chinese label
Backend & data Database

Wikidata's plain `zh` label is where mainland releases live — 盜夢空間 for Inception, 肖申克的救贖 for Shawshank, 借東西的小人阿莉埃蒂 for Arrietty — and falling back to it whenever zh-tw was absent is how this log ended up with 饮食男女 in simplified characters. It is no longer consulted.

`zh-hant` sits between zh-tw and English rather than being skipped. Both titles that were wrong here have no zh-tw label at all: Wikidata files their Taiwanese release titles under traditional Chinese. Going straight to English, which is what the rule says literally, would have replaced a Taiwanese film's own name with "Eat Drink Man Woman".

Checked across seven films: five resolve through zh-tw, the two that were wrong now resolve through zh-hant to their Taiwanese titles.

The migration corrects those two rows directly. The lookup cannot do it — the title is what a re-lookup matches on, so a wrong title has nothing to find itself with.

2026.08.04 4 commits
9d9d10d Refuse Commons-hosted artwork, and stop using Wikidata's P18
Backend & data Database Deploy & infra

The covers this log had collected were often not of the work at all: a Dortmund graffiti wall for Pulp Fiction, a photograph of paprika for Paprika, chilli peppers for Joker, a guppy for 熱帶魚, a 3.2MB aerial shot of Shanghai for Suzhou River. Twenty-four of them.

There is one cause behind all of it. Commons accepts only freely licensed files, and a film poster rarely is one — so P18, which can point nowhere else, gets filled by editors with whatever free image is loosely related, and an article lookup on an ambiguous title lands somewhere with the same problem. Non-free posters live on the local wiki instead, because Commons will not take them, so refusing Commons is a reliable proxy for keeping the real artwork.

The entries themselves were never wrong: 熱帶魚 had 陳玉勳 and Paprika had 今敏. Only the images were, which is why this was easy to miss.

Dropping P18 also stops it shadowing better answers. Pulp Fiction's correct poster was on zh.wikipedia the whole time; P18 was simply tried first and won. Checked after the change: Pulp Fiction and A Clockwork Orange now resolve to their posters, while 熱帶魚 and Paprika resolve to nothing and fall back to generated art — the right outcome, since a missing cover is honest and a wrong one is not. The cost is the handful of genuinely public-domain posters.

The backfill utility also refreshes the creator now, so older rows pick up the native-language name, but only when the lookup returned the same work. Without that guard a lookup that drifted would overwrite a correct hand-typed name.

4cd0eb3 Take creator names from Wikidata's native-language property
Backend & data Database

Reading the zh-tw label first made the name depend on whether Wikidata happened to carry a Chinese translation, so 今敏 stayed 今敏 while 是枝裕和 arrived as "Hirokazu Koreeda" — the same kind of person handled two ways for no reason visible in the log. P1559, "name in native language", answers the question directly instead of inferring it from which translations exist.

Where P1559 is absent the English label stands in. It is not always the original either, but it is at least never a transliteration into some third language.

Claims are fetched in their own small request rather than alongside the labels: only people and studios need them, and the genre entities fetched in the same call would multiply the response for nothing.

Checked against three films in the log: 幻之光 moves from "Hirokazu Koreeda" to 是枝裕和, 發條橘子 from 史丹力.庫柏力克 to "Stanley Kubrick", and 千年女優 stays 今敏. Existing rows keep what they have — this applies as entries are parsed.

06a9a86 Normalise entry text on save, and ask before logging a repeat
Frontend Backend & data Database Docs & spec

Three sources feed these fields — typed by hand, pasted from a lookup, read off an API — and each leaves its own marks. One title carried a trailing newline from a paste. Another read "Making waves : the art of cinematic sound" against an earlier "Making Waves: the art of cinematic sound": the same documentary logged twice on one day, invisible to any check comparing strings.

Text is cleaned rather than trimmed, since the damage is inside the string as often as at its ends. Full-width punctuation is left alone — "平克佛洛伊德:迷牆" is correctly typeset Chinese, and normalising it would be an error dressed as consistency.

Logging the same work on the same day now asks first. The date is what makes that judgement possible: the same record on two dates is this log working as intended, one listen nostalgic and the next relaxed, while the same record on one date is a form submitted twice. It asks rather than blocks — it is the owner's log.

Wikipedia covers now take the API's thumbnail rather than its original. The original is the file at whatever size it was uploaded, and for a freely licensed photograph that is unbounded: one cover here is a 3840px aerial shot weighing 3.2MB for a slot under 150px wide, against 104KB for a typical Apple sleeve. Nothing is lost — for the non-free posters most films use, both fields return the same modest image. Rewriting URLs to request a width was tried first and is not dependable; Wikimedia refuses arbitrary sizes for some files, inconsistently.

The migration handles the existing rows, and treats the two duplicate pairs differently, because they are different: one pair is identical and one copy can go, while the other differs in feelings and genres, so the survivor takes the union before the copy is removed. Blind de-duplication would have quietly dropped a feeling.

f3cf97e Keep the cover wall to records, and size the map for a phone
Frontend Design Database

The wall is vinyl only now. Not because the other types lack artwork — a grid of sleeves reads as a shelf, while the same grid holding film posters, book jackets and anime key visuals reads as a search result. All eighteen records have real covers, so nothing is missing from it.

The Mood Map's labels were sized in viewBox units the browser then scaled by about 0.58 on a phone, so a 16-unit label arrived near 9px: present, correctly placed, unreadable. Sizes are chosen in CSS pixels and divided back out, and land at 15/12.5/11/10.5 on any width.

Honest sizing alone made it worse — at a phone's width twelve cells in a 460×330 box leave almost none of them wide enough for a name, so correct type sizes meant almost no labels. The box goes portrait below ~420px, roughly doubling each cell's area; eight of twelve are labelled there against twelve of twelve on a laptop, and the four without are the smallest slices, which the tooltip covers. A treemap needs area and cannot be talked into less. Since the sizes depend on the rendered width, a debounced resize redraws it.

The hero's track row is a flex row rather than runs of non-breaking spaces, which wrapped raggedly once a fourth track existed, and "YOU MIGHT LIKE" is "PICKS" — the row needed ~644px in a 561px column. It is an index, not a heading.

2026.08.03 5 commits
d77788a Draft: give the home page the collection's own face, and a sense of time
Frontend Design Database

Two additions to Summary, deployed to the draft alias only.

The cover wall shows the twelve most recent entries that have real artwork. Everything else on this page is abstract — counts, coloured blocks, a treemap — while the covers, the one part of this log that looks like anything, sat unseen down in the timeline. Generated art is excluded: it stands in well enough beside a title in a list, but a wall of it says nothing about a collection. Clicking a cover searches for that entry, so the wall is a way in rather than decoration.

The time band draws every month between the first entry and the last, on a real axis rather than a list of months that have something in them. This log runs in bursts — a dense winter, years of near-silence, then a return — and compressing the empty stretches would throw away the most legible thing about its shape. Colour is the Mood Map's spectrum averaged over each month, so the two read as one system rather than two charts that happen to share a page.

Verified structurally against live data: twelve covers, 73 month columns of which 16 carry entries, tallest January 2021 at 16. The visual could not be checked here — the preview pane reports a zero-width viewport — so this wants a look before it goes near production.

32866b7 Read film details off the entity instead of querying for them
Backend & data

Parsing a film was slow, and the cover lookup was not why — that measured 345ms against 2.2s for the Wikidata step in the same parse. The Query Service is a general engine on a shared queue, and its latency shows it: the same three-property lookup measured 1.1s, 1.1s and 12s across three films. It was never doing anything a plain entity read could not.

Two API reads replace it — the entity, then one batched call for every label it references, rather than one per label. Measured across five films from the log, the whole step including the id search now averages 2.0s against roughly 5.4s before, and nothing spiked.

Genre order improves as a side effect. SPARQL had no ORDER BY so its order was arbitrary, while only the first three genres are kept; the entity preserves Wikidata's own ordering, which runs primary to incidental. A Clockwork Orange kept "black comedy, speculative fiction, satirical" and now keeps "drama, crime, dystopian".

IMDb links resolve through CirrusSearch's statement index, since an external id cannot be looked up by label. Titles, directors, genres and artwork were compared against the old path on four films first and match exactly.

9d2588b Read music genres from MusicBrainz as well as Apple
Backend & data

Apple already supplied the genre when a music link was pasted, but it gives exactly one per release and pitches it broad. Beach House, Clairo and a Taiwanese indie record all come back "Alternative"; New Order's post-punk landmark comes back "Pop". One coarse label per album is why this log's music genres had collapsed into a single bucket — Alternative accounted for seven of sixteen records.

MusicBrainz is community-tagged, free and CORS-open, and its votes carry the signal that makes the tags usable: single-vote tags include outright spam, so two is the floor and the top three are taken. Apple's genre still leads, being the shelf the record is actually filed under and always present; the tags follow with the detail Apple flattens away, capped at four so the chip row stays a summary. An obscure release clears neither bar and keeps Apple's answer alone, and a failed lookup is not a failed parse.

Verified against four records from the log: Depression Cherry now reads dream pop, indie pop and shoegaze beside Alternative, and Power, Corruption & Lies picks up new wave, synthpop and post-punk beside Pop.

3b13bcd Consolidate the genre vocabulary, and keep it from drifting again
Backend & data Database

Genre labels come from lookup APIs rather than a list this app owns, so they arrive title-cased, spelled several ways, and sometimes compound. That left 68 distinct labels across 93 entries with 39 used exactly once — a vocabulary too fragmented to filter or count by, and the reason the log read as untidy.

Compound labels are split into their parts, since genres is already an array and "Comedy Drama" was never one genre; synonyms and casing artefacts collapse onto one spelling; and plot descriptors are dropped — Flashback and Christmas are true of a film but cannot sort one. That leaves 41 labels, and no entry without any.

The rules live in canonicalGenres() and run on save, so the migration is a one-off rather than a chore that comes back. The migration applies the same rules to existing rows and previews before it writes, since it cannot be run from here — writing to entries needs the owner's session.

3cc1ef4 Offer the interface in Japanese, following the browser by default
Frontend Backend & data Framework & build

Translation is keyed by the English sentence rather than by an invented id. The usual approach would have meant editing every one of the ~200 places this site writes text, and moving the copy into a file nobody reads beside the markup it belongs to. Keying on the sentence keeps the components legible, degrades a missing translation to English instead of a raw key, and — because a mutation observer translates nodes as they are added — covers everything rendered at runtime without a single call site changing.

What that costs is that translation happens after rendering, so anything the owner wrote has to be kept away from the dictionary: entry titles, notes, messages and commit bodies are excluded wholesale. An entry titled "Book" would otherwise be quietly rewritten into Japanese. Ordinary controls inside those regions opt back in by marking themselves.

Three things had to be handled specially. Sentences carrying a count cannot be matched after the fact, so those keep placeholders filled in after lookup. Sentences the server composes from the media-type list have no fixed English to look up, so they carry their own translation alongside. And whitespace is collapsed before matching, since a paragraph written across several lines of markup arrives as one node still holding the indentation — which had left the longest and most worth translating strings never matching.

Genres, titles, creators and commit messages stay as written. They are data.

2026.08.02 13 commits
fc2eacc Point the site at moodlogremi.com
Deploy & infra Docs & spec

The custom domain is live, so the canonical URL, the OG image and the links at the foot of both mail templates name it. Those are absolute by necessity — scrapers and mail clients cannot resolve a relative path — which is exactly why they had to be changed rather than left to follow the request host.

pages.dev keeps serving the same deployment; nothing that already links there breaks. The apex is a CNAME flattened by Cloudflare, so Email Routing's MX records stay where they are and the forwarding alias is untouched.

Also drops a comment in the contact Function that had been left duplicated.

eae5195 Write the hero copy from the type list, and let the map pick a type
Frontend

The lede named "album, film, and series" long after anime and books could be logged, and the track list stopped at three while a fourth section had been added below it. Both were prose typed once and never revisited, the same failure as the pinned version number. They are written out from MEDIA_TYPES and from the panel order now, so a sixth type rewrites both sentences by itself.

The Mood Map gets its own type tabs, driving the timeline's existing filter rather than a second one of its own — two independent type filters is how a map and a list come to disagree about what is on screen. The tabs are single-select though the filter holds a set: picking from a tab row means "this one", not "add this one". With several types selected from the timeline side, no tab lights up, since claiming "All" would misdescribe what is drawn.

0431ce2 Count the version instead of typing it
Framework & build

The footer version was a literal, so it only moved when someone remembered to move it — and nobody did. It sat at v30 while some thirty releases shipped past it, telling every visitor the site was older than it was. It is counted from the commit history at build time now, alongside the date of the most recent one, so it cannot fall behind what is actually deployed.

Builds without git fall back to naming the stack alone; a confident "v0" would be a worse answer than not giving a number.

c73f4c3 Record what the hero line actually means
Frontend Backend & data

"Everything I've felt, in one shape" was nearly rewritten when the Mood Map became a treemap, on the reasoning that a treemap is many shapes rather than one. The shape was never the chart — it is the shape the owner has grown into by keeping this log, so the line holds however the map is drawn. Written down so the next reader doesn't make the same correction.

e96c842 Show the Mood Map as a treemap, and let it filter the timeline
Frontend Design Database Docs & spec

The radar drew one polygon per entry, which asked for at least two feelings — so entries naming one feeling, or none, were silently absent. That was most of the log. What remained was mostly two-point polygons, which are lines rather than shapes, layered until nothing could be read. Area per feeling shows every entry instead, and holds up as the log grows.

The twelve feelings run as a spectrum rather than sorted into families. Grouping meant ruling on whether nostalgia is a warm feeling or a sad one, and it is honestly both, as is hesitation; a continuous order lets the murky ones take a colour belonging to neither end. Multi-hue is the documented exception for semantic warmth, so the scale is shown beside it. Fill and ink step together and every label clears 6.8:1 against its own tile — a treemap's text sits on the colour, so that is the contrast that matters.

Clicking a feeling filters the timeline, which turns the map from decoration into the way into the log. It is built from every filter except that one: a control that collapses to the cell you just clicked leaves no way back.

The radar survives in the entry form, where a single shape is exactly the job. Its chrome was still dark-theme — near-white gridlines at 8% and a brown hub, both invisible on cream since the pastel redesign.

84b8a4c Log why a receipt was refused instead of discarding it
Frontend Deploy & infra

A failed receipt reported only `emailed: false`, which sent the investigation after the sending domain when the real cause was in Resend's response body: recipients at reserved domains are rejected outright, whatever the sender. The refusal is now logged where the site's operator can read it, and deliberately not returned — the reason belongs to whoever runs the site, not to the stranger who filled in the form.

9102bea Let the owner answer a message from the Inbox
Frontend Design Backend & data Deploy & infra

Replying from a normal mail client would put the owner's private address in front of the visitor, which is what the contact form exists to prevent. Sending as the site's own address instead needs an SMTP relay, and Resend will only send from a domain it has verified — the apex cannot be verified, because Resend's bounce MX would have to sit where Email Routing's receiving MX already is. One domain cannot both send and receive here, so replies are written where the messages already are.

The endpoint mails whatever recipient it is given, so authorisation is the whole design: the caller's Supabase token is resolved against the auth API and the address it returns must equal the OWNER_EMAIL binding. Both sides are server-side. Without that gate this would relay anything to anyone under this domain's name.

Sending a reply also marks the message read, answering it being the clearest evidence it was read.

a678e75 Keep the owner's address out of the repository too
Frontend Backend & data Framework & build Deploy & infra Docs & spec

The digest fix covered the shipped bundle but not the source, and this repository is public: the address was still readable in the standalone prototype, in every RLS policy quoted by the setup guide, and in the spec's auth section. The prototype now compares digests like the Astro app does, and the guides carry a placeholder with a note that the real address belongs in Postgres and in the Cloudflare bindings, both private.

Also records the reply alias: Reply-To is a forwarding address, deliberately not OWNER_EMAIL, so a visitor can hold a conversation without ever being handed the private inbox.

5406d75 Let the sender and reply address come from configuration
Backend & data Deploy & infra

Proving a domain in Resend is a DNS exercise that finishes hours after the code is ready, so the sender and reply address are bindings now rather than literals. Switching to a real address becomes a configuration change; until one is set, the shared resend.dev sender still stands in.

Reply-To on the visitor's receipt is the forwarding alias, deliberately not OWNER_EMAIL — the alias is meant to be handed out, the owner's inbox is not. The receipt only invites a reply when there is somewhere for it to go.

fc52639 Stop publishing the owner's address, and send them a copy
Frontend Backend & data Framework & build

The address was reaching visitors by two routes, both of which defeat the point of having a contact form at all. It was a plain string in the client bundle, readable by anyone who opened the shipped JavaScript, so the owner check now compares SHA-256 digests instead — the same decision without the address being there to find. It was also the Reply-To on every receipt, handing the real inbox to anyone who filled in the form; receipts now carry no Reply-To.

Submissions also notify the owner, who otherwise learned of a message only by signing in. That copy sets Reply-To to the visitor, so replying takes one keystroke, and it goes to the address held in the server-side binding.

Rendering asks who the owner is dozens of times per pass and hashing is async, so the answer is resolved once per sign-in and cached.

ec1e2cf Fail the contact Function with a reason, not a bare 1101
Backend & data Database Deploy & infra

With the env bindings missing, fetch() was handed an "undefined/rest/v1/…" URL and threw, which Cloudflare surfaced as error code 1101 — no indication of what was wrong. The bindings are now checked up front, and the database call is wrapped so a network failure or a PostgREST error reports what happened.

8cd7997 Email a receipt when someone leaves a message
Frontend Backend & data Framework & build Deploy & infra

Adds a Cloudflare Pages Function at /api/contact so the mail API key stays server-side. It stores the message then sends the visitor a copy, and treats the two independently: a failed send still returns success, because losing a message to a mail provider's hiccup is the worse outcome. The UI only claims a receipt went out when one actually did.

The requested [email protected] can't be used as the sender — noreply.com belongs to someone else, so mail sent as it fails SPF/DKIM and lands in spam or bounces. Sending as a domain means proving you own it, and a pages.dev subdomain has no editable DNS. Resend's shared [email protected] carries it instead, displayed as "Mood Log".

The client falls back to a direct insert when the Function isn't reachable, so the form still works under astro preview where Functions don't run.

652f337 Add real favicons, and require a valid email on the contact form
Frontend

Bookmarking the site produced a blank tile: the <head> carried no rel="icon" at all, and the manifest pointed only at an SVG, which Safari, most bookmark managers and every OS home-screen ignore. `npm run icons` now renders 16/32px favicons, a 180px apple-touch-icon and 192/512px manifest icons through sharp. The Apple icon is opaque, since iOS masks it itself and renders transparency as black.

The contact form's free-text "how to reach you" is now an email field, checked against a pattern rather than relying on type="email" — that check is bypassable and permissive, and an unreplyable address makes a message worthless.

2026.08.01 18 commits
c5287aa Reflow commit bodies so they fill the card
Design Database

The Dev Log printed commit messages verbatim with whitespace-pre-wrap, so the ~72-character hard wrapping that git messages are written with showed through as a narrow column inside a much wider card. It read as a CSS width problem but the line breaks were in the data.

Bodies are now split on blank lines and each paragraph joined back into one flow. Bullet lists keep their breaks — a list is meaningful line by line, a paragraph isn't — and continuation lines are folded into the item above.

205a4d5 Drop logged suggestions on the spot, and collapse long pagers
Deploy & infra

Logging a suggestion left it sitting in "You might like" until a reload. The cache key already includes the entry count so it self-invalidated eventually, but an action not reflected until you refresh reads as a bug. It's now removed from the suggestions and from Saved for later as soon as the insert succeeds.

The pager collapses past seven pages to 1 … 5 6 7 … 12; beyond that the numbers wrap to a second line and stop being scannable.

79c435a Speed up recommendations, and widen the contact form
Frontend Backend & data

Measured the sources first: Open Library was the bottleneck at 7-11s (and occasionally 500ing), because search.json?q=subject: scans about a million records. Its prepared /subjects/{name}.json index answers the same question in a fraction of that.

Three more cuts on top: - Cards paint per media type as each source answers, instead of after the slowest. Four of the five now appear in ~1.2s where the section used to sit on a spinner until everything was in - Candidate genres are queried in parallel rather than one at a time, which had meant up to four sequential round trips against already-slow sources - AniList's 19-entry genre list is inlined instead of fetched, removing a whole round trip before the real query could start

Results are cached in sessionStorage, keyed so the cache drops itself when the log changes — returning to Home is now instant.

The contact form also had a 560px cap while the copy above it ran full width.

1da29b1 Add a contact form and an owner-only inbox
Frontend Backend & data Database

Visitor messages write to a new `messages` table. RLS lets anyone insert but only the owner select, so the inbox is write-only to the public — no visitor can read another's message, and no email address or handle is published anywhere for a scraper to find.

The owner sees an inbox under the form: unread badges, mark read/unread, and delete behind a confirming second click. A honeypot field catches naive bots; filling it returns the normal success message and stores nothing.

Fixed while testing: the table probe used a HEAD count, and PostgREST answers HEAD on a missing table with a 404 and an empty body, which supabase-js doesn't report as an error — so the probe called a missing table present and the form surfaced a raw schema-cache error instead of explaining itself. A plain select distinguishes the cases correctly, and still returns cleanly for a visitor whose RLS grants insert but not select.

de3d500 Add social preview tags and a generated card image
Frontend

Shared links showed no thumbnail because the page had no Open Graph or Twitter Card tags at all — only a description. Both sets are now emitted with absolute URLs built from `site`, since scrapers don't resolve relative paths and a relative og:image is a common reason a preview stays blank.

The card is a real PNG at /og.png, generated by `npm run og` through sharp. Facebook, LINE and X ignore an SVG og:image outright, so shipping the SVG would have looked correct in the markup and still produced no thumbnail.

1464e20 Run body copy full width, and make the save/dismiss buttons hittable
Frontend Framework & build

The hero lede was widened last time but every other section kept its own max-w-[Nch] cap, so the paragraphs still wrapped early. Removed from the recommendations, Dev Log, build summary and About too.

Save and dismiss were 16px inline glyphs — well under a comfortable touch target, and hard to hit as reported. They're now 44px round buttons floating over the artwork, with titles and aria-labels so the icons aren't the only cue.

2b7de0e Recommendations get type tabs, like/dismiss, and a saved list
Frontend Database Framework & build

Five cards side by side left each too narrow for a title like "la di die (feat. Jaden Hossler) - Single" to fit in fewer than four lines. It's now one wide card with media-type tabs above, still cycling three picks with dots.

♡ saves a pick to a new "Saved for later" list; ✕ dismisses it permanently. Both persist to site_settings rather than the browser, so they follow the account and survive a rebuild. Dismissals are applied inside the sources rather than filtered afterwards, so turning one down doesn't just leave a shorter list.

The hero lede now runs the full column width, as asked — the usual 45-75 character measure reads more easily, but that's the call made.

05e3028 Give each suggestion card three picks, a link out, and a Log this button
Frontend Backend & data Database

One suggestion per type meant a single miss left the card useless. Each type now carries up to three, cycled with dots, and the candidate genres are accumulated rather than stopping at the first that answers — with few entries every genre ties at one, and one genre rarely has three unlogged titles behind it.

Cards link out to where the thing actually is: Apple Music, IMDb (via Wikidata's P345), AniList, Open Library. "Log this" hands the pick to the add form with type, title, creator, genre, artwork and link already filled, so a suggestion worth keeping doesn't have to be retyped.

The hero illustration is hidden below md — on a phone a full-width square pushed every word below the fold.

ae638f4 Add a Series type and a timeline search
Frontend Backend & data Docs & spec

Series joins the other four: Series title / Creator-studio fields, watch contexts, TV genre presets, resolved through Wikidata like films are — IMDb links already cover series, since tt ids span both.

Search matches title, creator, context, genres and the notes. Notes are included because a detail is often written only there, and paging through a growing log to find one entry was the obvious next pain point.

Backend: `entryKey()` now defines "the same work" in one place. The timeline's repeat-folding and the recommender's already-seen check had drifted apart — the latter compared titles alone, so an album could hide a film of the same name. The Wikidata lookup is parameterized by class rather than copied for series.

Needs a widened CHECK constraint for 'series' (see SPEC).

1829a53 Add a bulk importer for pasted lists
Frontend Backend & data Database

Paste one item per line — title, title | creator, or title | creator | date — pick a type, and each line runs through the same lookup the form uses. Results are shown with tick boxes for approval before anything is written: a wrong match buried in a batch of thirty is far harder to notice afterwards than before.

Unresolved lines are listed with the reason rather than dropped, and feelings are left empty on purpose — no lookup can supply them, and guessing would defeat the point of the log.

Built to carry an Instagram export into the log, but useful for any list.

c21fd21 Loosen Apple Music matching to accept romanized artist names
Backend & data

Requiring the artist to agree as well as the title looked like the safe reading, but it threw away most correct matches: iTunes romanizes names, so 落日飛車 comes back as "Sunset Rollercoaster" and 宋冬野 as "Song Dongye". Seven of eight albums found no link because of it.

An exact title match now stands on its own; a merely partial one still needs the artist to back it up, which is what actually stopped the "愛是唯一" → "I Swear" mismatch. If nothing matches, title and artist are retried swapped — entering an album under the artist's name is an easy slip and the swap recovers it.

5884c34 Stop storing wrong Apple Music links, and widen the ISBN lookup
Backend & data

iTunes ranks loosely — searching "愛是唯一" returns Sandy Lam's "I Swear" first, because one of its tracks carries that name, and taking results[0] on faith had already written that wrong link to a real entry. Candidates are now checked against the entry's title and artist, and an unconvincing search stores nothing: a missing link is recoverable, a wrong one silently isn't.

ISBN lookup now tries three shapes — Google Books' isbn: index, Google Books' plain search, then Open Library — and sends country=TW, which Google Books requires from some regions or it errors rather than returning an empty list.

Open Library genuinely has no record of the reported ISBN (numFound: 0), and Taiwanese titles are often missing from both indexes, so the failure message now names what was tried and points at a title search instead of implying the input was wrong.

1fde0b9 Render notes as Markdown, link music to Apple Music, and support phone capture
Frontend Backend & data Docs & spec

- Notes accept Markdown, rendered by a small in-repo renderer rather than a dependency. Safety comes from ordering: the source is escaped first and only then re-marked-up, so a note can't inject HTML, and link hrefs are restricted to http(s). Checked against <script>, <img onerror>, and javascript: links - Notes over 100 characters collapse to a plain-text preview with Read more; the full Markdown is parsed on demand rather than for every card up front - Saving a vinyl entry now looks up its Apple Music page when it doesn't have one, so albums logged from Spotify, YouTube, or by hand are still one tap from playing. The backfill button fills links as well as artwork - ISBNs are detected and queried exactly, via Google Books then Open Library — more reliable than a title search, and easy to copy off a bookshop page whose link can't be read - Added a web manifest for Add to Home Screen, and URL prefill (?add=1&type=&title=&creator=&note=) so a phone shortcut can hand data over without holding credentials — it opens a URL in the already-signed-in browser

Fixed while testing: blockquotes never rendered, because escaping runs first and turns "> " into "&gt; " before the block rule sees it.

26e40d9 Link entries out to their source, add All filters, and improve book lookup
Frontend Backend & data Database

- Every lookup now also returns where to go to play/read/watch the thing, stored in a new source_url column. Timeline cards get a link named after the destination — "Apple Music ↗" on an album, "IMDb ↗" on a film. Pasted links are kept as-is. Probed like cover_url, so it degrades until the column exists - Books try Google Books before Open Library: it indexes Chinese-language editions far better. A bare ISBN is detected and queried as isbn: - books.com.tw and the other online bookshops block cross-origin reads (403 even server-side), so those links are detected and answered with a nudge to type the title or ISBN, rather than a generic "not recognized" - Both filter rows lead with an explicit All chip — "select nothing to see everything" was never discoverable - Dropped the ↺ marker on remembered genres; their position already says it

a3bd907 Collapse repeat logs in the timeline, and tidy the filter and utility rows
Frontend Database

Logging the same work again now folds into one timeline card showing ×N and the last date, carrying the most recent log's mood. Identity is title + creator + media type, case-insensitive — title alone would conflate an anime with the book behind it.

Two deliberate limits on that: - The database still stores every log. This only folds them for display, so the history behind a "×3" survives and the choice stays reversible - Deduping is timeline-only. The Mood Map still plots every log so repeat listens keep their own shape, and Summary still counts logs. Remove on a folded card drops only the latest and says so, rather than silently taking three records with one click

UI: backup/maintenance actions move behind a "Manage this log" disclosure instead of reading as developer tooling; the genre filter collapses to the 8 most-used with a "+ N more" toggle (active filters stay visible regardless); and entries without notes render nothing instead of a grey "(no notes)".

Backend: split renderAll into renderAll/renderFiltered so a filter click stops rebuilding the stats and the About copy, and run the two schema probes concurrently instead of sequentially.

d3a2ad1 Drop the Home tab, rework Summary, and add recommendations
Frontend Backend & data Database

- The Home tab is gone; the MOOD LOG wordmark navigates there instead, the way a site logo usually does. Nav is now Log · Dev Log · About - Summary reworked: one large total, the date range collapsed to a single line with a day count, per-type counts inline as colored dots, and feelings/genres side by side. Previously six same-sized tiles with dates taking two of them - New "You might like" section: one suggestion per media type, drawn from the genres that type is tagged with and filtered against what's already logged

Three things the obvious implementation got wrong, found while testing: - iTunes only matches titles, so searching it for "Sci-Fi" returns nothing. Films now come from Wikidata, ranked by sitelink count - AniList keeps "Shounen"/"Isekai" as tags, not genres, and querying them as a genre returns an empty list rather than an error. The code now checks GenreCollection and picks the right field, with an alias map for romanization - With few entries every genre ties at one, so the "top" genre is arbitrary and was often a word the source didn't know. Candidates are now tried in rank order until one answers

4bc28f1 Add a Book type, editable About copy, and genre memory
Frontend Design Backend & data Database Docs & spec

- Book joins Vinyl/Movie/Anime: Book/Author fields, reading contexts, and lookups through Open Library (keyless, CORS-open, good on translations). Goodreads links can't be read cross-origin, so the title is recovered from the URL slug and searched instead - About prose now lives in a site_settings key/value table and is editable in place by the owner. Probed like cover_url, so it falls back to built-in copy when the table doesn't exist yet - Genre chips remember what each media type has actually been tagged with, ordered most-used first (↺) ahead of the presets — custom genres stop needing to be retyped - Replaced the capybara with a cozy window scene in the reference style

Needs a schema migration (see SPEC): cover_url, a widened media_type check for 'book', and the site_settings table.

c3bcd7d Paginate the mood map, visualize dev stats, and fix genre language and covers
Frontend Backend & data

- Mood Map legend paginates at 10. The radar still draws every visible entry, and clicking a polygon jumps the legend to the page holding it, so a click never lands on something off-page - Dev Log gains two wordless summaries: a proportional bar of what the work has been made of, and a per-day activity strip. No figures on purpose - About drops the generated data portrait for a capybara in a hot spring - Genre language: iTunes answers in the storefront's language, so a US lookup now backfills the genre when the first answer is CJK, and Latin-script labels win over CJK ones when both exist ("Alternative", not "另類音樂") - Covers for film and anime: Wikidata's P18 is often empty, so films fall through to Wikipedia's summary endpoint and anime to an AniList search — both keyed on Wikidata's English label, since neither source matches a Chinese title

2026.07.31 4 commits
74df920 Add an About tab, auto-fetch covers, and suggest genres up front
Frontend Backend & data Database

- New About tab. The prose is fixed, but the "portrait in data" beside it is generated from the log itself — counts, the feelings that recur, what gets returned to — so it stays true as entries accumulate rather than going stale - Covers are now looked up automatically on save when the entry doesn't already have one, so "Fetch missing covers" is only for backfilling old entries - Genres the lookup suggests are pinned ahead of the presets and marked, so the system's guess is what you see first - Removed "Clear all entries" (and the deleteMany call that only served it)

Backend tidy-up while in here: fetchEntries now sorts newest-first in the database instead of the client re-sorting in three places, and it propagates errors so a failed load says so rather than looking like an empty log.

a4bc487 Add entry editing, pagination, and real cover artwork
Frontend Design Backend & data Database Framework & build

Editing: each timeline card gets an Edit button for the owner that loads the entry back into the add form, swaps the submit label, and shows a cancel banner. Saving updates in place. An edit survives routine auth-token refreshes — only an actual loss of owner rights discards it.

Pagination: 10 per page on both the Timeline (re-rendered) and the Dev Log (hidden/shown in place, since Astro renders it statically; day headings collapse when a page leaves them empty). The pager hides itself below one page.

Covers: every lookup source now also returns artwork — iTunes (upscaled from the 100px thumb Apple returns), Spotify/YouTube thumbnails, Wikidata P18, AniList coverImage. Entries fall back to the generated SVG when there's no image or it fails to load. A "Fetch missing covers" utility backfills older entries.

The cover_url column is probed once at boot and omitted from writes when absent, so this works against a database that hasn't run the ALTER TABLE yet.

Also wired up astro check (0 errors) and pinned down a Tailwind/Vite types skew.

2380034 Repaint in pastel, rename Overview to Home, and surface the build story
Frontend Design Database Framework & build Deploy & infra Docs & spec

- New light pastel palette across tokens, emotions, genre cycle, and type accents, taken from the reference player artwork: soft peach/pink/lavender wash, translucent white cards, muted purple ink - Each tab gets its own illustration instead of sharing the record: a player window (Home), an open journal (Log), a code window (Dev Log). The record only spun on one tab's worth of meaning anyway - Dropped the SIDE A / SIDE B fineprint from Log and Dev Log - Overview renamed Home; hash is now #home - devlog.ts now classifies each commit into work areas (frontend, design, backend & data, database, framework & build, deploy & infra, docs) and reads the stack's versions out of package.json. Home gains a "How it was built" section showing the breakdown, the stack, and recent changes; Dev Log tags each commit with its areas - A tracked pre-push hook redeploys on every push so the live Dev Log always matches GitHub; failures are swallowed so they can't block a push. `npm run ship` does the same explicitly

87a5da3 Give each tab its own hero copy
Frontend Database Framework & build

The banner was one static block shared by all three tabs, so its text didn't describe whatever was actually showing below it. Hero now renders three variants (eyebrow/title/lede/fineprint), toggled by the same showTab() that already switches [data-panel] — Overview talks about the aggregate stats and mood map, Log about adding/browsing entries, Dev Log about the build history.

2026.07.30 10 commits
4765583 Scroll to top when switching tabs
Frontend

Keeps the new tab starting at its own beginning instead of inheriting the previous tab's scroll position. Honours prefers-reduced-motion.

c94d83d Add a Dev Log tab and regroup the page into three tabs
Frontend Backend & data Database Framework & build Deploy & infra

The nav is now a tab switcher rather than anchor links, with the active tab mirrored to the URL hash so tabs can be linked directly.

- Overview holds Summary + Mood Map; Log holds Add Entry + Timeline (the backup/utility row moves here, next to the entries it acts on); Dev Log is new - Dev Log reads `git log` at build time via src/lib/devlog.ts and groups commits by day. Chosen over the GitHub API so it needs no rate limit budget and keeps working while the repo is private; it refreshes on rebuild - "Locate on map" now switches to the Overview tab before scrolling

597bea3 Point deploys at the moodlogremi.pages.dev project
Framework & build Deploy & infra
27dc387 Add deploy script and document the Astro/Cloudflare setup
Framework & build Deploy & infra Docs & spec
986cd25 Rebuild as an Astro + Tailwind project
Frontend Design Backend & data Database Framework & build

Replaces the single hand-written index.html with a real build: Astro for the static shell, Tailwind v4 for styling, TypeScript for the client logic. Output is still a static site — no adapter, no server.

- src/components/* hold the page shell; all dynamic rendering stays client-side in src/lib/app.ts, which fills the sections from Supabase - src/lib splits the old monolith into config (media types, emotions, palette), supabase (typed CRUD), art (cover art + radar), parse (link/title lookup) - Design tokens moved into Tailwind's @theme, so colors and fonts are utilities; only the paper grain, record spin, and timeline rail stay as custom CSS - Behaviour and the Supabase schema are unchanged

Node was upgraded to 26 (Homebrew) since Astro's Vite needs >=20.12; the old system Node 20.10 at /usr/local/bin is untouched and still resolvable.

8a4f30e Fix stale note about the parse box being type-gated
Backend & data Docs & spec
04e2bae Update spec for movie/anime parsing and title search
Docs & spec
5766b10 Parse movie and anime sources, and accept a plain title anywhere
Backend & data Deploy & infra

The parse box is now shown for every media type and takes either a link or a title to search for.

- IMDb links resolve through Wikidata (keyless, CORS-open): localized title, director, and genres normalized against this app's presets - MyAnimeList and AniList links resolve through the AniList GraphQL API - Typing a name searches Apple Music (vinyl), Wikidata (movie), or AniList with a Wikidata fallback (anime) — Wikidata is what makes Chinese titles work, since AniList only indexes romaji/English/Japanese - Streaming hosts (Bahamut, Netflix, Bilibili, …) forbid cross-origin reads, so their pages cannot be inspected client-side at all; those links now say so and point at the title search instead - Enter in the parse box triggers a parse

55e5873 Update spec for single-page lifelog architecture and Supabase backend
Frontend Backend & data Docs & spec
b2c8fca Restructure as a single lifelog page with a type selector
Frontend Backend & data Database Framework & build Deploy & infra

Vinyl/Movies/Anime are no longer separate tabbed pages. The lifelog rollup is now the only header: one hero, then Summary, Add Entry, Timeline, Mood Map as sections of one page, with nav links as anchors.

- One add form; a Type chip row swaps field labels, placeholders, context options, genre presets, submit label, and shows the link parser for Vinyl only - Timeline and Mood Map cover every type at once, filterable by type and by genre (the genre list narrows to whatever the type filter leaves in scope) - Entries normalize to {mediaType,title,creator,...}, dropping the per-type titleKey/creatorKey indirection; DB schema unchanged - Migration is one button covering all three legacy stores - Link parser tolerates messy pasted URLs and supports Spotify - Added cover-art shapes for film/anime genres

2026.07.29 2 commits
8623dc9 Add migration for movie/anime tabs, support Spotify links in parser
Backend & data Database Framework & build

- migrateLocalLegacyData is now config-driven (per-media legacy storage keys) and checks both Claude storage and localStorage, skipping entries already in the cloud - Parser recognizes Spotify links via oEmbed (title only; Spotify does not expose artist or genre)

7e8cda7 Add Mood Log app (Vinyl/Movies/Anime/Lifelog, Supabase-backed public site)
Backend & data