Skip to content

feat(web): a real landing page, and a shell that isn't the app's - #12

Closed
ralyodio wants to merge 4 commits into
mainfrom
feat/landing-1a
Closed

ralyodio wants to merge 4 commits into
mainfrom
feat/landing-1a

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Ships direction 1a from the design options.

The structural bug behind "bad UI"

The root layout wrapped every route — including the public landing page — in the signed-in app shell: max-w-2xl (672px) under a fixed bottom tab bar. A stranger's first impression of the product was a narrow strip beneath a mobile app nav. No amount of restyling fixes that; the shell was wrong.

  • Route groups split it: (app) keeps the narrow column and BottomNav; (marketing) is full-bleed with no chrome. URLs are unchanged — route groups don't appear in paths.
  • login and verify move to (marketing), where they always belonged — they had a bottom app-nav before.
  • The root layout now holds only what every route needs.

The page

Leads with the product's actual output rather than a description of it: an approval card with a real quote, a source link, the recommended action and the approve/edit/skip row. Then the evidence trail (identities with confidence, and why name + city never merges), the refusal (409 policy_denied, gate named), and the CTA.

Type is Schibsted Grotesk + IBM Plex Mono via next/font, self-hosted at build time — no blocking request to Google, no layout shift. Deliberately not Inter/Roboto. Palette is unchanged from the app's existing tokens so marketing and product don't look like different companies.

One bug worth calling out

The font variables are not named --font-sans / --font-mono. next/font sets its variable on <html>, which is also :root, so reusing the theme's own names makes --font-sans: var(--font-sans), … a self-reference on a single element — an invalid cycle that silently drops the face. Caught by reading the built stylesheet; the build passed either way.

Verification

  • bun run check — format, typecheck, 395 tests, all passing
  • Production build run and requested live: / 200 (26KB), /login 200, /verify 200, /offline 200
  • Confirmed no max-w-2xl and no bottom nav in the marketing HTML
  • Confirmed the font chain resolves --font-schibsted → --font-sans → --default-font-family with no cycle

⚠️ I have no browser tooling in this session, so this is verified as rendering correct content with correct CSS, not as looking right. Worth an eyeball before merge.

🤖 Generated with Claude Code

ralyodio and others added 3 commits August 11, 2026 15:10
Adds `packages/ai` — the only package in the tree that talks to a model. The
composer writes the message body for a recommendation, and everything around
it exists to make that output safe to show a reviewer.

Two properties are structural rather than requested:

  1. It can only see grounded inputs. The prompt is assembled from stored
     evidence, stored facts and the customer's own offering. There is no path
     by which the model learns something it may not cite.
  2. Its output is checked, not trusted. Eight deterministic §14.2 gates run
     on every draft — grounding overlap, unsupported claims, identity
     confidence, flattery, spam patterns, cross-prospect duplication,
     sensitive topics, policy. A draft that fails is rewritten once, naming
     the exact invented fragments; still failing, it is withheld.

Withheld is a working state, not an error. The card keeps the prospect, the
evidence and the recommended action, and the reviewer writes the message. A
bad draft next to a caveat is still a bad draft someone might approve.

The undecidable question "is this claim true?" is inverted into a decidable
one: does every specific assertion — quoted phrase, number, mid-sentence
proper noun — appear in stored evidence? That is what `checks.ts` answers,
and it is why no model output reaches a human unexamined.

Also:

- `POST /recommendations/:id/draft` composes or recomposes on demand, returns
  200 with `drafted: false` and a reason when withholding, and audits it.
  A user-edited draft is never discarded by a recompose.
- The approval card gains "Write draft" / "Rewrite", and states plainly why
  no draft exists rather than offering a retry that cannot help.
- Prompt caching splits the stable offering/voice prefix from the
  per-prospect half, so a campaign pays for the prefix once.
- The composer is optional infrastructure: without ANTHROPIC_API_KEY the
  queue still runs end to end and drafting returns 503. Refusing to boot
  would take the system down for a feature designed to produce nothing.

No model decides a merge, a score, or a policy outcome. 367 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fresh login was an empty app with no way to fill it. The cause was not a
missing screen: registration created a user, organisation and workspace, but
no offering and no campaign — and a campaign requires an offering, a prospect
requires a campaign. There was no route that could add a person at all. The
pipeline had only ever been driven from a local script.

- Registration now provisions a starter offering and campaign. Existing
  accounts are backfilled on first use rather than told to create something
  the UI does not expose.
- POST /api/v1/prospects runs the full chain for a GitHub handle. It is
  synchronous because a first-run user needs to see something appear, and it
  reports identities linked and signals found rather than "queued". A handle
  that resolves to nothing answers 200 with a reason: a typo is information,
  not a server fault.
- GET /api/v1/people backs a real prospect list and detail page, so the
  evidence behind a recommendation is inspectable.
- Today's empty state now distinguishes "no prospects yet" from "no
  recommendations yet" and offers the action that fixes the first.

The pipeline moves from apps/worker to packages/pipeline. Both the API and
the background loop run it, and an app importing another app's source made
the dependency direction a lie. apps/worker held nothing else, so it is gone.

Email verification ships alongside it. Format was already checked; what was
missing was any proof the address exists. Tokens are stored as a SHA-256
digest like sessions, superseded rather than accumulated on resend, and every
failure answers identically so a guessed token learns nothing. The gate is on
sending, not on signing in — research and drafting still work while the mail
is in flight. Accounts predating this are grandfathered, and a send failure
never fails the signup that triggered it.

Resend is reached over plain HTTP; with no key the link is logged instead, so
a fresh checkout still completes a signup.

395 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The marketing page rendered inside the signed-in app shell: capped at
max-w-2xl and sitting under the bottom tab bar, so a stranger's first
impression of the product was a 672px strip beneath a mobile nav. Splitting
the shells is most of the fix.

- Route groups: (app) keeps the narrow column and the bottom nav, (marketing)
  gets full bleed and no chrome. URLs are unchanged — route groups do not
  appear in paths — and login and verify move to (marketing), which is where
  they always belonged.
- The root layout now holds only what every route needs.
- The landing page leads with the product's actual output: an approval card
  with a real quote, a source link and the recommended action. Showing the
  artifact argues the case better than describing it. Evidence and refusal
  sections follow, then the CTA.
- Type is Schibsted Grotesk and IBM Plex Mono via next/font, self-hosted at
  build time so there is no blocking request to Google and no layout shift.

The font variables are deliberately not named --font-sans/--font-mono.
next/font sets its variable on <html>, which is also :root, so reusing the
theme's own names makes `--font-sans: var(--font-sans), …` a self-reference
on a single element — an invalid cycle that silently drops the face. Caught
by reading the built stylesheet, not by the build.

Verified by running the production build and requesting the pages: landing,
login, verify and offline all 200 with content, no app chrome on any of them,
and the font chain resolves --font-schibsted -> --font-sans ->
--default-font-family with no cycle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ralyodio

Copy link
Copy Markdown
Contributor Author

Superseded by a branch cut cleanly from main — the original branched off the pre-squash head and conflicted.

# Conflicts:
#	apps/web/app/(app)/today/page.tsx
#	apps/web/app/prospects/page.tsx
@ralyodio ralyodio reopened this Aug 12, 2026
@ralyodio

Copy link
Copy Markdown
Contributor Author

Superseded by #13 — reopened briefly by automation to diagnose a stale head ref, closing again.

@ralyodio ralyodio closed this Aug 12, 2026
@ralyodio
ralyodio deleted the feat/landing-1a branch August 16, 2026 17:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant