Skip to content

Frontend ​

The frontend is plain HTML, CSS, and ES-module JavaScript in public/, served by Workers Static Assets. There is no build step, no framework, no external fonts, and no CDN. The landing page, the terms and privacy pages, and the 404 page use public/assets/site.css and site.js; the auth pages and the console use public/console/console.css. Both stylesheets repeat the same design tokens instead of sharing a file: orange #ff6b1a (with a #ff7a2e to #f25f0c gradient for accent buttons), ink #0f0d0b, text #18150f, muted #6b645c, page background warm off-white #faf8f5, white cards with a hairline border (rgba(24, 21, 15, 0.08)), 14px card radius, and an orange focus ring. Fonts are the system stacks Inter, ui-sans-serif, system-ui, ...; no web fonts are loaded.

PathFileJavaScript
/public/index.htmlassets/site.js
/signup/public/signup/index.htmlconsole/auth.js
/login/public/login/index.htmlconsole/auth.js
/console/public/console/index.htmlconsole/app.js
/terms/, /privacy/public/terms/index.html, public/privacy/index.htmlassets/site.js
any unknown pathpublic/404.htmlassets/site.js

Landing page (/) ​

Sections, in order:

SectionAnchorContent
HeadernoneSticky white bar (translucent with a backdrop blur and a hairline bottom border; it gains a soft shadow after scrolling a few pixels) with the orange rounded-square "RA" logo and the "Reform Assistant" wordmark, nav links to #how, #pricing, #faq, a secondary (outlined) Log in button linking to /login/, and an orange Get started button linking to /signup/. A menu toggle button opens the nav on screens up to 820px wide.
HerononeLight section with a faint dot grid and an orange glow behind the art. Headline "Help your loved one grow while they're inside." with "grow" highlighted, a one-line description, an orange Get started button linking to /signup/ and a secondary See pricing button linking to #pricing, a three-item trust row ("Works on facility tablets", "Cancel anytime", "Reply within 24h"), and the illustration assets/hero.svg (a tablet showing a chat, with floating cards for legal research, design lessons, and a book outline). Below 820px the hero stacks and the art is capped at 480px wide.
What they getnoneDark (ink) band with four cards that have line icons: Legal help, Business, Writing, Art & design.
How it works#howThree numbered steps: Create an account, Add your inmate, Choose a plan.
Pricing#pricingThree static plan cards (below), each with a Choose button, plus the footnote "Facility taxes and fees may apply." The middle plan (3 months) is the featured card: orange border and glow with a "Most popular" badge, and an orange Choose button; the others use outlined buttons.
FAQ#faqFour details items: facilities, start time, running out of messages ("Messages refill each billing month."), cancelling.
Closing call to actionnoneDark rounded card "Ready to help them grow?" with a Get started button linking to /signup/.
FooternoneLogo and wordmark, links to /terms/ and /privacy/ and the placeholder mailto support@reformassistant.example, and the copyright line.

The pricing cards are static HTML and repeat the numbers from src/plans.ts (the same data GET /api/plans returns): each card shows the price, the billing note, the per-month equivalent ("Flexible, no commitment" for monthly, $7.20 / month and $6.80 / month for the others), a "Save N%" pill, and the list "All features", "40 messages a month", "Cancel anytime". If you change a plan, update both. The Choose buttons link to /signup/?plan=monthly, /signup/?plan=quarterly, and /signup/?plan=annual.

assets/site.js makes no network calls and does three things: it toggles the mobile nav (closing it on link click, on Escape, and when the window grows past 820px), it adds a shadow to the header once the page is scrolled, and it makes the FAQ an accordion with one item open at a time.

/terms/ and /privacy/ are draft placeholder pages (their titles say "(draft)"). They and the 404 page share the landing page's header and footer.

Auth pages (/signup/, /login/) ​

Both pages load console/auth.js; <body data-mode="signup"> or data-mode="login" selects the behavior.

