[CHANGELOG]

What shipped

Every release of the Whirlwind template, straight from the repository buyers clone. Updates are included with every licence.

Unreleased

  • ›AI replies render as formatted text.

    Models answer in Markdown, and the chat and content writer showed it raw, so replies read as **stars**, # signs and dashes. components/ui/Markdown.jsx renders the parts replies use (paragraphs, headings, lists, quotes, code, bold, italics and links) as React elements, so model output can never inject HTML, and links are limited to http(s) and mailto. It copes with half-streamed text, and leaves snake_case, __init__ and 2*3*4 alone. What the user types in the chat is still shown exactly as typed.

  • ›One header on every page, and a way around once signed in.

    The landing page and dashboard used one header, the docs and blog each had their own, and the legal and post-checkout pages had none, so a signed-in user could not reach the docs or blog from the dashboard, or pricing from the docs. components/layout/Header.jsx is now the only header, on every page, with its links in config/site.js nav.links. Signed in, a Dashboard button replaces Sign in beside the account menu, which gains "AI features". Every dashboard page gets tabs (DashboardNav.jsx, rendered by app/dashboard/layout.jsx), and the blog keeps its categories as a row under the header. Links marked docs: true follow the same rule as app/docs itself (siteConfig.showDocs), so the header and footer no longer link to docs that return 404 in production. HeaderDocs.js, HeaderBlog.js and the @headlessui/react dependency are removed.

  • ›Security: signed-in users could give themselves unlimited credits.

    consume_credit, refund_credit and claim_pending_purchases are SECURITY DEFINER, and Supabase lets the anon and authenticated roles call public-schema functions unless that is revoked. Nothing revoked it, and the anon key ships to every browser, so any user could call refund_credit on their own id with a large amount and run AI generations on the owner's keys indefinitely, or drain another user's balance with consume_credit. Migration 007_lockdown_and_trial_budget.sql revokes all three from everyone but service_role; the app already called them only from API routes with the service-role client, so nothing else changes. A new flow test, "a signed-in user cannot call the credit functions", fails on any database without 007. Run 007 on every existing project.

  • ›A daily ceiling on free-trial spend.

    Signups cost only an email address, so the per-user trial could not stop someone farming accounts against your AI keys. config/billing.js trialDailyCreditCap (default 1000) caps credits spent per UTC day by all users without access combined; past it, unpaid users get a 429 and paying users are unaffected. The counter lives in the database (trial_spend, consume_trial_budget) so it holds across serverless instances. The docs' deploy checklist now also points at Supabase's own sign-in rate limits and CAPTCHA, which are the controls that apply to the sign-in request itself.

  • ›The audit now catches an AI route that charges nothing.

    "Credit discipline" checked that routes which charge also refund, but never that a route calling a model charges at all, so a route whose charge() an agent removed was simply not examined and the step passed. It now fails any API route that calls AIClient or a provider SDK without charging, unless the route is marked credits: exempt with a reason.

  • ›doctor and setup --check now see migrations they used to miss.

    doctor checked seven tables and reported "Healthy" on databases without email_log (005) or the trial defaults from 006, which change nothing the REST API can observe. setup --check --json, the command agents are told to verify with, hard-coded migrations: null and returned ok: true on a database that was behind. Migration 007 adds whirlwind_schema_version(); both commands now compare it with the newest template migration on disk and fail with the files to run. check-docs fails if a future template migration does not bump the version.

1.0.0

  • ›The agent never handles your keys.

    The documented setup flow used to have the agent collect each key in chat and pass it to npm run setup --non-interactive, which meant every secret went through the agent's context and transcript. SETUP_WORKFLOW.md, CLAUDE.md, AGENTS.md and the skill now have the agent tell you which key goes where and then verify with npm run setup --check --json and npm run doctor, while you run npm run setup yourself in your own terminal and paste the keys at its prompts. --non-interactive stays for CI, where the values are already in the environment. No script behaviour changed; the script never printed a secret and still writes .env.local with mode 600.

  • ›In-app docs now describe what ships.

    app/docs had generic Supabase guides for email/password and OAuth sign-in, a schema with made-up profiles columns, a streaming example that called a route that does not exist, npx supabase db push as the migration step, and markdown syntax that rendered as literal asterisks. Every topic was rewritten from the code: magic-link auth as wired, the email-first checkout flow and the five webhook events, the eight real tables and their functions, rateLimitMiddleware, npm run deploy preflight, and the actual config/theme.js keys. The two unwired auth guides became one "Adding OAuth or Passwords" page that says so in its first line. The blog placeholder no longer mentions Whirlwind or a boilerplate.

  • ›Two more vibe presets: studio and ledger.

    Five presets total, each combining a hero, features and pricing layout with its own font pair, accent and background. The appearance tests derive from vibePresets, so a new preset is covered without touching the spec.

0.9.0

Packaging + verification fixes. Every fix below was reproducible in the shipped 0.8.0 zip.

  • ›CI could not pass, in two further ways, both introduced when the audit was wired into it.

    First, audit.mjs exits 2 for warnings-only and 1 for real failures — the convention deploy.mjs already gates on — but the workflow treated any non-zero as failure. The buyer-path step creates .env.local, which the audit warns about by design, so the step warned at itself and died with zero actual failures. CI now fails only on 1 and surfaces warnings as a run notice. Second, under CI the Playwright suite serves the production build (dev optimises fonts differently and would void the webfont assertions), but next start is production runtime, where validateEnv throws on missing credentials on purpose: the landing page 500'd, Playwright waited for a response under 400, and both test steps died on the 120s webServer timeout. The appearance step now gets correctly-shaped placeholder credentials — presence is all requireEnv checks, and those tests need no real services — while the flow step exits early with a notice when no secrets are set, because without real credentials there is no server for its tests to skip against. The whole pipeline was then replayed in a clean clone: install, lint, docs check, build + audit, smoke test, 16 appearance tests, flow-step skip.

  • ›The billing switch changed behaviour but not the interface.

    Setting model: "license" made the routes stop metering, yet every credit display was unaware of it: the dashboard still showed a balance under "Remaining this month", the image button still advertised a per-image price, and the chat toolbar still reported credits left. A perpetual licence showing a number counting down to zero reads as a product about to stop working. The dashboard card now reads "Licence / Perpetual licence · no usage limit" with Active/Inactive from has_access, and the two feature readouts hide themselves when nothing is metered. Verified in a real browser in both modes, because the dashboard is a client component and its server response is only a loading shell — curl cannot see the card at all.

    Two smaller things fell out of it. The card's balance fell back to a hardcoded ?? 100, stale since the trial became 20; it now shows an em dash when the figure is genuinely unknown. And the per-image price was written out in two route files and three prose mentions — it moved to billingConfig.imageCredits, which both routes and the button now read, so the price cannot disagree with itself.

  • ›A first subscription granted no credits and recorded no subscription.

    users.stripe_customer_id is written by claim_pending_purchase, which runs when the buyer clicks their magic link. Stripe does not wait: on a first subscription customer.subscription.created and invoice.paid arrive within seconds of checkout and before any claim, so looking the user up by customer id found nobody — the credit reset was skipped and the subscriptions table stayed empty. From the second month onward it worked, because by then the id had been stored, which is exactly how it stayed invisible. Observed live: the handler logged No user for customer cus_… — no reset while the database showed that very id on the user, stored moments later by the claim.

    The lookup now falls back to the Stripe customer's email — the key the rest of the template is built on — and stores the id on the way through, so the detour happens once per customer. It also no longer swallows its own errors: the previous version used .single(), which errors when no row matches, and destructured only data, so a failed query and an absent user were indistinguishable.

    Verified by reproducing the failure: unlink the customer id, replay the real invoice.paid from a live subscription, and credits go 20 → 500 with Linked … to Stripe customer … in the log. The subscription branch then wrote its row too, where it had written none.

  • ›A subscription event missing its period fields answered Stripe with a 500.

    new Date(undefined * 1000).toISOString() throws, and a throw here means Stripe retries an event that can never succeed. Newer Stripe API versions have begun moving current_period_* onto subscription items, so this is a real upgrade hazard rather than a hypothetical. Those two conversions now go through a helper that returns null instead of throwing; the same payload that produced a 500 now answers 200.

  • ›The free allowance equalled the paid one, so subscribing bought nothing.

    Signup granted 100 credits and a paid month reset to 100: the dashboard figure was identical before and after paying, which is how it was noticed. 006_trial_credits.sql makes the free grant a trial of 20 — twenty text generations or four images, enough to see all three bundled features — and config/billing.js raises the paid allowance to 500. Existing users are untouched: changing balances people already hold would take credits from anyone mid-trial, and anyone who has paid gets the paid figure on their next invoice anyway.

    The two numbers necessarily live apart — one is a database default applied the instant a user row appears, the other is config you can change without a migration — so the audit now asserts that they disagree and that config/billing.js's trialCredits matches what the signup trigger actually grants. Both failure modes negative-tested.

    Adding a sixth migration also made the numbering guard earn its keep: it immediately failed twelve stale references across six documents, which is precisely the drift it was written for after the same thing happened with 004 and 005.

  • ›Nothing ever renewed the credit allowance.

    The dashboard said "Remaining this month" and the webhook handled six Stripe events, none of them invoice.paid. So credits were granted once by the signup trigger and never again: a subscriber paid every month, ran out in month two, and stayed out. Separately, a completed purchase granted has_access without granting any credits at all, so anyone who spent their allowance and bought more paid and received nothing usable — the upsell path. Found by buying Pro on a spent-out account and still seeing zero.

    invoice.paid now resets the balance to monthlyCredits and clears a stale refunded_at, scoped to subscription invoices so the one-time invoices that invoice_creation now produces don't hand out free allowances.

  • ›Credits or a one-time licence is now a switch, not an assumption.

    config/billing.js sets model to credits (the default, metered, needs a recurring price) or license (users.has_access is the gate, nothing meters). The five paid routes called consume_credit directly and each had its own copy of the refuse-and-refund logic; they now go through lib/credits.js, so the model is decided in one place and the feature components read the same flag rather than locking a paying licensee out of a UI the server would serve.

    Three guards needed fixing to keep up, and all three had the same shape — a check anchored to something hand-maintained: audit.mjs's undefined-identifier step is keyed on a curated HELPERS list that had never heard of charge or refund, so four routes calling them unimported passed clean; deriving the list from every lib/ export replaced that with five false positives (refund in the terms page's "a full refund.", code samples in the docs, a scaffold template, supabase as a parameter name), so the list stays curated but a new step now fails if any lib/ export is neither checked nor explicitly waived. Its usage regex also treated a full stop as property access. And the credit-discipline step matched only consume_credit, so it declared the image routes uncharged the moment they were refactored. Every one of these was negative-tested.

  • ›A refund revoked access but not the credits.

    charge.refunded set has_access: false and cancelled subscriptions, and stopped there. Nothing gates on has_access: the five credit-charging AI routes call the credit function, and that function only checks the remaining balance. So a refunded customer kept their whole balance and could spend every credit of it against the operator's provider bill. Found by refunding a real test purchase and noticing the balance was still 100. The handler now zeroes usage.credits_remaining, leaving credits_used alone as the record of what was actually consumed. audit.mjs asserts the refund branch touches the balance — statically, because a webhook event cannot be delivered from a test — and flows.spec.js covers the half that is testable: a zero balance refuses further charges rather than going negative. Both negative-tested.

    Two notes on what this does not change. /dashboard is still gated on being signed in rather than on has_access, so a refunded user still reaches it and sees a FREE badge; that is a deliberate freemium-style degradation and the credit system is what protects revenue. And Stripe correctly keeps showing the charge as paid-then-refunded — refunding does not un-pay it.

  • ›The webfont test failed whenever it ran first against a cold server.

    webServer.url waits for the dev server to answer, which in dev is not the same as the page being compiled: the first response for / can arrive before the font CSS is in it, document.fonts is empty, and the assertion fails with nothing actually wrong. Because it is the first test in appearance.spec.js, it passed in a full parallel run — another worker had already warmed / — and failed 3 times out of 3 when run on its own. Verified both ways: against a warm server it passed, cold it did not. tests/global-setup.js now compiles / before any test runs. A spurious failure in the suite that is meant to be evidence of correctness costs more than the test is worth.

  • ›The test suite and the admin dashboard were undocumented.

    npm test appeared nowhere in README.md or SETUP.md — one incidental mention in CLAUDE.md, inside the description of npm run deploy. For a template whose argument is correctness, the suite proving it was invisible. SETUP.md has a section now: what each suite covers, that 25 passing means credentials are configured and 18-with-7-skipped means they are not, which vars flows.spec.js reads, that a live Stripe key skips the checkout tests deliberately, the dedicated port, test:ui, and the browser-install command. /dashboard/admin had zero mentions across every doc: a whole feature — signups, paying customers, conversion, credit consumption, recent activity — that no buyer could discover. Documented in both files, including that it answers 404 rather than 403 to anyone outside ADMIN_EMAILS, so a 404 while expecting access means an address mismatch rather than a broken page.

  • ›Fifteen env vars the code reads were absent from .env.example.

    Among them RESEND_API_KEY, EMAIL_FROM, CRON_SECRET, CREDITS_LOW_THRESHOLD, ADMIN_EMAILS, HEALTH_CHECK_TOKEN and every model override — so three whole features (lifecycle emails, /dashboard/admin, the detailed health view) were invisible to a buyer reading the file the docs tell them to copy. All fifteen are now present and grouped by the feature they switch on, with their real defaults, and every value still ships empty.

    check-docs.mjs only ever guarded one direction: every env var *named in the docs* had to exist in .env.example. It now also guards the inverse — every process.env.X the app reads must be documented there, with NODE_ENV and NEXT_PHASE excluded as runtime-provided. Negative-tested both ways. It immediately flagged an illustrative process.env.X inside a comment in create-portal/route.js, which is reworded rather than allow-listed; an allow-list entry would have hidden the next real one.

  • ›One-time purchases produced no invoice.

    create-checkout never set invoice_creation, and a Stripe Checkout payment creates a charge and a receipt but no Invoice object unless asked. The customer portal's invoice history was therefore empty for this template's headline flow — someone who paid once had nothing to file against an expense claim. Now enabled for mode: "payment", guarded because Stripe rejects the parameter for subscriptions, which invoice themselves. Verified against the live API.

  • ›The lifecycle emails were undocumented.

    SETUP.md gained a section: that they are the app's own emails rather than Supabase's, that the copy lives in TEMPLATES in lib/email.js, that /api/cron/lifecycle-emails sends them on the daily schedule in vercel.json and needs CRON_SECRET in production, what CREDITS_LOW_THRESHOLD does, and that email_log's unique constraint makes them send once per user per template — so re-testing one means deleting that row.

  • ›stripe listen failing with api_key_expired had no entry.

    The Stripe CLI authenticates with its own session key from stripe login, which Stripe expires after about 90 days — not with STRIPE_SECRET_KEY from .env.local, which comes from the dashboard and does not expire. So the normal experience of setting a project up and returning to it months later is a 401 that looks like the template's Stripe config is broken when nothing about it has changed. TROUBLESHOOTING.md now covers the error, names stripe login as the fix, states that the two keys are unrelated, points at ~/.config/stripe/config.toml where test_mode_key_expires_at records the lapse date, and notes npm run doctor -- --deep as the way to confirm the env key separately. The same entry also records that listen prints a new signing secret every session, so restarting it means updating STRIPE_WEBHOOK_SECRET again.

  • ›The required email-template step had an undocumented prerequisite.

    Having made "point the auth emails at /auth/confirm" a required setup step, the docs still didn't say that Supabase gates template editing behind custom SMTP on current projects — so a buyer reached a step marked required and found the dashboard wouldn't let them do it. SMTP setup now comes first in SETUP.md and SETUP_WORKFLOW.md, with Resend's host, port, username and sender spelled out. Two things that follow from it are documented too: onboarding@resend.dev only delivers to the address that owns the Resend account, which is fine for signing in as yourself and breaks the post-purchase magic link to a customer (so a verified domain is needed before testing checkout); and Supabase SMTP is a different system from RESEND_API_KEY in .env.local, which drives the app's own lifecycle emails through lib/email.js. The same API key serves both, which is exactly why the two get confused. TROUBLESHOOTING.md gained entries for "Supabase won't let me edit the email templates" and "the sign-in email arrives for me but not for a customer".

  • ›The documented type= value broke sign-in for every new user.

    The auth email templates must point at /auth/confirm with a token_hash, and the route's own doc comment, SETUP_WORKFLOW.md, SETUP.md and TROUBLESHOOTING.md all specified type=magiclink. Measured against a live project:

    | address | type=email | type=magiclink | type=signup | | --- | --- | --- | --- | | already exists | verifies | verifies | rejected | | first time | verifies | rejected | rejected |

    magiclink verifies only for an address Supabase already knows, so a template using it works for whoever set the project up and fails for their first real customer — an intermittent-looking bug with a dashboard cause. signup, which an earlier pass of this changelog recommended for the "Confirm signup" template, is rejected outright. type=email verifies in both states and is now the only value documented, in all five places plus the e2e open-redirect probe so no file in the repo models the broken one.

    Established by minting tokens with auth.admin.generateLink and driving the real route, not by reading the Supabase docs.

  • ›Sign-in could not work on a fresh Supabase project, and the setup docs never said so.

    /auth/confirm verifies magic links server-side, which needs Supabase to put token_hash in the link. The default email templates use {{ .ConfirmationURL }}, which routes through Supabase's own endpoint and carries no token_hash, so the route answers ?signin_error=missing_token. SETUP.md mentioned editing the Magic Link email only as copy polish — "the default copy is confusing" — filed under going live rather than setup, so anyone following the human path hit a dead sign-in on their first attempt.

    Worse, SETUP_WORKFLOW.md did mark the step required, but only for the Magic Link template. signInWithOtp runs without shouldCreateUser: false, so Supabase picks the template by whether the address already exists: a new email gets Confirm signup, a returning user gets Magic Link. Fixing one makes sign-in work for whoever set the project up and fail for their first real customer, which reads as an intermittent bug.

    Both template bodies are now a required step in SETUP.md next to the redirect allowlist, both are in SETUP_WORKFLOW.md, TROUBLESHOOTING.md has an entry keyed on the literal signin_error=missing_token string a stuck buyer will search for, and npm run deploy reminds about them alongside the Redirect URLs. Also added: set Site URL to the local origin while testing, since {{ .SiteURL }} resolves from it.

    Neither guard could have caught this. doctor cannot read Supabase email templates (that needs a Management API token, not the service-role key), and check-docs.mjs validates that documented paths and env vars exist, not that a documented procedure is sufficient.

  • ›SETUP.md undercounted the schema.

    It said "all three migrations are idempotent" and that the dashboard should show "four new tables". Five migrations ship and they create eight tables — the three ai_* tables and email_log were missing from the checkpoint, so a buyer verifying their database against the docs would have confirmed a half-applied schema as correct.

  • ›The verification command handed to agents hung on a prompt.

    CLAUDE.md and skills/whirlwind/SKILL.md both tell an agent to confirm setup with node scripts/setup.mjs --check --json, but --check only suppressed writing, not asking: it fell through to the interactive prompts and stopped at NEXT_PUBLIC_APP_NAME: instead of returning JSON. An agent following the documented path waited forever, and the flag that actually worked headless — --non-interactive --json — appeared only parenthetically, as the CI alternative. --check now takes the same early return as --non-interactive: it validates what is already in .env.local and the environment, prompts for nothing, writes nothing, and exits 1 when the minimum is not met. The interactive path is untouched. Found by unpacking the release artifact and setting it up as a buyer would, which is the first time that had been done.

  • ›npm run lint failed on a pristine tree, so the CI lint step could never pass.

    Ten react/no-unescaped-entities errors across app/success/page.js, app/tos/page.js and components/marketing/CheckoutModal.jsx — raw apostrophes and double quotes in JSX text. next lint exits non-zero on errors, so the npm run lint --if-present step failed for anyone who pushed the template to GitHub. The two page files were byte-identical to the shipped 0.9.0, so this was never a local quirk. Escaped to ' and ", which renders identically. Three warnings remain (a react-hooks dependency and two anonymous default exports); warnings do not fail the step and are left alone.

  • ›The flow tests could never run, on anyone's machine.

    Playwright's runner is a separate process from the dev server it starts, and only Next reads .env.local; flows.spec.js reads process.env directly to decide what it can test. So the seven tests covering signup credits, credit atomicity, RLS and checkout validation skipped themselves even on a fully configured machine, and npm test reported a confident green while covering 18 of 25 tests and none of the money paths. playwright.config.js now loads .env.local (then .env) with real environment variables taking precedence, so CI secrets are never clobbered. Ten lines, no new dependency.

  • ›npm run doctor checked tables but never functions.

    CORE_TABLES was probed; the rpc functions the app calls were not. A project whose migrations only partly applied can hold every table and still be missing refund_credit — which five AI routes call to return a credit when a provider call fails. Without it the credit is silently kept and doctor stayed green, so the buyer learns about it from their users. CORE_FUNCTIONS joins the contract and doctor probes both functions with arguments that match no row. Found a real instance the moment it shipped.

  • ›npm run deploy never mentioned rate limiting.

    The in-memory limiter is fine on one local process and useless across serverless instances, each of which keeps its own Map — so the per-user limits on the AI routes quietly stop holding at the exact point where every call costs money. Upstash is optional everywhere else and the preflight checked Stripe live-vs-test and the app URL but not this. Deploy now warns once the app URL is a real one and Upstash is unset.

  • ›CI never ran the checks most likely to break.

    The npm run audit step ran without --build, so step 33 — the compiled-CSS and theme assertions — never executed; that is why the chunk-reading bug above survived. The separate build step and the audit step are now one npm run audit -- --build, which builds exactly once and checks more. The smoke test also asserts that /dashboard redirects to the sign-in prompt, not merely that it 307s, since middleware now redirects unconfigured requests too.

  • ›flows.spec.js now runs in CI when credentials are available.

    The suite already skips every test whose requirements are absent, so the new step is green-and-skipped by default and becomes real coverage of the money paths the moment TEST_SUPABASE_* and TEST_STRIPE_* secrets are set. A step that silently goes green reads as "the money paths passed" when they never ran, so it emits a GitHub notice naming what was not covered — and a second one if the Stripe secret isn't an sk_test_ key. Documented in the README's "CI included" bullet, including the warning to point those secrets at a throwaway project: the suite writes rows and creates checkout sessions.

  • ›npm run type-check never worked.

    tsc --noEmit with no tsconfig.json prints the compiler's help text and exits non-zero; the repo is JavaScript with a jsconfig.json for path aliases and has no TypeScript sources. The dead script is removed rather than left to mislead.

  • ›The env contract was duplicated.

    scripts/env-contract.mjs and lib/validateEnv.js each carried their own copy of the required/optional lists, with a comment in both asking whoever edited one to remember the other. The contract now lives in lib/env-contract.mjs and both import it. It sits under lib/ rather than scripts/ because the app imports it, and a buyer who deletes scripts/ after setup must not break their build; scripts/env-contract.mjs re-exports it so the scripts are unchanged.

  • ›check-docs.mjs derives the shipped-migration list instead of hardcoding it.

    Template migrations now carry a -- whirlwind:template marker and the guard scans for it, so adding one cannot silently disarm the check built to catch adding one. The old list also doubled as a "did a migration go missing" check, which a derived list can't do by name, so that is now a contiguity assertion: the marked migrations must run 001..N with no gaps. lib/supabase/migrations/README.md joined DOC_SOURCES — it carried its own stale numbering advice and had gone unchecked precisely because it wasn't on the list — and the content-generator example no longer tells an agent to create 005_content_items.sql.

  • ›Dependencies are pinned.

    Every range in package.json is now exact, at the version the lockfile already resolved, so npm install months from now installs what was actually tested rather than whatever minor has shipped since. Resolved versions are unchanged.

  • ›CRON_SECRET is compared in constant time.

    The lifecycle-email endpoint used === on the Authorization header. Low practical risk over HTTP, but the fix is three lines and this is the kind of thing a buyer's security review flags.

  • ›A fresh clone could not build, and its landing page answered 500.

    Four API routes plus lib/supabaseClient.js constructed Stripe and Supabase clients at module scope. next build collects every route, so building threw on a tree with no credentials — meaning npm run audit -- --build could never pass pristine, exactly as 0.9.0 predicted. Worse, supabaseClient.js sits on the chain app/page.js → Header → ButtonSignin, so the landing page itself 500'd during SSR before a buyer had any chance to run setup. All five are now lazily initialised, following the pattern lib/auth.js already used. The browser client additionally falls back to a placeholder URL and key rather than throwing: every caller reaches it from a useEffect or an event handler, and throwing there tore down the surrounding subtree — the header and hero stopped rendering and the theme toggle never finished mounting. It now logs one clear console error and leaves the page interactive. `npm run build, npm run audit -- --build` and all 18 e2e tests pass on a tree with no .env.local at all.

  • ›middleware.js 500'd every /dashboard request before setup.

    The file already fails closed around getUser() — its comment says so — but createServerClient throws one step earlier on missing credentials, outside the guard. Unconfigured, /dashboard returned 500 instead of redirecting to sign-in. The credential check now happens before the client is built, and both paths share one redirectToSignin helper.

  • ›The --build audit read only one CSS chunk.

    Next splits the compiled stylesheet, and readdirSync(...).find() returned the small chunk, so step 33 reported .ww-display and both DaisyUI themes as missing from CSS that contained all three. It reads every .css file now. This had never fired in anger because the build it depends on always failed first.

  • ›Checkout no longer fails open on an empty price allow-list.

    create-checkout skipped price validation entirely when STRIPE_ALLOWED_PRICE_IDS and both NEXT_PUBLIC_STRIPE_PRICE_ID* vars were absent, which let a caller name any price object in the account. An empty allow-list is a server misconfiguration, so it now returns 500 and logs which var to set, matching how the route already handles a missing NEXT_PUBLIC_APP_URL.

  • ›Six docs told agents to create a migration number that already ships.

    005_email_log.sql (the lifecycle-email dedup table lib/email.js depends on) has been in the template since 0.8.0, but CLAUDE.md, AGENTS.md, SKILL.md, SETUP_WORKFLOW.md and .cursor/rules/whirlwind.mdc all still said "001–004 ship" and "start custom migrations at 005" — so the first custom feature an agent generated would collide with it. check-docs.mjs exists to catch exactly this (it was added when the same thing happened to 004), but its TEMPLATE_MIGRATIONS list was hardcoded and never gained 005, so the guard was blind to the recurrence. 005 added to the list, all six docs corrected to 001–005 / start at 006, and the guard extended to also catch the "new tables go in NNN+" phrasing that slipped past its other patterns.

  • ›playwright.config.js defaulted to port 3000.

    With reuseExistingServer on, the suite silently adopted whatever was already listening — usually a different project — and reported a wall of failures about an app you weren't testing. The default is now 3847 and the port is passed to Next explicitly. PORT still overrides.

  • ›@playwright/test is pinned exactly.

    The ^1.50.1 caret would resolve to ≥1.59 the moment the lockfile was regenerated, and Playwright dropped WebKit support for macOS 13 in 1.58 and macOS 14 in 1.59 — an OS-support cliff a caret shouldn't be allowed to cross on its own. Relatedly, deploy's "browsers not installed" hint now says npx playwright install chromium rather than the bare form, which also pulls the WebKit build that breaks on those macOS versions but that this suite never drives.

  • ›scripts/verify.mjs is now scripts/audit.mjs, and the verify npm alias is gone.

    0.8.0 renamed the command to npm run audit but left the file named verify.mjs and kept npm run verify as an alias, so the repo referenced "audit" everywhere while the file on disk said "verify" — a buyer whose audit failed went looking for scripts/audit.mjs and found nothing. One script, one name. (Supersedes the 0.8.0 note below that says npm run verify still works — it no longer does.)

  • ›Three theme-toggle e2e tests could never pass.

    appearance.spec.js selects the toggle by [data-testid="theme-toggle"] and reads data-theme-state, but neither attribute existed anywhere in the template, so all three failed with "element(s) not found" on a fresh clone. The mounted-guard behaviour they describe was implemented correctly in components/layout/Header.jsx all along — only the test hooks were missing. Added both attributes (pending before mount, then light/dark).

  • ›.env.example was missing from the distribution.

    check-docs.mjs crashed on it (so npm run audit step 15 failed on a fresh download), setup docs told buyers to copy a file that didn't exist, and the file wasn't even gitignored — it was dropped in packaging. Restored, with every value empty (a truthy placeholder here broke npm run build once before — see 0.4.x) and NEXT_PUBLIC_APP_NAME/_DESCRIPTION commented out so they can't override config/site.js. check-docs.mjs also gained an allow-list entry for the NEXT_PUBLIC_*_KEY anti-pattern examples the security docs name only to forbid.

  • ›npm run deploy now runs the whole suite.

    It previously gated on doctor alone; audit and the e2e tests were easy to forget at exactly the moment they matter most. Deploy now runs verify.mjs (hard gate, warnings don't block), attempts npx playwright test (downgrades to a warning with instructions when browsers aren't installed; --skip-tests to skip), then doctor --deep. --force still overrides everything.

  • ›doctor --deep now pings Gemini too.

    It verified Claude and GPT model IDs but not Gemini's, though lib/ai-client.js ships all three. Gemini is probed through the same OpenAI-compatible endpoint the client uses (GEMINI_BASE_URL respected), so a retired GEMINI_MODEL now fails the preflight instead of failing your users.

  • ›npm run build required real credentials just to compile.

    requireEnv() threw on any missing var whenever NODE_ENV=production — but next build also sets that, so a fresh tree couldn't compile before setup and npm run audit -- --build could never pass pristine. It now throws only at production runtime (NEXT_PHASE distinguishes compiling from serving), so a misconfigured deploy still fails fast at next start. (The remainder this entry left for a future release — four API routes constructing Supabase and Stripe clients at module scope, so building still needed some value in those vars — is closed above: those clients are lazy now and empty-env builds work.)

  • ›doctor --deep read HTTP 403 as "model is live."

    Any status under 500 that wasn't 401/404/429 counted as success, so a key without model permission — or a corporate proxy denial — reported a healthy model. 403 is now a warning that says it can't confirm the model.

  • ›Stale audit output.

    verify.mjs's header still described "checks 1–15" under an old filename, and every clean run emitted a permanent warning suggesting model verification be wired into doctor — which --deep has done for a while. The header now matches reality and the warning is a pass note pointing at npm run doctor -- --deep.

0.8.0

Correctness pass. Every fix below was reproducible in a shipped build.

  • ›Section headers were inconsistent.

    FAQ hand-rolled its own eyebrow markup with hardcoded [QUESTIONS] brackets — so the brackets appeared even under vibes whose labelStyle is plain — and hardcoded "FREQUENTLY ASKED / QUESTIONS" in caps. Testimonials used a smaller heading ramp than every other section. Both now use <Eyebrow> and the shared `text-5xl md:text-6xl lg:text-7xl ramp, with copy in config/site.js → faqSection`. Two new audit checks enforce one heading ramp across sections and require eyebrows to go through the shared component.

  • ›Style consistency sweep.

    The mobile nav hardcoded `uppercase tracking-wider while the desktop nav used .ww-label` — so under aurora or editorial the mobile menu shouted and the desktop one didn't. The Testimonials CTA bypassed the type classes entirely (no vibe radius, no optical centring) and hardcoded GET [YOUR PRODUCT] as its label. FAQ was the last section on a fixed py-32; the account dropdown and checkout panel used rounded-lg instead of the vibe's radius. All now follow config/theme.js, and npm run audit gained three checks so this is enforced rather than periodically rediscovered.

  • ›Buttons ignored the vibe's corner radius.

    Only the hero set --ww-radius-btn inline, so sign-in, checkout, CTA, testimonial and dashboard buttons stayed square even under aurora (radiusBtn 9999px). Now applied in CSS to a/button.ww-display and .ww-label:where(a, button), so no call site has to remember it.

  • ›The sign-in modal was translucent.

    .ww-card under aurora is 55% opaque with a backdrop blur — right for a card on a page, wrong for a dialog, where content behind bleeds through over the dimmed backdrop. Any .ww-card inside [role="dialog"] is now opaque regardless of vibe.

  • ›Effects wired in.

    ShinyText shipped unused; it now sweeps the hero eyebrow. Two new components: <Magnetic> (elements lean toward the cursor — uses translate rather than transform so it can't create a containing block for fixed descendants) on the primary CTA, and <CountUp> on pricing figures. Both no-op under reduced motion, and CountUp renders the real number if JS never runs.

  • ›Features and Pricing layout variants

    , matching the hero treatment. Each vibe now selects a composition for all three sections: terminal → centred hero / spotlight grid / pricing cards; aurora → split hero / alternating rows / comparison table; editorial → left-ranged hero / numbered list / single-column pricing. Nine compositions from three vibes, verified in the rendered HTML.

  • ›Section copy moved out of components.

    Features hardcoded "INFRASTRUCTURE DONE / YOU BUILD THE MAGIC" and Pricing hardcoded "ONE PAYMENT / BUILD FOREVER" — Whirlwind's own pitch, and actively wrong for the subscription products most people build. Both now read from config/site.js (featuresSection, pricing.eyebrow/heading/highlight), which also gained pricing.currencySymbol for non-USD products.

  • ›ButtonCheckout accepts a whole plan object

    , deriving price id, mode and label from it, so the pricing layouts stay thin. The old individual props still work.

  • ·usePlans() and useFeatures() filter holes and malformed entries — a stray trailing comma in config/site.js produces a sparse array, which previously crashed the production build rather than degrading.
  • ›Hero layout variants.

    Vibes previously changed only decoration, so every generated product shared one composition and read as a recolour. There are now three hero layouts and the vibe picks one: centered (terminal) symmetrical with an animated backdrop, split (aurora) copy left and a product panel right, editorial (editorial) left-ranged with a hairline rule and no motion. Shared pieces live in hero/parts.jsx so copy edits land in every variant; adding a fourth is one file plus one line in hero/index.jsx. config/site.js gained hero.eyebrow and configurable CTA labels and hrefs.

  • ›Vibes now change typography, not just decoration.

    Every vibe used Bebas + IBM Plex Mono, so generated products looked like recolours of each other. Each preset now carries a font pairing — terminal: Bebas/IBM Plex Mono, aurora: Outfit/Inter, editorial: Fraunces/Inter — plus its own section spacing and shadow treatment. All five faces load via next/font; the vibe resolves which two are used. The all-caps optical nudge on buttons is now scoped to the Bebas vibe, since Outfit and Fraunces have real descenders.

  • ›Hero and Footer hardcoded /whirlwindlogo.png

    — a file that only ever existed on Whirlwind's own site. In a generated product it 404s, and the alt text overflows the fixed 96px box (the truncated "LedgerLigh" in a demo). Both now use <Logo>, which falls back to a themed mark.

  • ›Testimonials advertised Whirlwind inside the customer's product

    — "SHIPPED WITH WHIRLWIND" was hardcoded, with @user1/@user2 placeholder quotes. Copy and quotes now come from config/site.js, and the section renders nothing when quotes is empty.

  • ›The blog was broken out of the box.

    The example authors entry was all empty strings while articles set author: "author name" as a string — but Avatar.js reads article.author.slug. Every article page also read article.image.urlRelative unguarded while the example article ships with its image commented out, so /blog/<slug> returned a 500. Author is now a real object derived from config/site.js, and image reads are optional.

  • ›Display-face buttons looked bottom-heavy.

    Bebas Neue has tall ascenders and almost no descender, so its line box sits low relative to the visible glyphs and symmetrical padding reads as off-centre. .ww-display on links and buttons now trims the line box — fixed in CSS rather than across the 21 call sites.

  • ›Modals rendered inside their containing block.

    The sign-in modal lives in the header, and the header uses backdrop-blur-xl — which makes it the containing block for position: fixed. The modal therefore centred itself inside a 72px header bar rather than the viewport, with the backdrop clipped to the same strip. Both modals now render into document.body via createPortal, and npm run audit fails any dialog that is not portalled. This is a CSS spec behaviour rather than a Tailwind quirk: any ancestor with transform, filter or backdrop-filter does the same thing.

  • ›safeNextPath was used in ButtonSignin without being imported

    — a ReferenceError thrown the moment anyone clicked sign-in. The build compiled fine, because Next.js compiles each module without resolving free identifiers, so nothing caught it until a user interacted with the page. npm run audit now checks the inverse of import resolution: every helper that is used must be imported or declared in that file.

Added

  • ›Tests.

    Playwright suites at tests/e2e/. appearance.spec.js needs no credentials and runs in CI — webfonts, favicon, theme toggle, vibe casing, invisible sections, 404s, open redirects, hydration. flows.spec.js covers the money paths (signup provisions credits, consume_credit is atomic under concurrency, RLS denies, checkout validates) and skips itself cleanly when Supabase or test Stripe keys are absent, so a fresh clone is green rather than misleadingly red.

  • ›npm run audit

    — the 58-check static audit, renamed from verify to be findable by buyers. npm run verify still works.

  • ›lib/analytics.js

    — six named Plausible events so the funnel is measurable, defined in one place so they can't drift into a dozen spellings. No-ops when Plausible isn't configured.

  • ›/dashboard/admin

    — read-only stats (signups, paying, conversion, credit usage, recent signups), gated on ADMIN_EMAILS. Returns 404 rather than 403 to anyone not listed, since a 403 confirms the page exists. No destructive actions by design.

  • ›LICENSE now addresses client and agency work

    explicitly — each client project counts as one Derivative Work, Advanced covers unlimited, the client receives the work rather than a Template license, and delivering the unmodified Template remains redistribution. The word "client" previously appeared zero times, which is the first thing a freelancer asks.

  • ·CI runs the appearance suite and the audit on every push.
  • ›Lifecycle email

    (lib/email.js). Deliberately scoped to what nothing else sends: Stripe already sends receipts, and Supabase already sends magic link / confirmation / password reset. This adds the day-7 onboarding nudge and the credits-low / credits-empty warnings. - Idempotency is enforced by a unique constraint on email_log (user_id, template), not application logic — on serverless, several instances can reach the send path at once, so the log row is claimed first and the email only sent if the insert succeeded. A failed send releases the claim so a retry can work. - Credit warnings are fire-and-forget: an email provider outage can never delay or fail a generation. - Without RESEND_API_KEY every function no-ops and returns a reason. - /api/cron/lifecycle-emails is protected by CRON_SECRET and refuses to run in production when unset — an open endpoint that sends mail is a spam vector aimed at your own domain reputation. vercel.json schedules it daily.

Security

  • ›Stripe checkout URLs came from the Origin header.

    The caller controls that header, so anyone could create a checkout whose success_url pointed at their own site — a convincing "payment complete" page on a domain you do not control. Now built from NEXT_PUBLIC_APP_URL, with the header used only in development.

  • ›Billing portal return_url came from the request body

    , unchecked, so Stripe would return the customer wherever it pointed. Now re-anchored to your own origin. The same line had an operator-precedence bug: process.env.X + "/dashboard" || "http://localhost:3000/dashboard" — + binds tighter than ||, so with X unset it produced the truthy string "undefined/dashboard" and the fallback could never run.

  • ›Open redirect in the magic-link flow.

    /auth/confirm passed the attacker-controllable next query parameter straight into new URL(next, req.url), and /auth/callback passed it to window.location.assign(). Both send the user off-site for https://evil.com, //evil.com or /\evil.com — a phishing link that traverses your real sign-in flow before landing on the attacker's page, which is what makes it convincing. A shared safeNextPath() in lib/utils.js now rejects anything that is not a same-origin path, and ButtonSignin sanitises before embedding next in emailRedirectTo.

  • ·/auth/confirm also validates the OTP type against a known set instead of forwarding it, and no longer reflects raw provider error messages into the address bar.
  • ›/api/health leaked reconnaissance.

    The unauthenticated endpoint reported NODE_ENV, process uptime, and which of Supabase/Stripe/AI were configured. It now returns {status, timestamp} publicly; the detail needs HEALTH_CHECK_TOKEN in production.

  • ›Middleware 500-ed on a Supabase blip.

    getUser() was uncaught, so a transient network failure returned a 500 on every /dashboard request instead of redirecting. It now fails closed.

  • ›lib/api.js removed

    — an axios wrapper nothing imported, missing "use client" despite calling toast, so importing it from a server component would have failed confusingly. Every component uses fetch. axios went with it (19 dependencies now, from 26).

  • ·CheckoutModal's focus timer is cleared on unmount.
  • ›Blog routes crashed on unknown URLs.

    /blog/[articleId], /blog/category/[categoryId] and /blog/author/[authorId] used .find() and then read a property off the result — so any unrecognised URL threw a TypeError and returned a 500 instead of a 404. All three now call notFound(). /docs/[slug] rendered an inline "Page Not Found" with an HTTP 200 (a soft 404 search engines index); it now returns a real 404.

  • ›The sign-in modal had no accessibility.

    No role="dialog", no aria-modal, no Escape handler, no focus management — screen readers never announced it and keyboard users could not dismiss it. CheckoutModal had everything except the Escape handler. Both are complete now.

  • ›Two variables for one site URL.

    next-sitemap and config/site.js read NEXT_PUBLIC_APP_URL while lib/seo.js read NEXT_PUBLIC_SITE_URL. seo.js now accepts either. The sitemap also excludes /docs unless the docs flag is set, since those URLs 404 in production.

  • ›README

    claimed GPT-4, a DALL-E image studio, and an in-memory-only rate limiter. All three were out of date.

  • ›npm run doctor -- --deep now verifies AI model IDs.

    Doctor checked that AI keys were set but never that the configured models still exist. A retired model returns 404, which reads like an auth failure — this is how both claude-3-5-sonnet-latest and dall-e-3 reached production. It now sends a 1-token request per provider and names the env var to change.

  • ›Feature scaffold produced off-theme components.

    skills/whirlwind/templates/component.jsx used text-black on the primary button and hardcoded red-500/red-600 for errors, so every feature a buyer generated was unreadable in some themes and ignored primaryColor. Now text-primary-content and text-error.

  • ›Session lookups swallowed network failures.

    Three getSession().then() calls — including the app-wide auth provider — had no rejection handler, so a network blip stranded the loading state permanently. All now recover.

  • ›NEXT_PUBLIC_SUPPORT_EMAIL

    had the same override problem as NEXT_PUBLIC_APP_NAME; commented out. AI_DEFAULT_PROVIDER, AI_TIMEOUT_MS and GEMINI_BASE_URL are now documented in .env.example.

  • ›Product name never reached page titles.

    .env.example shipped NEXT_PUBLIC_APP_NAME=Your AI Product, and config/site.js reads process.env.NEXT_PUBLIC_APP_NAME || "<your name>" — so the env placeholder won over the name setup wrote. Every <title>, the blog heading, the legal pages and the OG tags said "Your AI Product" while the landing page was correct. Both placeholders are now commented out.

  • ›Two parallel product configs.

    14 files (blog, docs, ToS, privacy, checkout, and lib/seo.js, which drives every page title) imported @/config — a root config.js with its own appName/appDescription — while the landing page imported @/config/site. Setup only edits config/site.js. config.js is now a thin derivation of it.

  • ›Phantom config reads.

    config.mailgun.supportEmail and config.twitterHandle were read but never defined; optional chaining hid it, so the support-email fallback silently never fired and Twitter cards carried no author. Both now resolve.

  • ›Six unused dependencies removed

    — react-syntax-highlighter (8.9 MB), react-tooltip, zustand, swr, react-copy-to-clipboard, form-data. Dead config.stripe.plans and config.colors blocks removed with them, plus commented-out imports pointing at assets that never shipped.

  • ›Every dark: variant was inert.

    tailwind.config.js set darkMode: "class", but next-themes writes data-theme and never adds a .dark class — so all 19 dark: utilities across 5 files did nothing. darkMode now keys off the configured dark theme, and the light/dark pairs they guarded collapse to single DaisyUI semantic tokens.

  • ›The sign-in modal was hardcoded dark and off-brand.

    It used bg-[#0a0a1a], indigo borders, cyan accents and white text regardless of primaryColor or defaultMode — unreadable in a light-mode app and the wrong colour in every app. Rebuilt on theme tokens. Its backdrop had also been commented out and the panel positioned 40vh down the viewport; both fixed, and the literal "SIGN IN" text is now sentence case so .ww-display can apply the vibe's casing.

  • ·Modal backdrops use the neutral token instead of bg-black; error and success text use text-error / text-success instead of hardcoded reds and greens.
  • ›Image optimizer was an open proxy.

    next.config.js allowed remotePatterns: [{ hostname: "**" }], so anyone could request /_next/image?url=<any-host> and make your deployment fetch and transform arbitrary remote images — consuming bandwidth and image-transformation quota. Now restricted to your Supabase storage host, derived from NEXT_PUBLIC_SUPABASE_URL.

  • ›Accessible names

    added to the message, image-prompt and email inputs. They relied on placeholder alone, which disappears on focus and does not reliably provide an accessible name.

  • ›Credit discipline.

    custom/chat, custom/create-image and custom/generate-content consumed credits and never refunded them when the provider call failed — only ai/generateText did. All five paid routes now refund; the streaming route refunds only when no tokens were delivered.

  • ›ai/generateImage charged nothing

    while custom/create-image charged 5 for the same work, so any signed-up account could generate images on the operator's OpenAI bill at 5/minute indefinitely. It now consumes and refunds the same 5 credits.

  • ›dall-e-3 was removed from the OpenAI API on 2026-05-12.

    Both image routes now use gpt-image-1 (override with OPENAI_IMAGE_MODEL), with the corrected size and quality enums and handling for base64 responses — the new models return b64_json where DALL-E returned a URL, so a naive model swap would have returned undefined image URLs.

  • ›Error boundaries added

    : app/error.jsx, app/not-found.jsx and app/global-error.jsx. Without them any runtime error rendered Next.js's unstyled default screen.

  • ›Version drift

    : SKILL.md frontmatter said 0.4.0 and skill.json said 0.7.0 while package.json said 0.8.0. All four sources now agree, and scripts/verify.mjs asserts it.

  • ›OpenClaw compatibility

    : skill-relative paths use {baseDir}, gating metadata added as single-line JSON frontmatter, and SKILL.md now states the working directory explicitly rather than assuming the agent is in the project root.

  • ›Agent-doc accuracy.

    Eight places across CLAUDE.md, AGENTS.md, SETUP.md, .cursor/rules/whirlwind.mdc, SKILL.md, SETUP_WORKFLOW.md and the worked example told agents that migrations 001–003 ship and that custom ones start at 004 — which is now 004_ai_features.sql. Following those instructions overwrote the migration backing the three bundled AI features. SETUP.md also omitted 004 from the manual run list, so anyone following the human path got a database those features can't write to. All corrected to 001–004 shipped / start at 005.

  • ›scripts/check-docs.mjs extended

    to catch that whole class: quoted migration ranges must match the shipped set, "start at NNN" must point at the next free number, placeholder filenames must not collide, every shipped migration must be present, the three bundled features and /dashboard/features must exist, and every themeConfig.* field the code reads must be declared. The assertions anchor to the template's known migration filenames rather than the highest number on disk, so a product that adds 005_invoices.sql doesn't fail its own CI.

  • ›SETUP_WORKFLOW.md Step 7.5

    now requires daisyThemeLight alongside daisyTheme (setting one alone leaves the other mode mismatched), notes that labelStyle: null follows the vibe, documents hero.highlight, and points at the pricing / faq / cta / footer sections of config/site.js that the old sketch omitted.

  • ›Dark/light toggle actually works.

    <html data-theme> was hardcoded to themeConfig.daisyTheme while next-themes toggled a class that DaisyUI v4 doesn't read — clicking changed nothing. next-themes now owns data-theme and swaps between daisyTheme / new daisyThemeLight, with a matching whirlwind-light custom theme. Header.jsx gained a mounted guard so the first click isn't swallowed by an unresolved theme.

  • ›enableSystem no longer overrides your config.

    It's enabled only when defaultMode: "system", so "dark" means dark.

  • ›Bebas Neue is actually loaded.

    tailwind.config.js named it but app/layout.js never called next/font for it, so headings fell back to the system sans-serif for anyone without the font installed locally. Both families now load and Tailwind references the generated CSS variables.

  • ›config/theme.js is now genuinely the single source of truth for vibes.

    globals.css previously hardcoded the same radius/casing/tracking numbers in [data-vibe] blocks, so editing a preset changed nothing. layout.js writes themeConfig.cssVars inline on <html>; the CSS consumes them. Adding a fourth vibe is a config edit. Presets also own labelCase, labelTracking and labelStyle — <Eyebrow> follows the vibe unless you set labelStyle explicitly.

  • ›New .ww-label

    class + per-vibe label casing, for nav links and meta text that previously couldn't follow the vibe. All 87 hardcoded font-bebas uppercase tracking-* sites now route through .ww-display / .ww-label.

  • ›<Reveal> can no longer hide content.

    It started at opacity: 0 and relied on a useEffect to reveal; if hydration failed, whole sections stayed invisible at full height. It now renders visible and only arms the hidden state once an observer is attached, with a 2s failsafe.

  • ›Favicon works.

    lib/seo.js set metadata.icons explicitly, overriding the Next.js file convention and pointing at five paths that don't ship. Removed; app/icon.svg now ships and is served automatically.

  • ›AI model IDs current

    (claude-sonnet-5, gpt-5-mini, gemini-3.6-flash); the old defaults were retired and returned 404s that look like auth failures. AIClient now returns { text, usage, model } with token counts for credit accounting, wraps provider errors with actionable messages, and exposes AIClient.verify() for npm run doctor. All call sites and the feature scaffold in skills/whirlwind/templates/ updated.

  • ›/docs is dev-only

    unless NEXT_PUBLIC_SHOW_TEMPLATE_DOCS="true" — the flag the docs already advertised but nothing implemented. Customers were shipping Whirlwind's documentation on their own sites.

  • ›components/ui/Logo.jsx

    added; Header uses it.

  • ·Docs code samples referenced @/lib/supabase/client and @/lib/ai/client; the real paths are @/lib/supabaseClient and @/lib/ai-client.

0.7.0

Whirlwind ships with product, not just plumbing.

  • ›3 working AI features

    (migration 004_ai_features.sql + routes + components), showcased at /dashboard/features: AIChat (streaming multi-turn chat, persisted conversations), ContentWriter (blog/LinkedIn/email presets, saved history), ImageStudio (DALL-E 3 with gallery, 5-credit cost). All follow the canonical authenticate → rate-limit → consume-credit → generate → persist order; the chat route demonstrates doing that with a ReadableStream.

  • ›The 4-question theme step is real.

    SETUP_WORKFLOW.md Step 7.5 has the agent ask brand color, vibe, mode, and label style, and write config/theme.js. New labelStyle option + <Eyebrow> component control bracketed vs. plain section labels.

  • ›CI + docs-drift guard.

    .github/workflows/ci.yml builds via the exact buyer path, boots the server, and smoke-tests landing/docs/health/auth-redirect. scripts/check-docs.mjs fails the build when docs mention paths or env vars that don't exist (it already caught three stale references in 0.5.x docs, now fixed).

  • ·Dashboard links to the feature showcase.

0.6.0

UI kit, vibe presets, and styling/docs fixes.

  • ›New components/ui/ kit

    (zero dependencies): GradientText, ShinyText, SpotlightCard, Reveal, Marquee, AnimatedBackground — all themed from DaisyUI tokens and reduced-motion safe. Documented at /docs/ui-components.

  • ›Vibe presets.

    config/theme.js gains vibe: "terminal" | "aurora" | "editorial" controlling radius, display-type treatment, card style, and the hero background effect. config/theme.js is now CommonJS and is the single source of truth — tailwind.config.js requires it (previously the two files could drift).

  • ›Landing page restyle.

    Hero: staggered word reveal, animated gradient highlight, perspective grid with scan line, glowing CTA. Features: spotlight cards with scroll-stagger. Testimonials: infinite marquee. Pricing: rotating glow border on the featured plan.

  • ›Styling fixes.

    .text-gradient was silently dropped by Tailwind (referenced undefined secondary color); hsl(var(--p)) usages fixed to oklch(...) for DaisyUI v4 (scrollbar, ::selection, testimonial accent); *-primary-focus classes now exist (were no-ops); text-black on primary buttons replaced with text-primary-content for light-theme safety; hardcoded #22d3ee removed from Hero/ClientLayout.

  • ›Env fix.

    .env.example shipped a truthy MAILCHIMP_API_KEY placeholder that made npm run build fail after cp .env.example .env.local. Now empty. .env removed from the distribution.

  • ›Docs corrections

    (in-app /docs): Node 20 (was 18), .env.local (was .env), npm run setup/doctor now the recommended path, real paths (lib/ai-client.js, app/api/webhook/stripe/, SupabaseAuthProvider, lib/supabase/migrations/), real AIClient class API in AI + streaming examples (docs previously showed non-existent generateText/streamText imports), removed Resend section (not used by the code), Docker section notes no Dockerfile ships, theming docs rewritten for config/theme.js.

0.5.0

Deterministic, agent-agnostic setup.

  • ›New scripts/ setup engine.

    npm run setup (collect keys → write .env.local → run migrations), npm run doctor (read-only health check: env, migrations, test-vs-live keys), npm run deploy (production preflight + go-live guidance). Setup actions are owned by reviewed scripts, not improvised by an AI agent.

  • ›Works with or without an agent.

    Front doors for each: CLAUDE.md (Claude Code), AGENTS.md (Cursor/Codex), .cursor/rules/whirlwind.mdc, and the existing OpenClaw skill — all thin pointers to the same scripts. Or run the commands yourself in a terminal.

  • ›SKILL.md / SETUP_WORKFLOW.md

    reconciled so setup actions route through the scripts (no more freehand .env writes or pasted migrations).

  • ›Docs reframe.

    README.md and SETUP.md lead with the scripts and present OpenClaw as one option, not a requirement.

  • ›Fixes.

    SETUP.md now allow-lists /auth/confirm for the magic-link flow (was only /auth/callback); .whirlwind/ gitignored; NEXT_PUBLIC_SITE_URL documented in .env.example.

0.4.0

  • ·Prior release (auth on /auth/confirm, credit RPCs, Upstash rate limiter, consolidated skill).