Tiny pieces of writing for very specific feelings.
Translation pending
CommerceHTML · PythonGitHub @AkshayDigheGithubOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Loading the full README. The saved excerpt is shown below.
A no-login static site. Each page is a short piece of writing built to make the
reader feel one specific emotion. Payment happens on Buy Me a Coffee (BMC);
BMC's post-payment redirect is the delivery mechanism — there is no backend,
no database, no email.Read the full README ↗A no-login static site. Each page is a short piece of writing built to make the reader feel one specific emotion. Payment happens on Buy Me a Coffee (BMC); BMC's post-payment redirect is the delivery mechanism — there is no backend, no database, no email.
content/moods.json THE SHELF — the 8 purchasable pieces: price, teaser,
palette, Buy Me a Coffee URL
content/feelings.json THE TAXONOMY — 12 feelings the free tools run on, plus
the mood-finder quiz and its weights
content/wheel.json the emotion wheel: 9 categories x 4 branches x 3 leaves
content/tool-content.json every curated line the tools compose from
content/i18n/<lang>.json THE TRANSLATIONS — one catalogue per language
(en de es fr pt hi zh ja ru ar). en.json is the source.
scripts/build.py generates the whole site. Run after any content edit.
scripts/i18n.py the languages: catalogues, URL shape, hreflang
scripts/tools_pages.py the /tools pages, imported by build.py
templates/*.html hand-written body copy for the bespoke pages
--- generated, do not hand-edit ---
index.html moods/ mood/<slug>/ collections/ about/ send/ for/
help/payment/ 404.html today/
<lang>/... the same pages in the nine other languages
tools/ tools/<tool>/ the seven free tools
moods-data.js <lang>/moods-data.js feelings-data.js tool-content.js
sitemap.xml robots.txt img/og/<slug>.jpg
--- shared, hand-written ---
moodshop.css one stylesheet for every public page
moodshop.js homepage + mood page behaviour
recommend.js the scoring engine: pure, deterministic, no DOM
tools.js all seven tools' interaction
analytics.js msTrack() over Vercel Analytics + Datafast
gift.js gifting from a paid page
types/globals.d.ts ambient types so `tsc` can check the .js files
jsconfig.json typecheck config — never shipped (.vercelignore)
--- the paid product: NEVER generated, never restyled by the shop ---
calm.html nostalgia.html happiness.html hope.html
rage.html hurt.html sorrow.html grief.html
bundle.html the pack's delivery page
api/counter.js Vercel serverless function
.vercelignore keeps content/, scripts/, templates/ and
jsconfig.json out of the deployment — they build the site, they are not
part of it, and nothing should be able to fetch them from moodshop.lol.
(The build scripts used to live in tools/, which is now a public route.)
The reader pages are what people paid for. They are self-contained: their
own <style>, their own fonts, no dependency on moodshop.css or
moodshop.js. A change to the shop can therefore never break a page someone
already bought. scripts/build.py does not write to them. Keep it that way.
content/moods.json (copy an existing one).<slug>.html — the paid piece — by copying an existing reader page.https://moodshop.lol/<slug>.html, and paste the /e/ URL into
checkoutUrl.EMOTIONS in api/counter.js.python3 scripts/build.py — the homepage grid, /mood/<slug>/, /moods/,
the finder, the sitemap, robots.txt, the JSON-LD and the OG image all
update themselves.Nothing in that list involves touching a component, which is the whole point: the shop scales to 50 moods without a rewrite.
The site is published in English, German, Spanish, French, Portuguese,
Hindi, Chinese, Japanese, Russian and Arabic. Not a widget that swaps words
at runtime: every language is generated at build time, with its own URLs,
its own <title>, its own JSON-LD and its own copy of the writing.
/mood/calm/ en — and the x-default
/es/mood/calm/ es
/ar/mood/calm/ ar — <html dir="rtl">
English sits at the root; the other nine live under their own prefix.
content/i18n/en.json is the source of truth and the fallback. Each
catalogue has the same shape:
| key | what it holds |
|---|---|
meta | native name, hreflang, og:locale, dir |
ui | every string the page furniture and the templates use |
js | the strings moodshop.js writes into the DOM |
pages | title / description / og:* for each page |
faq | the seven questions on the homepage |
moods | the eight feelings: name, tagline, teaser, seoTitle, keywords |
bundle, collections, finder, writingNow | the rest of the shelf |
A key a translation hasn't got yet falls back to English rather than
vanishing, so a half-finished language still renders a whole site.
{price}, {name}, {n} and friends are filled in by the build — the
currency symbol sits in the translation, because "$1" and "1 دولار" don't
put it in the same place.
Templates use two placeholders: {{t:key}} for a string and {{u:/path/}}
for a link, so one templates/home.html is the source of all ten homepages.
Every translated page is canonical to itself — /es/mood/calm/ points at
/es/mood/calm/, never at the English one — and carries the full
rel="alternate" hreflang set (ten languages plus x-default → English).
sitemap.xml repeats the same set as xhtml:link on every entry. That is
what tells a crawler these are one page in ten languages, not ten thin
duplicates of one.
<lastmod> is real. It is not the build time — every build rewrites every
file, so that would claim 143 pages changed every time and be worth
nothing. It is the date of the last commit that touched the sources of
that page's words (the content JSON, the language catalogue, the
template), with an uncommitted edit counting as today. Changing
scripts/build.py deliberately does not bump it: new page furniture is
not new writing.
/for/ and /help/payment/ are noindex, so they carry no hreflang
set at all — a page you have asked not to index has no business
advertising alternates. They keep their self-canonical and their language
picker, which is a reader's concern rather than a crawler's.
The tools, /today/ and the free reader pages are English-only for now:
one URL each, no hreflang set, and no invented translated URL that would
404. i18n.LOCALIZED is the single place that decides, and a link to one of
those pages from a translated page points at the English original. The
language picker on such a page sends you to that language's home instead.
content/i18n/en.json to content/i18n/<code>.json and translate it.
Set meta.hreflang, meta.locale and, for a right-to-left script,
meta.dir to rtl.ORDER in scripts/i18n.py, in picker order.python3 scripts/build.py. The pages, the picker, every hreflang, the
sitemap and the per-language moods-data.js all update themselves.Nothing about moodshop.js changes: it reads its strings and its link
prefix from <lang>/moods-data.js, with the English text compiled in as a
fallback.
index.html answers one question — how do you want to feel? — and then
gets out of the way. Top to bottom:
/mood/<slug>/, carrying its name, its one-line tagline and its
price. A ninth dashed card marks Love as being written; it is not
clickable and not buyable.content/moods.json under finder./send/.FAQPage JSON-LD),
then the signed author note.Motion is gated behind prefers-reduced-motion, and scroll-reveal is gated
behind a .js class on <html> plus a 2.5s timer fallback, so a JS failure
can never leave the page blank.
Every teaser is the real opening of the piece it sells — 24–35% of the
words, cut mid-thought with a CSS mask. If you rewrite a piece, update its
teaser / teaserTail / words in content/moods.json and rebuild, or the
preview stops matching what buyers get.
| Plan | Price | What it is | Status |
|---|---|---|---|
| One feeling | $1–$5 | Any single piece — the 8 existing BMC Extras | Live |
| The Complete Mood Pack | $9 | All eight in one payment (vs $23 separately) | Needs one BMC step — see below |
| Tip jar | Any | The plain BMC profile, no delivery promised | Live |
The per-piece prices here are the prices of the live Buy Me a Coffee
Extras. They are set on BMC, not in this repo, and the site displays
whatever price says in content/moods.json. Those two must agree: showing
"Enter this feeling — $1" on a button that opens a $5 checkout is a broken
purchase and a false price claim, so the data mirrors reality.
If you want the flat $1-per-piece / $5-pack model:
"price": 1 in content/moods.json, and the bundle to 5.python3 scripts/build.py.Every price on the site — cards, buttons, JSON-LD offers, OG images, the "$23 → save $14" anchor — is computed from that one file, so it is a three-minute change once BMC agrees.
bundle.html (the delivery page) is built and live. What does not exist is a
BMC Extra to sell it, so content/moods.json has bundle.checkoutUrl: null
and every pack block on the site renders as a price anchor pointing at
/moods/, never as a checkout. That is enforced in one place —
bundle_block() in scripts/build.py — so it cannot be half-done.
Why: a real checkout would take $9 and deliver nothing automatically.
Why keep the card at all: it is a price anchor. $9 for eight makes a $1 piece read as trivial, which is exactly the decision a first-time visitor should make.
To open it:
https://moodshop.lol/bundle.html./e/ URL in content/moods.json as bundle.checkoutUrl.python3 scripts/build.py.Step 4 flips every pack block on every page to a live "Get the complete collection — $9" button with a Best value badge. Nothing else to edit.
Each reader page ends with a quiet line under the share button — "You've got
Grief. Seven other feelings are on the shelf, from $1." — now linking to
/moods/ rather than the old #shelf anchor, which no longer exists. It sits
below the piece and after the share button on purpose: the product gets read
first, the shop gets mentioned second.
Those pages also load analytics.js and fire purchase_completed on load —
they are reachable only through the BMC redirect, so a load is a purchase.
A load carrying a gift fragment is excluded: there, the sender paid.
Every emotion page has a Send this to someone button under the piece. The
buyer types a recipient name, a short note and their own name; gift.js
encodes that into the link's #fragment and hands it back:
https://moodshop.lol/hope.html#g=eyJ0IjoiU2FtIiwiZiI6...
They send that link themselves, in whatever app they already talk to that person in. The recipient opens it, sees the note in a card above the piece, and the footer line switches from "You've got Hope" to "Someone paid to send you this."
Why a fragment and not a query string or a database:
Trade-offs worth knowing:
textContent, so a note can't inject markup,
and a malformed payload is ignored rather than breaking the page.Gifting is currently free with any purchase rather than its own product. That's deliberate: it costs nothing to run, raises the value of every piece sold, and puts your writing in front of people who didn't know the shop existed. If you'd rather sell it, make a "Gift a feeling" BMC Extra the same way as the bundle and point it at the piece.
The emotion pages also carry Open Graph tags now, so a gift link shows a
card ("Someone sent you a feeling") instead of a bare URL when it's pasted
into a messenger. They stay noindex.
A short signed note sits between the FAQ and the footer, answering the question every reader now asks by default: did a person make this, or did a model?
It says, in Akshay's voice, that Moodshop is one person; that the pieces were
written with AI in the loop; and that what he did was choose the eight
feelings and decide what stays. That disclosure is deliberate. The pieces
were drafted by an AI assistant (see the first commit, ab72e1b), so a note
claiming they were hand-written would be false in exactly the way a suspicious
reader suspects — and it would be doing that work on a page asking for money.
Saying it plainly is both true and, in a market where everyone else stays
quiet, the more persuasive move.
Keep it accurate if the shop changes. If pieces get rewritten by hand later, the note should say so; if an email list ever appears, the last line stops being true.
free/ holds three short pieces that are free and indexable, plus a hub
page at /free/. They exist because the shop could not rank: the product is
deliberately noindex, which left exactly one indexable page with 831 words
and nothing for Google to match a query against.
| URL | The search it is written for |
|---|---|
/free/cant-sleep.html | "something to read when you can't sleep", 3am racing thoughts |
/free/waiting-for-news.html | waiting on a result, a diagnosis, a decision |
/free/the-strong-one.html | "tired of being the strong one", everyone leans on me |
Deliberately different feelings from the paid eight, so nothing on the
shelf is cannibalised. Each is self-canonical, carries Article schema,
ends with a link into /#taste, and is listed in sitemap.xml. The landing
page links to /free/ under the shelf, giving crawlers a path in.
This took the site from 1 indexable page / 831 words to 5 pages / 1,914 words with real internal linking. It will not rank for anything competitive — it is aimed at long-tail phrasings where the intent match is exact and the competition is weak.
Add more over time; that is the whole mechanism. One new free piece a week is worth more than any amount of meta-tag work.
moodshop.lol 308-redirects to www.moodshop.lol, so every URL in this
repo points at www — canonical tags, og:url, sitemap, robots.txt,
and the share captions in gift.js and the emotion pages. Previously they
pointed at the bare apex, which meant the canonical named a URL that
immediately redirected.
If you would rather the bare domain be primary, flip it in Vercel (Project → Settings → Domains) and reverse this repo with:
grep -rl 'https://www\.moodshop\.lol' --include='*.html' --include='*.js' \
--include='*.txt' --include='*.xml' . \
| xargs sed -i 's#https://www\.moodshop\.lol#https://moodshop.lol#g'
The rule is only that the repo and Vercel must agree.
The site used to open with ~250 words of free writing before any product was visible. That writing is still on the homepage — under "Before any of this is a shop" — but it now sits after the mood grid, the finder and today's mood.
The reason for the move: a visitor arriving cold could not tell what Moodshop was until they had scrolled past an essay. The page now answers "how do you want to feel?" in the first viewport and gives the free thing once the offer is understood. Nothing was deleted.
Deployed on Vercel, live at https://moodshop.lol
Every page loads <script defer src="/_vercel/insights/script.js"></script>
— the standard way to add Vercel Web Analytics to a plain static site with
no build step (no @vercel/analytics npm package needed, since there's no
bundler here).
Confirmed working: the Analytics API returns real (currently 0/0, since there's no traffic yet) data for this project rather than an "enabled" error, so tracking is live on the Vercel side — no dashboard toggle needed. Once real visitors show up, numbers appear in Project → Analytics in the Vercel dashboard within a few minutes.
index.html, robots.txt, sitemap.xml, and every emotion page's share
script all point at this domain now (canonical, Open Graph, JSON-LD, the
share caption link).
The Vercel project has SSO/deployment protection enabled on its
*.vercel.app URLs (moodshop-delta.vercel.app,
moodshop-akshay-d111.vercel.app) — visiting those shows a Vercel login
wall to anyone who isn't logged into this Vercel account. Custom domains
are exempt from this, so moodshop.lol / www.moodshop.lol are the only
publicly reachable URLs.
This matters a lot for the BMC redirects below: if any Extra's "redirect to
a URL" field points at a .vercel.app URL instead of moodshop.lol,
paying customers get sent to a login page instead of their piece.
Double-check every one of the 8 Extras uses moodshop.lol, not
vercel.app, in its redirect URL — earlier setup guidance (including
from me, before the custom domain was connected) suggested .vercel.app
URLs, so this is worth verifying even if it "worked" during testing while
logged into Vercel yourself.
All 8 Extras are created and wired into index.html:
Each Extra's Success page must be set to "Redirect to a URL after
purchase" pointing at that emotion's page on the live domain (e.g. Calm →
https://moodshop.lol/calm.html) — "Confirmation message" (BMC's default)
does not deliver anything, it just shows a thank-you message on BMC's own
site. See "Deployment protection" above — use moodshop.lol, not a
vercel.app URL.
og-image.jpg (600×315) lives in the repo root and is wired into
index.html's og:image/twitter:image tags. It's a brand card — dark
gradient, "Moodshop" wordmark, the 8 emotion-color swatches, moodshop.lol
— generated by rendering og-image.html-style markup through headless
Chromium and screenshotting it (no design tool involved). To regenerate it
with different copy/colors, edit the HTML, re-screenshot at 1200×630 (crop
to exact size if the renderer pads it — Chrome's --screenshot CLI can add
a few dozen px of blank space depending on version), and replace the file.
This is the "unlisted, not locked" approach: the only way to reach
sorrow.html etc. is the BMC redirect after payment. To keep it that way:
/mood/<slug>/, which sells it and shows only its opening.<meta name="robots" content="noindex, nofollow">.robots.txt disallows them, and scripts/build.py regenerates that list from
readerPage in content/moods.json, so a new mood can't be forgotten.sitemap.xml lists only public pages.It's a soft gate, not real security — anyone with the direct URL can still open the page. That's the intended tradeoff (no backend, no auth).
Do not confuse the two URL shapes:
| URL | What it is | Indexed? |
|---|---|---|
/mood/grief/ | the shop page — tagline, teaser, price, buy button | yes |
/grief.html | the piece itself, what a payment opens | no |
Every feeling has its own indexable page at /mood/<slug>/ with a unique
title, description, canonical, Open Graph + Twitter card, its own 1200x630
OG image, and Product + BreadcrumbList JSON-LD. Titles are written around
the intent someone actually types — "something to read when you're grieving
someone", "something to read when everything feels too loud" — rather than
stuffed with keywords.
Multiply that by ten: the same page exists in ten languages, each canonical
to itself, each carrying the full hreflang set and its own og:locale,
inLanguage and intent-shaped title in that language. See Ten languages
above.
Page inventory and their markup:
| Route | JSON-LD |
|---|---|
/ | WebSite + ItemList of 8 Product/Offer + FAQPage |
/moods/ | ItemList |
/mood/<slug>/ | Product + Offer + BreadcrumbList |
/free/ | CollectionPage (pre-existing) |
/tools/ | ItemList of the seven tools |
/tools/<tool>/ | WebApplication (free) + BreadcrumbList |
/today/ | none — the content changes daily |
/collections/, /about/, /send/ | none needed |
/for/, /help/payment/ | noindex — not search destinations |
sitemap.xml and robots.txt are both generated by scripts/build.py. Adding
a mood adds its sitemap entry and its reader-page Disallow automatically.
Search Console first reported all eight shelf items as invalid: one
critical issue each, because a Product with no image can't produce a rich
result. The ItemList now gives every piece the full set:
| Property | Value |
|---|---|
image | https://www.moodshop.lol/img/og/<slug>.jpg — 1200×630, the piece's own gradient |
url | https://www.moodshop.lol/mood/<slug>/ — that feeling's own page |
sku | moodshop-<key> |
brand | Moodshop |
offers.url | the BMC Extra that actually sells it |
offers | price, priceCurrency, availability, itemCondition, priceValidUntil, seller |
Two things to keep in mind when editing:
price in content/moods.json. This used to be a manual-action
risk; now it is one number in one file. Same for the word counts.priceValidUntil is 2027-12-31. Once it's in the past Google treats the
offer as expired and the item drops out of rich results. Push it forward.review and aggregateRating are still reported as missing. That's a
non-critical warning and it stays: there are no real reviews, and inventing
them is exactly the kind of thing that earns a structured-data penalty. If
real ratings ever exist, add them then. shippingDetails and
hasMerchantReturnPolicy are likewise absent — nothing ships, and there's no
written refund policy to encode yet (the FAQ says a wrong piece gets swapped,
not refunded). Encode it only once it's a real policy.
Regenerate the OG images after a palette change:
pip install Pillow
python3 scripts/build.py
scripts/make-product-images.py is the older 1200x1200 square generator; its
output in img/*.jpg is no longer referenced by any markup, and its palette
table is a hand-kept copy. scripts/build.py reads the palettes straight from
content/moods.json, so there is nothing to keep in sync.
noindex — they're the paid product, not content you
want ranking or showing up in search results out of context. /mood/<slug>/
is the page that ranks for a feeling.Deployed on Vercel specifically because it now needs one small serverless function (see below) — a pure static host (GitHub Pages, Netlify's static tier) would no longer be enough. Any host with lightweight function support would still work.
The Vercel project is connected to this GitHub repo, so every push to the production branch deploys itself. Nothing to run by hand.
claude/moodshop-static-site-cue6zs. This repo has
no main — that branch is the default, and it is what Vercel builds and
aliases to moodshop.lol. Merge feature branches into it to ship..vercel.app
URL, which is behind the SSO wall described above.Worth knowing, because it cost an afternoon once: the project spent its early
life unlinked, deployed by direct file upload from a laptop. If a push
ever stops showing up on the live site, check Settings → Git first — an
unlinked project accepts pushes silently and deploys none of them, and a
project linked to the wrong repo will happily publish that repo's site to
moodshop.lol.
The landing page shows an honest running total: "N pieces unlocked so far." It's real, not decorative — no number moves unless someone actually loads an emotion page.
How it works: each emotion page fires a silent fetch() to /api/counter
(a Vercel serverless function, api/counter.js) on load, which server-side
proxies to Abacus (a free, keyless public
counter API) and increments that emotion's key under the
moodshop-digheakshaf namespace. index.html calls the same function
(?action=total) to sum all 8 keys and show the total — or "No one has
unlocked a piece yet — be the first." at zero. If anything fails, the stat
line just stays hidden rather than showing a stale or wrong number.
This goes through a same-origin API route rather than calling Abacus
directly from the browser specifically to avoid ad blockers. A direct
client-side fetch() to a third-party "hit counter" domain is exactly the
kind of cross-site request tools like uBlock Origin and Brave Shields block
by default — which was silently hiding the counter for real visitors. The
proxy makes it a same-origin call, which those tools don't touch.
Known limitations, worth knowing before you rely on this:
?action=total return 502 and the landing page silently hid
the stat line — it had never rendered for a single visitor. A 404 from
upstream is now read as the zero it is; a real outage still fails loudly.Two different share surfaces, on purpose.
On the shop (/mood/<slug>/) — a row of five: Send this feeling
(native share sheet), Copy link, Post on X, WhatsApp, Save the card.
The last one draws a 1080x1080 quote card client-side on a <canvas> in that
feeling's own gradient, then offers it to the native share sheet or downloads
it. Everything shared points at /mood/<slug>/ — the page with the teaser,
not the piece — so a shared link converts instead of leaking.
On a reader page — the pre-existing quiet "Share this feeling" button, unchanged. It shares a caption and the card, never the reader page's own URL, because that link would give the full piece away.
/send/ and /for/)A pre-purchase gifting loop that needs no backend, no account and no database.
/send/, someone picks a feeling, a recipient name and a short note.moodshop.js encodes {m, t, f, n} as url-safe base64 into the
fragment of https://www.moodshop.lol/for/#<payload>./for/ decodes the fragment, shows "For Sam." over their note in that
feeling's colours, and offers Open the feeling → into /mood/<slug>/.A fragment is never transmitted to the server. The note reaches Vercel, this repo and every analytics provider exactly never — it exists only in the two people's browsers. There is nothing to store and nothing to leak.
The recipient gets the free teaser, like everyone else. This is deliberately
not a way to give away paid writing: that is what gift.js on a reader page
is for, and it requires having paid first.
/for/ is noindex and degrades gracefully — a missing, truncated or
mangled payload shows a written empty state, and <noscript> explains why the
note needs JS and links on to the shelf.
/tools)Seven tools, all free, all no-account, and none of them call a model or a
server. Every result is composed in the browser from hand-written blocks
in content/tool-content.json using the rules in recommend.js. That is a
product decision before it is a technical one: these are supposed to feel
written, and a tool that has to wait on an API is a tool that fails at 2am
on a bad connection.
| Route | What it does | Where the output comes from |
|---|---|---|
/tools/ | the hub | static |
/tools/mood-finder/ | 3 questions → a feeling | weighted scoring over content/feelings.json |
/tools/emotion-wheel/ | 9 categories → 36 branches → 108 precise feelings | content/wheel.json |
/tools/message-i-cant-send/ | who × what × tone → a message | 61 hand-written blocks composing 336 messages |
/tools/mood-journal/ | private journal | localStorage, nothing else |
/tools/send-a-feeling/ | a curated card with a share link | 24 curated lines; the link is a URL fragment |
/tools/thought-generator/ | one original line per feeling | 84 hand-written thoughts |
/tools/2am/ | one question at a time, darker | 16 prompts |
recommend.js is pure: no DOM, no fetch, no Math.random, no model. The
mood finder adds up the weights on the answers you picked and takes the
highest. Ties break on a published order (quiz.tiebreak) rather than
on object key order, so re-serialising the JSON can never silently change
what the tool tells people. The same three answers always produce the same
feeling — a tool that answers differently each time is not telling you
anything.
The result says "You might be feeling…", never "You are". It is a weighted score over three taps, and the page says so underneath.
content/feelings.json is deliberately wider than the shelf: a tool has to
be able to name what someone is in, including the four feelings with no
piece written yet (Love, Loneliness, Anxiety, Peace — and Joy, which is
called Happiness on the shelf). Every feeling therefore carries:
product — the purchasable mood it resolves to, andproductNote — the sentence that says so out loud when they differ.So Loneliness recommends Sorrow and tells you it is doing that, rather
than quietly swapping the product under the reader or pretending a
Loneliness piece exists. resolveProduct() is the only place that mapping
lives; nothing else in the codebase guesses.
localStorage under moodshop.journal.v1. No account,
no server, no sync. Every read and write is wrapped — private mode and a
full quota both fail closed with a written message rather than an
exception.#, so it reaches
neither Vercel nor either analytics provider.journal_entry_created carries a mood slug
and nothing else; message_generator_used carries nothing at all. This is
asserted in the test suite, not just intended.Every tool degrades in writing rather than silently: each carries a
<noscript> explaining why it needs JS (the work happens in your browser,
which is the reason nothing has to be sent anywhere) and links somewhere
useful. The emotion wheel goes further — the full 108-feeling taxonomy is
rendered into the HTML as a nested list under "Every feeling, as a list",
so keyboard and screen-reader users get the whole thing without the
three-step interaction, and so do crawlers.
A feeling: add it to content/feelings.json with a product that already
exists, add its thoughts to content/tool-content.json, rebuild. It appears
in the wheel's targets, the thought generator, the journal's tag list and
the finder's scoring without touching a component.
A tool: add an entry to TOOLS in scripts/tools_pages.py and a body
function in BODIES. The hub card, the route, the SEO metadata, the
breadcrumb and WebApplication JSON-LD, the sitemap entry and the
"explore another tool" links on all the other tools update themselves.
The browser gets the same .js files that are in the repo — there is no
bundler and no TypeScript to compile. The types are real anyway: the code
is annotated with JSDoc, the window.* globals are declared in
types/globals.d.ts, and
npx tsc -p jsconfig.json
type-checks the lot. It passes clean. jsconfig.json is in .vercelignore,
so adding it cannot change how Vercel builds or serves the site.
analytics.js defines window.msTrack(name, props) over the two providers
already on the site — Vercel Web Analytics (window.va) and Datafast
(window.datafast). Either may be blocked by a content blocker; every call is
wrapped, so a missing provider is a no-op and never an exception. Calls made
before the deferred provider scripts run are queued and flushed.
Events, and where they fire:
| Event | Fired by |
|---|---|
homepage_view | the providers' own pageview — not duplicated here |
mood_selected | any mood card, today's mood, a finder result, a gift open |
mood_finder_started | first answer tapped, or the nav/hero Find my mood |
mood_finder_completed | a recommendation is shown |
mood_page_view | the providers' own pageview on /mood/<slug>/ |
teaser_view | the teaser scrolls 40% into view |
checkout_started | any click on any buymeacoffee.com link, anywhere |
purchase_completed | a reader page or bundle.html loads without a gift fragment |
bundle_clicked | any pack CTA |
share_clicked / share_completed | the share row |
send_feeling_clicked | the Send a feeling entry points and /send/ submit |
tools_viewed | a tool card, on the hub or the homepage |
emotion_wheel_used | each step of the wheel, and the result |
message_generator_used | a message is composed — never the message |
journal_entry_created | an entry is saved — mood slug only, never the text |
send_feeling_started | the send tool builds a link |
thought_generated | a thought is shown |
2am_mode_started | 2AM mode opens |
mood_recommendation_clicked | any "explore this feeling" out of a tool |
Declarative usage: data-track="…" plus optional data-track-mood /
data-track-from on any element. No personal data is collected — event names
and a mood slug, nothing else. Custom events need a Vercel Web Analytics plan
that supports them; the calls are harmless either way.
| State | Page |
|---|---|
| 404 | 404.html — "Looks like this feeling got lost." + all eight moods. Vercel serves it automatically for unmatched paths on a static deployment. |
| Payment cancelled / declined / paid-but-nothing-opened / link lost | /help/payment/, linked from every mood page under the buy button |
| A gift link that carried nothing | the empty state on /for/ |
JS off on /for/ | <noscript> explains and links to /moods/ |
There are no testimonials, so none are shown. The structure is in
templates/home.html as a commented-out section, and content/moods.json
has an empty testimonials array. Real quotes go in there. Nothing
invented goes there — fake reviews are the fastest way to lose both trust
and a rich result.
The actual writing lives in each emotion page — that's the product. Feel free to edit tone/length; each is currently 150–300 words in second-person or letter format, per the spec.