/signup//login/
Fieldsname, email, password (minimum 10 characters)email, password
API callPOST /api/auth/signup with {email, password, name}POST /api/auth/login with {email, password}
Client checks before sendingemail present, name present, password at least 10 charactersemail present, password present
Link to the other page"Log in" to /login/"Create an account" to /signup/

Layout (identical on both pages, from console/console.css): a two-column screen. The left column is a decorative peach-gradient panel with the RA logo and wordmark, the headline "Help your loved one grow while they're inside.", and three check-marked points ("Legal research, coaching and art help on their tablet", "Set up in three simple steps", "From $8 a month, 40 messages included"). The right column holds the form card (title, one-line lead, the fields, a full-width orange submit button, a link to the other page) and a "Back to Reform Assistant" link. Below 960px the left panel is hidden and a compact logo appears above the form card.

Behavior shared by both:

  • The request is a fetch with credentials: "same-origin" and a JSON body. A network failure shows "Could not reach the server. Check your connection and try again." Any API error shows the server's error.message, or "Something went wrong (HTTP" followed by the status code if the body has none.
  • The submit button is disabled and relabelled ("Creating account..." or "Logging in...") while the request runs.
  • On success: after a signup that came with a plan query parameter (from the landing page's Choose buttons), the browser goes to /console/#billing; otherwise it goes to the next query parameter, or /console/.
  • next is accepted only if it is a same-origin path: auth.js resolves it against the page's own origin with new URL(value, location.origin) and drops it unless the result has that same origin, so //other.example, /\other.example, and absolute URLs to other hosts are all rejected. The accepted value is the resolved path plus query and fragment. next and, for the signup page, plan are carried over to the link to the sibling page.

Console (/console/) ​

console/app.js is a single-page app with a sidebar and a content area, on the same warm off-white background as the rest of the site. The sidebar is a white panel with a right hairline border (not dark): logo and wordmark, navigation, a "Need help?" box with an orange-tinted border, and the user block. All values from the API are inserted with textContent or text nodes, never innerHTML. The page is marked noindex.

Chrome ​

  • Sidebar navigation: Overview, Inmate, Usage, Plan & billing, each with a line icon, with aria-current="page" on the active link. Below 800px the sidebar becomes a sticky top bar: the logo, icon-only navigation buttons (the text labels stay available to screen readers), and the logout button; below 600px the wordmark is hidden too.
  • A "Need help?" box with "We reply within 24 hours." and support@reformassistant.example. It is hidden in the top-bar layout.
  • User block (initials avatar, name, email) and a logout button. Logout sends POST /api/auth/logout and then navigates to /login/. In the top-bar layout only the avatar and the logout button remain.
  • A #notices live region shows one notice at a time (kinds ok, error, warn, info).

Hash routes ​

RouteView
#overviewOverview (also the fallback for any unknown or empty hash)
#inmateInmate form and connection status
#usageUsage
#billingPlan & billing

A route may carry a query after the hash, as in #billing?checkout=success. The router also accepts checkout in the real query string. On navigation the checkout flag is read once and then removed from the URL with history.replaceState, so reloading does not repeat the banner.

Data loading ​

On every navigation the console calls GET /api/me. GET /api/plans is loaded on #overview, #billing, and #usage and cached for the page's life; the overview and usage views use it to label the plan by name (for example "Quarterly plan"), and the billing view builds the plan grid from it. GET /api/usage is loaded on #usage only when me.subscription exists. A 401 from any call redirects to /login/?next=%2Fconsole%2F. If /api/me fails for any other reason the view shows "We could not load your account" with a Try again button. A token counter discards results of navigations that have been superseded.

Overview ​

The page title is "Welcome" with a chip (below) beside it. A "Get set up" card shows "N of 3 complete" with a small progress bar and a three-step checklist driven by me.setup; completed steps show a check mark, the current step is highlighted, and later steps are muted:

  1. Add your inmate (setup.inmate_added), linking to #inmate. Once done it shows the inmate's name and number and a View button.
  2. Choose a plan (setup.plan_chosen), linking to #billing. Once done it shows "<Plan label> plan · <status>" and a Manage button.
  3. We connect them (setup.connected) with a status badge.

When setup.connected is true, a highlighted "<Name> is connected" card with the plan name and a View usage button appears above the checklist.

The title chip shows one of: "Connected", "Needs attention" (connection status failed), "Connecting..." (inmate present and plan chosen but not connected), or "Setup not finished". The third step shows connection_detail from Hermes when present.

Inmate ​

A form with inmate number, first name, last name, state (a select of the 50 states and DC), and facility. Submitting sends PUT /api/inmate; Remove inmate sends DELETE /api/inmate after a browser confirm.

States:

  • No inmate yet: empty form, button "Add inmate".
  • Inmate saved: values filled in, button "Save changes", remove button, and a Connection card with a status badge and detail text.
  • Locked: when the subscription is active, trialing or past_due, the inmate number field is read-only with a hint. A server 409 inmate_locked shows an explanatory notice.
  • Validation error: for 400 invalid_field the console matches the field named in the server message and shows the error under it.

Connection badge labels: not_connected "Not connected", pending "Connecting...", connected "Connected", failed "Connection failed".

Usage ​

Backed by GET /api/usage. The page subtitle is "Messages answered this month." and a chip beside the title names the plan (for example "Monthly plan"), which is why this route also loads GET /api/plans. The allowance is per month on every plan, so quarterly and annual subscribers also see one month at a time.

  • No subscription: an empty state, "No active plan yet", with a Choose plan button.
  • Usage unavailable: a message that usage is not available right now.
  • Normal: a "This month" card with the monthly window dates (period_start to period_end) and, when present, a "Plan renews" chip with billing_period_end (the Stripe period end, which can be months later for quarterly and annual plans), three stat tiles (Allowance, Used, Remaining), a "N% used" line with "X of Y messages", and a progress bar capped at 100%. A "Message history" card follows with an entry-count chip and a table (Date, Kind, Stamps), or "No messages yet this month." Kind labels: Reply, Snap, Videogram.

Plan & billing ​

  • If there is no inmate, an info notice links to #inmate.
  • If there is a subscription, a "Your plan" card shows the plan name, a status badge (Active, Trial, Payment past due, Canceled, Incomplete), the period start, and "Renews on" or, with cancel_at_period_end, "Cancels on" the period end. Manage billing calls POST /api/billing/portal and redirects to the returned URL.
  • A plan grid from /api/plans. Each card shows label, price, billing, the per-month equivalent ("Flexible, no commitment" for one-month plans), a "Save N%" chip, and the list "All features", "N messages a month", "Cancel anytime". The 3-month plan carries a "Most popular" flag, and the account's current plan shows a "Current" chip instead of the savings chip. With no live subscription (active, trialing, or past_due) the button is Choose and calls POST /api/billing/checkout with the plan id, then redirects to the returned Stripe URL. With a live subscription the current plan shows a disabled Current plan button, and the others show Switch in billing portal, which opens the billing portal.
  • Billing error handling: billing_not_configured shows "Payments are not switched on yet (sandbox setup pending)."; already_subscribed (409) shows a warning that the account already has an active plan and points to Manage billing; inmate_taken (409) shows an error that the inmate already has an active plan on another account, with a Contact support mailto link; inmate_required links to #inmate; no_customer says to choose a plan first; anything else shows the server message. Redirects only follow URLs starting with http:// or https://.
  • After returning from Stripe: ?checkout=success shows "Payment received. We're connecting your loved one now"; ?checkout=cancel shows "Checkout was cancelled. You have not been charged."

How the frontend calls the API ​

All calls are same-origin fetch requests with credentials: "same-origin" so the HttpOnly ra_session cookie is sent. Request bodies are JSON. Responses are parsed as JSON (except 204), and errors are read from error.code and error.message; see the HTTP API. The pages never read the session cookie and contain no secrets; Stripe is reached only through the URLs the Worker returns.