Compare commits

..
257 Commits
Author SHA1 Message Date
Amruth Pillai 725be158c0 feat(deploy): deploy Vercel as frontend and backend services
Vercel now deploys Reactive Resume as two services in one project:
`frontend` serves the static Vite build from apps/web/dist, and
`backend` runs the Hono server Function from apps/server. Top-level
rewrites send server-owned paths (/api, /uploads, /mcp, /.well-known,
robots.txt, sitemap.xml, llms.txt, schema.json, index.html) and every
path without a file extension to `backend`, so HTML shells keep their
injected SEO metadata. Paths with a file extension go to `frontend`.

The service builder needs a few accommodations:

- apps/server/vercel.mjs replaces api/index.mjs as the entrypoint,
  because a service entrypoint must exist before the build runs.
- `outputDirectory: "."` stops the builder from using the Docker
  entrypoint in dist/ as the Function handler.
- The builder loads external CommonJS dependencies through pnpm links
  that it leaves out of the Function. ioredis and react-reconciler are
  now bundled with their dependencies, and bcrypt is replaced with
  bcryptjs, which reads and writes the same $2b$ hashes.

The Vercel compatibility workflow now builds with the services
framework and loads a copy of the backend Function outside the
checkout, so a dependency missing from the Function fails CI. The stale
PDFKit trace checks are removed.

Existing Vercel installations must set Framework Preset to Services
before redeploying; the self-hosting guide documents this.
2026-09-30 00:17:24 +02:00
Amruth Pillai d4265e76e0 docs: add clarification on Turborepo configuration and task behavior 2026-09-30 00:16:59 +02:00
Amruth Pillai f8981502f1 feat(web): add an FAQ to the landing page and tighten SEO for answer engines
- Add a Questions section to the homepage: eight answers as folded paper
  notes that unfold from their crease, with a pencil circle and underline
  drawn on open, a new doodle, and FAQPage structured data rendered from
  the same localized answers.
- Prerender /ats-checker per locale alongside the homepage
  (dist-prerender/<page>/<locale>.html) and add a "What it checks"
  section; PDF.js and the importers now load only once a file is chosen.
- Make the server the single owner of SEO head tags: canonical, hreflang,
  social cards with og:locale and the page's localized title, and JSON-LD
  with an Organization entity. The router now sets only the title,
  description and robots tags, so nothing is duplicated after startup and
  the ATS checker keeps its structured data.
- Add hreflang alternates to the sitemap, expand llms.txt, and delete the
  stale v5 robots.txt and sitemap.xml from public/.
- Draw decorative heading and typed-text layers as generated content, so
  page text holds each heading once.
2026-09-30 00:16:40 +02:00
Amruth Pillai 8ded9de46e chore: update versioning and dependencies, remove unused configuration files 2026-09-29 23:34:31 +02:00
Amruth Pillai f82e9f33f3 fix(web): use the brand icon in the app sidebar and rail
The sidebar and the collapsed rail drew a placeholder "Rr" tile; they now
show the real logomark.
2026-09-29 23:25:15 +02:00
Amruth Pillai 5b84bf800a feat(web): trim the editor bar and mark a public resume on Share
Undo leaves the desktop editor bar (it stays on ⌘Z), leaving history,
the assistant, Share and Download. A public resume now shows a "Public"
badge on the Share button instead of an unlabelled dot, and the button's
accessible name says so. The first page caption drops "· click any line to
edit it" and its reserved height.
2026-09-29 23:25:13 +02:00
Amruth Pillai 01e87a6f1b feat(web): show resume details in the document menu and ask for donations after a download
The document menu's Information item opened a donation appeal and a row of
project links, which isn't what anyone expects from a resume's menu. It's now
Details: when the resume was created and last edited, its template, language
and page count, and whether it's private or shared (with its link). The
resume API returns `createdAt` for this.

The donation ask moves to the moment someone has what they came for: after a
successful download in Share & export (resumes and cover letters), one quiet
line wishes them luck and links to Open Collective. The old dialog's links
(docs, source, translations, bug reports, donate) live in the Settings
sidebar footer.
2026-09-29 23:24:15 +02:00
Amruth Pillai 48b27802fd test: remove redundant cases and repeated setup 2026-09-29 23:17:30 +02:00
Amruth Pillai 6cfcdea327 docs: update repository agent instructions 2026-09-29 23:04:22 +02:00
Amruth Pillai b399e289d6 feat(web): redesign the landing page as one scroll-driven story
"Everything you've done, on one page." A single resume page is assembled,
written, restyled, checked, tailored and shared as the visitor scrolls,
followed by live community numbers, languages, a support receipt, a
closing call to action and the footer. It replaces the previous homepage
and its playgrounds.

Motion: a small scroll engine (features/homepage/scroll.ts) writes each
section's progress as --p on every animation frame, and the scenes derive
their motion from it with CSS calc(). React only re-renders on coarse
steps held in a zustand store. Reduced motion collapses every pinned
scene to one screen at its end state and stops all ambient motion.

Server-rendered copy: the web build now prerenders the homepage once per
locale (Vite app builder, features/homepage/prerender.tsx) into
apps/web/dist-prerender, which the server sends for "/" by `?locale=`,
then the saved locale cookie. main.tsx waits for the route to load before
React replaces the prerendered page, so it never flashes a loading screen.
dist-prerender is added to turbo outputs, the Docker image and the Vercel
function files.

SEO: localized title and description, a canonical per locale, hreflang
alternates for every locale over `/?locale=` addresses (the app now reads
that parameter), and SoftwareApplication JSON-LD. The FAQ structured data
is dropped because the page shows no FAQ.

Also: self-hosted Anybody and Martian Mono (landing only) and Newsreader
italic, graphite doodles as WebP, new Material Symbols in the icon subset,
the pull-cord theme switch on the app's theme cookie, and new catalog
strings extracted for translation.
2026-09-29 22:34:16 +02:00
Amruth Pillai 708a956174 fix(ui): show the focus ring on segmented control items
`outline-none` on the item beat the base-layer `:focus-visible` ring and
nothing replaced it, so keyboard focus was invisible. Dropping it lets the
design system's 2px accent ring show again.
2026-09-29 22:34:00 +02:00
Amruth Pillai 8a2fa26451 ci: run the database-backed suites and drop coverage from the unit step
The cover-letter and MCP OAuth flow suites were gated on environment variables
CI never set, so they never ran. Point them at the job's PostgreSQL. The
cover-letter suite works in its own schema; the OAuth suite gets a database of
its own because it writes signing keys under its own secret, which the e2e
server can't decrypt.

Nothing reads the unit step's coverage report, so test:ci no longer collects it.
2026-09-29 22:31:56 +02:00
Amruth Pillai c0c7984712 test: keep only the tests that guard real breakage
Prune the unit and e2e suites to what protects security, user data, public
contracts and past regressions. Drop what slowed development without catching
bugs: markup and label assertions, wrappers that only proved Base UI works,
copied inventories and snapshots, mock call-shape checks, permutation matrices
across templates, fonts and locales, and env-gated suites that never ran in CI.

- Unit: 4749 tests in 421 files become 1212 in 227. PDF tests that rendered or
  rasterized every template, font and locale combination go; one render per
  template stays and now checks that every visible section reaches a page,
  which a blank page used to pass.
- E2E: 55 tests in 35 specs become 11 in 9, one journey per severe area: sign-up
  and sign-in, autosave, a failed save during navigation, JSON export and import,
  public and password-protected sharing, slug redirects, OAuth consent for MCP
  clients, and the assistant.
- Tests a coverage review found to be the only guard of a contract were kept or
  restored, each checked by breaking the code it guards.
- Remove exports, tooling and dev dependencies that only the pruned tests used.
- The Semantic CSS guide check now reads indented code fences, so the guide
  example it skipped is compiled too.
2026-09-29 22:31:45 +02:00
Amruth Pillai bc1aaedf1f fix(api): prune superseded read_letter snapshots
SNAPSHOT_TOOL_NAMES lacked read_letter, and the snapshot check looked for the resume key on every result other than read_resume's, so each cover letter read stayed in the model context. The superseded note now names both read tools.
2026-09-29 22:10:14 +02:00
Amruth Pillai 2622eeff12 fix(api): count only unsent attachments toward the per-message limit
attachments.create counted every file in the thread against MAX_ATTACHMENTS_PER_MESSAGE, so a conversation could never hold more than 10 files. Files already sent with a message still count toward the thread's byte quota.
2026-09-29 22:10:14 +02:00
Amruth Pillai 03c3a88841 fix(schema): case the present label by its language only
page.locale accepts any string. A malformed tag such as "de-DE-" made toLocaleUpperCase throw a RangeError, so parseResumeData failed and the resume could not load. The language code always comes from the parser's word list, so it is a valid tag.
2026-09-29 22:10:14 +02:00
Amruth Pillai 461d3c4e64 fix(docx): keep tel: links and drop script and style text
toSafeDocxLink accepted only http, https and mailto, so phone links in custom fields and rich text lost their link. The rich-text converter printed the text of script and style elements through its inline fallback; it now removes those elements after parsing.
2026-09-29 22:10:14 +02:00
Amruth Pillai acdb9d88d3 fix(import): escape plain-text fields from json resume
Summaries, highlights, courses, and award, publication and reference texts went into the HTML as written, so text such as "C<T>" was lost. They now go through the same escapeHtml helper as the LinkedIn and plain-text importers.
2026-09-29 22:10:14 +02:00
Amruth Pillai 54a9bfc307 fix(resume): match whole network names when picking profile icons
A substring test gave the X logo to any network whose name contains an "x", such as Xing, Dropbox, Stack Exchange, Mixcloud and Fox, for example on JSON Resume import.
2026-09-29 22:10:13 +02:00
Amruth Pillai 23ea18241b fix(api): keep trashed resumes and hidden content out of public sharing
Moving a resume to Trash is meant to stop its public link, but the public
PDF, the download counter and the social card still looked resumes up by
username and slug alone, so a trashed public resume kept serving its PDF,
counting downloads and rendering a card. They now skip trashed resumes,
like getBySlug and verifyPassword already did.

The public getBySlug response also carried everything the author had
hidden (sections, entries, the summary and the picture URL), although the
builder says hidden sections aren't printed or shared. redactResumeForViewer
now strips them for anyone but the owner; the rendered resume is unchanged.
The server-rendered social card read the raw row, so it could show the
hidden summary and the owner's private dashboard title; it now builds from
the same redacted data an anonymous visitor gets.
2026-09-29 22:09:50 +02:00
Amruth Pillai c6c51e5b23 fix(web): stop application sheets clipping the top focus ring
The sheet bodies scroll with no top padding, so the 4px focus ring on the
first control (the job description accordion, a field or a button) was cut
off. Pull each body up 4px and pad it back so the ring fits and the layout
doesn't move.
2026-09-29 22:08:11 +02:00
Amruth Pillai eaccd1d5c0 fix(ui): restore the focus ring on radios, checkboxes, switches, accordions and toasts
These controls set outline-none, which overrides the global :focus-visible
accent ring, so keyboard users saw no focus indicator. Toast buttons also
set focus-visible:outline-accent, which only changes the colour and left
the outline style at none. The radio now transitions only border-color so
the ring doesn't fade in from the text colour.
2026-09-29 22:08:10 +02:00
Amruth Pillai 61f481055a fix(server): serve untrusted uploads as downloads
S3 and Vercel Blob hand back the content type the client declared at
upload time, and the upload proxy served it inline on the app origin, so
an uploaded text/html or image/svg+xml file could run script as the app.

Only raster images (gif, jpeg, png, webp) now render inline; everything
else, PDFs included, is served as an application/octet-stream
attachment. The check runs at serve time, so it also covers objects
stored before this change.
2026-09-29 22:07:00 +02:00
Amruth Pillai e74403e12f perf(server): compress the web app's static files and HTML shells
Self-hosted Docker installs served the SPA's CSS, JS and HTML shells without any Content-Encoding. Hono's compress() now wraps only the web routes: it is registered after every API, MCP and upload route, so their streams are never buffered or re-encoded. The Vercel app keeps relying on its CDN, which already compresses.
2026-09-29 22:04:03 +02:00
Amruth Pillai a9c4b84d38 fix(web): keep every document in the account export zip
Resume and letter ids are UUIDv7, so their first eight characters are a
timestamp. Two documents with the same name created within about a minute
got the same path in the "Export everything" zip, and one silently
replaced the other. Name each file with the full id instead.
2026-09-29 22:03:19 +02:00
Amruth Pillai bdfe6fe421 chore: ignore the local plans directory 2026-09-29 18:50:02 +02:00
Amruth Pillai 16008a95d6 refactor(web): leave moving sections between columns to the write panel 2026-09-29 18:50:01 +02:00
Amruth Pillai 394e59e1af refactor(resume): drop semantic css features without a clear pdf meaning
Remove the revert and unset keywords, vw/vh units, the |= and $= attribute
matchers, :nth-child(... of ...), the stylesheet analyzer and the
engine-unsupported warnings. inherit and initial remain the CSS-wide keywords.
2026-09-29 18:50:00 +02:00
Amruth Pillai dc7e1e0431 chore: configure react doctor for this repo
Turns off the React Compiler "todo" rule (it lints before the Lingui macro runs, so it flags code that compiles) and only-export-components, ignores test fixtures for dialog/label rules, and turns off two rules that don't apply to the single-pass PDF renderer.
2026-09-29 18:13:13 +02:00
Amruth Pillai a6ef340c09 feat(web): add a password input and label the two-factor code fields
One PasswordInput replaces seven copies of the show/hide password field. The OTP code fields get a label, prompts and keyword and name inputs ignore Enter during IME composition, and a failed profile save shows an error instead of hanging.
2026-09-29 18:13:12 +02:00
Amruth Pillai 39f30ac9a1 fix(web): name the color picker and keep a typed address while it saves
The color picker trigger receives the id and label FormControl gives it, and the address field no longer overwrites what you are typing when its own auto-save lands. Picture settings, the share tabs and the public page's copy button are split into shared parts and compile with the React Compiler.
2026-09-29 18:13:12 +02:00
Amruth Pillai 7304c38303 refactor(web): share page canvas chrome between the resume and letter builders
usePageScale, CanvasStatusPill and DocumentMenuTrigger replace code duplicated across both builders, the letter bar and share sheet compile with the React Compiler, and a viewed letter version is fitted with its own page format.
2026-09-29 18:13:11 +02:00
Amruth Pillai 59337fcd51 refactor(web): let the assistant, theme and ai settings compile with the react compiler
Also guards the composer's Enter for Safari IME, gives the undo-detection effect real dependencies, lets the composer grow with its text, names the API keys table and moves acceptResumeProposals next to the other proposal logic.
2026-09-29 18:13:10 +02:00
Amruth Pillai 18597a6de0 fix(web): reconnect live resume updates after a stream error and let the builder compile
The retry counter was missing from the subscription effect's dependencies, so after any stream error the builder stopped receiving AI, MCP and other-tab edits until a reload. Entry and section rows, the preview, the PDF canvas, check, export and the custom CSS editor now compile with the React Compiler, and the CSS editor no longer blanks its color swatches on every keystroke.
2026-09-29 18:13:09 +02:00
Amruth Pillai c0e9d3fc62 refactor(web): let the homepage, command palette and ats checker compile with the react compiler
The ATS sample fetch checks the response and reports failures, the ats-pdf chunk loads alongside extraction, and the feature explorer and command palette pages are split into one component per view.
2026-09-29 18:13:08 +02:00
Amruth Pillai 38447bd041 fix(web): label the application form and fix react doctor findings in applications and documents
- Every field in the add/edit application sheet gets an accessible name.
- Enter no longer commits half-converted IME text (new isImeComposing helper).
- Relative-time formatters are cached per locale instead of built per row.
- Import, font fetch and interview/list state handle errors and prop changes without a stale frame.
- The remaining React Compiler bailouts in these features are gone, and the application form and new-document dialog are split into smaller parts.
2026-09-29 18:13:07 +02:00
Amruth Pillai 861feb22eb fix(server): keep replacement patterns in resume text literal in page metadata
A name or summary containing $&, $' or $$ was expanded by String.replace into chunks of index.html, garbling the public resume's head. The inserts now use function replacers.
2026-09-29 18:13:07 +02:00
Amruth Pillai 73a3dfc423 fix(web): keep babel 7 so the react compiler compiles every component
Under @babel/core 8, babel-plugin-react-compiler 1.0 skipped every function with a destructuring default without saying so, which left about 70 components unmemoized, including every packages/ui primitive. A test compiles a component with a default prop through the same preset and fails if that comes back.
2026-09-29 18:13:06 +02:00
Amruth Pillai 92459122c5 feat(server): convert legacy style rules with a manual script instead of at startup
The conversion of stored legacy style rules to Semantic CSS no longer runs
when the server starts. The image now ships
apps/server/dist/migrate-legacy-styles.mjs, run by hand against
DATABASE_URL:

- without flags it's a dry run that converts in memory and reports counts
- --apply --backup <file> converts, appending every replaced stylesheet to
  the backup file before its row is written
- --restore <file> puts those stylesheets back, except on rows edited since

Each table is scanned once for the rows that need converting, then they're
converted in batches with progress logged. Only metadata.stylesheet is
rewritten, a row whose stylesheet changed after it was read is left alone,
and running it again skips what's converted. The data_migration table that
recorded the startup run is gone. The self-hosting guide explains the
one-time run.
2026-09-29 16:57:21 +02:00
Amruth Pillai 677ff17c1f fix(pdf): give a section heading with an icon its custom text styles
With a section icon the heading renders as a row holding the icon and a
separate title text, and that text only took the heading's color from
Custom Styles. Font size, weight, style, letter spacing, line height,
alignment, decoration and text transform written for section-heading now
reach the title too, as they do without an icon.
2026-09-29 16:57:10 +02:00
Amruth Pillai c8aead4ff0 feat(server): convert stored legacy style rules once at startup instead of on every read
Stored resumes, resume versions, letters and letter versions still in
the old editor's legacy mode, or carrying legacy style rules with no
stylesheet, are converted to Semantic CSS once, right after the SQL
migrations, and the result is recorded in a new data_migration table so
later starts skip it. Only metadata.stylesheet is rewritten (the rules
stay for rollback), a row edited meanwhile is retried on the next start,
and a failure leaves the data as it was without stopping the server.

The API no longer converts on every read and save. Imports of old
Reactive Resume JSON exports convert their legacy rules on the way in.
2026-09-29 16:25:50 +02:00
Amruth Pillai a325232a09 refactor(pdf): render every resume with semantic css and drop the legacy styler
Now that the API hands out every resume with its legacy style rules
converted, the renderer runs one styling system: the stylesheet mode,
the per-slot legacy rule hooks in the template primitives and the
section style provider are gone, and the Custom Styles editor loses its
legacy draft banner and Activate step. The converter stays (the API
uses it), with its fixtures now checked by rendering the converted
stylesheets.
2026-09-29 15:25:44 +02:00
Amruth Pillai db97381b3d feat(api): convert legacy style rules to semantic css on every read and save
Resumes from before Semantic CSS carry legacy style rules that only the
old renderer understood. The API now converts them (with the existing,
parity-tested converter) whenever resume data is read or written, so
everything it hands out uses a Semantic CSS stylesheet and the database
catches up on the next save. The rules stay stored for rollback, and a
draft typed in the old editor but never activated is kept, commented
out, after the conversion. New resumes start with an empty stylesheet.
2026-09-29 15:09:57 +02:00
Amruth Pillai cfb272229b feat(web): pick an element on the page to style it in custom styles
With Custom Styles open, clicking a section or entry on the page adds a
rule for it under a comment naming it (or moves the cursor into its
existing rule). While the cursor is in a rule, everything it matches is
outlined on the page. Autocomplete offers sections and entries by name
and inserts their selector, instead of escaped UUIDs. The guide gains a
Style one element section, including the :nth-of-type form.
2026-09-29 14:53:23 +02:00
Amruth Pillai 9acf289b11 feat(web): mark the active phone tab with a bar that springs between tabs
The phone tab bars (resume builder, letter builder, dashboard) drop the
pill behind the active icon for a short accent bar on the tab's top
edge. Switching tabs springs it across with a little overshoot
(spring 0.4s, bounce 0.3); reduced motion moves it at once. Dashboard
tab taps swap the page without the cross-fade, like a native tab bar,
so the bar's movement isn't hidden behind the page transition.
2026-09-29 14:44:16 +02:00
Amruth Pillai 25131b3a82 chore(web): refresh translation catalogs
Adds the new Draft label and drops messages whose source is gone (the
stylesheet status, the removed notes editor).
2026-09-29 14:37:17 +02:00
Amruth Pillai 31fb576099 feat(web): write custom styles as plain CSS without a version line or checker
Custom styles no longer need @version 1; at the top: the language
version already lives beside the text, so new and converted stylesheets
leave it out, the editor hides it in older ones, and the compiler
ignores it. The validity status and inline diagnostics are gone too:
whatever applies shows in the preview and anything else is left out.
2026-09-29 14:31:59 +02:00
Amruth Pillai a6676b3268 fix(web): keep the design panel where it's scrolled
Scrolling the Design panel over the template cards jumped it back:
browser scroll anchoring misfired in the editor's panel scroller, so it
is off there. The panel was also one scroller shared by every mode, so
Design opened at Write's scroll position; each mode now gets its own.
In-page URL updates (mode, filters, open sheet) no longer ask the router
to reset or restore scroll.
2026-09-29 14:26:00 +02:00
Amruth Pillai 0a5323520f feat(web): stack entry fields in one column and let dates fill the row
The write panel is narrow, so an entry's fields now take the whole
column. Start and end dates still share a line and now span it, with
Present below. A new entry's badge just says Draft.
2026-09-29 14:25:53 +02:00
Amruth Pillai 3d65aea58c test(e2e): wait for the share sheet to rise before turning on the link 2026-09-29 13:27:09 +02:00
Amruth Pillai 09134035a8 fix(ui): let a mouse press click inside a bottom sheet
The drawer took a mouse press anywhere outside Drawer.Content as the
start of a swipe and captured the pointer, so switches and buttons in a
bottom sheet never received the click. The sheet's children now sit in
Drawer.Content; touch swipes still dismiss from anywhere.
2026-09-29 13:26:55 +02:00
Amruth Pillai e3d72ab0e7 fix(web): make motion animations instant when reduced motion is on
Motion's reducedMotion="user" only drops transforms, so opacity fades
still ran and could be caught half-faded (axe flagged the assistant).
The design makes every duration instant under reduced motion, as the
CSS tokens already do.
2026-09-29 13:26:54 +02:00
Amruth Pillai 8a8de96a96 chore(web): keep single-file motion constants private 2026-09-29 13:14:57 +02:00
Amruth Pillai 8e0a76bcb1 feat(web): give phone surfaces their push, rise and swipe
The phone entry screen pushes in from the end edge and slides back out on
Back, reversing mid-push. The Design sheet keeps its full height and moves
by translate instead of animating height, and rises from the tab bar when
Design opens. The selection bar rises and fades in.

Sheet is now backed by Base UI Drawer: bottom sheets follow a downward
swipe, dismiss on a flick or a drag past half their height, and settle back
otherwise. Side and top sheets are unchanged.

On phones, Settings rows push the section in from the end and the back link
returns the list from the start, layered on the page view transition.
2026-09-29 13:11:18 +02:00
Amruth Pillai c652a288ba feat(web): swap content in place without a one-frame jump
Views that replace each other (new-document steps, auth post-submit
screens, ATS checker states, consent, social sign-in, new API key) now
fade up 4px as they arrive. The ATS lenses both stay mounted so switching
back never re-reads the PDF, and the 1024-1279 assistant swap crossfades
in the panel's cell (instant on Cmd+J).

Things that used to push layout no longer move what the user is reading:
the bulk-selection bar floats at the bottom of the list, the rich text
toolbar sits under the text, and the letter draft bar takes the Stop
button's place under the draft. The drop-to-import frame, follow-up
nudge, job-match panel, public-link options, and rare save states fade in.

Adds a Swap primitive so copy buttons crossfade Copy -> Copied at a fixed
width, and POP_CLASS for status icons (progress checks, tool status,
proposal Applied, the public download icon).
2026-09-29 13:08:14 +02:00
Amruth Pillai 28eea458d9 feat(web): let content arrive instead of popping in
Loaded content now settles in: library cards and rows, checker issue rows,
assistant suggestions and past conversations rise in with a short stagger on
first appearance only; new assistant messages and "Thinking…" rise in while
history stays put; a newly made document fades up from 96% with its accent
ring fading in.

Resume thumbnails keep the last drawn page of the same resume up while a new
one renders, so edits and return visits no longer drop to a placeholder. The
builder's first preview render paints hidden under the placeholder pages and
fades in; the PDF viewer reserves an A4 slot and holds its overlay until a
page has painted. The ATS checker ring fills on first reveal, ring colour
blends across the threshold, and report and insights bars grow from their
baseline.

Hand-rolled pulsing placeholders become the static Skeleton, shaped like the
view that is loading (documents grid or list, applications views, insights,
API keys). ENTER_CLASS and stagger() live in libs/motion.ts.
2026-09-29 13:02:24 +02:00
Amruth Pillai 607c561792 feat(ui): make overlays and controls move from where they come from
Menus, popovers and combobox lists now slide in from their trigger on
whichever side they open (data-side aware, RTL-correct), and submenus
open in 120ms and close in 84ms. The combobox list exits at 0.7x like
every other popup, and tooltips scale from their trigger.

Toasts keep travelling in the swipe direction when dismissed, ease back
on a cancelled swipe, and a replaced toast drops out before the new one
has finished rising.

The tabs indicator uses the movement curve and now also draws the line
variant's underline, so it slides instead of teleporting. Hotkey mode
switches in the resume and letter builders land instantly. Radio dots
and checkbox ticks grow in instead of popping or only fading.
2026-09-29 12:56:19 +02:00
Amruth Pillai 7827ff11bf feat(web): animate every expand/collapse with one collapsible
Add a Collapsible primitive to @reactive-resume/ui wrapping Base UI's
collapsible, styled like the accordion panel: height grows over 200ms and
folds over 140ms on ease-enter, reversing mid-animation.

Use it for entry cards, section rows, Basics, Check categories,
Design > Advanced, More options, AI provider rows and ATS checker findings,
so content no longer teleports while the chevron rotates. Chevrons at those
sites now share duration-standard ease-enter.

Opening an entry scrolls it back into view once the previous one has folded
away if it slid above the panel, and the page-to-panel reveal re-aims its
scroll after the panels settle. Advanced's open state moves up to the
Design panel so its nav pill can open it; the e2e fixture drives the new
trigger via aria-expanded.
2026-09-29 12:53:27 +02:00
Amruth Pillai d1aabaecb3 feat(web): make every drag-to-reorder move, lift and settle the same way
Builder sections and entries, keyword chips and layout pages now shuffle and
drop with the shared DRAG_SETTLE timing (200ms, ease-in-out-strong). Dragged
builder rows lift on a raised surface with a shadow instead of fading to 40%.
Chips get a drop animation instead of vanishing, and both drag overlays skip
the drop flight under reduced motion.
2026-09-29 12:50:37 +02:00
Amruth Pillai 30819e2fc7 feat(web): land dropped kanban cards where they were dropped
The board now shows a dropped card in its target column in the same
render the drag ends, via a one-card override that clears once the list
query catches up or rolls back, so the card no longer blinks back to its
old column. The overlay then settles into the card's new slot (or back
home) with a 200ms on-screen-movement drop animation, skipped under
reduced motion. The floating card lifts in on pickup, flattens while it
lands, and shows a grabbing cursor.

Adds a shared DRAG_SETTLE dnd-kit timing to libs/motion.
2026-09-29 12:49:02 +02:00
Amruth Pillai 2a71d74e7f perf(web): open the assistant without re-rendering the page every frame
The PDF canvas now stretches its last bitmap while the page's size changes
and redraws once the new scale has held for 150ms, rendering off-screen and
copying over in one step so the page never blanks during the assistant
column's resize, zoom clicks or window resizes. New documents and pages still
render at once.

The assistant column closes in 70% of its open time, keeps its content at the
open width so it travels in with the column edge, and fades that content in
and out instead of unmounting it at once. The tablet drawer slides in from the
end edge and the phone screen rises from the bottom. Cmd+J switches all of
them without a transition via a new assistantInstant store flag.
2026-09-29 12:47:05 +02:00
Amruth Pillai 08c513ca26 feat(web): keep overlay content on screen while it animates closed
Sheets and dialogs were closed by nulling the data they render, so they
went blank, flipped titles or tabs, or dropped rows on the first frame
of their exit. Add useClosingValue, which holds the last value until
Base UI's onOpenChangeComplete(false), and move same-tick resets into
onOpenChangeComplete. The edit-provider dialog and stylesheet color
picker stay mounted so their exit transition runs.
2026-09-29 12:44:15 +02:00
Amruth Pillai 0bc128ff3a feat(web): give pressable surfaces a quick press response
Buttons (every variant except link, skipped while loading), chips, tiles,
nav items, rail and tab-bar icons now dip to 0.97 on press; document,
template, application and suggestion cards dip to 0.98. Full-width settings
and conversation rows darken to the press colour instead of shrinking.
Joined button groups cancel the scale so their seams stay closed, and the
switch thumb stretches toward its travel while held.

Document and template cards transitioned transform, but Tailwind v4 lifts
with the translate property, so their hover lift jumped; they now list
translate and scale. A new document card keeps its accent ring while
hovered, and letter template cards lift like resume template cards.
2026-09-29 12:40:58 +02:00
Amruth Pillai d9979bbc8d feat(web): fade between pages with view transitions
Pathname changes after the first load now run a typed `page` view transition: the old page fades out over 140ms and the new one fades up 6px over 200ms on the house tokens. Search-param and hash changes, the first load and browsers without view-transition types stay instant. The app nav, settings nav and auth logo are named so they hold still.

The auth card's load-time entrance is removed. Applications views, the Documents grid/list switch and Calendar month changes now enter with a short fade (months slide from the direction of travel), only after the user switches.
2026-09-29 12:38:03 +02:00
Amruth Pillai 500cabaf4a perf(web): stop every navigation from waiting on the network
Root beforeLoad now reads the session and flags through the query cache
instead of refetching them on every navigation and preload. A signed-in
session is reused for a minute, a signed-out one is always re-checked,
and every sign-in, profile and 2FA change invalidates the cached session
before re-running route guards. The active Lingui locale is no longer
re-activated per navigation.

Links now preload on intent with TanStack Query deciding freshness, the
pending loader shows after 300ms and holds for 300ms, and it fades in on
in-app navigations while still taking over the HTML loader without a
blink on first load.
2026-09-29 12:35:38 +02:00
Amruth Pillai 982e688e65 refactor(web): put app transitions on the house motion tokens
Set Tailwind's default transition duration and curve to --d1/--ease so
bare transition-* utilities run quick on the house curve. Mirror the
motion tokens in libs/motion.ts (EASE, EASE_MOVE, D1-D3, EXIT) and move
app UI off ease-out-strong and hand-typed 150/300ms durations; the
landing page keeps --ease-out-strong.

Give snapping hover and focus states a transition, slide progress fills
with translate instead of animating width, keep selection outlines'
width and style constant so they fade from transparent instead of
flashing dark, and drop the unused agent-prompt-marquee keyframes.
2026-09-29 12:32:45 +02:00
Amruth Pillai 741b080296 fix(web): clear the query cache when signing out from the avatar menu 2026-09-29 12:28:49 +02:00
Amruth Pillai 3c71b4e7c3 fix(web): list custom section types directly once every section is in use 2026-09-29 11:10:49 +02:00
Amruth Pillai ab2e263a2c fix(pdf): draw azurill's timeline line under forme
Forme gives an absolute box no height from its top and bottom offsets,
so the bar that react-pdf stretched down each section's items vanished.
The converter now draws such a bar as the left border of a box around
the other children, which is as tall as they are and is painted on
every page the section reaches.
2026-09-29 11:06:20 +02:00
Amruth Pillai 7c33ebae11 chore(api): keep the version summary type private 2026-09-29 10:50:48 +02:00
Amruth Pillai 218aad51a8 refactor(web): leave memoization to the React Compiler where it compiles
Removes useCallback, useMemo and memo from the 27 files the compiler compiles without bailing. Files it bails on (destructuring defaults, try/finally, refs read during render) keep their manual memoization.
2026-09-29 10:50:26 +02:00
Amruth Pillai eae7de3fb0 refactor(web): drop server-rendering guards from the client-only app 2026-09-29 10:48:08 +02:00
Amruth Pillai bf3ca81c49 refactor(web): answer confirmations and prompts with one dialog 2026-09-29 10:45:57 +02:00
Amruth Pillai 6e412c2f6b refactor(api): share one version history between resumes and letters 2026-09-29 10:44:03 +02:00
Amruth Pillai 29647ec634 refactor(api): queue resume update notifications with events.on 2026-09-29 10:42:33 +02:00
Amruth Pillai 22dbbdc547 refactor(web): write private notes with the standard rich text editor
RichInput, the full-toolbar editor only notes used, is gone with its tests; the editor extensions it defined move to rich-text-extensions.ts, which the rich text editor already used.
2026-09-29 10:41:32 +02:00
Amruth Pillai 538fd316a1 fix(web): swap preview renders instantly when motion is reduced
The preview cross-fades each new render with motion, which animates in JavaScript and ignores the CSS reduced-motion rule. A render landing from the PDF worker while a sheet was open could leave a page half-faded, so its caption read at low contrast (and moved for users who asked for less motion).
2026-09-29 10:40:01 +02:00
Amruth Pillai 36163a52b4 chore(web): keep an internal sidebar type private 2026-09-29 10:28:49 +02:00
Amruth Pillai cb3c594655 refactor(web): drop combobox groups, SSR hydration options and a hand-typed form store 2026-09-29 10:27:07 +02:00
Amruth Pillai c302faa70b refactor(web): delete code only tests used and reuse shared locale and colour helpers 2026-09-29 10:25:13 +02:00
Amruth Pillai e368e5955d chore: remove unused UI parts, dependencies and turbo tasks, and share escapeHtml 2026-09-29 10:21:20 +02:00
Amruth Pillai 0f6e08a922 refactor: drop dead render props, duplicate template tables and unused import helpers 2026-09-29 10:19:02 +02:00
Amruth Pillai a42cacc057 refactor(api): share cookie, signature and AI error handling instead of copying them 2026-09-29 10:15:56 +02:00
Amruth Pillai 8951f45a3a refactor(api): drop hand-rolled helpers the platform or one caller already covers 2026-09-29 10:13:19 +02:00
Amruth Pillai 2506509538 feat: make cover letters documents of their own
Resumes no longer hold cover-letter sections. A migration saves every
letter a resume carried as a letter linked to that resume's details and
design, hands a resume's only letter to its only letter-less application,
then removes the sections from resumes and their layouts. rollback.sql
puts them back.

Every resume write on the server moves any letter it still carries into
a saved letter in the same transaction, so stale tabs, older files, API
clients and restored versions keep working without duplicating letters.

The resume editor no longer adds or imports letters, the resume download
drops its Cover letter tab, and resume PDF downloads (API, signed links,
MCP) no longer take a cover-letter target. copy_embedded_cover_letter and
POST /cover-letters/from-resume are removed.
2026-09-29 09:30:07 +02:00
Amruth Pillai de3ffeacca docs: log the redesign's completion 2026-09-29 08:44:06 +02:00
Amruth Pillai 48793e04be chore(pdf): drop what the Forme port left unused 2026-09-29 08:40:22 +02:00
Amruth Pillai b775cbc15c fix(ui): let callers name an icon's slot again 2026-09-29 08:35:34 +02:00
Amruth Pillai bfb19ce56e chore(web): extract messages 2026-09-29 08:34:40 +02:00
Amruth Pillai cbc76b03b1 refactor!: contract the redesign's legacy fields
The expand steps of the redesign kept older app versions and API clients
working. This removes those compatibility paths:

- Entry date text (period/date) is written from the structured dates on
  every save; an edit to the text alone is overwritten.
- application.archived is dropped; archived rows close first. CSV import
  still reads the flag from older exports.
- resume_version.label is dropped; versions are named by kind and name.
- rejected is no longer accepted as a stage; remaining rows and history
  close first. CSV import still maps it to closed.

BREAKING CHANGE: older app versions and API clients that read or write
archived, version labels, the rejected stage or date text alone no
longer work. migrations/20260929063245_contract_redesign_legacy_fields
has a rollback.sql that restores the dropped columns.
2026-09-29 08:34:27 +02:00
Amruth Pillai 29ab0326b2 fix(pdf): mirror the award title and date right to left 2026-09-29 08:27:24 +02:00
Amruth Pillai 3405200cf4 fix(pdf): keep a list marker with its item's first line 2026-09-29 08:25:06 +02:00
Amruth Pillai ce2f1857f9 perf(web): render resume PDFs in a web worker 2026-09-29 08:21:59 +02:00
Amruth Pillai 5dc67c3bd4 fix(web): restyle the custom CSS editor in the app's inks and show its toolbar icons 2026-09-29 08:17:12 +02:00
Amruth Pillai 49422e98f2 feat: give a letter its own type, colors and page 2026-09-29 08:14:37 +02:00
Amruth Pillai 17d25c5ffa test(pdf): letters print the sender's header in every template 2026-09-29 08:08:12 +02:00
Amruth Pillai 6c2bc74f92 feat: pin the exported PDF's findings to the page in Check
The file-level check places each finding's evidence on its page: the
rule's own box, or the line holding its snippet. Check keeps the report
with the resume it ran on and, while the resume is unchanged, outlines
each finding on the page with a pin that opens the full report.
2026-09-29 08:05:41 +02:00
Amruth Pillai 098df60230 feat: choose which side the sidebar sits on
Two-column templates take a sidebarSide from the layout (left or right).
Without one, each template keeps its own side and right-to-left pages
mirror it. The PDF templates reverse only their columns, header band
and sidebar background, and the DOCX export follows the same choice.
Design's Sidebar panel offers Left and Right.
2026-09-29 08:00:33 +02:00
Amruth Pillai 11fe2b7a28 feat(web): say what an import brought in when the editor opens
Opening an imported resume shows a note at the top of Write: the file,
the sections and entries found, and how many fields still need a look.
The counts follow the resume as flags are cleared, and the note stays
until it is dismissed.
2026-09-29 08:00:33 +02:00
Amruth Pillai 5f7ed15a5b feat(web): dock the text toolbar above the phone keyboard
On phones the formatting toolbar follows the visual viewport and sits on
the keyboard, 44px tall, with Improve and Done. Done closes the keyboard.
Larger screens keep the toolbar inside the field.
2026-09-29 07:59:59 +02:00
Amruth Pillai 722f5fa14c fix(pdf): close the remaining gaps in the forme port
Work around more Forme 0.25 layout defects in the converter: row-reverse
laid out as row, min and max widths ignored along a row, text dropping
view children, empty text taking a line, overflowing optimal line
breaks, a text in a splitting row wrecking later pages, page breaks on
row items, four equal border sides, percentage corner radii, pictures
under their shadow, and imported table rows. Rotation is left out while
Forme misplaces rotated boxes; the stylesheet editor warns about it and
about percentage padding.

The page map rebuilds blocks Forme drops from its layout, the browser
renderer rejects documents whose fonts can't load so callers fall back
to the server PDF, and every render gets fresh font entries. Glalie's
sidebar is one band at the combined tint.

Tests are re-baselined where they encoded react-pdf metrics, and engine
limits (list marker keep-together, RTL line order, characters above
U+FFFF, percentage padding) are expected failures. The plan logs the
migration in section 13.
2026-09-29 07:37:25 +02:00
Amruth Pillai c0bf1aebaf feat(pdf): remove react-pdf and move every consumer to forme
The web preview, downloads, template gallery, server export and public
PDF render through Forme. react-pdf, react-pdf-html, the react-pdf
hyphenation package, the Phosphor react-pdf icons and the four patches
are gone. Hyphenation now follows the page language for every language
Forme has patterns for.

Semantic CSS keeps its language; declarations Forme can't draw raise an
ENGINE_UNSUPPORTED warning in the editor. The page map rebuilds blocks
Forme leaves out of its layout when they break across pages, and a
render that misplaces a box is repeated with nested rows kept whole.

Tests move to a small shim with the react-pdf calls they were written
against; tests of react-pdf internals are dropped. Forme limits are
recorded as expected failures (RTL line order, characters above
U+FFFF).
2026-09-29 07:11:40 +02:00
Amruth Pillai bc28441b66 feat(pdf): render resumes with the forme engine
Templates keep their react-pdf-style primitives. A small React renderer
records what they draw, and the result is converted to a Forme document:
styles, fonts (with ligatures disabled for text extraction), Phosphor
icons as SVG paths, pictures fitted by object-fit, and the page map from
source locations. Workarounds for Forme 0.25 layout defects live in the
converter: opaque translucent colours, border insets, row splitting,
column gaps across pages, absolute positioning and repeated backgrounds.

Consumers, the Semantic CSS adapter and the old react-pdf dependencies
follow in later commits.
2026-09-29 06:22:38 +02:00
Amruth Pillai 195fbaaaf4 docs: log M12 in the redesign plan 2026-09-29 01:29:36 +02:00
Amruth Pillai 82e2c92a51 test(e2e): wait for the assistant to settle and for focus to return
Enter in the composer is ignored until the previous reply has finished, so the helper presses again until the message goes; the palette's focus return is polled rather than read once.
2026-09-29 01:29:12 +02:00
Amruth Pillai 9062114379 chore(i18n): extract catalogs for the m12 fixes 2026-09-29 01:24:47 +02:00
Amruth Pillai 92dc11bd2f test(e2e): axe on the main screens, and focus return
Adds @axe-core/playwright and audits the editor's modes, the assistant, both Share tabs, Documents, New, the letter editor, the command palette, Applications, Settings, the shared resume, the ATS checker and sign-in against WCAG 2.1 AA, in light and dark and at phone width. A keyboard spec checks that the Share sheet, the assistant, New and the command palette return focus to what opened them.
2026-09-29 01:22:21 +02:00
Amruth Pillai 62572a20ca fix(web): accessibility and tablet fixes from the M12 audit
- The resume and letter canvases are focusable, labelled regions, so the page
  scrolls from the keyboard even before its lines load.
- Closing the assistant returns focus to the ✦ button.
- The ✦ button shows on tablets and phones too, where the assistant opens as
  a drawer or full screen.
- On tablets, Download PDF drops its label so the bar fits beside the mode
  switch.
- The phone reflow darkens a template colour that's too faint to read on
  white, and the checked download format's extension steps up to ink-2.
2026-09-29 01:22:20 +02:00
Amruth Pillai dcddc5639a fix(ui): 44px touch targets and readable descriptions on tinted rows
On coarse pointers, buttons, switches, checkboxes and tabs get an invisible hit area of at least 44x44 centred on them, without changing how they look. A checked switch row's description steps up to ink-2, since ink-3 falls short of 4.5:1 on the accent tint. Adds contrastOnWhite to the colour utilities.
2026-09-29 01:22:19 +02:00
Amruth Pillai 55a3284360 refactor(web): move the app chrome from phosphor to material symbols
Auth pages, dialogs, the command palette, the user menu, Applications, the rich-text toolbar, the stylesheet editor and the error screens use the Material Symbols set instead of Phosphor; the icon subset gains the 35 glyphs they need. Phosphor stays for brand logos, the landing page and the icons printed on resumes. Also removes the form field and test mock that only wrapped the old rich input.
2026-09-29 01:01:16 +02:00
Amruth Pillai f8767c32f1 refactor(ui): retire the shadcn colour aliases
Every class that used the old names (muted, primary, card, border, input and the rest) now uses the Desk & Paper token it resolved to, with text colours on their text tokens (accent-text, danger-text, ink-3). The compatibility block in the theme is gone.
2026-09-29 00:56:59 +02:00
Amruth Pillai 3a69dfc4d5 refactor(ui): remove the agent chat primitives and sidebar
Attachment, bubble, empty, marker, message, message scroller, questionnaire, resizable and sidebar had no users after the assistant moved into the editor, and neither did react-resizable-panels or @shadcn/react.
2026-09-29 00:55:50 +02:00
Amruth Pillai f2eb230f69 docs: log M11 in the redesign plan 2026-09-29 00:54:00 +02:00
Amruth Pillai d2ef001be9 test(server): give the first app route test time for the cold import
It imports the whole app and timed out under parallel runs.
2026-09-29 00:53:31 +02:00
Amruth Pillai 3604d6feb2 chore(i18n): extract catalogs for settings and public pages 2026-09-29 00:50:33 +02:00
Amruth Pillai 991d7e0c32 docs: describe the new ATS checker and its fix flow 2026-09-29 00:50:21 +02:00
Amruth Pillai 2ca57fab80 test(e2e): check a file on the public checker and fix it after signing up
Checks the sample file, reads it as software does, then signs up from Fix these in the editor and lands in Check on the imported resume.
2026-09-29 00:50:21 +02:00
Amruth Pillai d9ed63f720 feat(web): rebuild the public ATS checker around fixing the file
Idle is a drop zone with "Check a sample file" and an optional posting; busy
shows three labelled steps; the result is a 440px column with the score ring,
the categories that could cost a match (and the posting's missing terms), and
"Fix these in the editor", beside the file shown two ways: the original page
and the text as software reads it. Phones get one column with the fix pinned.

"Fix these in the editor" imports the same file, read in the browser as the
check was, and opens it in Check. Visitors sign up first; the file waits in
IndexedDB and is imported when they're back.

The public checker's AI review is dropped (Check -> Writing covers it), with
its locked card, the marketing parse preview and the old uploader. The PDF
viewer also renders a given file, for the original page.
2026-09-29 00:50:20 +02:00
Amruth Pillai 6282bf77db test(e2e): the shared page on phones, and links that aren't shared
Adds a phone visit that checks the reflow, tap-to-mail contacts and the pinned Download and Share, and an unknown link's message. The download preference spec expects the page's single Download button, and the root spec the new footer credit.
2026-09-29 00:41:54 +02:00
Amruth Pillai cc28f78537 feat(web): the shared resume page, reflowed on phones
Desktop and tablets get a 64px bar with the owner's name, headline and city,
Copy link and Download PDF, the page on the sunken canvas, and the footer
credit. Phones get the resume as readable text in the template's colour and
body font, in the order the PDF prints it (read from the semantic tree), with
contact details as tap targets and Download and Share pinned.

The owner's rich text is parsed into an allowlist of formatting elements and
plain links, never injected. With downloads off, Download is hidden and
printing shows a note instead of the page. Off, unknown and trashed links all
read "This resume isn't shared right now." with nothing about the owner.
2026-09-29 00:41:54 +02:00
Amruth Pillai a043b28867 docs: point the guides at the new settings pages
Guides now name Account, Preferences and AI & developer, the new key flow, and what the account export contains.
2026-09-29 00:33:44 +02:00
Amruth Pillai abca49120c test(e2e): cover the settings pages
Old addresses redirect, the name saves on blur, Export everything downloads a zip, the theme applies at once, and a new key is shown once and revoked with Undo.
2026-09-29 00:33:43 +02:00
Amruth Pillai 9f809890e4 feat(web): settings in three pages that save as you go
Six settings pages become Account, Preferences and AI & developer, with an
in-page nav beside a 680px column (tabs on tablets, a three-row root on
phones, which the Account tab opens).

- Account: photo upload, name, username with the instance host, and email,
  each saving on blur; password, a two-step verification switch, passkeys and
  connected sign-in; Export everything as one zip of documents and
  applications; Delete account with the counts of what goes and typing
  "delete"; Sign out.
- Preferences: Light, Dark and System tiles, the interface language and the
  motion note.
- AI & developer: provider rows with Test ("Connected · 420 ms" or the exact
  error) and an Edit dialog holding the switch and delete; Add provider with
  all sixteen; API keys in a table with a New key dialog (30 days, 90 days,
  Never), the key shown once, and Revoke with Undo; the MCP address with Copy.

The old settings addresses redirect, and links across the app, the command
palette and the user menu point at the new pages.
2026-09-29 00:33:37 +02:00
Amruth Pillai 55db11aea8 feat(api): include applications in the account export
Export everything downloads documents and applications together, so the account export now returns the user's applications, without the owner id.
2026-09-29 00:33:29 +02:00
Amruth Pillai cbd47ec1fb docs: log M10 in the redesign plan 2026-09-29 00:11:23 +02:00
Amruth Pillai 4acc5f19d4 chore(i18n): extract catalogs for the assistant 2026-09-29 00:11:22 +02:00
Amruth Pillai 3ef8eaeb26 test(e2e): drive the assistant and Improve against a scripted provider
A local OpenAI-compatible stub plays a short conversation: read the document, propose an edit, ask a question when asked, and stream slowly enough to stop. The spec connects it in place, accepts an edit and checks it was saved, answers a question, stops a reply, improves a line, asks from the command palette and follows the old /agent links. The E2E workflow allows the loopback base URL the stub needs.
2026-09-29 00:11:21 +02:00
Amruth Pillai e1d5b9ba9b feat(web): the assistant beside the document, and Improve in every text field
The assistant opens beside the resume or letter it works on: a third column
at 1280px and wider, in place of the left panel from 1024px, a drawer below
that, and full screen on phones. The bar's button and Mod+J toggle it.

The panel sets up a provider in place, suggests what to ask, streams replies
with Stop and Continue, asks clarifying questions, and shows proposed edits as
change sets with page marks, the outline's "n proposed" pill and the page
caption. Context chips decide whether the next message shares the document and
the posting. Errors keep the message with Retry and Switch model, past
conversations are grouped by document with their outcomes, and the
conversation can be copied as a transcript.

Improve in the rich-text toolbar suggests a stronger verb, a result, a shorter
line or the user's own request for the line holding the caret, and replaces it
only on Replace.

The Agents pages, the builder's assistant sheet and the application copilot
panel are removed; /agent links redirect to the document with the assistant
open. Command palette Ask, Job match's missing terms, Applications' Prepare for
next step and Copy for a job all open the assistant on the right document.
Removes the unused react-resizable-panels and @shadcn/helpers dependencies.
2026-09-29 00:11:16 +02:00
Amruth Pillai 26044b5346 docs: regenerate the OpenAPI spec and JSON schema guide
The published spec had fallen behind the runtime routes, including the cover-letter draft and version routes, and the schema guide was missing Check metadata. The OpenAPI test now lists the new letter routes and the Trash wording.
2026-09-29 00:11:06 +02:00
Amruth Pillai 6463a9e9c1 feat(api): an assistant bound to a resume or letter that proposes edits
Threads belong to a resume or a cover letter (agent_threads.cover_letter_id)
and count the edits they proposed and the user accepted. The propose_edits
tool rewrites or adds passages by id, resolved against the document as it is
now, and the edit statuses are stored with the message. read_letter joins
read_resume; apply_resume_patch, approvals, revert and archiving are removed.

Message context lets the user leave the document or the linked posting out of
a message; the posting now includes the application's notes. Redis is
optional: without it replies stream directly.

The proposal core (passages, additions, states) moves to
@reactive-resume/resume/proposals so the server and the web app share it.

Adds ai.improve, which suggests a rewrite of one line (stronger verb, a
result, shorter, or the user's own request) and says when it states
something new.
2026-09-29 00:11:00 +02:00
Amruth Pillai e86179187e docs: log M9 in the redesign plan 2026-09-28 23:08:13 +02:00
Amruth Pillai 12c7869ea7 chore(i18n): extract catalogs for letters 2026-09-28 23:08:12 +02:00
Amruth Pillai 2bf88584e8 test(e2e): drive the letter editor 2026-09-28 23:08:11 +02:00
Amruth Pillai 82fa327900 feat(web): letters on the editor shell, with drafting, History and both files to download 2026-09-28 23:08:10 +02:00
Amruth Pillai aa9a5113c0 fix(pdf): keep the letter's header when the page tree is built 2026-09-28 23:08:10 +02:00
Amruth Pillai fdad39c632 feat(api): structured letters with live links, versions and a streaming draft 2026-09-28 23:08:04 +02:00
Amruth Pillai c061367a27 docs: log M8 in the redesign plan 2026-09-28 22:15:25 +02:00
Amruth Pillai 2a41d45efc chore(i18n): extract catalogs for applications 2026-09-28 22:14:22 +02:00
Amruth Pillai 8dc45ee7e7 test(e2e): drive the rebuilt applications page 2026-09-28 22:14:21 +02:00
Amruth Pillai 462db6011a feat(web): applications as a grouped list, a rebuilt detail sheet and an add dialog
- The page: CSV import/export behind one icon, Add application, a dismissible
  nudge for the application waiting longest without a reply (10+ days),
  List · Board · Insights · Calendar, search across role, company, contacts
  and tags, and Show closed.
- List (the default): grouped by stage in the order that needs you first,
  with collapsible groups, the next step (warn when overdue), what was sent,
  sorting from the headers and row checkboxes for bulk moves, tags, closing
  and deleting. Phones get two-line rows and no board.
- Board: a column per stage; drops and Move to… show the same toast.
- Detail sheet (480 px, full screen on phones): the stage stepper with Move to
  next, the next step (the next interview or follow-up) with Edit and Add to
  calendar (.ics), what was sent (opening the version sent, read-only, in
  History), Tailor a resume and Write a letter, editable salary and source,
  contacts, tags, autosaved notes and the activity timeline. Close
  application… takes a reason; Delete is in ⋯ and asks first.
- Add dialog: paste a link or posting; its role, company and requirements are
  read into editable fields and the posting is saved with the application.
  Add, or Add and tailor a resume.
- Insights: how far applications get (from their stage history), how many
  heard back and how fast, and tailored against base resumes, above the
  existing charts.
- CSV import shows how columns were matched before saving and lets you
  download the rows it skips; export adds the closed reason.
- The builder opens History on a version from ?version=.
2026-09-28 22:14:20 +02:00
Amruth Pillai ce0b4c606a feat(api): a closed stage with reasons, what was sent, posting requirements and a posting reader
- Applications end in a `closed` stage with a reason (not selected, withdrew,
  accepted another offer, no response). The migration moves `rejected` to
  closed + not selected and archived applications to closed, rewrites
  `rejected` in their stage history, and ships rollback.sql for older
  versions. `archived` stays, deprecated; `rejected` is still accepted as
  input and means closed.
- Once an application with a linked resume reaches Applied, the resume is
  saved as a "sent" version named after the company, and the application keeps
  its id and the resume's Check score then.
- New columns: closed_reason, cover_letter_id (backfilled where exactly one
  letter was written for the application), sent_resume_version_id,
  sent_check_score and requirements.
- applications.ai.parsePosting reads a pasted link or posting. Links are
  fetched on the server: https only, public addresses checked at connect time,
  three redirects, 2 MB and 10 s at most. A page's JobPosting data fills the
  fields without AI; with a provider, the model reads role, company,
  location, salary and requirements.
- MCP application tools take closedReason and coverLetterId.
2026-09-28 21:44:55 +02:00
Amruth Pillai f5a3fa35eb docs: note the DOCX page alignment in the M7 log 2026-09-28 21:35:51 +02:00
Amruth Pillai 25306630e7 fix(docx): print pages the way the PDF does for one-column templates and full-width pages 2026-09-28 21:35:39 +02:00
Amruth Pillai 4fbc0d7b1d docs: log M7 in the redesign plan 2026-09-28 21:34:16 +02:00
Amruth Pillai 1253ef08a6 chore(i18n): extract catalogs for check mode 2026-09-28 21:33:43 +02:00
Amruth Pillai 3397c77917 test(e2e): drive check mode's issues, job match and parser view 2026-09-28 21:33:42 +02:00
Amruth Pillai 565424631a feat(web): check mode with pinned issues, job match, a writing review and proposals
- The score ring (live checks only, easing to each new score), the verdict,
  and Issues · Job match · Writing tabs.
- Issues: numbered cards with category, explanation and fix, pinned to their
  lines on the page with warn pins and wavy underlines. One-step fixes apply
  with undo; the rest open the field in Write. Show on page, and Ignore (Keep
  for the two-column issue), stored with the resume. Category rows below, open
  when they need attention. "Also check the exported PDF" reports in a toast.
- Job match reads the linked application's posting, or a pasted one that can
  be saved as an application. Missing terms ask where they belong (Add to
  Skills) or can be hidden; covered terms light up their entries on the page.
- Writing: an opt-in AI review that says what it sends. Rewrites of bullets
  and paragraphs arrive as proposals: struck-through old text and highlighted
  new text on the page, numbered markers, Accept, Reject, Accept all, A/R and
  arrow keys, one undo step. Out-of-date proposals can't be applied.
- "What a person sees / What a parser reads": the parser view extracts the
  text of the PDF on the page, in reading order, flagging issue lines.
- Phones step through issues on the page; tablet pins open the drawer.
- The builder's ATS section and its deep-check UI are gone.
2026-09-28 21:33:41 +02:00
Amruth Pillai 1608d56903 feat(api): passage rewrites in the writing review, and a resume's linked application
- ai.atsReview takes optional passages (id, where, text). A suggestion that
  rewrites one names it in passageId with the whole new passage, which the
  editor offers as a proposal to accept or reject.
- The review prompt fills its placeholders in one pass, so resume text that
  looks like a placeholder is sent as it is.
- resume.getById returns applicationId, the job application a resume was made
  for, so Check's job match can read its posting.
2026-09-28 21:33:18 +02:00
Amruth Pillai e26ce08fc5 feat(resume): check categories, stable issue keys, ignores and a two-column rule
- Every live check has a category (contact details, dates, layout, section
  headings, writing). Reports score the applicable rules, per category too.
- Findings carry a key that uses entry ids instead of array indexes, so it
  survives reordering. Keys in the new metadata.check.ignored set findings
  aside without counting them against the score.
- TWO_COLUMN_LAYOUT flags a two-column template that prints a sidebar.
- Full-width pages print no sidebar, so sections placed only there are now
  reported as never printing instead of passing as main-column content.
- metadata.check also holds job-posting terms hidden as not true; public
  viewers don't receive it.
- ats-pdf exports the job-description matcher, spelling variants and the
  semantics reader for the editor's job match and parser view.
2026-09-28 21:33:11 +02:00
Amruth Pillai 0e36729b80 docs: log M6 in the redesign plan 2026-09-28 20:46:23 +02:00
Amruth Pillai 7f162bf230 fix(web): satisfy Biome in the first-paint theme script and loader styles 2026-09-28 20:45:01 +02:00
Amruth Pillai 7831cc04a7 chore(i18n): extract catalogs for documents, trash and the new-document dialog 2026-09-28 20:39:05 +02:00
Amruth Pillai 443897dcb0 test(e2e): drive documents, trash and the new-document dialog
The shared fixture creates its sample resume through the API and opens
it; documents-new covers Start blank (named after the headline) and Copy
for a job (linked and searchable by the application). dashboard-lifecycle
renames inline, duplicates, trashes with undo, restores and deletes now;
the letter, lock, view, import, auth and direction specs follow the new
library and menus.
2026-09-28 20:39:04 +02:00
Amruth Pillai daa7d331f3 feat(web): documents home, trash and a new-document dialog
The dashboard becomes Documents: resumes and saved letters in one
library, in a new app shell (a 240px sidebar, an icon rail on tablets and
bottom tabs on phones) with Documents, Applications, Trash (when it has
items), New (N) and the account row.

- Documents: All / Resumes / Letters with counts, search (/) across
  titles, tags and linked applications, sort, grid or list (remembered on
  the device), tag chips once tags exist, cards with the real first page,
  "Resume · Edited 2h ago", the linked application, lock and "New" badges,
  and the first-run screen. A file dropped anywhere on the page imports.
- One menu on cards, rows and right-click/long-press: Open, Rename
  (inline), Duplicate, Copy for a job… (resumes) or Link to application…
  (letters), Tags…, Lock editing, and Move to Trash with Undo.
- Trash lists days left, with Restore and Delete now (asks once).
- New: import a file in three labelled steps with the result and flagged
  fields (resumes, and saved letters' JSON), copy a resume for a job,
  start blank (named after its headline until renamed), a new letter, or
  a sample resume.

Also:
- /dashboard/resumes and /dashboard/cover-letters redirect to Documents.
- Settings pages sit behind a tab strip until they're regrouped, and the
  account menu gains Settings.
- A letter written inside a resume can be copied to Documents from its
  entry menu (Q3k), replacing the library's copy action.
- The builder's document menu and the letter editor move documents to
  Trash instead of deleting them.
- Dialogs reopened right after closing start fresh.
- Removed: the resume and letter libraries and the old create and import
  dialogs.
2026-09-28 20:38:54 +02:00
Amruth Pillai d4406c729b feat(api): name imports after their person and keep locked documents' details
An imported resume is named from its content (the person's name, else the
headline) instead of a random name. Renaming, tagging or linking a locked
document is refused, as moving it to Trash already was.
2026-09-28 20:38:32 +02:00
Amruth Pillai a568f64b43 feat(api): one documents library with Trash, job links and automatic names
Schema: resume gains application_id (the job a copy was made for),
trashed_at and auto_name; cover_letter gains tags, is_locked and
trashed_at.

A new documents router treats resumes and saved letters as one library:
list (live or in Trash, with the linked application), counts, rename,
setTags, setLocked, linkApplication, trash, restore, purge (only from
Trash) and copyForJob, which duplicates a resume, links the copy to the
application and gives the application the copy when it has none.

- resume.delete and coverLetters.delete now move to Trash. Documents stay
  there for 30 days and are purged when their owner next lists documents,
  so no scheduler is needed.
- Documents in Trash are left out of resume and letter lists, and a resume
  in Trash isn't shared: getBySlug and verifyPassword skip it.
- Locked documents can't be moved to Trash; locked letters can't be edited.
- A blank resume created with autoName takes its headline as its name
  until someone renames it.
2026-09-28 19:59:03 +02:00
Amruth Pillai dc6de786f7 docs: log M5 in the redesign plan 2026-09-28 19:48:46 +02:00
Amruth Pillai 13787333d4 chore(i18n): extract catalogs for the share and export sheet 2026-09-28 19:48:17 +02:00
Amruth Pillai 6540c2aee9 test(e2e): drive the share and export sheet
The sharing specs use Share → Link (public switch, visitor downloads, the
password switch and the public address from Open public page), and the
export specs pick a format in the Download tab. share-history covers
renaming the address with the old one redirecting, and naming, previewing
and restoring versions.
2026-09-28 19:48:15 +02:00
Amruth Pillai c8df749258 feat(web): one sheet for sharing, downloads and version history
Share & export is one 440px sheet (a full-height bottom sheet on phones)
with Link, Download and History tabs. Share opens Link, the ▾ beside
Download PDF opens Download, the clock opens History, and ⌘⇧S / ⌘⇧E open
their tabs. On desktop the page moves 120px aside so it stays visible.

- Link: the public switch card; the address with a live check (300 ms),
  the reason it can't be used and a suggestion, saved only once valid, so
  the old address stays live; Copy ("Copied" for 2 s); visitor downloads;
  the password (Q3a); Open public page; a QR code; Share via… where the
  platform offers it; and views, downloads and time since the last view
  over 30 days with a daily chart, or the explanation while the link is
  off.
- Download: format cards explained by when to use them, Resume or Cover
  letter (with the resume header option) when the resume has a letter,
  the file name recruiters see (First-Last-Resume by default, unsafe
  characters stripped), a non-blocking note about open Check issues, and
  progress inside the button; a failure offers Try again and PDF.
- History: name the current state; a timeline of Now, sessions, named
  versions, restores and where the document came from. Picking a version
  shows it on the page, read-only and outlined, with its banner; Restore
  saves "Before restore" first. Named versions can be renamed or deleted.

Also:
- Autosaves send this visit's session id, and naming or restoring a
  version saves pending edits first.
- The one-click PDF and every export use the First-Last-Resume name.
- Resume dialogs stop asking for a slug (it's edited in Share), which also
  stops Rename from overwriting a custom slug with one made from the name.
- The public route redirects a renamed resume's old address.
- Removed: the download dialog, the version-history menu and the sharing,
  statistics and export sections.
2026-09-28 19:48:08 +02:00
Amruth Pillai 060750a868 feat(api): version kinds, session autosaves, named versions and slug redirects
Versions:
- resume_version gains kind (created, import, auto, named, before-restore,
  restored, ai, sent), name and session_id. The migration backfills kind
  from the old English labels; label stays for API clients.
- Each editor visit sends a session id with resume.update, and its saves
  keep one autosave version, refreshed at most every two minutes. Calls
  without a session keep the old throttled autosave.
- Creating a resume writes a "created" version and importing an "import"
  one, so history is never empty.
- New procedures: getVersion (for previews), createVersion (name the
  current state), renameVersion and deleteVersion (named versions only).
- Retention replaces the 30-row cap: autosaves, AI edits and restores
  expire after 90 days, pruned when a resume gets a new version (there is
  no scheduler, and the Vercel entry skips startup hooks); a cap of 500
  autosaves per resume bounds storage.

Slugs:
- resume.checkSlug validates ^[a-z0-9]+(-[a-z0-9]+)*$, reports which of the
  user's resumes uses a taken slug, and suggests a free one.
- A changed slug must match the pattern (existing ones keep working until
  changed). The old slug is kept in resume_slug_redirect for 30 days, and
  getBySlug and verifyPassword resolve it; the response carries the current
  slug so clients can redirect.
- create and duplicate make the slug optional and generate a unique one
  from the name; duplicate no longer falls back to the original's slug,
  which always collided.

The migration also applies the pending drop of the redundant
resume_user_id_index removed from the schema in b953435f2.
2026-09-28 19:46:41 +02:00
Amruth Pillai 31e8dc39d4 docs: note the PDF engine and Semantic CSS move to Forme next phase 2026-09-28 19:26:24 +02:00
Amruth Pillai c18911aaca docs: log M4 in the redesign plan 2026-09-28 19:08:42 +02:00
Amruth Pillai 448db84e50 chore(i18n): extract catalogs for the library letter picker 2026-09-28 19:07:04 +02:00
Amruth Pillai 4da00ddce1 fix(web): start a resume cover letter from a library letter again
The entry dialogs removed with the Write panel carried "Import from
library", which copied a saved letter's recipient and text into a new
cover-letter entry. A new, empty cover-letter entry now offers the same
picker inline. The copy stays independent of the library letter.

The cover-letter-library spec drives the inline picker and waits for the
renamed JSON copy before checking the original, which removes a race with
the list refetch.
2026-09-28 19:07:03 +02:00
Amruth Pillai 49464bb7ff chore(i18n): extract catalogs for the Design panel 2026-09-28 19:04:28 +02:00
Amruth Pillai 9dd38433d6 test(e2e): drive the Design panel
template-switch now previews a template on hover, applies it from its card,
checks it after a reload and undoes a switch from the toast. The section
helper opens Design groups and the exact-value sections inside Advanced.
2026-09-28 19:04:27 +02:00
Amruth Pillai 1ef7c9fa10 feat(web): rebuild Design with template previews, presets and Fit
The Design mode is a single panel with a sticky group nav: Template, Type,
Color, Page and a collapsed Advanced.

- Template: filter chips, and thumbnails rendered from the user's own
  content at idle time, cached by template and content. Hovering or
  focusing a card (holding it, on touch) previews the template on the page
  with a "Previewing" chip; leaving the cards or Esc restores it; a click
  applies it with an Undo toast. Two-column templates get a sidebar
  sub-panel for its width and sections.
- Type: five font pairings, text size 9-12.5 pt and density. Color: eight
  accents and a hex field with its contrast on white, offering a darker
  shade below 4.5:1. Page: paper, language, margins, icons and link
  underlines.
- Advanced: the date format, every exact-value editor, custom CSS and
  Reset to template defaults.
- When content runs past the authored pages the canvas says by how many
  lines and offers Fit, which tightens density, then margins, then size
  (never below 9 pt), re-rendering after each step, as one undo step.
- Phones get the groups in a half-height sheet over the live page.

The template gallery dialog, the Template section, the collapsible section
chrome and its collapse store are gone. ATS design findings now open the
Design group that fixes them. The desktop panel is a containing block, so
screen-reader text deep in a long panel no longer stretches the document.
2026-09-28 19:04:21 +02:00
Amruth Pillai 85352ee11b feat(schema): describe template layouts in one place
Columns, sidebar side, header placement and ATS safety for every template
now live in templateLayouts. The template gallery metadata and the layout
editor read it, and a DOCX test checks the two-column configurations
against it.
2026-09-28 19:04:03 +02:00
Amruth Pillai 7244485d6e docs: note when the preview moves to a worker 2026-09-28 18:20:48 +02:00
Amruth Pillai 1b079dd5bd docs: log M3 in the redesign plan 2026-09-28 18:20:29 +02:00
Amruth Pillai de85121f5e chore(i18n): extract catalogs for the Write panel 2026-09-28 18:20:28 +02:00
Amruth Pillai e2e5c15580 test(e2e): cover inline editing and click-to-select
Specs follow the outline rows, the eye on each row, the photo row, the
structured dates and the Full name field, and a new spec checks that a
click on the page opens its entry and focusing a field outlines its block.
The download-preference spec waits for the share address after a reload.
2026-09-28 18:20:27 +02:00
Amruth Pillai a82741f42a feat(web): edit resumes inline in the Write panel
Write becomes the Basics card, the outline of sections in print order and
Add section. Entries open in place (one at a time, the editor selection),
save as you type and show drafts as "Draft · not printed". Dates use a
structured field with a Present switch and the review note for text that
wasn't read exactly; descriptions use a restricted rich-text toolbar shown
on focus. Sections and entries reorder by drag or Alt+Up/Down, rows carry
the eye, count and every section option, and deleting shows Undo.

Clicking the page opens the entry and scrolls it into view; focusing a field
outlines its block. Phones push an open entry full screen. While a field
has focus the preview waits for a pause in typing.

The left section sidebar, the entry dialogs and the hidden-sections list are
removed.
2026-09-28 18:20:26 +02:00
Amruth Pillai bda01febd5 feat(schema): structured resume dates with a read-time upgrade and dual write
Dated entries and roles carry dates (start, end, present, and raw when the
text couldn't be read exactly). The parser moves to the schema package and
reports how each date was written; parseResumeData fills dates, infers the
date format from how dates were typed and rewrites the legacy period/date
text from them, so older clients and API readers keep working. API writes
sync against the stored data, so an edit to the text alone is read back
into dates.

getById and getBySlug return upgraded data, ATS date rules and sorting read
dates, JSON Resume and LinkedIn imports map their dates directly, entry
titles may be empty (drafts aren't printed), and the MCP schema resource is
generated live in place of the stale schema.json.
2026-09-28 18:20:18 +02:00
Amruth Pillai fa19b891db docs: log M2 in the redesign plan 2026-09-28 17:18:12 +02:00
Amruth Pillai 91f1aba2e5 chore(i18n): extract catalogs for the editor shell 2026-09-28 17:18:11 +02:00
Amruth Pillai ffa16f2efa test(e2e): drive the new editor bar, modes and share sheet
openSidebarSection now switches to the mode that hosts a section or opens the
Share & export sheet, and openDownloadDialog opens every format from the
Download PDF split button. Specs follow the back link, the save status line,
the document menu, the zoom bar and page-scale zoom.
2026-09-28 17:18:10 +02:00
Amruth Pillai f2a76b2f69 feat(web): replace the builder with the Desk & Paper editor shell
One editor at /builder/$resumeId: a bar with the document menu and save
status, the Write, Design and Check modes (in ?mode=), undo, history,
assistant, Share and the Download PDF split button, over the panel and the
page canvas. Tablets get a drawer that can be pinned in landscape; phones
get Write, Page, Design and Check tabs.

The canvas renders pages at the zoom level (60-150%, Fit), outlines the
selected entry, links page clicks to the panel and panel focus to the page,
and offers Edit entry on phones. Share & export hosts sharing, statistics and
every download format. Offline, the panel explains the state and Share and
Download wait for a connection.

Until their milestones land, the modes host today's section editors, design
sections and ATS check. The resizable-panel shells, rails, dock, header,
mobile shell and react-zoom-pan-pinch are removed.
2026-09-28 17:18:04 +02:00
Amruth Pillai 639abf12b6 feat(web): keep unsaved drafts on the device and deepen undo
Failed saves report Offline or Not saved with a retry, keep the draft in
localStorage per resume, replay it when the connection returns and restore
it when the editor opens again. Undo keeps 200 steps as shared immer trees,
merges typing in one field within a second and keeps structural actions as
steps of their own.
2026-09-28 17:17:56 +02:00
Amruth Pillai f73238ba3b feat(pdf): pass the page map through the browser renderer 2026-09-28 17:17:56 +02:00
Amruth Pillai a887a72d77 feat(ui): add a breakpoint hook, panel icons and CSS-drawn icon glyphs
Icons now draw their ligature from data-icon in a pseudo-element, so icon
names stay out of copied text, find-in-page and text queries. useBreakpoint
reports the design system's mobile, tablet, desktop and wide ranges.
2026-09-28 17:17:54 +02:00
Amruth Pillai 90d7d0a19b docs: document the Desk & Paper design system
Rewrite DESIGN.md for the new tokens, type, icons, motion and
components, add the redesign's terms to GLOSSARY.md, and log M1 in the
redesign plan.
2026-09-28 16:29:36 +02:00
Amruth Pillai 9e041e140b chore(i18n): extract catalogs for the System theme option
Also picks up LinkedIn import strings that hadn't been extracted yet.
2026-09-28 16:29:36 +02:00
Amruth Pillai 0f934bd849 feat(ui): adopt Desk & Paper tokens, fonts, System theme and restyled primitives
Replace the achromatic shadcn palette with the Desk & Paper tokens (warm
neutrals, one moss accent, danger, warn and info), exposed to Tailwind
under their spec names; the previous names resolve to the new tokens
until every screen is rebuilt. Hard-coded palette classes become semantic
tokens and stage colors follow the spec.

Fonts move to Newsreader, Hanken Grotesk and JetBrains Mono. The theme
gains a System option that follows the operating system live, applied
before first paint, and Base UI now receives the locale's direction.

Primitives follow the component spec: button variants primary,
secondary, ghost, danger and link with a loading state, accent focus
rings on inputs, SwitchRow, semantic badges, segmented and underline
tabs, restyled menus, dialogs, sheets, tooltips and command bar, and a
single bottom-center toast with an Undo action. New: IconButton,
SegmentedControl, RadioGroup and NativeSelect.
2026-09-28 16:29:30 +02:00
Amruth Pillai 4485825dfe feat(ui): add a self-hosted Material Symbols icon subset
Icon renders Material Symbols Rounded at weight 300 from a subset font
that holds only the glyphs listed in packages/ui/src/icons/names.ts.
pnpm icons:build checks every name against Google's codepoints and
regenerates the font; a test keeps the manifest and the list in sync.
Icons are aria-hidden and untranslated, and directional ones mirror in
right-to-left layouts.
2026-09-28 16:29:17 +02:00
Amruth Pillai def4f72169 feat(pdf): map rendered header, section and item boxes
Templates tag the views that own the header, each section and each item
with data-resume-node. ResumeDocument's new onPageMap callback reads React
PDF's layout tree after each render and returns page-relative boxes, so the
editor can link lines on the page to entries in the panel. The attribute
stays on layout nodes and never reaches the PDF.
2026-09-28 16:01:16 +02:00
Amruth Pillai b0aecdfb5a docs: add Desk & Paper redesign plan
Plan for the UI redesign handoff: repo map, route changes, schema
migrations, component inventory, capability map, milestones and the
answered open questions. The handoff folder stays out of version control.
2026-09-28 16:01:15 +02:00
Amruth Pillai a7f1829484 ci: default to GitHub-hosted runners with opt-in Blacksmith
Workflows now run on GitHub-hosted runners unless the repository
variable USE_BLACKSMITH is "true", so forks work without setup. When
enabled, jobs run on Blacksmith runners (32 vCPU for build/test, 2 vCPU
for lightweight jobs) and use useblacksmith/checkout,
useblacksmith/setup-docker-builder, and useblacksmith/build-push-action.
Docker layer caches are keyed per architecture.

Replaces the CI_RUNNER_X64 and CI_RUNNER_ARM64 variables.
2026-09-28 09:01:00 +02:00
Amruth Pillai 328bf73cee chore(i18n): drop unregistered ckb-IR catalog
Crowdin shipped an empty Central Kurdish (ckb-IR) catalog with no translated
strings. The locale is not registered in the Lingui config or locale schema,
so remove the file and its PDF section title entry.
2026-09-28 08:59:38 +02:00
FalconSpyClaude Opus 5.5Amruth Pillaiautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1b78e546e2 feat(applications): schedule interviews and view them on a calendar (#3539)
* feat(applications): schedule interviews and view them on a calendar

Interviews (screening, technical, behavioral, onsite, other) are stored as
"interview" entries on an application's activity timeline, so an application
can have any number of them and they show in its timeline without a
migration. Each interview has a start date-time (timezone-aware), duration,
and optional location and notes.

- schema: interview timeline entry type, interviewDetailsSchema, INTERVIEW_KINDS
- api: addInterview / updateInterview procedures (delete via timeline entry);
  generic timeline updates now only edit text on note entries
- web: Calendar view on the Applications page (month grid, type legend,
  upcoming list grouped by day, schedule button with application picker,
  per-day "+" and "+N more" popover), Interviews section and interview
  dialog in the application detail panel, interview rows in the timeline
  and CSV export
- mcp: add_application_interview / update_application_interview tools
- i18n: extract new strings into all locale catalogs (English fallback)
- docs: MCP tool table, scheduling guide section, resume-builder skill

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

* fix(applications): keep interview dates in local time and guard generic timeline edits

- Format the timeline date chip for interview entries in the viewer's local
  timezone so it matches the interview's date-time label; stage and note
  entries still render in UTC.
- Reject interview entries in the generic updateTimelineEntry path. A
  day-granular date edit kept the UTC time of day and could move an interview
  to the wrong local day; callers are pointed to updateInterview
  (update_application_interview). The MCP tool description now says so, and
  a service test covers the rejection.
- Associate each interview dialog label with its control via useId/htmlFor.
- Drop the duplicate onInput handler on the date-time input; onChange covers
  controlled inputs.
- Give the calendar's per-day schedule button an accessible name that
  includes the date, and update the extracted locale catalogs for the new
  message.

* [autofix.ci] apply automated fixes

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-09-28 08:57:25 +02:00
Syed Ali Abbas ZaidiandAmruth Pillai fb756026fa feat(import): add LinkedIn data export as a resume import source (#3538)
* feat(import): add LinkedIn data export as a resume import source

LinkedIn's "Get a copy of your data" export ships a ZIP of per-topic
CSVs (Profile, Positions, Education, Skills, Languages,
Certifications). Reading these directly gives structured data without
needing a connected AI provider, unlike the existing PDF/DOCX import
path.

Also fixes a latent bug found while building this: parseJSONResume
(and the new LinkedIn parser) built their result via a shallow spread
of the shared `defaultResumeData` singleton, so assigning into
`result.sections.x` mutated that singleton in place and leaked section
data into the next unrelated import call in the same process. Both now
start from a structuredClone.

* fix(import): escape LinkedIn text, harden zip parsing and date handling

Move escapeHtml and toHtml from the plain-text importer into html.ts and
use them for LinkedIn summary, position descriptions and education notes,
so CSV text is HTML-escaped and line breaks become paragraphs or bullet
lists instead of collapsing into one run-on paragraph.

Only an empty end date now marks an entry as ongoing. Date cells that are
not "Mon YYYY" are kept verbatim, so a finished role with an unexpected
date format no longer reads as "Present".

Unzip only the six CSVs the importer reads, matched by exact file name,
and reject any of them larger than 5 MB. This avoids inflating the rest
of a complete LinkedIn export in the browser and stops Learning_Profile.csv
being read as Profile.csv.

Map LinkedIn's five language proficiency options onto levels 5 to 1,
falling back to parseLevel for anything else. Drop the literal BOM strip,
which TextDecoder already handles.

The import dialog no longer mentions an AI provider in the loading toast
for LinkedIn imports, which are parsed entirely in the browser.

Add a regression test for the JSON Resume importer leaking section data
through the shared defaultResumeData object, plus LinkedIn tests for HTML
escaping, unrecognised end dates, BOM headers, exact file name matching,
language levels and oversized entries.

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-28 08:57:20 +02:00
Lihan YANGAmruth Pillaiautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
73e7a3cb6d fix(dev): make dotenvx available through pnpm (#3537)
* fix(dev): make dotenvx available through pnpm

* fix(dev): load local env from root scripts

* [autofix.ci] apply automated fixes

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-09-28 08:57:14 +02:00
Amruth Pillai 685fcab605 fix(e2e): launch server directly so Playwright can stop it
pnpm 12.6 moves script children into their own process group. Playwright
stops its webServer with a process-group kill, so the server spawned via
`pnpm start` survived teardown and every E2E job hung until the 30 minute
timeout after all tests had passed.
2026-09-28 08:41:29 +02:00
Amruth Pillai 6db26e9b5c chore(pdf): regenerate section title catalog for ckb-IR locale 2026-09-28 08:07:15 +02:00
Amruth Pillai 2b31d70a8a chore: update translations 2026-09-28 07:44:52 +02:00
Amruth Pillai d0d20ce0fd chore(deps): upgrade dependencies
Bump workspace dependencies to their latest versions and dedupe the lockfile.

The upgrade left stale duplicates in pnpm-lock.yaml that broke the build and tests:

- @deepseek-ai/schemastery resolved to both 3.18.2 and 3.18.4. Both copies declare
  the global Schemastery namespace, so dsh-plugin's declaration emit failed with
  TS2883 on `Config`. `pnpm dedupe` collapses it to 3.18.4.
- vite's optional tsx peer resolved to 4.23.13 for importers without a direct tsx
  dependency and 4.23.15 elsewhere, producing two vitest 5.0.2 instances. Loading
  both in one run broke `expect(...).rejects`. Re-resolving tsx unifies the graph.
2026-09-28 00:23:21 +02:00
Amruth Pillai b48a9c2142 feat(web): refine motion system and simplify animated UI (#3546)
Audit every animation in the app and shared UI primitives against a
frequency-first motion bar: keyboard and high-frequency actions no longer
animate, remaining motion uses interruptible CSS transitions with shared
easing tokens, and redundant animation code is removed.

UI primitives (@reactive-resume/ui)
- Dialog, alert dialog, popover and tooltip move from tw-animate keyframes
  to Base UI data-starting/ending-style transitions; menus, popovers and
  tooltips skip motion when opened from the keyboard (data-instant).
- Dialog gains an `instant` prop; the command palette uses it.
- Accordion animates its real panel height; caret rotates instead of
  swapping icons.
- Menu backdrop blur moves onto the popup so it no longer snaps in after
  the fade; context menus and comboboxes fade only.
- Sidebar collapse uses the strong ease-out curve and snaps on Cmd+B.
- Toast, sheet, checkbox, tabs, toggle, inputs and message scroller get
  tokenised easing, correct transition properties and press feedback.
- Tabs no longer squeeze a trigger narrower than its label.
- Spinners keep spinning under prefers-reduced-motion.

Web app
- Remove the default route view transition and page-entrance slides.
- Add EASE_OUT_STRONG for Motion; replace built-in "easeOut" everywhere.
- Switch LazyMotion to domMax so layout and Reorder animations run.
- Builder: consolidate 12 section list files into one ItemsSection,
  opacity-only list rows with popLayout, instant Cmd+0, faster dock zoom,
  crossfade that no longer dips, transform-based progress bars.
- Dashboard: keep previous results while sorting/filtering, no empty-state
  flash, uncontrolled sidebar (no network round trip on collapse), calmer
  resume card tilt, no stacked hover wrappers.
- Settings: drop entrance/stagger wrappers and ActionButton.
- Agent: CSS marquee paused on hover; thread switches keep the layout.
- Homepage: fix invalid transition declarations, CSS spotlight drift,
  scroll-hiding header without a JS spring, tokenised curves.
- Theme switches change every color at once.
- Remove SSR-only useIsClient guards from the SPA.

Docs: rewrite the DESIGN.md animation section around the new tokens.
2026-09-27 17:10:42 +02:00
Amruth Pillai 0cb83602f5 fix(docker): create SeaweedFS bucket with aws-cli instead of minio/mc (#3544)
quay.io/minio/mc:latest is no longer publicly pullable (401 UNAUTHORIZED),
which broke the Docker publish workflow. Use the official amazon/aws-cli
image to create the bucket idempotently via head-bucket || s3 mb.
2026-09-26 10:25:04 +02:00
Amruth Pillai 712298843b chore(release): v5.3.2 (#3542) 2026-09-26 02:55:57 +02:00
Amruth Pillai 8c40313980 feat(deploy): support Vercel Hobby alongside Docker (#3541)
* feat(deploy): support Vercel Hobby alongside Docker

* fix(deploy): include PDFKit runtime font assets

* docs(deploy): document Vercel and Docker setup

* docs(deploy): record storage persistence checks

* refactor(deploy): drop scheduled staging cleanup

Staging uploads are deleted after finalization and expired ones are swept
on each new upload, so the Vercel cron job, its route, and CRON_SECRET are
no longer needed. The Deploy with Vercel wizard now asks for two secrets.

* docs(deploy): restructure Vercel guides

Split the Vercel page into a how-to with its environment reference, move the
large RPC staging protocol to an API reference page, and move CI deployment
checks to the contributing section. Point Deploy with Vercel buttons at main.

* chore: remove agent planning records and fix web app description

Delete superpowers plans/specs, ADRs, issue plans, execution briefs, domain
context maps, and Europass research. Describe apps/web as a TanStack Router
SPA served by apps/server.

* refactor(deploy): simplify Vercel support code

- Share one Redis client and key namespace through @reactive-resume/db/redis
  for API and auth instead of a second auth-only client.
- Drop the auth seeding retry; the provider already treats concurrent inserts
  as no-ops and deployment preparation seeds before runtime.
- Detect staging support from POST /api/storage/stage (404 on Docker) instead
  of a separate GET probe.
- Read staged bodies directly; the signed upload already caps their size.
- Close per-subscription Redis connections with disconnect() alone.
- Check Blob health with one list call instead of write/read/delete.
- Remove redundant tsdown onlyBundle list, dead namespace fallbacks, and the
  conditional spread in the health status.

* fix(deploy): heal stopped runs with dead owners and keep auth up without Redis

- Run owners refresh a Redis heartbeat until they release their claim. Stop
  requests reap the run immediately when the owner has stopped heartbeating,
  instead of leaving the thread blocked until the 15-minute TTL reaper.
- Auth and oRPC rate limiters fall back to per-instance memory limits when
  Redis errors, instead of rejecting every login or failing requests.

* ci: allow esbuild build for Vercel CLI and register deployment deps with knip

pnpm 12 fails dlx installs with ignored build scripts, so allow esbuild
explicitly. The server bundle keeps @vercel/blob, ioredis, and jose external,
and api/index.mjs is the Vercel Function entry.

* fix(web): send buffered RPC bodies instead of teed streams

Reading a request clone turned the original body into a stream, which
browsers send without inspectable request data and which needs duplex
mode. Send the already buffered Blob for direct requests.

* fix(web): send direct RPC bodies as bytes

Blob request bodies are sent as data pipes, so browser tooling cannot
inspect them. Buffer the original request as an ArrayBuffer and send those
bytes; this restores the e2e save assertions that match on request data.
2026-09-26 02:37:22 +02:00
Amruth Pillai 73ed3f9b03 chore(server): update version from 5.2.2 to 5.3.1 2026-09-23 00:12:42 +02:00
Lihan YANGandAmruth Pillai f0bc26cb3d fix(ci): restore Docker publishing with portable runner fallbacks (#3533)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-22 17:07:28 +02:00
s3kfm 0ac320b0e9 fix(web): enabled drag and drop by moving file input on top of the button (#3529) 2026-09-22 14:32:34 +02:00
PerryLinkandAmruth Pillai d3131e0977 fix(import): reject out-of-range months that render as "undefined" (#3527)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-21 19:47:02 +02:00
Santhi PrakashandAmruth Pillai 28d0170b05 fix(resume): detect section headings set in a side column (#3521)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-21 19:46:46 +02:00
Santhi PrakashandAmruth Pillai ac69dd3f1a fix(server): verify migrated schema at startup (#3513)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-21 19:46:36 +02:00
Amruth Pillai 3d4ae8679a Update Star History chart sources in README 2026-09-21 16:30:51 +02:00
Amruth Pillai 4fde62df6d chore: update dependencies 2026-09-19 22:05:55 +02:00
Amruth Pillai b5ff720f9d chore: update translations 2026-09-17 22:27:22 +02:00
Amruth Pillai b953435f2c fix: audit code for reduction 2026-09-17 22:16:44 +02:00
Amruth Pillai a30bf371ff docs: add IDEA.md as a symlink to AGENTS.md 2026-09-17 21:16:00 +02:00
Amruth Pillai 582a6fb429 fix(web): preserve dialogs opened during close animations
Opening another dialog during the previous dialog's 300 ms close animation could clear the new dialog and its close handler. This caused the post-merge dashboard lifecycle test to lose the Duplicate Resume dialog after renaming a resume.

- Scope delayed cleanup to the original dialog and require it to remain closed.
- Add regression coverage for both open and closing replacement dialogs; both cases failed before the fix and pass afterward.
- Include the fix in the v5.3.1 release notes.

Validation: `pnpm check`, `pnpm typecheck`, `pnpm test`, and the focused dialog-store suite (12 passing tests).
2026-09-17 12:19:06 +02:00
Amruth Pillai 24d9e5fb5c chore: release v5.3.1
Prepare v5.3.1 with dashboard search and thumbnail improvements, PDF layout fixes, cover-letter integrations, and self-hosting updates.

- Bump the root version and add release notes with contributor credits, cover-letter REST migration instructions, and the new GHCR image path.
- Align the dashboard authentication plugin with Better Auth's fetch dependency to restore auth-client type inference.
- Regenerate the OpenAPI specification so published validation limits match runtime schemas.

Validation: `pnpm lingui:extract` (no missing translations), `pnpm check`, `pnpm typecheck`, and `pnpm test`.
2026-09-17 12:02:03 +02:00
Emanuele Tonello 2a2d08a8d2 fix(web): keep resume search local (#3510) 2026-09-17 00:14:42 +02:00
Emanuele TonelloandAmruth Pillai b42eb6ec06 fix: stop application search session refetches (#3507)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-17 00:13:33 +02:00
Emanuele TonelloandAmruth Pillai fbf1f8fbac docs(api): describe cover letter endpoints (#3509)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-17 00:13:21 +02:00
Emanuele TonelloandAmruth Pillai 232f48578b fix(web): cache dashboard resume thumbnails (#3506)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-17 00:12:05 +02:00
Emanuele TonelloandAmruth Pillai e6a6bf0e6a feat(mcp): add independent cover-letter tools (#3508)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-16 22:08:23 +02:00
Amruth Pillai 96c7142fbc chore: update dependencies 2026-09-16 18:36:50 +02:00
PerryLinkandAmruth Pillai 3c5908819c docs(self-hosting): add Kubernetes self-hosting guide (#3515)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-16 18:03:44 +02:00
Lystran 3e129c9d9d fix(web): keep AI provider names untranslated across locales (#3516) 2026-09-16 17:40:43 +02:00
Amruth Pillai fd3494ccac docs: confirm repository migration and current-version redeployment 2026-09-12 11:29:04 +02:00
Amruth Pillai f89acb4368 chore: migrate repository links to reactive-resume/reactive-resume 2026-09-12 11:18:32 +02:00
Amruth Pillai 08e61ded7b Add powered by Blacksmith section to README
Added a powered by Blacksmith image and link to README.
2026-09-11 12:54:59 +02:00
Amruth Pillai f1d5c6bab4 ci: use reachable Ubuntu mirror for Playwright dependencies 2026-09-11 11:40:03 +02:00
Amruth Pillai e3717251cb docs: switch to verified public GHCR images 2026-09-11 11:21:49 +02:00
Amruth Pillai 30b21fa1e3 ci: verify anonymous container pulls before deployment 2026-09-11 11:17:10 +02:00
Amruth Pillai d77cb93494 fix: complete repository links and container publishing migration 2026-09-11 11:09:55 +02:00
Amruth Pillai ce996349fa Merge branch 'codex/repository-migration' 2026-09-11 10:53:08 +02:00
Amruth Pillai a62ee22f20 ci: use 32-vCPU Blacksmith runners 2026-09-11 09:51:52 +02:00
Amruth Pillai 0a4608bf9d ci: trigger 2026-09-11 03:26:42 +02:00
Amruth Pillai 9550910f17 revert: remove repository migration changes from main 2026-09-11 03:23:27 +02:00
Amruth Pillai 4076b1a523 ci: use Blacksmith runners and native multi-architecture Docker builds 2026-09-11 03:16:11 +02:00
Amruth Pillai 9699dbf2d8 ci: publish nightly images for amd64 and arm64 2026-09-11 03:02:26 +02:00
Amruth Pillai 31d6ee6251 chore: prepare repository migration and Docker Build Cloud publishing 2026-09-11 02:55:52 +02:00
Amruth Pillai d9fdf7a30a docs: announce planned repository and GHCR migration 2026-09-11 01:46:59 +02:00
Amruth Pillai 81341a107f fix(mcp): align tool annotations and descriptions with behavior 2026-09-11 00:31:58 +02:00
Amruth Pillai 3fc0896a34 fix(auth): honor client-requested token_endpoint_auth_method during DCR
Dynamic client registration unconditionally rewrote token_endpoint_auth_method
to "none" for every unauthenticated request, downgrading clients that asked for
client_secret_basic or client_secret_post to public clients. Those clients were
issued no client_secret but still authenticated at the token endpoint with the
method they registered, so the exchange failed with 401 invalid_client.
Connecting Composio to the MCP server hit this on every attempt.

Default to "none" only when the client omits the field, which keeps PKCE-only
MCP clients working while confidential clients receive a usable secret.
2026-09-10 12:47:52 +02:00
Amruth PillaiandClaude Fable 5.1 7aaed8e30b chore: pin Node.js runtime and make root TS strict mode explicit (#3501)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-10 11:15:38 +02:00
Amruth Pillai 730f795073 fix(pdf): pin @napi-rs/canvas to 1.0.8
1.0.9 rejects the Path2D objects pdfjs-dist 6.3.289 passes to fill/clip,
failing every raster test with "Value is none of these types `String`, `Path`".
2026-09-10 00:07:23 +02:00
Amruth Pillai dc8f9787a4 chore: update dependencies 2026-09-09 23:57:37 +02:00
Amruth Pillai 742526af53 chore: drop duplicated overrides and patchedDependencies from root package.json
pnpm reads audit overrides and patch mappings from pnpm-workspace.yaml, which
already carries both. The top-level package.json copies were npm-shaped fields
that pnpm never consults, and they had already drifted: the workspace file maps
'@react-pdf/textkit' unversioned while the package.json copy pinned 7.0.1.

pnpm install --frozen-lockfile still passes with pnpm-lock.yaml unchanged, and
all four patches remain applied at their recorded hashes, which is what shows
the removed block was inert.
2026-09-09 13:04:19 +02:00
Amruth Pillai 812d396120 test(pdf): compare raster baselines only on their authoring platform
Adding packages/pdf to CI turned it red on ubuntu-latest for two reasons,
neither of which is a real regression.

Rasterized pixels depend on the host font rasterizer. The chikorita, ditto
and all-template baselines differ on Linux in rasterSha256 alone: every page
count, item count and text coordinate is byte-identical to the macOS-authored
baseline. Compare the portable geometry on every host and the pixels only on
the platform the PNGs were generated on, so the characterization keeps
protecting layout without asserting another machine's antialiasing.

The picture-fit override case rasterizes twice and timed out at Vitest's 5s
default on a CI runner, with the date suite landing at 3.5-4.7s. Give the
package a 30s timeout rather than leaving every rendering test a runner
slowdown away from failing.

Verified on linux/amd64 in Docker: both files pass, 18/18.
2026-09-09 12:46:41 +02:00
Amruth Pillai 1106562169 test(pdf): refresh Lapras date-layout baseline and run all packages in CI
The Lapras section marginTop added in c0c658c0 shifted every date marker in
that template down the page, but the date-layout characterization baseline
was not regenerated, so packages/pdf has been failing on main since. The
delta is geometry-only: same page count (2), same text item count (87), same
markers, x and width unchanged; 17 markers move on y and the raster hashes
follow.

Nothing caught it because the workflow ran test:ci for only server and
tooling, leaving 17 packages uncovered. Replace that filter list with the
full workspace run so a package cannot silently lose coverage again, and
move it after the migration and storage steps that the api suite needs.

Serial execution is deliberate. Running the packages in parallel oversubscribes
the runner and starves the PDF rasterization and API rate-limit suites past
their timeouts; 19/19 pass consistently at --concurrency=1.
2026-09-09 12:33:04 +02:00
Amruth Pillai 607eafd3e8 chore(deps): bump ai-sdk, aws-sdk, react-email and tooling dependencies
Patch and minor bumps across the AI provider SDKs (@ai-sdk/*, ai),
@aws-sdk/client-s3, react-email/@react-email/ui, knip and jszip, with
pnpm-lock.yaml regenerated to match.

Also records the audit overrides and patched dependencies in the root
package.json alongside the existing pnpm-workspace.yaml entries.
2026-09-09 12:16:20 +02:00
Amruth PillaiandClaude Opus 5 ffe889b832 test: remove flaky slow-save navigation e2e test
The "stops waiting for a slow save while preserving late acknowledgements
and queued edits" test races Playwright's fake clock against real debounce
and network timing, and has failed intermittently on main and in PRs since
it landed. Six prior stabilization attempts, including bumping its timeout
to 60s, did not hold; the latest run on main still exceeded that budget.

The same behavior is covered deterministically with fake timers in
apps/web/src/features/resume/builder/draft.test.ts ("ends a stalled
navigation wait without aborting or discarding the pending save", plus
the queued-edit and pending-snapshot cases), so removing the e2e test
loses no coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018z9CKmSSEuS2UFMoqhHWtQ
2026-09-09 12:09:54 +02:00
Santhi Prakashcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Amruth Pillai
51ac77295e fix(pdf): preserve list indentation on continuation pages (#3495) (#3497)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-09 11:55:48 +02:00
Diego Vega Centeno c0c658c00c fix(pdf): add marginTop to style of "section" in "Lapras" template (#3498) 2026-09-09 11:35:23 +02:00
Amruth Pillai 6416da28a4 feat(homepage): rebuild landing page and fix untranslated homepage strings in 16 locales (#3496) 2026-09-08 18:01:59 +02:00
Amruth Pillai 614a1ff9df test: wait for recovery state persistence 2026-09-07 23:06:19 +02:00
Amruth Pillai 4f60856706 refactor: remove unused UI and runtime scaffolding 2026-09-07 22:40:50 +02:00
Amruth Pillai ad91a0838c docs: remove build with ona 2026-09-07 10:00:10 +02:00
Amruth Pillai 15d6443b4f chore: update translations 2026-09-06 21:38:58 +02:00
Amruth Pillai 1f8c46b4f1 test: bump timeout for slow-save navigation e2e test
Default 30s budget was too tight for this multi-step test (resume
creation, warm-up save, fake-clock save juggling, re-navigation),
causing a CI timeout that surfaced as a generic closed-context error
rather than a real assertion failure.
2026-09-06 21:38:27 +02:00
Amruth Pillai de9a6dcfad fix: add missing twitter:url meta tag across social meta sites
Also fill in twitter:title/twitter:description on the ATS checker page,
matching its existing og: tags.
2026-09-06 21:27:40 +02:00
Amruth Pillai e19f706efd docs: expand v5.3.0 changelog
Expand the v5.3.0 changelog with shipped features, issue-linked fixes, OAuth consent details, and contributor credits.
2026-09-06 10:06:25 -07:00
1585 changed files with 431674 additions and 303989 deletions
-3
View File
@@ -1,3 +0,0 @@
---
exclude_paths:
- "migrations/**"
+23 -2
View File
@@ -13,11 +13,22 @@ APP_URL="http://localhost:3000"
# Unset or blank keeps the marketing home. Restart after changes.
# ROOT_RESUME_ID=
# Vercel: APP_URL can be omitted; production uses VERCEL_PROJECT_PRODUCTION_URL.
# --- Database (PostgreSQL) ---
# PostgreSQL connection URL. In Docker Compose, the hostname is usually `postgres`;
# when running directly on your machine, `localhost` is typical.
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/postgres"
# Optional direct connection for migrations (Neon: DATABASE_URL_UNPOOLED alias).
# DATABASE_MIGRATION_URL=""
# DATABASE_POOL_MAX="10"
# When "true", the server refuses to boot if the live database schema has drifted from
# the migration ledger (e.g. a table dropped outside migrations). Default "false" logs
# the drift loudly at startup and continues.
STRICT_SCHEMA_CHECK="false"
# --- Authentication ---
# Generated using `openssl rand -hex 32`
AUTH_SECRET="change-me-to-a-secure-secret-key-in-production"
@@ -66,7 +77,15 @@ SMTP_FROM="Reactive Resume <noreply@rxresu.me>"
SMTP_SECURE="false"
# --- Storage (optional) ---
# If all S3 keys are disabled, the app uses local filesystem storage instead.
# Backend defaults to S3 when all credentials are present, otherwise local.
# Vercel defaults to private Blob. Explicit selection: local, s3, blob.
# STORAGE_BACKEND="local"
# BLOB_READ_WRITE_TOKEN=""
# BLOB_STORE_ID=""
# DEPLOYMENT_NAMESPACE="default"
# Vercel previews need isolated resources before setting ALLOW_PREVIEW_MIGRATIONS=true.
# If all S3 keys are disabled, Docker uses local filesystem storage instead.
# Make sure to mount this directory to a volume or the host filesystem to ensure data integrity.
# LOCAL_STORAGE_PATH overrides where local uploads/cache are written.
# Defaults to /app/data in the official Docker image; in dev, defaults to <workspace>/data.
@@ -81,7 +100,9 @@ S3_BUCKET="reactive-resume"
S3_FORCE_PATH_STYLE="true"
# --- AI Agent Workspace (optional) ---
# Required only for the authenticated /agent workspace and saved AI providers.
# Required for the authenticated /agent workspace and saved AI providers.
# Redis also shares rate limits, resume events, cancellation and view deduplication.
# Vercel Upstash KV_URL is accepted as an alias for REDIS_URL.
REDIS_URL="redis://redis:6379"
ENCRYPTION_SECRET="change-me-to-a-secure-agent-secret-in-production"
+2 -2
View File
@@ -20,8 +20,8 @@
"url": "https://rxresu.me"
},
"repositoryUrl": {
"url": "https://github.com/amruthpillai/reactive-resume",
"wellKnown": "https://github.com/amruthpillai/reactive-resume/blob/main/.github/.well-known/funding-manifest-urls"
"url": "https://github.com/reactive-resume/reactive-resume",
"wellKnown": "https://github.com/reactive-resume/reactive-resume/blob/main/.github/.well-known/funding-manifest-urls"
},
"licenses": ["spdx:MIT"],
"tags": ["data", "design", "productivity", "resume-builder"]
+2 -2
View File
@@ -1,8 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Questions and support
url: https://github.com/amruthpillai/reactive-resume/discussions/categories/q-a
url: https://github.com/reactive-resume/reactive-resume/discussions/categories/q-a
about: Get help with setup, configuration, and using Reactive Resume.
- name: Security vulnerability
url: https://github.com/amruthpillai/reactive-resume/security/advisories/new
url: https://github.com/reactive-resume/reactive-resume/security/advisories/new
about: Report security vulnerabilities privately.
+7 -2
View File
@@ -13,12 +13,17 @@ env:
jobs:
autofix:
runs-on: ubuntu-latest
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-latest' }}
steps:
- name: Checkout Repository
if: ${{ vars.USE_BLACKSMITH != 'true' }}
uses: actions/checkout@v6
- name: Checkout Repository (Blacksmith)
if: ${{ vars.USE_BLACKSMITH == 'true' }}
uses: useblacksmith/checkout@v1
- name: Check for merge conflict markers
run: |
if git grep -nEI '<{7} |>{7} |^={7}$' -- ':(exclude)*.md' ':(exclude)*.mdx'; then
@@ -32,7 +37,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "lts/*"
node-version-file: ".nvmrc"
cache: "pnpm"
- name: Install Dependencies
+9 -1
View File
@@ -10,7 +10,7 @@ concurrency:
jobs:
crowdin-sync:
runs-on: ubuntu-latest
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
permissions:
contents: write
@@ -18,8 +18,16 @@ jobs:
steps:
- name: Checkout Repository
if: ${{ vars.USE_BLACKSMITH != 'true' }}
uses: actions/checkout@v6
# The Crowdin action runs in a container that cannot reach the git mirror mount, so copy its objects.
- name: Checkout Repository (Blacksmith)
if: ${{ vars.USE_BLACKSMITH == 'true' }}
uses: useblacksmith/checkout@v1
with:
dissociate: true
- name: Sync Translations from Crowdin
uses: crowdin/github-action@v2
with:
+147 -35
View File
@@ -2,6 +2,11 @@ name: Build Docker Image
on:
workflow_dispatch:
inputs:
release:
description: Publish release aliases and redeploy production (false runs a cache-only build, then publishes a canary)
type: boolean
default: false
push:
branches:
- main
@@ -13,30 +18,39 @@ concurrency:
cancel-in-progress: true
env:
IMAGE: ${{ github.repository }}
GHCR_IMAGE: ghcr.io/${{ github.repository }}
DOCKER_IMAGE: docker.io/amruthpillai/reactive-resume
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
mode:
runs-on: ubuntu-latest
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
outputs:
nightly: ${{ steps.mode.outputs.nightly }}
release: ${{ steps.mode.outputs.release }}
matrix: ${{ steps.mode.outputs.matrix }}
canary: ${{ steps.mode.outputs.canary }}
steps:
- name: Determine publishing mode
id: mode
env:
EVENT_NAME: ${{ github.event_name }}
GIT_REF: ${{ github.ref }}
RELEASE: ${{ inputs.release }}
run: |
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
if [[ "$EVENT_NAME" == "push" && "$GIT_REF" == "refs/heads/main" ]]; then
echo "nightly=true" >> "$GITHUB_OUTPUT"
echo "release=false" >> "$GITHUB_OUTPUT"
echo 'matrix={"include":[{"platform":"linux/amd64","runner":"ubuntu-latest","arch":"amd64"}]}' >> "$GITHUB_OUTPUT"
echo "canary=false" >> "$GITHUB_OUTPUT"
elif [[ "$EVENT_NAME" == "workflow_dispatch" && "$RELEASE" != "true" ]]; then
echo "nightly=false" >> "$GITHUB_OUTPUT"
echo "release=false" >> "$GITHUB_OUTPUT"
echo "canary=true" >> "$GITHUB_OUTPUT"
else
echo "nightly=false" >> "$GITHUB_OUTPUT"
echo "release=true" >> "$GITHUB_OUTPUT"
echo 'matrix={"include":[{"platform":"linux/amd64","runner":"ubuntu-latest","arch":"amd64"},{"platform":"linux/arm64","runner":"ubuntu-24.04-arm","arch":"arm64"}]}' >> "$GITHUB_OUTPUT"
echo "canary=false" >> "$GITHUB_OUTPUT"
fi
build:
@@ -44,7 +58,14 @@ jobs:
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.mode.outputs.matrix) }}
matrix:
include:
- platform: linux/amd64
runner: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-latest' }}
arch: amd64
- platform: linux/arm64
runner: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-32vcpu-ubuntu-2404-arm' || 'ubuntu-24.04-arm' }}
arch: arm64
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
@@ -57,16 +78,53 @@ jobs:
steps:
- name: Checkout Repository
if: ${{ vars.USE_BLACKSMITH != 'true' }}
uses: actions/checkout@v6
- name: Get version from package.json
id: version
run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT"
- name: Checkout Repository (Blacksmith)
if: ${{ vars.USE_BLACKSMITH == 'true' }}
uses: useblacksmith/checkout@v1
- name: Setup Docker Buildx
if: ${{ vars.USE_BLACKSMITH != 'true' }}
uses: docker/setup-buildx-action@v4
# Persists BuildKit layers and the Dockerfile's pnpm cache mounts between runs, one cache per architecture.
- name: Setup Docker Builder (Blacksmith)
if: ${{ vars.USE_BLACKSMITH == 'true' }}
uses: useblacksmith/setup-docker-builder@v2
with:
cache-key: Dockerfile-${{ matrix.arch }}
- &registries
name: Determine registries
id: registries
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: |
set -euo pipefail
dockerhub=false
ghcr_image="${GHCR_IMAGE,,}"
docker_image="${DOCKER_IMAGE,,}"
images="$ghcr_image"
if [[ -n "$DOCKER_USERNAME" && -n "$DOCKER_PASSWORD" ]]; then
dockerhub=true
images="${images}"$'\n'"$docker_image"
fi
{
echo "ghcr_image=$ghcr_image"
echo "docker_image=$docker_image"
echo "images<<EOF"
echo "$images"
echo "EOF"
echo "dockerhub=$dockerhub"
} >> "$GITHUB_OUTPUT"
- name: Login to Docker Hub
if: ${{ steps.registries.outputs.dockerhub == 'true' }}
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
@@ -83,16 +141,28 @@ jobs:
id: meta
uses: docker/metadata-action@v6
with:
images: |
ghcr.io/${{ env.IMAGE }}
docker.io/${{ env.IMAGE }}
images: ${{ steps.registries.outputs.images }}
tags: |
type=sha,prefix=sha-,suffix=-${{ matrix.arch }}
- name: Cache-only smoke build
if: ${{ needs.mode.outputs.canary == 'true' && vars.USE_BLACKSMITH != 'true' }}
uses: docker/build-push-action@v7
with: &cache-only-build
context: .
platforms: ${{ matrix.platform }}
outputs: type=cacheonly
- name: Cache-only smoke build (Blacksmith)
if: ${{ needs.mode.outputs.canary == 'true' && vars.USE_BLACKSMITH == 'true' }}
uses: useblacksmith/build-push-action@v2
with: *cache-only-build
- name: Build and Push by Digest
id: build
if: ${{ vars.USE_BLACKSMITH != 'true' }}
uses: docker/build-push-action@v7
with:
with: &build-push
context: .
sbom: true
push: true
@@ -101,13 +171,17 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.annotations }}
cache-from: type=gha,scope=${{ env.IMAGE }}-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ env.IMAGE }}-${{ matrix.arch }}
- name: Build and Push by Digest (Blacksmith)
id: build-blacksmith
if: ${{ vars.USE_BLACKSMITH == 'true' }}
uses: useblacksmith/build-push-action@v2
with: *build-push
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
digest="${{ steps.build.outputs.digest || steps.build-blacksmith.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
@@ -123,7 +197,11 @@ jobs:
- mode
- build
timeout-minutes: 30
runs-on: ubuntu-latest
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
env:
DEPLOY: ${{ secrets.SSH_KEY != '' && secrets.SSH_HOST != '' && secrets.SSH_USER != '' }}
PURGE_CLOUDFLARE: ${{ secrets.CLOUDFLARE_ZONE_ID != '' && secrets.CLOUDFLARE_API_TOKEN != '' }}
permissions:
contents: read
@@ -133,11 +211,17 @@ jobs:
steps:
- name: Checkout Repository
if: ${{ vars.USE_BLACKSMITH != 'true' }}
uses: actions/checkout@v6
with:
with: &checkout-package-json
sparse-checkout: package.json
sparse-checkout-cone-mode: false
- name: Checkout Repository (Blacksmith)
if: ${{ vars.USE_BLACKSMITH == 'true' }}
uses: useblacksmith/checkout@v1
with: *checkout-package-json
- name: Get version from package.json
id: version
run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT"
@@ -152,7 +236,10 @@ jobs:
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v4
- *registries
- name: Login to Docker Hub
if: ${{ steps.registries.outputs.dockerhub == 'true' }}
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
@@ -179,11 +266,10 @@ jobs:
id: meta
uses: docker/metadata-action@v6
with:
images: |
ghcr.io/${{ env.IMAGE }}
docker.io/${{ env.IMAGE }}
images: ${{ steps.registries.outputs.images }}
tags: |
type=sha,prefix=sha-
type=raw,value=canary-${{ github.run_id }}-${{ github.run_attempt }},enable=${{ needs.mode.outputs.canary == 'true' }}
type=raw,value=nightly,enable=${{ needs.mode.outputs.nightly == 'true' }}
type=raw,value=nightly-{{date 'YYYYMMDDHHmmss' tz='UTC'}},enable=${{ needs.mode.outputs.nightly == 'true' }}
type=raw,value=latest,enable=${{ needs.mode.outputs.release == 'true' }}
@@ -199,6 +285,8 @@ jobs:
if [[ "${{ needs.mode.outputs.nightly }}" == "true" ]]; then
FINAL_TAG="nightly"
elif [[ "${{ needs.mode.outputs.canary }}" == "true" ]]; then
FINAL_TAG="canary-${{ github.run_id }}-${{ github.run_attempt }}"
else
FINAL_TAG="v${{ steps.version.outputs.version }}"
fi
@@ -211,17 +299,19 @@ jobs:
--annotation "index:org.opencontainers.image.vendor=Amruth Pillai" \
--annotation "index:org.opencontainers.image.url=https://rxresu.me" \
--annotation "index:org.opencontainers.image.documentation=https://docs.rxresu.me" \
--annotation "index:org.opencontainers.image.source=https://github.com/amruthpillai/reactive-resume" \
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.version=${{ steps.version.outputs.version }}" \
$(printf 'ghcr.io/${{ env.IMAGE }}@sha256:%s ' *) \
$(printf 'docker.io/${{ env.IMAGE }}@sha256:%s ' *)
$(printf '${{ steps.registries.outputs.ghcr_image }}@sha256:%s ' *)
# Get the digest of the multi-arch manifest
GHCR_DIGEST=$(docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
DOCKER_DIGEST=$(docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
GHCR_DIGEST=$(docker buildx imagetools inspect ${{ steps.registries.outputs.ghcr_image }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
echo "final_tag=$FINAL_TAG" >> "$GITHUB_OUTPUT"
echo "ghcr_digest=$GHCR_DIGEST" >> "$GITHUB_OUTPUT"
echo "docker_digest=$DOCKER_DIGEST" >> "$GITHUB_OUTPUT"
if [[ "${{ steps.registries.outputs.dockerhub }}" == "true" ]]; then
DOCKER_DIGEST=$(docker buildx imagetools inspect ${{ steps.registries.outputs.docker_image }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
echo "docker_digest=$DOCKER_DIGEST" >> "$GITHUB_OUTPUT"
fi
- name: Install Cosign
uses: sigstore/cosign-installer@v3
@@ -229,18 +319,40 @@ jobs:
- name: Sign images with Cosign
run: |
# Sign GHCR image
cosign sign --yes ghcr.io/${{ env.IMAGE }}@${{ steps.manifest.outputs.ghcr_digest }}
cosign sign --yes ${{ steps.registries.outputs.ghcr_image }}@${{ steps.manifest.outputs.ghcr_digest }}
# Sign Docker Hub image
cosign sign --yes docker.io/${{ env.IMAGE }}@${{ steps.manifest.outputs.docker_digest }}
if [[ "${{ steps.registries.outputs.dockerhub }}" == "true" ]]; then
# Sign Docker Hub image
cosign sign --yes ${{ steps.registries.outputs.docker_image }}@${{ steps.manifest.outputs.docker_digest }}
fi
- name: Inspect image
run: |
docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:${{ steps.manifest.outputs.final_tag }}
docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:${{ steps.manifest.outputs.final_tag }}
docker buildx imagetools inspect ${{ steps.registries.outputs.ghcr_image }}:${{ steps.manifest.outputs.final_tag }}
if [[ "${{ steps.registries.outputs.dockerhub }}" == "true" ]]; then
docker buildx imagetools inspect ${{ steps.registries.outputs.docker_image }}:${{ steps.manifest.outputs.final_tag }}
fi
- name: Verify anonymous pulls on both architectures
run: |
set -euo pipefail
registry_config=$(mktemp -d)
trap 'rm -rf "$registry_config"' EXIT
# Prevent Docker from discovering a system credential helper.
printf '%s\n' '{"auths":{"ghcr.io":{},"https://index.docker.io/v1/":{}}}' > "$registry_config/config.json"
images=("${{ steps.registries.outputs.ghcr_image }}")
if [[ "${{ steps.registries.outputs.dockerhub }}" == "true" ]]; then
images+=("${{ steps.registries.outputs.docker_image }}")
fi
for image in "${images[@]}"; do
for platform in linux/amd64 linux/arm64; do
docker --config "$registry_config" pull --quiet --platform "$platform" \
"$image:${{ steps.manifest.outputs.final_tag }}"
done
done
- name: Redeploy Stack
if: ${{ needs.mode.outputs.release == 'true' }}
if: ${{ needs.mode.outputs.release == 'true' && env.DEPLOY == 'true' }}
uses: appleboy/ssh-action@v1
with:
key: ${{ secrets.SSH_KEY }}
@@ -251,7 +363,7 @@ jobs:
./manage_stack.sh up reactive_resume
- name: Purge Cloudflare cache
if: ${{ needs.mode.outputs.release == 'true' }}
if: ${{ needs.mode.outputs.release == 'true' && env.PURGE_CLOUDFLARE == 'true' }}
env:
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+28 -5
View File
@@ -17,10 +17,16 @@ env:
FLAG_DISABLE_EMAIL_AUTH: "false"
FLAG_DISABLE_API_RATE_LIMIT: "true"
LOCAL_STORAGE_PATH: /tmp/reactive-resume-e2e-storage
# The assistant spec talks to a scripted provider on 127.0.0.1.
FLAG_ALLOW_UNSAFE_AI_BASE_URL: "true"
# Real-database unit suites. The cover-letter suite works in its own schema; the OAuth flow suite writes
# signing keys under its own secret, so it gets a database the e2e server never reads.
COVER_LETTER_TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
OAUTH_TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/oauth_test
jobs:
e2e:
runs-on: ubuntu-latest
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 30
services:
@@ -40,26 +46,31 @@ jobs:
steps:
- name: Checkout Repository
if: ${{ vars.USE_BLACKSMITH != 'true' }}
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Checkout Repository (Blacksmith)
if: ${{ vars.USE_BLACKSMITH == 'true' }}
uses: useblacksmith/checkout@v1
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "24"
node-version-file: ".nvmrc"
cache: "pnpm"
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Run Server and Tooling Tests
run: pnpm exec turbo run test:ci --filter=server --filter=@reactive-resume/tooling
- name: Install Playwright Browser
timeout-minutes: 10
run: pnpm exec playwright install --with-deps chromium
- name: Generate Test Secrets
@@ -73,6 +84,18 @@ jobs:
- name: Run Database Migrations
run: pnpm db:migrate
- name: Prepare OAuth Test Database
run: |
psql "$DATABASE_URL" -c "CREATE DATABASE oauth_test"
DATABASE_URL="$OAUTH_TEST_DATABASE_URL" pnpm db:migrate
# Runs every workspace package, not a hand-maintained filter list, so a package
# cannot silently lose coverage by being left out. Serial execution: the PDF
# rasterization and API rate-limit suites time out when several packages' Vitest
# thread pools oversubscribe the runner at once.
- name: Run Unit Tests
run: pnpm exec turbo run test:ci --concurrency=1
- name: Build
run: pnpm build
+8 -1
View File
@@ -10,14 +10,21 @@ permissions:
jobs:
label:
runs-on: ubuntu-latest
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
steps:
- name: Checkout Repository
if: ${{ vars.USE_BLACKSMITH != 'true' }}
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Checkout Repository (Blacksmith)
if: ${{ vars.USE_BLACKSMITH == 'true' }}
uses: useblacksmith/checkout@v1
with:
persist-credentials: false
- name: Apply Form Labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
+1 -1
View File
@@ -10,7 +10,7 @@ permissions:
jobs:
stale:
runs-on: ubuntu-latest
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
steps:
- name: Close Inactive Issues Awaiting Information
+115
View File
@@ -0,0 +1,115 @@
name: Vercel compatibility
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
artifact:
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 20
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_PASSWORD: postgres
ports: [5432:5432]
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s --health-timeout 5s --health-retries 10
env:
APP_URL: http://localhost:3000
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
AUTH_SECRET: isolated-ci-auth-secret-32-characters
ENCRYPTION_SECRET: isolated-ci-encryption-secret-32-characters
REDIS_URL: redis://localhost:6379
STORAGE_BACKEND: blob
BLOB_READ_WRITE_TOKEN: vercel_blob_rw_ci_fake_build_only
VERCEL: "1"
VERCEL_ENV: production
steps:
- if: ${{ vars.USE_BLACKSMITH != 'true' }}
uses: actions/checkout@v6
with:
persist-credentials: false
- if: ${{ vars.USE_BLACKSMITH == 'true' }}
uses: useblacksmith/checkout@v1
with:
persist-credentials: false
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
# Local project settings avoid authentication and API calls. Forks receive no cloud credentials.
- name: Build Vercel artifact against isolated PostgreSQL
run: |
mkdir -p .vercel
node --input-type=module - <<'JS'
import { writeFileSync } from 'node:fs';
writeFileSync('.vercel/project.json', JSON.stringify({
projectId: 'prj_ci', orgId: 'team_ci', projectName: 'reactive-resume-ci',
settings: { framework: 'services', nodeVersion: '24.x', createdAt: 0 }
}));
JS
pnpm dlx --allow-build=esbuild vercel@61.0.0 build --prod --yes --global-config "$RUNNER_TEMP/vercel-offline"
# Runs the backend Function from a copy outside the checkout, so a dependency the build left out fails here.
- name: Check backend Function loading and budget
run: |
node --no-experimental-require-module --input-type=module - <<'JS'
import assert from 'node:assert/strict';
import { cpSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync } from 'node:fs';
import { dirname, join } from 'node:path';
const func = '.vercel/output/services/backend/functions/index.func';
const config = JSON.parse(readFileSync(`${func}/.vc-config.json`));
assert.equal(config.runtime, 'nodejs24.x');
assert.equal(config.maxDuration, 300);
assert.equal(config.handler, 'apps/server/vercel.mjs');
const root = join(process.env.RUNNER_TEMP, 'backend-function');
cpSync(func, root, { recursive: true, verbatimSymlinks: true });
for (const [path, source] of Object.entries(config.filePathMap ?? {})) {
mkdirSync(dirname(join(root, path)), { recursive: true });
if (lstatSync(source).isSymbolicLink()) symlinkSync(readlinkSync(source), join(root, path));
else cpSync(source, join(root, path));
}
const { default: app } = await import(join(root, config.handler));
const stage = await app.fetch(new Request('http://localhost:3000/api/storage/stage', { method: 'POST', body: '{}' }));
assert.equal(stage.status, 401);
const home = await app.fetch(new Request('http://localhost:3000/'));
assert.equal(home.status, 200);
assert.match(await home.text(), /application\/ld\+json/);
process.exit(0);
JS
live-smoke:
if: github.event_name == 'workflow_dispatch'
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
environment: vercel-smoke
timeout-minutes: 10
steps:
- if: ${{ vars.USE_BLACKSMITH != 'true' }}
uses: actions/checkout@v6
with:
persist-credentials: false
- if: ${{ vars.USE_BLACKSMITH == 'true' }}
uses: useblacksmith/checkout@v1
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Smoke-test dedicated deployment
env:
SMOKE_URL: ${{ vars.VERCEL_SMOKE_URL }}
SMOKE_AI_BASE_URL: ${{ vars.VERCEL_SMOKE_AI_BASE_URL }}
SMOKE_AI_API_KEY: ${{ secrets.VERCEL_SMOKE_AI_API_KEY }}
run: node tooling/deployment/smoke.mjs
+5 -1
View File
@@ -4,6 +4,7 @@ node_modules
# Build Outputs
dist
dist-prerender
.vercel
.wrangler
@@ -54,12 +55,15 @@ temp
.superpowers
.worktrees
.migration
graphify-out
/plans
# Local Storage Data
/data
/apps/web/data
# Redesign handoff (design references, not source)
/design_handoff_reactive_resume_redesign
# Git Hooks
.vite-hooks
-31
View File
@@ -1,31 +0,0 @@
config:
default: true
MD007: false
MD009: false
MD010: false
MD012: false
MD013: false
MD001: false
MD022: false
MD024: false
MD025: false
MD028: false
MD031: false
MD032: false
MD033: false
MD034: false
MD036: false
MD040: false
MD041: false
MD046: false
MD060: false
frontMatter: "^---[\\s\\S]*?---"
gitignore: true
globs:
- "**/*.{md,mdx}"
ignores:
- ".design-sync/**"
- "node_modules/**"
- ".turbo/**"
- "dist/**"
-17
View File
@@ -1,17 +0,0 @@
// @ts-check
const betaPackages = ["drizzle-zod"];
const rcPackages = ["drizzle-orm", "drizzle-kit"];
/** @type {import('npm-check-updates').RunOptions} */
module.exports = {
upgrade: true,
workspaces: true,
install: "always",
packageManager: "pnpm",
target: (packageName) => {
if (betaPackages.includes(packageName)) return "@beta";
if (rcPackages.includes(packageName)) return "@rc";
return "latest";
},
};
+1
View File
@@ -0,0 +1 @@
24
@@ -1,39 +0,0 @@
# Imported-table raster CI fix
## Root cause
Hosted runs `34007560930` (PR #3471) and `34007788443` (PR #3472) failed only in
`tests/e2e/specs/imported-table.spec.ts` with `horizontal: 18` instead of Plan 16's
`horizontal: 17`; text and vertical checks passed.
The PDF operator dump from the failed hosted artifact showed 29 table path records matching the Plan 16 contract
(17 horizontal, 12 vertical), followed by an unrelated `constructPath` `endPath` bbox:
`[0, 19.65, 358.93, 20.65]`. Its stroke color was reported as `#cc00cc` only because the helper retained the
last table stroke color. It was a later red section-divider fill/no-paint path, not an extra table border. The old
helper classified every thin bbox after the last matching color state, so it counted this false positive.
The table's explicit width is stable at 300pt, while row height legitimately changes from 30pt to 31pt after the
`Beta!` edit. The helper therefore scopes candidate paths by the fixture's 300pt horizontal grid envelope, not by a
row-height tolerance. Missing or duplicated paths inside that envelope still change the exact 17/12 contract.
## Change
- Added `tests/e2e/fixtures/pdf-borders.ts` with deterministic `countTableBorderGeometry` filtering.
- Updated browser/server PDF inspection in `tests/e2e/specs/imported-table.spec.ts` to use the helper.
- Added `tests/e2e/fixtures/pdf-borders.test.ts`; regression proves old stale-color counting returns 2 horizontal
paths while topology-scoped counting returns 1.
## Verification
- Intent skill inventory: 7 packages, 26 skills; no matching local skill for this E2E/PDF helper.
- Focused helper regression: 1 file, 1 passed.
- Dedicated imported-table E2E: 2 consecutive runs, each 1 passed; both exercise initial, unrelated-edit, and table-edit
stages plus browser and server PDF exports.
- Production build: 3/3 tasks successful.
- Web typecheck via `rtk proxy pnpm --filter web typecheck`: passed (`tsgo --noEmit`).
- Turbo boundaries: 1,443 files across 20 packages, no issues.
- Targeted Biome: 3 files, no issues.
- `git diff --check`: passed.
The root `pnpm typecheck` wrapper was also tried but invokes an incompatible `tsc` path and reports TS5096 for
`allowImportingTsExtensions`; the package's documented `tsgo --noEmit` typecheck passes.
-33
View File
@@ -1,33 +0,0 @@
# Plan 21 implementation evidence
## Revision and scope
- Worktree: `issue-3060-section-heading-visibility`
- Base: current `origin/main` at dispatch, `2a4a1583b` (`fix(pdf): restore Gengar skill rating order (#3473)`)
- Product decisions applied: Q1 explicit Show heading toggle; Q2 Move-to continuations default visible; Q3 visual omission in preview/PDF/DOCX with accessible outline labels retained.
- No `.codegraph/` directory exists in this worktree, so CodeGraph was skipped after the required presence check.
- Intent discovery ran before edits; no matching local skill was available for this schema/PDF/DOCX/web change.
## Implementation
- Added backward-compatible `showHeading` section data for summary, built-ins, and custom sections. `parseResumeData` normalizes absent legacy values to `true`; explicit `false` survives round trips.
- Added heading toggles to built-in/summary and custom section menus. Toggle mutations use `useUpdateResumeData`, preserving undo/autosave/save/reload behavior; legacy absent values are treated as visible. Existing lock fieldset remains authoritative.
- Move-to-created custom sections explicitly set `showHeading: true`, independent of source heading state or copied title.
- `SectionShell` omits complete heading/icon/decoration output when disabled in both icon and no-icon branches. Empty titles still resolve localized defaults.
- DOCX section renderers omit visible heading paragraphs for summary, built-in, and custom sections while retaining content. Screen-reader mirror continues to expose section labels regardless of visual setting.
- Added characterization for Semantic CSS `section[id="..."] section-heading { display: none; }`; body remains while heading is omitted.
- Updated default/sample fixtures, generated schema references, recovery hashes, and compatibility tests; existing Gengar renderer/order changes remain untouched.
## Verification
- `pnpm --filter @reactive-resume/schema test`: 9 files, 132 tests passed.
- `pnpm --filter @reactive-resume/pdf test`: 81 files, 1059 tests passed.
- `pnpm --filter @reactive-resume/docx test`: 9 files, 76 tests passed.
- `pnpm --filter web test`: 135 files, 942 tests passed.
- `pnpm test`: full Turborepo suite passed (19 successful tasks; 10 cache hits).
- Affected typechecks passed: schema, PDF, DOCX, web.
- Focused menu, Move-to, accessible-outline, schema, PDF semantic, and DOCX renderer tests passed.
- `pnpm exec turbo boundaries`: passed (1108 files, 20 packages).
- Read-only `pnpm exec biome check` on 22 changed source/test files: passed; no write-capable `pnpm check` run.
- `git diff --check`: passed.
- Final diff review completed; local commit follows.
@@ -1,32 +0,0 @@
# Plan 23 item-pagination execution evidence
## Scope
Plan 23 steps 1–3 were evaluated from `origin/main` at `368858a56` (Plan 21 / PR #3477 merged). Widow/orphan UI and authored-page continuation guidance remain deferred from this execution, and Semantic CSS was not changed.
## Durable diagnostic matrix
`packages/pdf/src/templates/shared/item-pagination.test.tsx` renders physical PDF pages and checks numbered tokens exactly once for:
- an item that fits remaining space;
- an item that fits a full page but not the remaining space;
- an oversized item taller than one page;
- a two-line paragraph at a boundary;
- nested bullets; and
- built-in plus custom items in an Azurill sidebar/main-column overflow fixture.
The fixture also keeps authored `metadata.layout.pages` separate from renderer-generated physical pages.
The current deterministic baseline is: fit remainder = 1 physical page; full-page-but-not-remainder = 3 pages with sampled tokens on pages 2/2/3; oversized = 5 pages with sampled tokens on pages 1/3/5; two-line boundary = 2 pages; nested bullets = 1 page; Azurill built-in/custom/sidebar = 5 pages with sampled tokens on pages 1/4/5/5/1. Page numbers here are 1-based; every token still appears exactly once.
## Concrete blocker
React PDF's only available item-level keep-together primitive is `View wrap={false}`. A durable renderer fixture with 180 paragraph-like child views shows that a non-wrapping item cannot safely fall back when its content exceeds one page: the renderer omits the oversized tail instead of splitting it. Applying the same prop to shared `SectionItem` would therefore violate the lossless token requirement; no item schema flag or menu control was added.
Do not estimate item height from HTML length, persist physical pages, alter existing Semantic CSS, or claim #3350 complete. A future implementation needs renderer-supported conditional keep-together behavior or an actual measured two-pass fallback that preserves every token.
## Verification
- `pnpm --filter @reactive-resume/pdf exec vitest run src/templates/shared/item-pagination.test.tsx`: 7 tests passed.
- No production source or schema changes made after the unsafe fallback was reproduced.
- Undo/persistence/lock UI coverage is intentionally absent because no item control was shipped; add it only when safe fallback exists.
-157
View File
@@ -1,157 +0,0 @@
# Plan 27 Phase A diagnostic evidence
Date: 2026-09-06
Issue: [#3377](https://github.com/amruthpillai/reactive-resume/issues/3377)
Revision: `2a4a1583b` (`origin/main` at run start)
Scope: Phase A, steps 1–2 only. No resolver, runtime behavior, or remote-source behavior changed.
## Drift and authority
- Worktree started clean and `HEAD` matched `origin/main`; `git diff origin/main...HEAD` was empty.
- Current catalog is `packages/fonts/src/webfontlist.json`. Its web font records point at both Google Fonts static assets and jsDelivr assets; “Google blocked” is not an offline proof.
- Browser preview is `apps/web/src/components/typography/font-display.tsx` and calls `FontFace.load()` against each catalog preview URL.
- Browser PDF preview/download is `apps/web/src/features/resume/export/pdf-document.tsx` → `@reactive-resume/pdf/browser`; registration is `packages/pdf/src/hooks/use-register-fonts.ts`.
- Server PDF is `apps/server/src/http/resume-pdf.ts` → `createResumePdfDownload`; Playwright browser routing cannot observe that process’s outbound font fetches.
- The issue is open and unmodified. PR #3455 is the approved planning PR; its plan/decision log grants execution of this bounded diagnostic and manifest evidence.
## Deterministic fixture
`tests/e2e/fixtures/offline-fonts.ts` seeds one disposable resume after sample creation. It writes the same text into basics and summary, hides the picture, selects IBM Plex Serif 400/700 for body and heading, and marks the row public for the server-PDF surface.
The exact markers are versioned as `offline-font-scripts-v1`:
| Marker | Script or coverage |
| --- | --- |
| `Latin punctuation • — “quotes” €` | Latin plus General Punctuation and currency |
| `简体中文` | Han / Simplified Chinese |
| `العربية` | Arabic |
| `עברית` | Hebrew |
| `ไทย` | Thai |
| `Emoji 🚀` | Emoji |
`tests/e2e/specs/offline-fonts.spec.ts` is opt-in (`OFFLINE_FONT_DIAGNOSTIC=1`) so the normal PR E2E suite does not become network-dependent. Each surface creates a new browser context with persisted auth state, disabled service workers, and no prior browser cache. Every non-same-origin request is aborted and recorded as `{ hostname, path }`; query strings, fragments, headers, bodies, tokens, and full URLs never enter diagnostic output. Reports are attached as JSON and emitted with the same sanitized shape.
The four surfaces are separate tests:
1. Font picker preview opens Typography → Font Family and waits for lazy `FontFace` preview loads.
2. Builder PDF preview navigates to the builder, captures the active PDF canvas, and measures marker-local raster crops.
3. Browser PDF download uses the Export dialog, rasterizes the downloaded PDF, and measures marker-local crops when generation succeeds.
4. Server PDF calls the public PDF endpoint and records text-layer marker presence when generation succeeds.
Builder/browser-PDF reports keep PDF text extraction as a separate `textLayerMarkers` signal; it does not prove visible glyph outlines. Raster evidence attaches a rendered PNG and per-marker crop metrics, failing for blank or tofu-like visible crops. Blocked browser font requests classify browser surfaces as `network-error`. The server report deliberately says `server-outbound-requests-unobservable-from-playwright`; its cold-network gate remains unresolved because server outbound capture and verifiable restart identity require external host-level controls.
## Run protocol and cold-cache boundary
Build and database setup follow `tests/e2e/README.md`. Run each surface in a separately restarted production server process so module-level PDF font registration state cannot leak between controls:
```text
OFFLINE_FONT_DIAGNOSTIC=1 OFFLINE_FONT_DIAGNOSTIC_SERVER_RESTARTED=1 \
pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --grep "picker preview"
```
Stop and restart the production server before repeating the command with `builder PDF`, `browser PDF`, and `server PDF` grep patterns. The environment used for this change had no built `apps/server/dist` or `apps/web/dist`, no running PostgreSQL instance, and no production server to restart, so the cold E2E matrix was not run. This is an explicit infrastructure blocker, not a pass claim. The test records `serverRestartFlag` only as caller input and labels it non-proof; it does not claim a completed cold-network gate.
The current Playwright route guard cannot impose host-level egress denial on Node.js running the server. A genuinely cold server test therefore needs a separately restarted server plus host-level egress capture/deny (for example, a controlled network namespace or an approved outbound proxy). Do not infer server network behavior from an empty browser request list.
## Administrator-hosted manifest proposal
This is a proposal, not an asset download. It intentionally contains only the primary family and glyph fallbacks required by the fixture and current PDF fallback map, not the full catalog.
```json
{
"schemaVersion": "offline-fonts-v1",
"mode": "local-only",
"assetRoot": "/fonts/offline/v1",
"families": {
"IBM Plex Serif": {
"normal": { "400": "ibm-plex-serif/400.ttf", "700": "ibm-plex-serif/700.ttf" },
"italic": { "400": "ibm-plex-serif/400-italic.ttf", "700": "ibm-plex-serif/700-italic.ttf" },
"preview": "ibm-plex-serif/preview.ttf"
},
"IBM Plex Sans": {
"normal": { "400": "ibm-plex-sans/400.ttf", "700": "ibm-plex-sans/700.ttf" },
"italic": { "400": "ibm-plex-sans/400-italic.ttf", "700": "ibm-plex-sans/700-italic.ttf" },
"preview": "ibm-plex-sans/preview.ttf"
},
"Noto Serif": {
"normal": { "400": "noto-serif/400.ttf", "700": "noto-serif/700.ttf" },
"italic": { "400": "noto-serif/400-italic.ttf", "700": "noto-serif/700-italic.ttf" },
"preview": "noto-serif/preview.ttf"
},
"Noto Sans": {
"normal": { "400": "noto-sans/400.ttf", "700": "noto-sans/700.ttf" },
"italic": { "400": "noto-sans/400-italic.ttf", "700": "noto-sans/700-italic.ttf" },
"preview": "noto-sans/preview.ttf"
},
"Noto Sans SC": { "normal": { "400": "noto-sans-sc/400.ttf", "700": "noto-sans-sc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-sc/preview.ttf" },
"Noto Serif SC": { "normal": { "400": "noto-serif-sc/400.ttf", "700": "noto-serif-sc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-sc/preview.ttf" },
"Noto Sans TC": { "normal": { "400": "noto-sans-tc/400.ttf", "700": "noto-sans-tc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-tc/preview.ttf" },
"Noto Serif TC": { "normal": { "400": "noto-serif-tc/400.ttf", "700": "noto-serif-tc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-tc/preview.ttf" },
"Noto Sans JP": { "normal": { "400": "noto-sans-jp/400.ttf", "700": "noto-sans-jp/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-jp/preview.ttf" },
"Noto Serif JP": { "normal": { "400": "noto-serif-jp/400.ttf", "700": "noto-serif-jp/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-jp/preview.ttf" },
"Noto Sans KR": { "normal": { "400": "noto-sans-kr/400.ttf", "700": "noto-sans-kr/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-kr/preview.ttf" },
"Noto Serif KR": { "normal": { "400": "noto-serif-kr/400.ttf", "700": "noto-serif-kr/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-kr/preview.ttf" },
"Noto Sans Arabic": { "normal": { "400": "noto-sans-arabic/400.ttf", "700": "noto-sans-arabic/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-arabic/preview.ttf" },
"Noto Naskh Arabic": { "normal": { "400": "noto-naskh-arabic/400.ttf", "700": "noto-naskh-arabic/700.ttf" }, "italic": "reuse-normal", "preview": "noto-naskh-arabic/preview.ttf" },
"Noto Sans Hebrew": { "normal": { "400": "noto-sans-hebrew/400.ttf", "700": "noto-sans-hebrew/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-hebrew/preview.ttf" },
"Noto Sans Thai": { "normal": { "400": "noto-sans-thai/400.ttf", "700": "noto-sans-thai/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-thai/preview.ttf" },
"Noto Emoji": { "normal": { "400": "noto-emoji/400.ttf", "700": "noto-emoji/700.ttf" }, "italic": "reuse-normal", "preview": "noto-emoji/preview.ttf" }
}
}
```
### Candidate source, license, script, and size evidence
Sizes are `Content-Length` bytes from a HEAD request to the exact current catalog assets on 2026-09-06. Responses reported `Content-Encoding: gzip`; these are compressed transfer-size estimates, not a claim about the eventual on-disk representation. Preview paths are aliases to the selected 400 face and add no extra bytes when stored once. Primary sources: [IBM Plex LICENSE.txt](https://github.com/IBM/plex/blob/master/LICENSE.txt), [Noto core LICENSE](https://github.com/notofonts/noto-fonts/blob/main/LICENSE), [Noto CJK Sans LICENSE](https://github.com/notofonts/noto-cjk/blob/main/Sans/LICENSE), and [Noto Emoji font LICENSE](https://github.com/googlefonts/noto-emoji/blob/main/fonts/LICENSE).
| Family | Style/weights in proposal | Current catalog source version | License | Script/fallback role | Gzip bytes (selected files) | Build owner; runtime owners |
| --- | --- | --- | --- | --- | ---: | --- |
| IBM Plex Serif | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/ibmplexserif/v20` | OFL 1.1, Reserved Font Name `Plex` | Primary serif; Latin and punctuation stack | 294,717 | `packages/fonts`; `apps/web` FontDisplay; `packages/pdf` registration |
| IBM Plex Sans | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/ibmplexsans/v23` | OFL 1.1, Reserved Font Name `Plex` | Primary sans | 435,469 | `packages/fonts`; `apps/web` FontDisplay; `packages/pdf` registration |
| Noto Serif | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/notoserif/v33` | OFL 1.1 | Serif punctuation fallback | 1,055,120 | `packages/fonts`; `packages/pdf` fallback registration |
| Noto Sans | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/notosans/v42` | OFL 1.1 | Sans punctuation fallback | 1,236,259 | `packages/fonts`; `packages/pdf` fallback registration |
| Noto Sans SC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanssc/v40` | OFL 1.1 (Noto CJK) | Simplified Han; CJK fallback | 12,766,416 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Serif SC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoserifsc/v35` | OFL 1.1 (Noto CJK) | Simplified Han serif fallback | 17,350,185 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Sans TC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanstc/v39` | OFL 1.1 (Noto CJK) | Traditional Han fallback | 8,628,278 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Serif TC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoseriftc/v36` | OFL 1.1 (Noto CJK) | Traditional Han serif fallback | 11,804,923 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Sans JP | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosansjp/v56` | OFL 1.1 (Noto CJK) | Kana and Japanese Han fallback | 6,383,035 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Serif JP | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoserifjp/v33` | OFL 1.1 (Noto CJK) | Kana and Japanese Han serif fallback | 8,685,862 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Sans KR | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanskr/v39` | OFL 1.1 (Noto CJK) | Hangul and Korean Han fallback | 6,102,888 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Serif KR | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoserifkr/v31` | OFL 1.1 (Noto CJK) | Hangul and Korean Han serif fallback | 11,113,442 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Sans Arabic | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosansarabic/v33` | OFL 1.1 | Arabic sans fallback | 178,455 | `packages/fonts`; `packages/pdf` script fallback |
| Noto Naskh Arabic | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notonaskharabic/v44` | OFL 1.1 | Arabic serif fallback | 190,924 | `packages/fonts`; `packages/pdf` script fallback |
| Noto Sans Hebrew | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanshebrew/v50` | OFL 1.1 | Hebrew fallback for both serif/sans slots | 55,707 | `packages/fonts`; `packages/pdf` script fallback |
| Noto Sans Thai | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosansthai/v29` | OFL 1.1 | Thai fallback for both serif/sans slots | 55,173 | `packages/fonts`; `packages/pdf` script fallback |
| Noto Emoji | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoemoji/v62` | OFL 1.1 for font files; assets/tools have separate licenses | Emoji outline fallback; verify renderer support | 1,153,847 | `packages/fonts`; `packages/pdf` script fallback |
Estimated transfer size for all rows and listed styles: **87,490,700 bytes (~83.44 MiB)**. This confirms why a full-catalog bundle is out of scope. A later implementation should subset by declared glyph requirements or make the administrator choose fallback families; it must not silently fetch another CDN.
### Source and license obligations
- Pin an upstream release/commit and retain source attribution plus the complete applicable license with hosted assets. Do not use mutable `@latest` URLs as runtime sources.
- IBM Plex’s license has Reserved Font Name `Plex`; modified/subset outputs must follow OFL naming requirements.
- Noto core, Noto CJK, and Noto Emoji font files are OFL 1.1, but Noto Emoji documents separate Apache/public-domain treatment for tools and flag image assets. Bundle only font files unless those other assets are intentionally needed and separately attributed.
- License checks are build-owner responsibility (`packages/fonts`/tooling); runtime owners (`apps/web` and `packages/pdf`) consume only the validated manifest.
### Missing-family and missing-asset behavior
Local mode must resolve only same-origin administrator-hosted manifest paths. If imported resume data names an unavailable family, show an actionable missing-family error naming the family and required local asset; apply a configured local fallback only when the administrator explicitly supplied one. If a required weight/style/fallback asset is absent, fail the affected preview/export with an actionable diagnostic containing family/style/weight and local path. Never retry Google Fonts, jsDelivr, or any other remote URL in local mode.
Standard PDF families (Helvetica, Courier, Times-Roman) remain file-free. They do not prove that a document containing punctuation, CJK, Arabic, Hebrew, Thai, or emoji is network-free; the script fallback rows remain required.
## Verification record
Completed read-only checks before handoff:
- CodeGraph exploration of font catalog, picker preview, browser PDF, server PDF, and existing fallback tests.
- `pnpm dlx @tanstack/intent@latest list`: no matching local intent skill for this work.
- `pnpm exec biome check tests/e2e/specs/offline-fonts.spec.ts tests/e2e/fixtures/offline-fonts.ts turbo.json`: passed.
- `git diff --check`: passed.
- `pnpm --filter @reactive-resume/fonts test`: passed (55 tests).
- `pnpm --filter @reactive-resume/pdf exec vitest run src/hooks/use-register-fonts.test.ts`: passed (35 tests).
- Web typography/regression suite: passed (940 tests across 135 files); web and server package typechecks passed.
- `pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --list`: passed (4 diagnostic tests collected).
- E2E diagnostic execution: blocked by missing build outputs and unavailable PostgreSQL/server; no success claim made.
- `pnpm exec turbo boundaries`: passed on fresh rerun (Turbo 2.10.12, 1108 files, no issues).
The implementation intentionally stops at diagnostic fixtures and manifest evidence. Shared source resolution, asset hosting, local-mode configuration, and production behavior remain Phase A step 3+ work.
@@ -1,22 +0,0 @@
# Plan 27A remediation round 2
Date: 2026-09-06
Base: `ae8e2f76f`
## Focused fixes
- Removed multilingual markers from the fixture headline. Each marker now exists only in its dedicated summary paragraph.
- Added pure marker-location helpers. Marker lookup joins PDF text items, supports markers split across items, rejects duplicate occurrences, and rejects non-whitespace neighbors that could contaminate a local crop.
- Raster measurement still scans with antialiasing padding but counts ink only inside the marker box, preventing neighboring glyphs from making blank or tofu-like evidence pass.
- Browser PDF download now separates download errors from post-download evidence errors. A received download with failed rasterization is reported as `unresolved-raster-evidence-error` and fails the opt-in test rather than passing as a generic download error.
- Added focused pure tests covering duplicate, split, neighboring, blank, and tofu-like cases.
- Removed trailing spaces from `plan-27a-remediation.md`.
## Verification
- `pnpm exec vitest run tests/e2e/fixtures/offline-font-markers.test.ts` — 5/5 passed.
- `pnpm exec biome check tests/e2e/specs/offline-fonts.spec.ts tests/e2e/fixtures/offline-fonts.ts tests/e2e/fixtures/offline-font-markers.ts tests/e2e/fixtures/offline-font-markers.test.ts` — passed.
- `pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --list` — 4 tests collected.
- `git diff --check` — passed after remediation-document whitespace cleanup.
Full diagnostic E2E remains opt-in and was not run in this focused round. Server outbound request capture and verifiable restart identity remain explicit external host-level blockers; no production resolver changes were made.
-24
View File
@@ -1,24 +0,0 @@
# Plan 27A remediation
Date: 2026-09-06
Base: `61b58ae9a`
Scope: concrete findings from `.orchestration/plan-27a-independent-review.md` only.
## Remediated findings
- Builder PDF preview and browser PDF download now produce raster evidence. The fixture stores each multilingual marker in its own summary paragraph, allowing the diagnostic to locate marker-local PDF text boxes and measure only those raster crops. Reports attach a rendered PNG plus per-marker `inkPixels`, trimmed dimensions, and status. Blank and tofu-like crops fail assertions; no whole-page snapshot is used.
- PDF text extraction is reported separately as `textLayerMarkers`. It is not described or asserted as proof of visible glyph outlines.
- Server PDF output remains text-extraction-only and is explicitly classified as `serverGateStatus: unresolved-external-host-level-blocker`. `serverRestartFlag` is caller input, not restart proof. Browser Playwright routing is not used to infer server egress, and no production resolver or instrumentation behavior was added.
- `.orchestration/plan-27a-diagnostic.md` now records a fresh boundaries pass and the corrected `87,490,700 bytes (~83.44 MiB)` arithmetic.
- Diagnostic remains opt-in through `OFFLINE_FONT_DIAGNOSTIC=1`; normal CI behavior remains unchanged. Request logs stay sanitized to hostname and pathname.
## Verification
- `pnpm exec biome check tests/e2e/specs/offline-fonts.spec.ts tests/e2e/fixtures/offline-fonts.ts` — passed.
- `git diff --check` — passed.
- `pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --list` — 4 tests collected.
- `pnpm --filter @reactive-resume/fonts test` — 55/55 passed.
- `pnpm --filter @reactive-resume/pdf exec vitest run src/hooks/use-register-fonts.test.ts` — 35/35 passed.
- `pnpm exec turbo boundaries` — passed on fresh rerun (Turbo 2.10.12, 1108 files, no issues).
Full diagnostic E2E remains unrun because this environment lacks production build output, PostgreSQL, and a production server. Server cold-network capture and verifiable restart identity remain external host-level blockers by design; this remediation does not claim that gate is complete.
@@ -1,17 +0,0 @@
# Issue 3350 remediation evidence
## Findings addressed
- `item-pagination.test.tsx` now derives complete numbered-token inventories for each generated fixture and asserts every token exactly once. Sampled token-to-physical-page placement checks remain separate.
- Pagination fixtures snapshot `metadata.layout.pages` before rendering and assert authored layout pages are unchanged afterward. Overflow fixtures also assert physical PDF page count exceeds authored page count.
- Unsafe `wrap={false}` renderer coverage remains diagnostic-only; no item controls, schema flags, or runtime behavior were added.
## Verification
- `rtk proxy pnpm --filter @reactive-resume/pdf exec vitest run src/semantic/pagination.test.tsx src/templates/shared/item-pagination.test.tsx` — 2 files, 11 tests passed.
- `rtk proxy pnpm --filter @reactive-resume/pdf typecheck` — passed.
- `rtk proxy pnpm exec biome check packages/pdf/src/templates/shared/item-pagination.test.tsx` — passed.
- `rtk proxy pnpm exec turbo boundaries` — passed; 1109 files checked.
- `rtk git diff --check origin/main...HEAD` — passed.
Only PDF test coverage and this evidence file changed; production behavior remains untouched.
+27
View File
@@ -0,0 +1,27 @@
.env*
!.env.example
.git
.codegraph
.superpowers
.agents
.codex
.claude
.turbo
**/node_modules
**/dist
**/coverage
**/reports
data
apps/web/data
screenshots
.vercel
.wrangler
.tanstack
.worktrees
.migration
.supermemory
.cache
tmp
temp
**/test-results
**/playwright-report
+143 -32
View File
@@ -1,3 +1,7 @@
# Reactive Resume: agent instructions
This file applies across the repository. Follow a closer `AGENTS.md` when one exists. Keep this guide focused on agent workflows; user-facing documentation lives in `README.md` and `docs/`. Format guidance: [agents.md](https://agents.md/).
<!-- intent-skills:start -->
## Skill Loading
@@ -27,20 +31,70 @@ Auto-Clarity: drop caveman for security warnings, irreversible actions, user con
Boundaries: code/commits/PRs written normal.
<!-- caveman-end -->
<!-- graphify-begin -->
<!-- BEGIN:turborepo-agent-rules -->
# This is NOT the Turborepo you know
Turborepo configuration, task behavior, and CLI commands can vary between installed versions and may differ from your training data. Resolve the `turbo` package from this file's directory or relevant workspace; in monorepos, it may not be visible from the repository root. For example, run `node -p "require.resolve('turbo/package.json')"` from a workspace that depends on `turbo`.
Read `docs/README.md` inside that installed package first, then read the relevant pages from its `docs/` directory before changing Turborepo configuration or commands. Heed deprecation notices. These bundled docs match the installed package version and are available without network access.
This block is written and re-added by `turbo` before repository-scoped commands when an AI agent is detected. In the Turborepo source repository, its template is defined in `crates/turborepo-cli/src/cli/agent_guidance.rs`. Removing the managed block while updates are enabled means a later qualifying invocation will add it again. Set `"agentGuidance": false` in the root `turbo.json` or `turbo.jsonc` to opt out; this does not remove an existing block. Keep the block committed with your work to avoid an uncommitted change on the next agent invocation.
<!-- END:turborepo-agent-rules -->
## Agent skills
- Issues and specs: GitHub Issues for `amruthpillai/reactive-resume`. See `docs/agents/issue-tracker.md`.
- Domain docs use a multi-context layout. See `docs/agents/domain.md`.
- Issues and specs: GitHub Issues for `reactive-resume/reactive-resume`. See `docs/agents/issue-tracker.md`.
- Check `git status --short` before editing. Preserve unrelated changes, including existing edits in this file.
- Use scripts and configuration as the source of truth when documentation disagrees with them.
## Overview
Reactive Resume is a pnpm monorepo (Turborepo) with two deployable apps: `apps/web` (TanStack Start / React 19 / Vite) and `apps/server` (Hono / Node.js). The production Docker image runs a single Node.js process on port 3000; `apps/server` mounts the API/auth/MCP/static routes and serves the built web app.
Reactive Resume is a free, open-source resume builder for creating, importing, exporting, and sharing resumes, cover letters, and job applications. It is a TypeScript pnpm monorepo managed by Turborepo, with two apps: `apps/web` (React 19 SPA with TanStack Router, TanStack Query, Tailwind CSS, and Vite) and `apps/server` (Hono / Node.js). oRPC connects browser workflows to server business logic; Better Auth handles authentication; Drizzle accesses PostgreSQL. Forme renders PDFs in the browser and on the server.
The production Docker image runs a single Node.js process on port 3000; `apps/server` mounts the API/auth/MCP/static routes and serves the built web app. On Vercel, the `frontend` service serves static assets through its CDN and the `backend` service runs the same Hono application in a Node.js Function.
Internal packages are source-consumed through `package.json` export maps pointing at `src` files. Do not assume package-local `dist` output exists unless a package explicitly adds it.
Prerequisites: **Node.js 24** (matches Dockerfile `ARG NODE_VERSION=24`), **pnpm 11.21.0** ([install guide](https://pnpm.io/installation)), and **Docker** for PostgreSQL (`sudo dockerd &` if the daemon isn't running).
## Setup
Prerequisites: **Node.js 24** (`.nvmrc`, root `engines`, and Dockerfile), **pnpm 12.6.0** (root `packageManager`; pnpm self-manages to this version), and **Docker with Docker Compose** for local infrastructure. The Dockerfile's `ARG PNPM_VERSION` chooses its base image, not the project's pnpm version. Start your Docker daemon before running Compose.
Run commands from the workspace root unless stated otherwise:
```sh
pnpm install --frozen-lockfile
test -e .env.local || cp .env.example .env.local
docker compose -f compose.dev.yml up -d postgres redis seaweedfs seaweedfs_create_bucket
docker compose -f compose.dev.yml ps
```
Copy the environment template only when `.env.local` does not already exist. For host-run development, edit these values in `.env.local`; the template uses container hostnames:
```dotenv
APP_URL=http://localhost:3000
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
S3_ENDPOINT=http://localhost:8333
REDIS_URL=redis://localhost:6379
```
Set `AUTH_SECRET` to a generated secret (`openssl rand -hex 32`). If using saved AI providers or `/agent`, also set a separate `ENCRYPTION_SECRET` of at least 32 characters. For database-only development, start just `postgres` and set `STORAGE_BACKEND=local` to avoid the template's S3 defaults.
## Development workflow
```sh
pnpm dev
pnpm dev:web
pnpm db:generate
pnpm db:migrate
pnpm db:studio
```
- `pnpm dev` runs Vite on `PORT` (default `3000`), Hono on `SERVER_PORT` (default `3001`), and the email template preview on `3002`. Vite proxies API requests to Hono. Vite supplies hot reload; `tsx watch` restarts the server.
- `pnpm dev:web` starts only Vite; API workflows still need a server. If ports are busy, change `PORT` and `SERVER_PORT` consistently in `.env.local`; keep the email preview's `3002` port free when running all dev tasks.
- Server startup applies migrations before initializing auth and serving traffic. `pnpm db:migrate` applies them without starting the app; `pnpm db:studio` opens the database UI.
- After adding user-facing strings, use Lingui macros and run `pnpm lingui:extract`. Catalogs live in `apps/web/locales/*.po`; `pnpm pdf:translations` regenerates PDF translations. Root build/check scripts run PDF translation generation automatically.
- `pnpm docs:gen` regenerates the OpenAPI spec and semantic CSS reference. Use it when changing those public surfaces.
## Ownership map
@@ -56,11 +110,12 @@ Where each concern lives, and where new code for it goes:
| Server env validation | `packages/env` (auto-loads root `.env`) |
| Resume/page/template Zod schemas | `packages/schema` |
| Pure resume-domain behavior (no DB/HTTP/DOM/renderer deps) | `packages/resume` (JSON Patch helpers, social-network icons) |
| Resume PDF rendering | `packages/pdf` (React PDF document, font registration, template primitives, browser/server adapters) |
| Resume PDF rendering | `packages/pdf` (React templates converted through `src/forme` to Forme documents, font resolution, browser/server adapters) |
| PDF.js viewer/canvas UI | `apps/web/src/features/resume` — never in `packages/pdf` |
| DOCX export | `packages/docx` |
| MCP tools/prompts/resources/server-card | `packages/mcp` |
| Generic UI primitives + hooks | `packages/ui` (Base UI/shadcn-style); workflow-specific UI stays in the owning web feature |
| Desktop Shell integration | `packages/dsh-plugin` (separately built/published plugin) |
| Focused support surfaces | `packages/fonts`, `packages/email`, `packages/import`, `packages/ai`, `packages/utils`, `packages/config` — prefer existing exports over cross-package shortcuts |
| Dev-only scripts | `tooling/`, not `packages/`, so packages only hold runtime-bundled code |
@@ -69,9 +124,10 @@ Narrow cross-cutting helpers go in `packages/utils` only after checking no domai
## Web app conventions
- `apps/web/src/router.tsx` initializes router context with `queryClient`, `orpc`, `theme`, `locale`, `session`, and `flags`. Reuse route context instead of refetching these ad hoc.
- Builder shell: `apps/web/src/routes/builder/$resumeId`. Its nested preview route is client-only (`ssr: false`); the public resume route `apps/web/src/routes/$username/$slug.tsx` uses `ssr: "data-only"`.
- Browser-only preview code: `apps/web/src/features/resume/preview`. Public PDF viewer: `apps/web/src/features/resume/public`. Keep PDF.js/canvas/browser APIs out of SSR paths.
- Isomorphic oRPC client: `apps/web/src/libs/orpc/client.ts` — server calls use an in-process router client, browser calls use `/api/rpc` with credentials included.
- The web app is a client-rendered SPA. The web build prerenders marketing homepages per locale; there is no request-time React SSR. `apps/server/src/static/web.ts` serves HTML and injects OpenGraph, canonical, and JSON-LD metadata. When adding a public marketing route, update its server fallback/SEO handling as well as the TanStack route; Vite's dev fallback can otherwise hide production 404s.
- Builder shell: `apps/web/src/routes/builder/$resumeId`. Public resume route: `apps/web/src/routes/$username/$slug.tsx`.
- Browser-only preview code: `apps/web/src/features/resume/preview`. Public PDF viewer: `apps/web/src/features/resume/public`. Keep PDF.js/canvas code in these features, not in `packages/pdf`.
- oRPC client: `apps/web/src/libs/orpc/client.ts` calls `/api/rpc` with credentials included. `apps/web/src/libs/orpc/fetch.ts` stages large request bodies through Blob on Vercel.
- For React components with explicit props, use a named props type (e.g. `type FooProps = {...}` with `function Foo(props: FooProps)`) rather than inline object annotations, especially with more than one field or with generics.
## Package boundaries
@@ -81,46 +137,101 @@ Narrow cross-cutting helpers go in `packages/utils` only after checking no domai
- Workspace deps go through package names and export maps. Never import another workspace's `src` tree via repo paths, `@reactive-resume/*/src/*`, or TS path aliases.
- Workspace `turbo.json` files declare coarse tags: `app:web`, `app:server`, `runtime:server` (server-only packages: API/auth/db/env/email/MCP), `runtime:browser` (browser-only shared UI), `runtime:universal` (environment-neutral domain packages), plus `role:domain|infra|adapter|api|rendering|tooling` for intent.
- Runtime-specific code lives behind explicit export subpaths (`@reactive-resume/pdf/browser`, `@reactive-resume/pdf/server`, `@reactive-resume/env/server`). Keep root exports environment-neutral unless the package is intentionally server-only.
- Wildcard exports are allowed only for leaf libraries with an intentionally file-like surface — currently `@reactive-resume/ui/components/*`, `@reactive-resume/ui/hooks/*`, and schema resume model files. Prefer explicit exports for packages owning runtime behavior.
- Wildcard exports are allowed only for leaf libraries with an intentionally file-like surface — currently `@reactive-resume/ui/components/*`, `@reactive-resume/ui/hooks/*`, and schema resume/application model files. Prefer explicit exports for packages owning runtime behavior.
- Prefer `protectedProcedure` from `packages/api/src/context.ts` for authenticated procedures. Expose only intentional public surfaces through `packages/api/package.json`.
- Shared PDF section filtering: `packages/pdf/src/templates/shared/filtering.ts`. Template-specific visual exceptions stay in the owning template directory unless multiple templates need the behavior. `packages/pdf/src/hooks/use-register-fonts.ts` owns font registration, standard PDF fonts, CJK fallback stacks, and global hyphenation.
- Shared PDF section filtering: `packages/pdf/src/templates/shared/filtering.ts`. Template-specific visual exceptions stay in the owning template directory unless multiple templates need the behavior. `packages/pdf/src/hooks/use-register-fonts.ts` resolves font families, weights, and script fallback stacks; the Forme adapter owns conversion/rendering. PDF generation needs no Browserless or Chromium service.
Multi-place changes:
- **Resume data shape**: `packages/schema/src/resume/*` first, then API DTOs, importers, PDF rendering, and web forms consuming it.
- **New template**: `packages/schema/src/templates.ts`, `packages/pdf/src/templates/index.ts`, source under `packages/pdf/src/templates/<name>/`, and previews under `apps/web/public/templates/{jpg,pdf}`.
- **New DB column/table**: `packages/db/src/schema/*`, then `dotenvx run -f .env.local -- pnpm db:generate`.
- **New env var**: `packages/env/src/server.ts` **and** the `globalEnv` array in `turbo.json`. Turborepo 2.x strict env mode filters out unlisted vars, so the variable will be `undefined` in child processes at runtime even when correctly set in the OS/container environment.
- **New DB column/table**: `packages/db/src/schema/*`, then `pnpm db:generate`.
- **New env var**: `packages/env/src/server.ts`, `.env.example`, **and** the `globalEnv` array in `turbo.json`. Add deployment aliases in `packages/env/src/deployment.ts` when needed. Turborepo strict env mode filters unlisted injected variables from task processes.
## Environment and database
Copy `.env.example` to `.env.local`. Three required vars: `APP_URL` (default `http://localhost:3000`), `DATABASE_URL` (default `postgresql://postgres:postgres@localhost:5432/postgres`), `AUTH_SECRET` (any non-empty string).
Host development requires `APP_URL`, `DATABASE_URL`, and non-empty `AUTH_SECRET`. `packages/env/src/server.ts` also loads root `.env` through Node's native `process.loadEnvFile`; existing process variables take precedence. Root dev/database scripts explicitly load `.env.local` through `dotenvx`. Tests and application code can have their own environment loaders; do not assume every command loads `.env.local`.
- **S3/SeaweedFS optional.** If `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, and `S3_BUCKET` are all set, the app uses S3-compatible storage. `.env.example` ships SeaweedFS defaults, so either start the `seaweedfs` compose service or comment those vars out to use local filesystem storage under `<workspace>/data`. `LOCAL_STORAGE_PATH` must be absolute when set.
- **Storage**: explicit `STORAGE_BACKEND=local|s3|blob` wins. Otherwise, complete S3 credentials select S3; Vercel selects private Blob; other deployments select local storage. `.env.example` ships SeaweedFS defaults, so either run SeaweedFS or select `local`/remove the S3 credentials. Local storage defaults to `<workspace>/data` in development and `/app/data` in Docker. `LOCAL_STORAGE_PATH` must be absolute and writable; persist it in deployed installations.
- **`REDIS_URL` and `ENCRYPTION_SECRET`** are optional for core resume flows but both required for saved AI providers and the authenticated `/agent` workspace. Host-run dev uses `REDIS_URL=redis://localhost:6379`; the container-run app uses `redis://redis:6379`.
- **`drizzle-kit` (used by `pnpm db:migrate`) reads `DATABASE_URL` from `process.env` directly** — it does not auto-load `.env`. Run migration commands through `dotenvx`.
- The production server auto-runs migrations at startup before serving traffic, so manual `pnpm db:migrate` is mainly for first setup, migration debugging, or applying migrations without starting the app.
- **`drizzle-kit` (used by `pnpm db:migrate`) reads `DATABASE_URL` from `process.env` directly** — it does not auto-load `.env`. The root migration scripts load `.env.local` through `dotenvx` before invoking Drizzle Kit.
- `DATABASE_MIGRATION_URL` supplies a direct migration connection when runtime `DATABASE_URL` is pooled. Review generated migration SQL before applying it; avoid resetting databases or deleting volumes to fix setup errors.
- Startup verifies the migrated schema. `STRICT_SCHEMA_CHECK=true` makes detected drift fatal; otherwise the server logs it and continues.
## Commands
## Testing and checks
Prefix dev servers and migration commands with `dotenvx run -f .env.local --`. Tests, typechecks, linters, boundary checks, and `pnpm build` do not need it; if one fails on a missing env var, rerun it with the prefix.
Prefer package-scoped checks for the files changed. Package names come from their `package.json`: the apps are `web` and `server`, most shared packages are `@reactive-resume/<name>`.
```
sudo docker compose -f compose.dev.yml up -d postgres # DB only
sudo docker compose -f compose.dev.yml up -d postgres redis seaweedfs seaweedfs_create_bucket # full infra
dotenvx run -f .env.local -- pnpm dev # port 3000 (dev:web for web only)
dotenvx run -f .env.local -- pnpm db:generate # db:migrate to apply
pnpm check # Biome — WRITE-CAPABLE (--write --unsafe)
pnpm test | pnpm typecheck | pnpm build | pnpm exec turbo boundaries
```sh
pnpm --filter web typecheck
pnpm --filter @reactive-resume/pdf test
pnpm --filter @reactive-resume/pdf test src/templates/shared/filtering.test.ts
pnpm --filter @reactive-resume/pdf exec vitest run src/templates/shared/filtering.test.ts -t "filterItems"
pnpm --filter @reactive-resume/pdf test:coverage
pnpm exec biome check apps/web/src/features/resume
pnpm exec turbo boundaries
```
Prefer package filters over repo-wide runs, e.g. `pnpm --filter web typecheck`, `pnpm --filter @reactive-resume/pdf test`. Vitest paths are package-relative under `pnpm --filter <package> test -- <path>`.
- Vitest tests live alongside source as `src/**/*.test.ts(x)` or `src/**/*.spec.ts(x)` (including integration tests). Paths under `pnpm --filter <package>` are package-relative. Pass paths directly after `test`: an extra `--` currently prevents Vitest from filtering the run. Shared settings live in `vitest.shared.mts` and setup in `vitest.setup.ts`; most packages use Node, while `packages/ui` uses `happy-dom`.
- Coverage uses V8 and writes package-local `coverage/` reports. No shared minimum coverage threshold is configured. `test:ci` writes JSON/JUnit results under package-local `reports/`.
- Root `pnpm test`, `pnpm test:coverage`, and `pnpm typecheck` run workspace checks through Turbo. CI runs `pnpm exec turbo run test:ci --concurrency=1` to avoid CPU contention in PDF/rate-limit suites.
- Real-database unit suites use `COVER_LETTER_TEST_DATABASE_URL` and `OAUTH_TEST_DATABASE_URL`; see `.github/workflows/e2e.yml` for isolated database setup. Never point test fixtures at production data.
- After changing shared contracts, exports, or imports, check affected consumers and run `pnpm exec turbo boundaries`.
### Browser tests
Playwright specs live in `tests/e2e/specs/*.spec.ts`, with fixtures in `tests/e2e/fixtures`. Configure a disposable PostgreSQL database and export test environment variables before building/running; these root scripts do not wrap `dotenvx`.
```sh
pnpm exec playwright install chromium
pnpm build
pnpm test:e2e
pnpm test:e2e tests/e2e/specs/auth.spec.ts
pnpm test:e2e:ui
```
- `playwright.config.ts` starts `node apps/server/dist/index.mjs` in production mode and waits for `/api/health`; locally it can reuse an existing server. Build first. Keep the direct Node command: pnpm's script process groups can prevent Playwright from cleaning up a server started through `pnpm start`.
- Export `APP_URL`, `PORT`, `DATABASE_URL`, `AUTH_SECRET`, and `ENCRYPTION_SECRET`, and choose an absolute writable `LOCAL_STORAGE_PATH`. Auth fixtures need signups/email auth enabled; `FLAG_DISABLE_API_RATE_LIMIT=true` is appropriate for this isolated test installation.
- Assistant specs use a deterministic local AI stub and need `FLAG_ALLOW_UNSAFE_AI_BASE_URL=true`; otherwise those specs skip. See `tests/e2e/README.md` for the full environment recipe; adapt its example storage path to your machine.
- Playwright runs Chromium with no retries. CI uses one worker and retains failure traces, screenshots, videos, and reports. PDF/DOCX rasterization and visual regression are outside this browser gate.
## Code style
- TypeScript is strict, including `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`, and unused-symbol checks; packages typecheck with `tsgo --noEmit`.
- Biome uses tabs, double quotes, 120-column lines, separated type imports, organized import groups, and sorted Tailwind classes in `clsx`, `cva`, and `cn`. Use existing file naming and feature-local conventions.
- **`pnpm check` modifies files**: it regenerates PDF translations and runs Biome with `--write --unsafe`. Call out its write behavior and review the diff; use narrow non-mutating commands when inspecting unrelated edits.
- Lefthook's pre-commit hook checks conflict markers and runs write-capable Biome on supported staged files, staging fixes. The commit-message hook enforces Conventional Commits (`fix:`, `feat:`, `docs:`, etc.).
## Build and deployment
```sh
pnpm build
NODE_ENV=production pnpm start
docker compose up -d --build
```
- Build outputs: `apps/web/dist` (SPA/assets), `apps/web/dist-prerender` (localized marketing HTML), and `apps/server/dist` (`index.mjs` plus server/deployment chunks). `pnpm start` runs the built server; set `NODE_ENV=production` so it uses `PORT` instead of `SERVER_PORT`. Export runtime variables or provide root `.env`; `.env.local` is not loaded by `start`.
- Production Compose loads `.env.example` then `.env`, not `.env.local`. Configure `.env` with container hostnames (`postgres`, `redis`, `seaweedfs`) and production secrets before running it. The Docker image runs as `node`, listens on `3000`, and persists local storage through `/app/data`. Health endpoint: `/api/health`.
- `vercel.json` defines Vercel Services (project framework must be `Services`): `frontend` (`apps/web`, static `dist`) and `backend` (`apps/server`, entrypoint `apps/server/vercel.mjs` re-exporting the tsdown build). The backend build runs `pnpm build` for both apps, then `node apps/server/dist/prepare-deployment.mjs`. Top-level rewrites send paths whose last segment has a file extension to `frontend` and everything else, including HTML shells, to `backend`, except the server-owned paths listed first. The Function uses Node 24 and a 300-second budget. `outputDirectory: "."` on `backend` stops the builder from treating `dist/index.mjs` (the Docker entrypoint) as the handler.
- Vercel environment normalization accepts `POSTGRES_URL`, direct/unpooled DB aliases, and `KV_URL`. `APP_URL` can be derived from Vercel host variables. Blob is the default when no S3 credentials are set. Preview deployments require isolated resources before enabling `ALLOW_PREVIEW_MIGRATIONS=true`; see `docs/self-hosting/vercel.mdx`.
- `.github/workflows/e2e.yml` gates core unit/browser flows; `vercel.yml` builds and checks the serverless artifact on PRs and pushes to `main`. `autofix.yml` runs write-capable `pnpm knip --fix` and `pnpm check`. GitHub runners are the default; `USE_BLACKSMITH=true` switches runners and paired actions.
- `docker-build.yml` publishes native AMD64/ARM64 images. `main` publishes nightly aliases; release tags/explicit release dispatch publish stable aliases and can trigger configured production integrations. See `docs/agents/container-publishing.md` before release work.
- Deployment smoke tests create/delete accounts and files; run only against a dedicated test installation. Details: `docs/contributing/deployment-checks.mdx`.
## Security and pull requests
- Keep credentials and personal resume data out of source, logs, test artifacts, issues, and PRs. Do not commit local environment files or substitute production secrets for test values.
- Authenticated procedures use `protectedProcedure`; enforce resource ownership in feature logic. Reuse shared auth resolution for API keys, bearer tokens, and cookies rather than adding a separate auth path.
- Keep unsafe OAuth redirect/AI URL flags disabled on public deployments. They relax redirect validation and SSRF protections for trusted self-hosted/test use.
- Keep PRs focused. Describe the problem, resulting behavior, and checks actually run; link the relevant GitHub issue. Conventional Commits are enforced for commit messages; no separate PR-title convention is configured.
- Before submitting, run applicable typechecks/tests and non-mutating lint checks; run the production build for runtime/bundling changes. Match CI's database/browser prerequisites when reproducing its checks. Report skipped checks and failures instead of claiming they passed.
- Never add AI attribution, co-author trailers naming AI tools, or session/chat links to commits or PR descriptions.
## Gotchas
- Email sending needs SMTP config; without it emails are logged to console. Dev still works — verification links appear in server logs.
- `lefthook.yml` pre-commit runs `biome check` on staged files. Run `pnpm check` before committing.
- `pnpm check` is write-capable. Call that out when using it, and use narrower Biome commands for a non-mutating inspection.
- Biome: tabs, double quotes, line width 120, organized import groups, sorted Tailwind classes for `clsx`, `cva`, `cn`.
- Most packages typecheck with `tsgo --noEmit` and test with `vitest run --passWithNoTests`.
- There may be unrelated local edits in the worktree. Check `git status --short` first; do not revert files you did not touch.
- Database connection errors: check `docker compose -f compose.dev.yml ps` and use `localhost` for host-run code, service names inside containers.
- S3 errors: check `docker compose -f compose.dev.yml logs seaweedfs seaweedfs_create_bucket`; verify endpoint and bucket, or select local storage.
- Route-tree errors after adding routes: run Vite dev/build to regenerate `apps/web/src/routeTree.gen.ts`; never edit it by hand.
- Serverless module-loading failures: inspect `bundledInteropPackages` in `apps/server/tsdown.config.ts` and the Vercel compatibility workflow. External CommonJS server dependencies break on Vercel because its service builder drops their pnpm links; bundle them with their dependencies.
- Most test scripts use `--passWithNoTests`; a successful run with zero tests does not verify the behavior you changed.
-3
View File
@@ -1,3 +0,0 @@
# Domain contexts
- [Resume](packages/resume/CONTEXT.md): authored resume content and presentation concepts shared by the builder and exporters.
+163 -305
View File
@@ -1,347 +1,205 @@
---
version: alpha
name: Reactive Resume
description: A monochrome, content-first design system for a free and open-source resume builder. Dark-by-default with light mode support.
version: 6.0.0
name: Reactive Resume · Desk & Paper
description: A quiet, warm desk around a bright page. The resume is the only white, detailed object on screen; one moss-green accent marks the next action. Light and dark themes, with the page always white.
colors:
primary: "#343434"
primary-foreground: "#FBFBFB"
secondary: "#F7F7F7"
secondary-foreground: "#343434"
background: "#FFFFFF"
foreground: "#252525"
muted: "#F7F7F7"
muted-foreground: "#8E8E8E"
card: "#FFFFFF"
card-foreground: "#252525"
border: "#EBEBEB"
input: "#EBEBEB"
ring: "#B5B5B5"
destructive: "#DC2626"
on-destructive: "#FFFFFF"
light:
bg: "#F8F7F3"
surface: "#FEFDFC"
raised: "#FFFFFF"
sunken: "#F0EFEB"
line: "#DFDEDA"
line-2: "#C5C4BE"
ink: "#1C1B15"
ink-2: "#4F4D47"
ink-3: "#6D6C65"
accent: "#337344"
accent-hover: "#206133"
on-accent: "#F7FEF8"
accent-soft: "#DCF2DF"
accent-text: "#195C2E"
danger: "#BA3630"
danger-soft: "#FFE7E4"
danger-text: "#A92321"
warn: "#D29922"
warn-soft: "#FCEDCD"
warn-text: "#81520A"
info-soft: "#E0F1FF"
info-text: "#1D5B92"
dark:
bg: "#100F0C"
surface: "#171613"
raised: "#1F1E1A"
sunken: "#0B0A08"
line: "#2C2B27"
line-2: "#494843"
ink: "#EFEEEB"
ink-2: "#BCBAB5"
ink-3: "#979590"
accent: "#6FC082"
accent-hover: "#83D494"
on-accent: "#07150A"
accent-soft: "#1A3520"
accent-text: "#8FD89E"
danger: "#D9544B"
danger-soft: "#47211D"
danger-text: "#FDA297"
warn: "#E4B750"
warn-soft: "#3E2D10"
warn-text: "#EFCC83"
info-soft: "#192F46"
info-text: "#9DC9F7"
paper: "#FFFFFF"
stages:
saved: "#908C7F"
applied: "#5590CC"
screening: "#00A0A6"
interview: "#AF8433"
offer: "#579F68"
closed: "#C67067"
typography:
heading:
fontFamily: IBM Plex Sans Variable
fontSize: 1rem
fontWeight: 500
body:
fontFamily: IBM Plex Sans Variable
fontSize: 0.875rem
fontWeight: 400
body-sm:
fontFamily: IBM Plex Sans Variable
fontSize: 0.75rem
fontWeight: 400
label:
fontFamily: IBM Plex Sans Variable
fontSize: 0.8rem
fontWeight: 500
hero-heading:
fontFamily: IBM Plex Sans Variable
fontSize: 3.75rem
fontWeight: 700
letterSpacing: -0.025em
display: { fontFamily: Newsreader, fontSize: 44px, lineHeight: 48px, fontWeight: 500, letterSpacing: -0.01em }
title: { fontFamily: Newsreader, fontSize: 30px, lineHeight: 36px, fontWeight: 500 }
sheet-title: { fontFamily: Newsreader, fontSize: 22px, lineHeight: 28px, fontWeight: 500 }
heading: { fontFamily: Hanken Grotesk, fontSize: 20px, lineHeight: 28px, fontWeight: 600 }
section-heading: { fontFamily: Hanken Grotesk, fontSize: 17px, lineHeight: 24px, fontWeight: 600 }
label: { fontFamily: Hanken Grotesk, fontSize: 15px, lineHeight: 22px, fontWeight: 600 }
body: { fontFamily: Hanken Grotesk, fontSize: 15px, lineHeight: 24px, fontWeight: 400 }
ui: { fontFamily: Hanken Grotesk, fontSize: 14px, lineHeight: 20px, fontWeight: 400 }
small: { fontFamily: Hanken Grotesk, fontSize: 13px, lineHeight: 18px, fontWeight: 400 }
caption: { fontFamily: Hanken Grotesk, fontSize: 12px, lineHeight: 16px, fontWeight: 500 }
mono: { fontFamily: JetBrains Mono, fontSize: 12px, lineHeight: 16px, fontWeight: 500 }
rounded:
sm: 0.18rem
md: 0.24rem
lg: 0.3rem
xl: 0.42rem
2xl: 0.54rem
3xl: 0.66rem
4xl: 0.78rem
spacing:
xs: 4px
sm: 8px
md: 16px
lg: 24px
xl: 32px
2xl: 48px
components:
button-default:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
button-outline:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
button-secondary:
backgroundColor: "{colors.secondary}"
textColor: "{colors.secondary-foreground}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
button-ghost:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
button-destructive:
backgroundColor: "{colors.destructive}"
textColor: "{colors.on-destructive}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
card:
backgroundColor: "{colors.card}"
textColor: "{colors.card-foreground}"
rounded: "{rounded.lg}"
padding: 16px
input:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.lg}"
height: 36px
padding: 10px
input-focus:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.lg}"
height: 36px
padding: 10px
badge:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
rounded: "{rounded.md}"
padding: 4px
popover:
backgroundColor: "{colors.card}"
textColor: "{colors.card-foreground}"
rounded: "{rounded.xl}"
padding: 4px
sidebar:
backgroundColor: "{colors.muted}"
textColor: "{colors.foreground}"
padding: 8px
sidebar-item:
backgroundColor: "{colors.muted}"
textColor: "{colors.muted-foreground}"
rounded: "{rounded.lg}"
padding: 8px
sidebar-item-active:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
rounded: "{rounded.lg}"
padding: 8px
tooltip:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
rounded: "{rounded.md}"
padding: 6px
separator:
backgroundColor: "{colors.border}"
height: 1px
dialog:
backgroundColor: "{colors.card}"
textColor: "{colors.card-foreground}"
rounded: "{rounded.xl}"
padding: 24px
input-invalid:
backgroundColor: "{colors.background}"
textColor: "{colors.destructive}"
rounded: "{rounded.lg}"
height: 36px
padding: 10px
sm: 6px
md: 8px
lg: 10px
xl: 12px
2xl: 16px
3xl: 18px
full: 999px
spacing: [4, 8, 12, 16, 24, 32, 48, 64]
motion:
quick: 120ms
standard: 200ms
emphasized: 320ms
easing: cubic-bezier(0.2, 0.8, 0.2, 1)
exit: 70% of the entering duration
---
## Overview
Reactive Resume is a monochrome, content-first design system built for a resume builder used by tens of thousands of people worldwide. The visual identity prioritizes readability and unobtrusiveness — the user's resume content is always the hero, never the chrome around it.
Reactive Resume's interface is a quiet, warm desk around a bright page. The resume or letter page is the only white, detailed object on screen; everything around it uses low-contrast warm neutrals, thin rules instead of boxes, and a single moss-green accent.
The system defaults to dark mode with a warm near-black backdrop that makes the resume preview "float" as the visual anchor. Light mode is supported as a full alternative. The authenticated app shell (dashboard, builder, settings) uses an entirely achromatic grayscale palette — the sole chromatic exception is destructive red for dangerous actions. The landing page introduces subtle chromatic accents: blue-tinted spotlight gradients on the hero, a multicolor text-mask animation on hover, and social auth provider brand colors (Google blue, LinkedIn blue) on the login page.
The full specification lives in the redesign handoff (`design_handoff_reactive_resume_redesign/README.md`, kept out of version control) and the milestone plan in `REDESIGN_PLAN.md`. This document records the rules the code follows.
The overall aesthetic is a professional tool UI: clean grid lines, subtle borders, generous whitespace, and typography that steps back to let the content shine. Think "VS Code meets Figma" — a productivity workspace, not a marketing site.
Five principles decide most questions:
One deliberate counterpoint to the serious UI: all resume templates are named after Pokemon (Azurill, Bronzor, Chikorita, Ditgar, Gengar, Pikachu, etc.). This is an intentional brand choice — playful naming for templates injects personality into an otherwise utilitarian interface, making templates feel collectible and memorable rather than generic ("Template 1", "Modern", "Classic").
1. **The page is the interface.** The live page is on screen in every editor mode. Clicking a line on the page opens its field.
2. **One obvious next step.** Each view has at most one accent-filled button. Accent means "do this next" or "this is working" and is never decoration.
3. **Nothing is lost.** Everything autosaves and can be undone. Confirmation dialogs are only for irreversible actions.
4. **Detail on demand.** Defaults cover most people; advanced controls sit one disclosure deeper.
5. **AI proposes, you decide.** The assistant never writes directly; every change is a reviewable proposal.
## Colors
Resume templates keep their Pokémon names (Azurill, Onyx, Glalie…), their own fonts and their own colors. None of the rules below apply inside a template.
The palette is rooted in achromatic OKLch values (chroma = 0), producing a pure grayscale scale without warm or cool casts. Colors are defined as CSS custom properties using `oklch()` and consumed through Tailwind CSS 4 theme tokens. Always prefer CSS variables (e.g., `var(--primary)`) or Tailwind tokens (e.g., `bg-primary`) over raw color values. The hex values in this document's YAML front matter are agent-friendly approximations of the canonical OKLch definitions in `packages/ui/src/styles/globals.css` — use hex only where OKLch is unavailable.
## Tokens
- **Primary (#343434 light / #EBEBEB dark):** Used for high-emphasis interactive surfaces — default buttons, selected states, and text selection. In dark mode this inverts to near-white so buttons remain prominent.
- **Foreground (#252525 light / #FBFBFB dark):** Body text and headings. High contrast against the background in both themes.
- **Background (#FFFFFF light / #252525 dark):** The canvas. Pure white in light mode, warm near-black in dark mode.
- **Card (#FFFFFF light / #343434 dark):** Elevated surface for cards, panels, and the builder sidebar. In dark mode, one step lighter than the background to create subtle depth.
- **Muted (#F7F7F7 light / #454545 dark):** De-emphasized backgrounds for secondary UI regions, hover states, and inactive tabs.
- **Muted Foreground (#8E8E8E light / #B5B5B5 dark):** Captions, helper text, timestamps, and metadata. Deliberately low-contrast against the background to recede visually.
- **Border (#EBEBEB light / white at 10% opacity dark):** Thin separator lines. In dark mode, uses transparent white rather than a solid gray to blend naturally with any underlying surface color.
- **Input (#EBEBEB light / white at 15% opacity dark):** Form field borders, slightly more prominent than general borders to make input areas discoverable.
- **Destructive (#DC2626 light / #EF4444 dark):** The only chromatic color in the palette. Reserved exclusively for delete actions, error states, and danger-zone operations. Used at 10% opacity as a background tint with full saturation for text, creating a soft but unmistakable warning.
- **Ring (#B5B5B5 light / #8E8E8E dark):** Focus ring indicator at 50% opacity, surrounding focused interactive elements.
- **Sidebar Primary (dark only, #6366F1):** An indigo value inherited from the shadcn/ui defaults. Not actively used in the current UI — sidebar active states use the standard grayscale primary token instead. Retained in the CSS custom properties for potential future customization.
Tokens are CSS custom properties in `packages/ui/src/styles/globals.css`, light on `:root` and dark on `.dark`. The source of truth is oklch; the hex values above are sRGB approximations. Tailwind exposes each one under the same name: `bg-bg`, `bg-surface`, `bg-raised`, `bg-sunken`, `border-line`, `border-line-2`, `text-ink`, `text-ink-2`, `text-ink-3`, `bg-accent`, `text-on-accent`, `bg-accent-soft`, `text-accent-text`, `bg-danger`, `bg-danger-soft`, `text-danger-text`, `bg-warn`, `bg-warn-soft`, `text-warn-text`, `bg-info-soft`, `text-info-text`, `bg-hover`, `bg-press`, `bg-scrim`, `bg-paper` and `bg-stage-*`.
Resume templates have their own independent color system — users pick primary, text, and background colors per resume through a color picker in the builder's Design panel. These template colors are completely separate from the app shell palette.
- **Surfaces:** `bg` is the app desk, `surface` holds panels and cards, `raised` holds menus, dialogs and inputs, `sunken` is for wells, tracks and the page canvas.
- **Text:** `ink` for primary text, `ink-2` for secondary text, `ink-3` for meta and placeholders. `ink-3` is the lightest color allowed for text (about 4.9:1).
- **Signals:** `danger` for errors and irreversible actions, `warn` for check issues and things to review, `info` for neutral guidance and the assistant's questions. Success uses `accent-soft`.
- **Overlays:** `hover` and `press` are translucent, so they work on any surface.
- **Paper:** `--paper` is white in both themes. Pages never invert.
- **Stages:** application stage colors share lightness and chroma. They appear only as 8px dots or 6px stepper bars, always next to the stage name.
The previous shadcn-style names (`background`, `foreground`, `primary`, `muted`, `border`, `input`, `ring`, `destructive`, `card`, `popover`, `sidebar-*`) still resolve to these tokens so screens that haven't been rebuilt stay legible. Don't use them in new code; they're removed once every screen has moved.
## Typography
The entire application uses a single typeface: **IBM Plex Sans Variable**. This is a humanist sans-serif with an extensive weight range (100–900) and excellent readability at small sizes, both on screen and in PDFs.
- **Newsreader** (display serif, optical sizes 6–72) is only for page titles, dialog and sheet titles, empty-state headlines and large stat numerals. Use `font-display`.
- **Hanken Grotesk** handles everything functional. It's the default `font-sans`.
- **JetBrains Mono** is for shortcuts, URLs and slugs, file names, counts and section eyebrows. Use `font-mono`.
- Field labels are 12px, medium weight, `ink-2`, above the control with a 5–6px gap. Uppercase group eyebrows are 12px semibold `ink-3` with 0.02em tracking.
- Inputs render at 16px on touch devices so iOS doesn't zoom.
- All three fonts are self-hosted through `@fontsource-variable`.
- **Hero heading (responsive: 2.25rem mobile / 3rem tablet / 3.75rem desktop, weight 700, tracking-tight):** Landing page headline only. Large, bold, and commanding. Scales across three breakpoints.
- **Section heading (1rem / 16px, weight 500):** Used for section titles in the builder sidebar, settings panels, and dashboard cards. Medium weight provides hierarchy without shouting.
- **Body (0.875rem / 14px, weight 400):** The workhorse. All form labels, descriptions, card content, and general UI text.
- **Small body (0.75rem / 12px, weight 400):** Captions, helper text, timestamps, and metadata.
- **Label (0.8rem / ~13px, weight 500):** Button text, badge labels, and form field labels. Slightly heavier than body to denote interactivity.
## Iconography
The resume content itself uses a separate font system — users choose from 1,000+ Google Fonts for their resume headings and body text, with category-aware fallback stacks including CJK support (Noto Sans SC, PingFang SC, Hiragino Sans GB for sans-serif; Noto Serif SC, Songti SC for serif). Standard PDF fonts (Helvetica, Courier, Times-Roman) are available as offline fallbacks.
App icons are **Material Symbols Rounded** at weight 300, rendered by `Icon` from `@reactive-resume/ui/components/icon`. The font is a self-hosted subset that contains only the glyphs listed in `packages/ui/src/icons/names.ts`:
Font rendering uses `antialiased` (grayscale AA) and `proportional-nums` across the board for clean rendering and properly spaced numerals in dates and phone numbers.
1. Add the name to that list (TypeScript then accepts it in `<Icon name="…" />`).
2. Run `pnpm icons:build`. The script checks every name against the published codepoints, downloads the subset and updates the manifest. A unit test fails if the manifest and the list disagree.
## Layout
Rules:
### Builder (Three-Panel Workspace)
- 20px on desktop, 24px on touch. Outline by default; `filled` only for the selected navigation item.
- Icons always sit beside a text label, except back, close, more, undo/redo, history, assistant and zoom. Those use `IconButton`, which requires a label and shows it in a tooltip with the shortcut.
- `Icon` is `aria-hidden` and `translate="no"`, so the ligature text never becomes an accessible name.
- Directional icons (arrows, chevrons, undo, redo) mirror in right-to-left layouts automatically.
- Icons inside resumes are a separate system: Phosphor, because resume data stores Phosphor names and the PDF renderer draws them.
The core builder uses a resizable three-panel layout powered by `react-resizable-panels`:
## Space, shape and elevation
- **Left sidebar (default 22%):** Resume section forms — personal info, experience, education, skills, and custom sections. Scrollable with collapsible section groups.
- **Center artboard (default 56%):** Live resume preview rendered via PDF.js canvas. Supports zoom, pan, and pinch gestures via `react-zoom-pan-pinch`. The preview maintains A4 aspect ratio (210:297) with a subtle shadow to simulate a physical page.
- **Right sidebar (default 22%):** Design controls — template picker, font selection, color picker, layout manager (page assignments, section ordering via drag-and-drop).
- **Spacing** follows a 4pt scale: 4, 8, 12, 16, 24, 32, 48, 64. Cards use 16px padding, panels 16–24px, the mobile margin is 16px and the desktop page margin 32–40px.
- **Radius:** `rounded-sm` 6px for chips and small buttons, `rounded-md` 8px for controls and inputs, `rounded-lg` 10px for list items, `rounded-xl` 12px for cards and menus, `rounded-2xl` 16px for dialogs, `rounded-3xl` 18px for mobile sheets, `rounded-full` for pills.
- **Elevation:** `shadow-e1` for cards, `shadow-e2` for menus, popovers and hover-lifted cards, `shadow-e3` for dialogs, sheets and toasts, `shadow-page` for the resume page on the canvas.
- **Control heights:** 28px small, 36px default, 44px touch. Icon buttons are 32–36px on desktop and 44px on touch.
- **Layout constants** are CSS variables: `--editor-bar` 56px, `--editor-panel` 400px, `--app-sidebar` 240px, `--sheet-share` 440px, `--sheet-detail` 480px, `--assistant` 400px.
Panel sizes persist in cookies. On mobile (< 768px), sidebars collapse to 0% width and become toggleable overlays (max 95% width when open). The desktop minimum collapsed width is 48px (icon rail).
## Motion
### Dashboard
| Token | Duration | Use |
|---|---|---|
| `duration-quick` | 120ms | hover, press, toggle, checkbox, focus |
| `duration-standard` | 200ms | menus, popovers, expand and collapse, mode switch, dialogs |
| `duration-emphasized` | 320ms | side and bottom sheets, toasts, the assistant column |
Standard sidebar navigation layout using the `Sidebar` component system. The sidebar contains: logo, resume list link, agent link, settings subnavigation (profile, preferences, authentication, API keys, integrations, danger zone), and a footer with user avatar. Content area shows a responsive grid of resume cards.
### Landing Page
Full-width single-column marketing layout:
1. **Floating builder preview** — A non-interactive screenshot of the builder as a hero visual, creating an immediate "this is what you get" impression.
2. **Hero** — Centered headline, subheadline, and two CTAs (primary "Get Started" with arrow, ghost "Learn More" with icon).
3. **Features grid** — 4-column responsive grid with icon + title + description cards, separated by thin border lines.
4. **Template carousel** — Horizontally scrolling row of template preview thumbnails with Pokemon-themed names.
5. **Testimonials** — Tiled user quotes in a masonry-style grid.
6. **Support / FAQ / Footer** — Accordion FAQ, community section, and a 4-column footer with logo, resource links, community links, and license info.
### Responsive Breakpoints
Mobile detection uses a 768px threshold via `MediaQueryList`. The layout is optimized for workspace productivity on larger screens, with responsive mobile support that adapts the multi-panel builder into a streamlined single-panel experience. Both desktop and mobile are supported experiences — the builder's three-panel layout leverages desktop space, while mobile surfaces the same editing capabilities through collapsible overlays.
### Page Aspect Ratio
A custom Tailwind token `--aspect-page: 210 / 297` enforces A4 paper proportions wherever resume pages are rendered (builder preview, public view, PDF export).
## Animation
Animations use the Motion library (formerly Framer Motion) and follow a consistent choreography pattern:
**Entrance animations** use a fade-up reveal: elements start at `opacity: 0, y: 20-100` and animate to `opacity: 1, y: 0`. The hero section uses a larger y-offset (100px) for dramatic effect; subsequent sections use 20px for subtlety.
**Timing principles:**
- **Base duration:** 0.35s–0.6s for standard section reveals, 0.45s for hero elements, up to 1.1s for the hero video entrance.
- **Stagger pattern:** Sequential delays within a group, typically 0.1s–0.15s apart (hero: 0.55s, 0.7s, 0.82s, 0.95s). For grids, use `index * 0.03`–`0.1` for per-item stagger.
- **Easing:** `easeOut` for entrances (elements decelerate into position). `easeInOut` for looping/ambient animations.
- **Performance:** Apply `will-change-[transform,opacity]` on animated elements and `will-change-transform` on continuously animated elements.
**Hover/interaction animations** are quick (0.2s) and subtle — small scale bumps (`scale: 1.01`), slight y-offsets (`y: -2`), and `active:translate-y-px` for button press.
**Ambient animations** loop infinitely with `easeInOut` — the scroll indicator bounces gently (`y: [0, 5, 0]` over 1.5s).
**Reduced motion:** All CSS transitions and animations collapse to `0.01ms` duration and single iteration when `prefers-reduced-motion: reduce` is active. Motion library animations should also respect this preference.
## Elevation & Depth
Elevation is handled through background color layering rather than drop shadows:
- **Level 0 — Background:** The base canvas (`--background`).
- **Level 1 — Card:** One step lighter in dark mode (`--card`), used for sidebars, panels, and cards.
- **Level 2 — Popover:** Same as card, but appears above the content layer in popovers, dropdowns, and command palette.
- **Level 3 — Overlay:** Backdrop blur (`backdrop-blur-xs` at 0.5px or `backdrop-blur-2xl` at 40px) with `backdrop-saturate-150` for modal overlays, creating a frosted-glass effect over the workspace.
The resume preview page uses a subtle drop shadow to simulate a physical sheet of paper floating above the dark artboard — one of the few places actual shadows appear.
## Shapes
Border radius follows a multiplicative scale from a single `--radius` base of `0.3rem`:
| Token | Value | Usage |
|:------|:------|:------|
| `sm` | 0.18rem (≈3px) | Small badges, inline chips |
| `md` | 0.24rem (≈4px) | XS/SM buttons, compact elements |
| `lg` | 0.3rem (≈5px) | Default buttons, cards, inputs |
| `xl` | 0.42rem (≈7px) | Larger cards, modal corners |
| `2xl` | 0.54rem (≈9px) | Dialog containers |
| `3xl` | 0.66rem (≈11px) | Large panels |
| `4xl` | 0.78rem (≈12px) | Full-page modals |
The radius scale is deliberately tight — the largest value (0.78rem) is still quite subtle. This avoids the "rounded everything" aesthetic and keeps the UI feeling precise and tool-like. Interactive elements consistently use `rounded-lg` as the default.
- Everything that enters uses `ease-enter` (`cubic-bezier(0.2, 0.8, 0.2, 1)`). Exits run at 70% of the duration.
- Motion explains where something went. Nothing loops, bounces or plays on load; loading placeholders stay still. Reflowing the page after an edit is never animated.
- With `prefers-reduced-motion`, the duration tokens become 1ms and every CSS transition collapses; spinners keep turning because they're status.
- Motion (`motion/react`) animations run under `MotionConfig reducedMotion="user"`; mirror the tokens in `apps/web/src/libs/motion.ts` when one needs them.
## Components
### Buttons
Generic primitives live in `packages/ui/src/components` and wrap Base UI (and cmdk for the command bar). Feature-specific UI lives with its feature in `apps/web`.
Six variants, all sharing `rounded-lg` corners, `font-medium`, `text-sm`, and a 1px `translate-y` on active press (except when the button opens a popup):
- **Buttons:** `primary` (accent fill, the one filled button per view), `secondary` (bordered surface), `ghost`, `danger`, `link`. Sizes `sm` 28, `default` 36, `lg` 44 (touch), plus icon sizes. `loading` shows a spinner, sets `aria-busy` and blocks activation; pair it with a present-participle label ("Preparing…").
- **Inputs:** 36px (44px on touch), `raised` background, `line-2` border. Focus is an accent border plus a 3px `accent-soft` ring. Errors appear after the first blur, in `danger-text`, with an icon and words that say how to fix it.
- **Switches:** prefer `SwitchRow`, where the whole row is the switch. Checkboxes are 18px with a 5px radius; radios are 18px with an 8px accent dot.
- **Segmented controls:** `SegmentedControl` for 2–4 options (a radio group); `Tabs` with the default variant when segments switch panels, `Tabs variant="line"` for underline tabs.
- **Menus** (dropdown, context, combobox lists): 220px minimum width, 12px radius, 36px items, destructive items last after a separator.
- **Layers, lightest to heaviest:** menu, popover, sheet, dialog. Sheets are for tasks beside the page and become bottom sheets on mobile. Dialogs are for decisions; destructive confirmations use `AlertDialog` and the cancel label says what is kept.
- **Toasts:** one at a time, bottom center, ink on the desk color, 6 seconds, with an optional underlined Undo action.
- **Alerts:** `info`, `success`, `warn` and `error`; only errors are announced (`role="alert"`).
- **Empty states:** a Newsreader 22px headline, a 14px body up to 300px wide, then a primary and a secondary action.
- **Default:** Solid primary background. The highest-emphasis action on any screen.
- **Outline:** Transparent with a border. For secondary actions that need clear boundaries.
- **Secondary:** Muted background. For paired actions alongside a primary button.
- **Ghost:** No background or border. For toolbar actions and inline controls where chrome would be noise.
- **Destructive:** Red at 10% opacity background with red text. Visually alarming without being garish.
- **Link:** Underline-on-hover text. For inline navigation within prose.
## Accessibility
Size scale: `xs` (28px), `sm` (32px), `default` (36px), `lg` (40px), plus `icon` variants at each size for square icon-only buttons.
WCAG 2.2 AA is the floor.
### Cards
- Every interactive element shows a 2px accent focus ring with a 2px gap on `:focus-visible`. Never remove it; inputs replace it with their accent border and soft ring.
- Pointer targets are at least 24px and touch targets at least 44px. Every drag has a keyboard and a menu alternative.
- Color is never the only signal: stages, issues and states always pair color with text and an icon.
- Sheets and dialogs trap focus; Esc closes the top layer and returns focus to its trigger. Save state and toasts announce through a polite live region.
White/dark surface with foreground text. Composed of `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`, and `CardAction` slots. Default vertical padding is `py-4` (compact: `py-3`).
## Themes
### Forms
Built on TanStack Form with Zod validation. Composed of `FormItem`, `FormLabel`, `FormControl`, `FormMessage`, and `FormDescription`. Validation errors only appear after field touch. Invalid fields get a red destructive border with a ring.
### Dialogs
Centralized dialog manager with 40+ dialog types, all rendered via pattern matching (`ts-pattern`). Dialogs support before-close validation, form blocking for unsaved changes, and confirmation prompts. Used for all CRUD operations on resume sections, settings changes, and import/export flows.
### Command Palette
Triggered by `Cmd+K` / `Ctrl+K`. Built on `cmdk` with fuzzy search via `Fuse.js`. Multi-page navigation (resumes, settings, preferences) with back navigation via Backspace. Screen-reader accessible with `sr-only` headings.
### Toast Notifications
Powered by Sonner, positioned bottom-right with rich colors. Used for auto-save feedback, form submission status, error reporting, and donation prompts. Loading toasts are used during async operations (PDF generation, resume creation) with dismiss-on-complete.
### Drag and Drop
Powered by `@dnd-kit` with `PointerSensor` and `KeyboardSensor`. Used in chip inputs (skill tags, URL lists) and page layout management (section ordering across resume pages). Smooth animations via Motion library.
The theme cookie holds `light`, `dark` or `system` (the default); `system` follows `prefers-color-scheme` live. `ThemeProvider` owns the `.dark` class on `<html>`, and an inline script in `index.html` sets it before first paint. Resumes and letters always render on white paper, whatever the theme.
## Internationalization
The app supports 40+ locales including RTL languages (Arabic, Hebrew, Persian, Urdu, Uyghur, Yiddish). i18n is not an afterthought — it shapes layout decisions:
- Every user-facing string goes through Lingui (`t`, `msg`, `<Trans>`); catalogs are PO files in `apps/web/locales`. Primitives in `packages/ui` can't use Lingui, so they take labels as props (for example `closeLabel`).
- 55 interface languages, including right-to-left ones. `<html dir>` follows the locale and `DirectionProvider` passes it to Base UI.
- Use logical properties (`ps`, `pe`, `ms`, `me`, `inset-s`, `inset-e`) instead of physical ones.
- Translations run 30–50% longer than English; avoid fixed widths on text.
**Direction:** The `<html>` element receives `dir="rtl"` or `dir="ltr"` based on the active locale, detected via `isRTL()` which checks the language prefix against a known RTL set. All layout mirroring flows from this single attribute.
## Do and don't
**Logical properties:** Use CSS logical properties (`ps-`, `pe-`, `ms-`, `me-`, `inline-start`, `inline-end`, `inset-s-`, `inset-e-`) instead of physical (`pl-`, `pr-`, `ml-`, `mr-`, `left`, `right`). Button components already use `has-data-[icon=inline-start]:ps-2` and `has-data-[icon=inline-end]:pe-2` patterns. This ensures correct spacing in both LTR and RTL layouts without separate stylesheets.
**Variable-length text:** Translations can be 30–50% longer than English (German, Finnish) or significantly shorter (CJK). UI elements should accommodate variable text length — avoid fixed widths on buttons and labels. Use `whitespace-nowrap` only where truncation is acceptable, and prefer `min-w-0` with `truncate` over fixed-width containers.
**Icons:** Directional icons (arrows, chevrons, progress indicators) should mirror in RTL contexts. Phosphor Icons provides mirrored variants for directional icons. Non-directional icons (settings gear, checkmark, delete) do not mirror.
**Strings:** All user-facing strings use Lingui macros (`t`, `msg`, `<Trans>`) — never hardcode English text in components. Translation files are `.po` format under `/locale/`.
## Do's and Don'ts
### Do
- **Use the grayscale palette for all app chrome.** The absence of color is the brand. The resume content is the only thing that should be colorful.
- **Default to dark mode.** The dark workspace makes resume previews pop and reduces eye strain during extended editing sessions.
- **Use `text-sm` (14px) as the base text size.** The UI is information-dense — form fields, section labels, metadata — and needs to be scannable without feeling cramped.
- **Keep border radius tight.** Use `rounded-lg` (0.3rem) as the default. The tool should feel precise, not playful.
- **Respect reduced motion preferences.** All animations collapse to 0.01ms when `prefers-reduced-motion: reduce` is active.
- **Use Phosphor Icons consistently.** Regular weight, `size-4` (16px) default. Icons should be functional labels, not decorative.
- **Maintain the three-panel builder proportions.** The center artboard should always dominate. Sidebars are support panels, not equal peers.
- **Use transparent-white borders in dark mode.** `oklch(1 0 0 / 10%)` blends naturally with any surface rather than introducing a distinct gray band.
### Don't
- **Don't introduce accent colors into the app shell.** No blues, greens, or purples for primary actions. The only chromatic color is destructive red. The inherited indigo sidebar-primary token exists in CSS custom properties but is not actively used.
- **Don't use drop shadows for elevation.** Rely on background color layering and border separation. The one exception is the resume page preview shadow.
- **Don't make the UI compete with the resume content.** If a new feature draws more visual attention than the resume preview, it needs to be toned down.
- **Don't use large border radii.** Nothing above `rounded-xl` on standard components. Large pills and full-round shapes conflict with the precision-tool aesthetic.
- **Don't hardcode colors outside the token system.** All colors flow through CSS custom properties so that dark/light mode switching works automatically.
- **Don't use multiple typefaces in the app shell.** IBM Plex Sans Variable is the only UI font. Resume templates have their own font system, but the chrome stays single-family.
- **Don't skip the `data-slot` attribute on components.** It's used for styling hooks and accessibility selectors throughout the component library.
- **Don't forget RTL.** The app supports 40+ locales including Arabic, Hebrew, Persian, and Urdu. Use logical properties (`ps`, `pe`, `ms`, `me`) instead of physical (`pl`, `pr`, `ml`, `mr`).
- **Do** keep one accent-filled button per view, and keep accent for next actions and working states.
- **Do** use `ink-3` as the lightest text color, and pair every color signal with text.
- **Do** build states in full: empty, loading (placeholders at their real size), error (why and what to do) and success.
- **Don't** hard-code colors, including Tailwind palette classes such as `amber-600`; use the semantic tokens.
- **Don't** use Newsreader for anything smaller than a sheet title.
- **Don't** ask for confirmation for something that can be undone; use an undo toast instead.
- **Don't** skip `data-slot` on primitives; tests and styles rely on it.
+4 -2
View File
@@ -1,5 +1,6 @@
# syntax=docker/dockerfile:1.7
# Base image only; pnpm self-manages to the `packageManager` version in package.json.
ARG PNPM_VERSION=11.21.0
ARG NODE_VERSION=24
@@ -25,7 +26,7 @@ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
pnpm install --frozen-lockfile
COPY --from=pruner /app/out/full/ ./
RUN rm -rf apps/web/dist apps/server/dist && pnpm turbo run build --filter=web --filter=server --force
RUN rm -rf apps/web/dist apps/web/dist-prerender apps/server/dist && pnpm turbo run build --filter=web --filter=server --force
FROM base AS runtime-pruner
COPY . .
@@ -47,7 +48,7 @@ LABEL org.opencontainers.image.description="A free and open-source resume builde
LABEL org.opencontainers.image.vendor="Amruth Pillai"
LABEL org.opencontainers.image.url="https://rxresu.me"
LABEL org.opencontainers.image.documentation="https://docs.rxresu.me"
LABEL org.opencontainers.image.source="https://github.com/amruthpillai/reactive-resume"
LABEL org.opencontainers.image.source="https://github.com/reactive-resume/reactive-resume"
ENV NODE_ENV="production" \
PORT=3000 \
@@ -62,6 +63,7 @@ COPY --from=pruner --chown=node:node /app/package.json /app/pnpm-lock.yaml /app/
COPY --from=runtime-deps --chown=node:node /app/apps/server/package.json ./apps/server/package.json
COPY --from=runtime-deps --chown=node:node /app/apps/server/node_modules ./apps/server/node_modules
COPY --from=builder --chown=node:node /app/apps/web/dist ./apps/web/dist
COPY --from=builder --chown=node:node /app/apps/web/dist-prerender ./apps/web/dist-prerender
COPY --from=builder --chown=node:node /app/apps/server/dist ./apps/server/dist
COPY --from=pruner --chown=node:node /app/migrations ./migrations
+38
View File
@@ -21,6 +21,10 @@ the catalog already uses one consistently):
Reactive Resume, GitHub, Crowdin, Docker, PostgreSQL, Better Auth, TanStack, Microsoft Word,
PDF, DOCX, JSON, CSV, API, MCP, oRPC, SSO, CSS, URL, JSON Resume.
AI provider names are brand names and stay in English: OpenAI, Anthropic Claude, Google
Gemini, Vercel AI Gateway, OpenRouter, Mistral AI, Cohere, xAI Grok, Groq, DeepSeek, Together.ai,
Fireworks, Cerebras, Perplexity, Ollama Cloud.
Template names are proper nouns and are never translated: Azurill, Bronzor, Chikorita, Ditgar,
Ditto, Gengar, Glalie, Kakuna, Lapras, Leafish, Meowth, Onyx, Pikachu, Rhyhorn, Scizor.
@@ -206,6 +210,40 @@ section, above.
**Public URL** — the shareable address of a published resume. Use one term consistently; the
English strings say "public URL" rather than "public link".
## Redesigned workspace
These terms arrive with the redesigned interface (see `DESIGN.md`).
**Documents** — the library that holds resumes and cover letters together. A plural noun, not the
verb "to document".
**Trash** — where deleted documents wait 30 days before they're removed for good. A place (noun),
like a recycle bin. Not the verb "to trash".
**Write · Design · Check** — the three modes of the editor, shown side by side as a switch. Each is
the name of a mode, so translate them as short, parallel labels. **Write** is editing the content,
**Design** is choosing how the resume looks (a noun here), and **Check** is reviewing whether
software can read it (a noun here, like "review"), not a bank cheque or a checkmark.
**Share & export** — the sheet with the public link, downloads and version history.
**Assistant** — the AI panel beside the page. It replaces both the "AI agent" page and the "AI
assistant" sheet, so there is now only one AI term.
**Proposed edit** — a change the assistant or Check suggests but hasn't made. It becomes part of the
resume only when the person accepts it. **Accept** and **Reject** are imperative verbs on buttons;
**Out of date** means the line was edited by hand after the suggestion was made.
**Version** — a saved state of a document in its history, which can be previewed and restored. Not
a software release.
**Next step** — the next thing to do for a job application, such as an interview or a follow-up.
**Closed** — the final stage of an application, whatever the outcome (not selected, withdrawn,
another offer accepted, no response). Not "shut" or "locked".
**System** — in Appearance, the option that follows the operating system's light or dark setting.
## Verbs that read as adjectives or nouns
Button labels and `aria-label` strings are usually **imperative verbs**: they say what the
+29 -15
View File
@@ -1,3 +1,9 @@
> [!IMPORTANT]
> **Repository moved:** Reactive Resume now lives at **[`reactive-resume/reactive-resume`](https://github.com/reactive-resume/reactive-resume)** on GitHub.
> **Docker Hub stays at `amruthpillai/reactive-resume`.** GHCR builds now publish to `ghcr.io/reactive-resume/reactive-resume`.
> Verified image tags: `latest`, `v5`, `v5.3`, and `v5.3.0` (AMD64 and ARM64). The current version was rebuilt and production redeployed for this rename; no new GitHub release or version bump was made. See [migration details](https://github.com/reactive-resume/reactive-resume/issues/3503).
> GitHub Sponsors and Open Collective funding links remain unchanged.
<div align="center">
<a href="https://rxresu.me">
<img src="apps/web/public/opengraph/banner.jpg" alt="Reactive Resume" />
@@ -14,9 +20,9 @@
</p>
<p>
<img src="https://img.shields.io/github/package-json/v/amruthpillai/reactive-resume?style=flat-square" alt="Reactive Resume Version">
<img src="https://img.shields.io/github/stars/amruthpillai/Reactive-Resume?style=flat-square" alt="GitHub Stars">
<img src="https://img.shields.io/github/license/amruthpillai/Reactive-Resume?style=flat-square" alt="License" />
<img src="https://img.shields.io/github/package-json/v/reactive-resume/reactive-resume?style=flat-square" alt="Reactive Resume Version">
<img src="https://img.shields.io/github/stars/reactive-resume/reactive-resume?style=flat-square" alt="GitHub Stars">
<img src="https://img.shields.io/github/license/reactive-resume/reactive-resume?style=flat-square" alt="License" />
<img src="https://img.shields.io/docker/pulls/amruthpillai/reactive-resume?style=flat-square" alt="Docker Pulls" />
<a href="https://discord.gg/aSyA5ZSxpb"><img src="https://img.shields.io/discord/1173518977851473940?style=flat-square&label=discord" alt="Discord" /></a>
<a href="https://crowdin.com/project/reactive-resume"><img src="https://badges.crowdin.net/reactive-resume/localized.svg?style=flat-square" alt="Crowdin" /></a>
@@ -143,7 +149,7 @@ The quickest way to run Reactive Resume locally:
```bash
# Clone the repository
git clone --depth=1 https://github.com/amruthpillai/reactive-resume.git
git clone --depth=1 https://github.com/reactive-resume/reactive-resume.git reactive-resume
cd reactive-resume
# Start all services
@@ -153,15 +159,13 @@ docker compose up -d
open http://localhost:3000
```
[![Build with Ona](https://ona.com/build-with-ona.svg)](https://app.ona.com/#https://github.com/amruthpillai/reactive-resume)
For detailed setup instructions, environment configuration, and self-hosting guides, see the [documentation](https://docs.rxresu.me).
## Tech Stack
| Category | Technology |
| ---------------- | ------------------------------- |
| Framework | TanStack Start (React 19, Vite) |
| Framework | TanStack Router (React 19, Vite) |
| Runtime | Node.js |
| Language | TypeScript |
| Database | PostgreSQL with Drizzle ORM |
@@ -185,7 +189,13 @@ The full documentation lives at [docs.rxresu.me](https://docs.rxresu.me):
## Self-Hosting
Reactive Resume can be self-hosted using Docker. The stack includes:
Reactive Resume supports Docker and Vercel Hobby.
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Freactive-resume%2Freactive-resume&project-name=reactive-resume&repository-name=reactive-resume&env=AUTH_SECRET%2CENCRYPTION_SECRET&envDescription=Generate+two+independent+secrets+with+openssl+rand+-hex+32.+Keep+these+values+across+deployments.&envLink=https%3A%2F%2Fdocs.rxresu.me%2Fself-hosting%2Fvercel&stores=%5B%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22integrationSlug%22%3A%22neon%22%2C%22productSlug%22%3A%22neon%22%7D%2C%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22integrationSlug%22%3A%22upstash%22%2C%22productSlug%22%3A%22upstash-kv%22%7D%2C%7B%22type%22%3A%22blob%22%2C%22access%22%3A%22private%22%7D%5D)
Vercel provisions Neon PostgreSQL, private Blob storage, and Upstash Redis through its deployment wizard. Supply two persistent secrets, then deploy. See the [Vercel guide](docs/self-hosting/vercel.mdx) for setup, limits, and optional SMTP/OAuth configuration.
For Docker, the stack includes:
- **PostgreSQL** — Database for storing user data and resumes
- **SeaweedFS** (optional) — S3-compatible storage for file uploads
@@ -199,7 +209,7 @@ Pull the latest image from Docker Hub or GitHub Container Registry:
docker pull amruthpillai/reactive-resume:latest
# GitHub Container Registry
docker pull ghcr.io/amruthpillai/reactive-resume:latest
docker pull ghcr.io/reactive-resume/reactive-resume:latest
```
See the [self-hosting guide](https://docs.rxresu.me/self-hosting/docker) for complete instructions.
@@ -221,17 +231,21 @@ Other ways to support:
- Star this repository
- Report reproducible bugs and suggest actionable features
- Help other users in [GitHub Discussions](https://github.com/amruthpillai/reactive-resume/discussions/categories/q-a)
- Help other users in [GitHub Discussions](https://github.com/reactive-resume/reactive-resume/discussions/categories/q-a)
- Improve documentation
- Help with translations
<a href="https://blacksmith.sh/">
<img width="368" height="126" alt="powered-by-blacksmith" src="https://github.com/user-attachments/assets/3e95d11b-4579-4082-8d0c-6b574f925625" />
</a>
## Star History
<a href="https://www.star-history.com/?repos=amruthpillai%2Freactive-resume&type=date&legend=top-left">
<a href="https://www.star-history.com/?repos=reactive-resume%2Freactive-resume&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&theme=dark&legend=top-left&sealed_token=QmaOn4Ech499R6kpQe8ONn911UjGUaJfQBT0MXlQLU9hTo-Ie7lTxIILWbBvmtzDGHk7ziWKN_N5iM5mgP8widn_FGHd9-PHNokPtSji8XLgbFpqatgyqIDPnOys-IhO40W3J0HeH07FL-Q8Bq6ArRk3LDtJDwjh4m0ya-2L59ULb7BaqxkSDuCytkCr" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&legend=top-left&sealed_token=QmaOn4Ech499R6kpQe8ONn911UjGUaJfQBT0MXlQLU9hTo-Ie7lTxIILWbBvmtzDGHk7ziWKN_N5iM5mgP8widn_FGHd9-PHNokPtSji8XLgbFpqatgyqIDPnOys-IhO40W3J0HeH07FL-Q8Bq6ArRk3LDtJDwjh4m0ya-2L59ULb7BaqxkSDuCytkCr" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&legend=top-left&sealed_token=QmaOn4Ech499R6kpQe8ONn911UjGUaJfQBT0MXlQLU9hTo-Ie7lTxIILWbBvmtzDGHk7ziWKN_N5iM5mgP8widn_FGHd9-PHNokPtSji8XLgbFpqatgyqIDPnOys-IhO40W3J0HeH07FL-Q8Bq6ArRk3LDtJDwjh4m0ya-2L59ULb7BaqxkSDuCytkCr" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=reactive-resume/reactive-resume&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=reactive-resume/reactive-resume&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=reactive-resume/reactive-resume&type=date&legend=top-left" />
</picture>
</a>
@@ -247,7 +261,7 @@ Every contribution helps, whether it is a typo fix or a new feature.
See the [development setup guide](https://docs.rxresu.me/contributing/development) for how to run the project locally.
Maintainers review the [`status: needs triage` queue](https://github.com/amruthpillai/reactive-resume/issues?q=is%3Aissue+is%3Aopen+label%3A%22status%3A+needs+triage%22)
Maintainers review the [`status: needs triage` queue](https://github.com/reactive-resume/reactive-resume/issues?q=is%3Aissue+is%3Aopen+label%3A%22status%3A+needs+triage%22)
weekly. Triaged bugs become `status: confirmed`; feature proposals become `status: accepted`; reports that need details become
`status: needs info`.
+1624
View File
File diff suppressed because it is too large Load Diff
+52 -52
View File
@@ -11,43 +11,40 @@
"typecheck": "tsgo --noEmit",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
"test:ci": "vitest run --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"imports": {
"#react-pdf-renderer": "@react-pdf/renderer"
},
"dependencies": {
"@ai-sdk/anthropic": "^4.0.49",
"@ai-sdk/cerebras": "^3.0.44",
"@ai-sdk/cohere": "^4.0.37",
"@ai-sdk/deepseek": "^3.0.39",
"@ai-sdk/fireworks": "^3.0.47",
"@ai-sdk/google": "^4.0.64",
"@ai-sdk/groq": "^4.0.37",
"@ai-sdk/mistral": "^4.0.39",
"@ai-sdk/openai": "^4.0.60",
"@ai-sdk/openai-compatible": "^3.0.44",
"@ai-sdk/perplexity": "^4.0.39",
"@ai-sdk/togetherai": "^3.0.45",
"@ai-sdk/xai": "^4.0.54",
"@aws-sdk/client-s3": "^3.1127.0",
"@better-auth/api-key": "^1.7.3",
"@better-auth/drizzle-adapter": "^1.7.3",
"@better-auth/infra": "^0.4.5",
"@better-auth/oauth-provider": "^1.7.3",
"@better-auth/passkey": "^1.7.3",
"@ai-sdk/anthropic": "^4.0.68",
"@ai-sdk/cerebras": "^3.0.59",
"@ai-sdk/cohere": "^4.0.52",
"@ai-sdk/deepseek": "^3.0.56",
"@ai-sdk/fireworks": "^3.0.62",
"@ai-sdk/google": "^4.0.85",
"@ai-sdk/groq": "^4.0.52",
"@ai-sdk/mistral": "^4.0.54",
"@ai-sdk/openai": "^4.0.81",
"@ai-sdk/openai-compatible": "^3.0.59",
"@ai-sdk/perplexity": "^5.0.3",
"@ai-sdk/togetherai": "^3.0.60",
"@ai-sdk/xai": "^5.0.12",
"@aws-sdk/client-s3": "^3.1143.0",
"@better-auth/api-key": "^1.7.6",
"@better-auth/drizzle-adapter": "^1.7.6",
"@better-auth/infra": "^0.4.13",
"@better-auth/oauth-provider": "^1.7.6",
"@better-auth/passkey": "^1.7.6",
"@bramus/specificity": "^2.4.2",
"@hono/node-server": "^2.1.1",
"@modelcontextprotocol/sdk": "^1.30.0",
"@orpc/client": "^1.15.0",
"@orpc/experimental-ratelimit": "^1.15.0",
"@orpc/json-schema": "^1.15.0",
"@orpc/openapi": "^1.15.0",
"@orpc/server": "^1.15.0",
"@orpc/zod": "^1.15.0",
"@react-pdf/hyphenate": "0.1.0",
"@react-pdf/renderer": "^4.9.0",
"@formepdf/core": "0.25.0",
"@formepdf/react": "0.25.0",
"@hono/node-server": "^2.1.3",
"@modelcontextprotocol/sdk": "^1.31.0",
"@orpc/client": "^1.15.4",
"@orpc/experimental-ratelimit": "^1.15.4",
"@orpc/json-schema": "^1.15.4",
"@orpc/openapi": "^1.15.4",
"@orpc/server": "^1.15.4",
"@orpc/zod": "^1.15.4",
"@reactive-resume/api": "workspace:*",
"@reactive-resume/auth": "workspace:*",
"@reactive-resume/db": "workspace:*",
@@ -58,45 +55,48 @@
"@sindresorhus/slugify": "^3.0.1",
"@t3-oss/env-core": "^0.13.11",
"@uiw/color-convert": "^2.10.3",
"ai": "^7.0.93",
"bcrypt": "^6.0.0",
"better-auth": "1.7.3",
"cjk-regex": "^3.4.0",
"@vercel/blob": "^2.8.0",
"@vercel/functions": "^3.9.9",
"ai": "^7.0.122",
"bcryptjs": "^3.0.3",
"better-auth": "1.7.6",
"cjk-regex": "^3.5.0",
"css-tree": "^3.2.1",
"deepmerge-ts": "^8.0.2",
"drizzle-orm": "1.0.0-rc.4",
"drizzle-zod": "1.0.0-beta.14-a36c63d",
"es-toolkit": "^1.52.0",
"fast-json-patch": "^3.1.1",
"fast-png": "^8.0.0",
"hono": "^4.13.7",
"fflate": "^0.8.3",
"hono": "^4.13.11",
"ioredis": "^6.0.0",
"jose": "^6.2.12",
"jsonrepair": "^3.15.0",
"node-html-parser": "^9.0.3",
"nodemailer": "^10.0.0",
"node-html-parser": "^9.0.4",
"nodemailer": "^10.0.12",
"ollama-ai-provider-v2": "^4.0.1",
"pg": "^8.23.0",
"phosphor-icons-react-pdf": "^0.1.3",
"react": "^19.2.8",
"react-email": "^6.9.3",
"react-pdf-html": "^2.1.5",
"resumable-stream": "^2.2.12",
"react": "^19.3.0",
"react-email": "^6.11.0",
"react-reconciler": "0.34.0",
"resumable-stream": "^2.2.13",
"sanitize-html": "^2.17.7",
"sharp": "^0.35.4",
"sharp": "^0.35.5",
"tokenx": "^2.1.0",
"ts-pattern": "^5.9.0",
"unique-names-generator": "^4.7.1",
"uuid": "^14.0.2",
"zod": "^4.5.4"
"zod": "^4.6.5"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/node": "^26.4.1",
"@types/node": "^26.6.3",
"@types/pg": "^8.23.1",
"@types/react": "^19.2.18",
"@types/react": "^19.3.0",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"tsdown": "^0.23.0",
"tsx": "^4.23.13",
"tsx": "^4.23.15",
"typescript": "^7.0.2",
"vitest": "^5.0.0"
"vitest": "^5.0.2"
}
}
+31 -36
View File
@@ -1,3 +1,4 @@
import { gunzipSync } from "node:zlib";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
@@ -113,20 +114,8 @@ describe("createApp", () => {
await expect(response.text()).resolves.toBe("oauth");
expect(mocks.handleOAuth).toHaveBeenCalledWith(request);
expect(mocks.handleAuth).not.toHaveBeenCalled();
});
it("routes signed resume PDF downloads before the web fallback", async () => {
const { createApp } = await import("./app");
const app = createApp();
const request = new Request("http://localhost:3001/api/resumes/resume-1/pdf?token=signed");
const response = await app.fetch(request);
await expect(response.text()).resolves.toBe("pdf");
expect(mocks.handleResumePdfDownload).toHaveBeenCalledWith(request, "resume-1");
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
expect(mocks.handleWebApp).not.toHaveBeenCalled();
});
// The first test pays for the cold import of the whole app, which takes seconds under a parallel run.
}, 15_000);
it("uses the transport address for public PDF fallback despite rotated forwarding headers", async () => {
const { createApp } = await import("./app");
@@ -173,30 +162,10 @@ describe("createApp", () => {
expect(mocks.handleOpenApi).toHaveBeenNthCalledWith(2, unknownOpenApiRequest, "unknown");
});
it.each([
["GET", "/robots.txt", "robots", mocks.handleRobots],
["HEAD", "/robots.txt", "", mocks.handleRobots],
["GET", "/sitemap.xml", "sitemap", mocks.handleSitemap],
["HEAD", "/sitemap.xml", "", mocks.handleSitemap],
["GET", "/llms.txt", "llms", mocks.handleLlms],
["HEAD", "/llms.txt", "", mocks.handleLlms],
])("routes %s %s before the static fallback", async (method, pathname, expectedBody, handler) => {
it("routes GET / to the web app handler so SEO markup is injected", async () => {
const { createApp } = await import("./app");
const app = createApp();
const request = new Request(`http://localhost:3001${pathname}`, { method });
const response = await app.fetch(request);
await expect(response.text()).resolves.toBe(expectedBody);
expect(handler).toHaveBeenCalledWith({ head: method === "HEAD" });
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
expect(mocks.handleWebApp).not.toHaveBeenCalled();
});
it.each(["GET", "HEAD"])("routes %s / to the web app handler so SEO markup is injected", async (method) => {
const { createApp } = await import("./app");
const app = createApp();
const request = new Request("http://localhost:3001/", { method });
const request = new Request("http://localhost:3001/");
const response = await app.fetch(request);
@@ -204,6 +173,32 @@ describe("createApp", () => {
expect(mocks.handleWebApp).toHaveBeenCalledWith(request);
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
});
it("compresses the web app's HTML but never API streams or the Vercel app", async () => {
const { createApp } = await import("./app");
const html = `<!doctype html>${"<p>Reactive Resume</p>".repeat(200)}`;
const htmlResponse = () =>
new Response(html, {
headers: { "Content-Type": "text/html; charset=UTF-8", "Cache-Control": "private, no-store", Vary: "Cookie" },
});
const stream = () => new Response("data: x\n\n".repeat(500), { headers: { "Content-Type": "application/json" } });
mocks.handleWebApp.mockImplementation(async () => htmlResponse());
mocks.handleRpc.mockImplementation(async () => stream());
mocks.handleMcp.mockImplementation(async () => stream());
const headers = { "Accept-Encoding": "br, gzip" };
const page = await createApp().request("http://localhost:3000/", { headers });
const rpc = await createApp().request("http://localhost:3000/api/rpc/agent/chat", { headers });
const mcp = await createApp().request("http://localhost:3000/mcp", { headers });
const vercelPage = await createApp({ serveStatic: false }).request("http://localhost:3000/", { headers });
expect(page.headers.get("content-encoding")).toBe("gzip");
expect(page.headers.get("vary")).toBe("Cookie, Accept-Encoding");
expect(page.headers.get("cache-control")).toBe("private, no-store");
expect(gunzipSync(Buffer.from(await page.arrayBuffer())).toString()).toBe(html);
for (const response of [rpc, mcp, vercelPage]) expect(response.headers.get("content-encoding")).toBeNull();
expect(vercelPage.headers.get("vary")).toBe("Cookie");
});
});
it.each(["/auth/consent", "/auth/consent/", "/auth/login"])("prevents framing or caching %s", async (path) => {
+21 -7
View File
@@ -3,6 +3,8 @@ import type { Context } from "hono";
import { isIP } from "node:net";
import { getConnInfo } from "@hono/node-server/conninfo";
import { Hono } from "hono";
import { compress } from "hono/compress";
import { prepareStagedBody, withStagedBody } from "@reactive-resume/api/features/storage/transport";
import { handleMcp } from "../mcp/handler";
import { handleOpenApi } from "../openapi/handler";
import {
@@ -33,8 +35,14 @@ const getTrustedClient = (context: Context<ServerEnvironment>): string => {
}
};
export function createApp() {
type AppOptions = {
serveStatic?: boolean;
trustedClient?: (request: Request) => string;
};
export function createApp(options: AppOptions = {}) {
const app = new Hono<ServerEnvironment>();
const client = (c: Context<ServerEnvironment>) => options.trustedClient?.(c.req.raw) ?? getTrustedClient(c);
app.use("/auth/*", async (c, next) => {
await next();
@@ -44,15 +52,16 @@ export function createApp() {
c.header("Cache-Control", "no-store");
});
app.all("/api/rpc", (c) => handleRpc(c.req.raw, getTrustedClient(c)));
app.all("/api/rpc/*", (c) => handleRpc(c.req.raw, getTrustedClient(c)));
app.all("/api/openapi", (c) => handleOpenApi(c.req.raw, getTrustedClient(c)));
app.all("/api/openapi/*", (c) => handleOpenApi(c.req.raw, getTrustedClient(c)));
app.post("/api/storage/stage", (c) => prepareStagedBody(c.req.raw));
app.all("/api/rpc", (c) => withStagedBody(c.req.raw, (request) => handleRpc(request, client(c))));
app.all("/api/rpc/*", (c) => withStagedBody(c.req.raw, (request) => handleRpc(request, client(c))));
app.all("/api/openapi", (c) => handleOpenApi(c.req.raw, client(c)));
app.all("/api/openapi/*", (c) => handleOpenApi(c.req.raw, client(c)));
app.get("/api/auth/oauth", (c) => handleOAuth(c.req.raw));
app.all("/api/auth/*", (c) => handleAuth(c.req.raw));
app.get("/api/health", () => handleHealth());
app.get("/api/resumes/:username/:slug/pdf", (c) =>
handlePublicResumePdf(c.req.raw, c.req.param("username"), c.req.param("slug"), getTrustedClient(c)),
handlePublicResumePdf(c.req.raw, c.req.param("username"), c.req.param("slug"), client(c)),
);
app.get("/api/resumes/:id/pdf", (c) => handleResumePdfDownload(c.req.raw, c.req.param("id")));
app.get("/api/uploads/*", (c) => handleUpload(c.req.raw));
@@ -73,10 +82,15 @@ export function createApp() {
app.on(["GET", "HEAD"], "/sitemap.xml", (c) => handleSitemap({ head: c.req.method === "HEAD" }));
app.on(["GET", "HEAD"], "/llms.txt", (c) => handleLlms({ head: c.req.method === "HEAD" }));
// Compresses only the web app's files and HTML shells: every route registered above answers before reaching
// it, so API, MCP, and upload streams are never buffered or re-encoded. Where a CDN serves the static files
// (Vercel), it also compresses at its edge.
if (options.serveStatic !== false) app.use("/*", compress());
// Must precede the static middleware: serveStatic resolves "/" to dist/index.html and would
// return it verbatim, skipping the OpenGraph/Twitter/canonical/JSON-LD injection in handleWebApp.
app.on(["GET", "HEAD"], "/", (c) => handleWebApp(c.req.raw));
app.use("/*", serveWebDistStatic);
if (options.serveStatic !== false) app.use("/*", serveWebDistStatic);
app.on(["GET", "HEAD"], "/*", (c) => handleWebApp(c.req.raw));
return app;
+21 -40
View File
@@ -53,13 +53,13 @@ describe("handleAuth", () => {
},
);
it("rejects untrusted dynamic OAuth redirect URIs in safe mode", async () => {
it("rejects unsafe dynamic OAuth redirect URIs in safe mode", async () => {
const { handleAuth } = await import("./auth");
const response = await handleAuth(
new Request("http://localhost:3001/api/auth/oauth2/register", {
method: "POST",
body: JSON.stringify({ redirect_uris: ["https://evil.example.com/callback"] }),
body: JSON.stringify({ redirect_uris: ["https://192.168.1.10/callback"] }),
headers: { "content-type": "application/json" },
}),
);
@@ -72,21 +72,6 @@ describe("handleAuth", () => {
expect(mocks.handler).not.toHaveBeenCalled();
});
it("forwards custom-scheme dynamic OAuth redirect URIs when unsafe mode is enabled", async () => {
const { handleAuth } = await import("./auth");
mocks.env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI = true;
const response = await handleAuth(
new Request("http://localhost:3001/api/auth/oauth2/register", {
method: "POST",
body: JSON.stringify({ redirect_uris: ["myapp://callback"] }),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBe(200);
expect(mocks.handler).toHaveBeenCalledOnce();
});
it.each(["localhost", "127.0.0.1", "[::1]"])(
"infers native application type for exact %s loopback callbacks",
async (host) => {
@@ -106,6 +91,25 @@ describe("handleAuth", () => {
},
);
it.each(["client_secret_basic", "client_secret_post"])(
"keeps an explicitly registered %s so the client receives a client secret",
async (method) => {
const { handleAuth } = await import("./auth");
await handleAuth(
new Request("http://localhost:3000/api/auth/oauth2/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
redirect_uris: ["https://example.com/callback"],
token_endpoint_auth_method: method,
}),
}),
);
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
await expect(forwarded.json()).resolves.toMatchObject({ token_endpoint_auth_method: method });
},
);
it.each([
{ redirect_uris: ["https://example.com/callback"] },
{ redirect_uris: ["http://localhost.evil.example/callback"] },
@@ -124,20 +128,6 @@ describe("handleAuth", () => {
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
expect((await forwarded.json()).application_type).not.toBe("native");
});
it("preserves repeated resource indicators during authorization sanitization", async () => {
const { handleAuth } = await import("./auth");
await handleAuth(
new Request(
"http://localhost:3000/api/auth/oauth2/authorize?resource=http%3A%2F%2Flocalhost%3A3000&resource=http%3A%2F%2Flocalhost%3A3000%2Fmcp",
),
);
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
expect(new URL(forwarded.url).searchParams.getAll("resource")).toEqual([
"http://localhost:3000",
"http://localhost:3000/mcp",
]);
});
});
describe("handleOAuth", () => {
@@ -182,15 +172,6 @@ describe("handleOAuth", () => {
expect(response.headers.get("location")).toBe("/auth/consent?client_id=client&sig=signed");
});
it("preserves provider failures instead of issuing an authorization code", async () => {
const { handleOAuth } = await import("./auth");
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
mocks.continueOAuth.mockResolvedValueOnce(Response.json({ error: "invalid_signature" }, { status: 400 }));
const response = await handleOAuth(new Request("http://localhost:3000/api/auth/oauth?sig=invalid"));
expect(response.status).toBe(400);
expect(response.headers.get("location")).toBeNull();
await expect(response.json()).resolves.toEqual({ error: "invalid_signature" });
});
it("preserves provider cookies and cache headers on forced reauthentication", async () => {
const { handleOAuth } = await import("./auth");
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
+5 -1
View File
@@ -82,8 +82,12 @@ async function defaultPublicClientRegistration(request: Request): Promise<Reques
if (allLoopback) body.application_type = "native";
}
// MCP clients that authenticate with PKCE alone omit the method, and Better Auth
// would otherwise register them as `client_secret_basic`. Honor an explicit choice:
// forcing it to "none" issues no client secret, so the client's own Basic/post
// credentials are rejected at the token endpoint with 401 invalid_client.
if (!request.headers.get("authorization")) {
body.token_endpoint_auth_method = "none";
body.token_endpoint_auth_method ??= "none";
}
return new Request(url.toString(), {
-10
View File
@@ -1,13 +1,3 @@
export function getCookie(request: Request, name: string): string | undefined {
const cookieHeader = request.headers.get("cookie");
if (!cookieHeader) return;
for (const part of cookieHeader.split(";")) {
const [rawName, ...rawValue] = part.trim().split("=");
if (rawName === name && rawValue.length > 0) return rawValue.join("=");
}
}
export function mergeResponseHeaders(response: Response, headers: Headers): Response {
if ([...headers].length === 0) return response;
+1 -28
View File
@@ -4,46 +4,19 @@ const { execute, healthcheck } = vi.hoisted(() => ({ execute: vi.fn(), healthche
vi.mock("@reactive-resume/db/client", () => ({ db: { execute } }));
vi.mock("@reactive-resume/api/features/storage", () => ({ getStorageService: () => ({ healthcheck }) }));
vi.mock("../app-version", () => ({ appVersion: "9.8.7" }));
import { handleHealth } from "./health";
describe("health version reporting", () => {
describe("health failure reporting", () => {
beforeEach(() => {
execute.mockResolvedValue([]);
healthcheck.mockResolvedValue({ status: "healthy" });
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it("reports the built application version when launched directly by Node", async () => {
vi.stubEnv("npm_package_version", undefined);
const response = await handleHealth();
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ service: "reactive-resume", version: "9.8.7", status: "healthy" });
});
it("ignores a package manager's workspace package version", async () => {
vi.stubEnv("npm_package_version", "0.0.0");
expect(await (await handleHealth()).json()).toMatchObject({ version: "9.8.7" });
});
it("keeps the version available when a dependency is unhealthy", async () => {
vi.stubEnv("npm_package_version", undefined);
execute.mockRejectedValueOnce(new Error("Database unavailable"));
vi.spyOn(console, "warn").mockImplementation(() => {});
const response = await handleHealth();
expect(response.status).toBe(503);
expect(await response.json()).toMatchObject({ version: "9.8.7", status: "unhealthy" });
});
it.each(["database", "storage"])("keeps thrown %s error details in server logs only", async (dependency) => {
const detail = "Connection failed for private-user at internal.example:5432";
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+17 -13
View File
@@ -2,6 +2,7 @@ import { sql } from "drizzle-orm";
import { withTimeout } from "es-toolkit";
import { getStorageService } from "@reactive-resume/api/features/storage";
import { db } from "@reactive-resume/db/client";
import { getRedis } from "@reactive-resume/db/redis";
import { appVersion } from "../app-version";
const HEALTHCHECK_TIMEOUT_MS = 1_500;
@@ -32,13 +33,13 @@ async function runCheck(check: () => Promise<object>): Promise<CheckResult> {
}
}
function publicCheck(check: CheckResult, name: "Database" | "Storage"): CheckResult {
function publicCheck(check: CheckResult, name: "Database" | "Storage" | "Redis"): CheckResult {
if (check.status === "healthy") return check;
return {
status: check.status,
latencyMs: check.latencyMs,
error: `${name} health check failed.`,
...(check.type === "local" || check.type === "s3" ? { type: check.type } : {}),
...(check.type === "local" || check.type === "s3" || check.type === "blob" ? { type: check.type } : {}),
};
}
@@ -51,8 +52,18 @@ async function checkDatabase() {
const checkStorage = () => getStorageService().healthcheck();
export async function handleHealth() {
const [database, storage] = await Promise.all([runCheck(checkDatabase), runCheck(checkStorage)]);
const status = [database, storage].some((check) => check.status === "unhealthy") ? "unhealthy" : "healthy";
const redisClient = getRedis();
const [database, storage, redis] = await Promise.all([
runCheck(checkDatabase),
runCheck(checkStorage),
redisClient
? runCheck(async () => {
await redisClient.ping();
return { status: "healthy" };
})
: undefined,
]);
const status = [database, storage, redis].some((check) => check?.status === "unhealthy") ? "unhealthy" : "healthy";
const checks = {
service: "reactive-resume",
@@ -62,19 +73,12 @@ export async function handleHealth() {
uptime: `${process.uptime().toFixed(2)}s`,
database: publicCheck(database, "Database"),
storage: publicCheck(storage, "Storage"),
...(redis ? { redis: publicCheck(redis, "Redis") } : {}),
};
if (status === "unhealthy") {
console.warn("[Healthcheck]", { route: "/api/health", database, storage });
}
const headers = new Headers();
const body = JSON.stringify(checks);
headers.set("Content-Type", "application/json; charset=UTF-8");
headers.set("Content-Length", Buffer.byteLength(body, "utf-8").toString());
return new Response(body, {
headers,
status: checks.status === "unhealthy" ? 503 : 200,
});
return Response.json(checks, { status: checks.status === "unhealthy" ? 503 : 200 });
}
@@ -39,41 +39,4 @@ describe("handlePublicResumePdf", () => {
trustedClient,
});
});
it("keeps password and private responses uncacheable", async () => {
mocks.createPublicResumePdf.mockResolvedValueOnce({
body: new File(["%PDF"], "resume.pdf", { type: "application/pdf" }),
filename: "resume.pdf",
});
const request = new Request("https://example.com/api/resumes/jane/resume/pdf");
const response = await handlePublicResumePdf(request, "jane", "resume", trustedClient);
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect(mocks.createPublicResumePdf).toHaveBeenCalledWith({
username: "jane",
slug: "resume",
requestHeaders: request.headers,
trustedClient,
});
});
it.each([
[{ code: "NEED_PASSWORD" }, 401],
[{ code: "NOT_FOUND" }, 404],
[{ code: "RATE_LIMIT_EXCEEDED" }, 429],
[{ code: "INTERNAL_SERVER_ERROR" }, 500],
])("maps controlled API errors without caching the response", async (error, status) => {
mocks.createPublicResumePdf.mockRejectedValueOnce(error);
const response = await handlePublicResumePdf(
new Request("https://example.com/api/resumes/jane/resume/pdf"),
"jane",
"resume",
trustedClient,
);
expect(response.status).toBe(status);
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
});
});
+2 -1
View File
@@ -15,7 +15,7 @@ export async function handlePublicResumePdf(
request: Request,
username: string,
slug: string,
trustedClient = "unknown",
trustedClient: string,
): Promise<Response> {
try {
const result = await createPublicResumePdf({
@@ -35,6 +35,7 @@ export async function handlePublicResumePdf(
});
} catch (error) {
const status = errorStatus(error);
if (status === 500) console.error("Public resume PDF generation failed", error);
return noStoreResponse(
status === 500 ? "Failed to generate public resume PDF" : "Public resume PDF unavailable",
status,
+1 -84
View File
@@ -23,7 +23,6 @@ describe("handleResumePdfDownload", () => {
ok: true,
resumeId: "resume-1",
userId: "user-1",
target: "resume",
expiresAt: "2026-06-01T10:10:00.000Z",
});
mocks.createResumePdfDownload.mockResolvedValueOnce({
@@ -41,89 +40,7 @@ describe("handleResumePdfDownload", () => {
expect(response.headers.get("Content-Disposition")).toBe('attachment; filename="Scizor.pdf"');
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect(await response.text()).toBe("%PDF");
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({ id: "resume-1", userId: "user-1", target: "resume" });
});
it("passes the cover letter target through to PDF rendering", async () => {
const pdf = new File([new Uint8Array([37, 80, 68, 70])], "Cover Letter.pdf", { type: "application/pdf" });
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
ok: true,
resumeId: "resume-1",
userId: "user-1",
target: "cover-letter",
expiresAt: "2026-06-01T10:10:00.000Z",
});
mocks.createResumePdfDownload.mockResolvedValueOnce({
headers: { "content-disposition": 'attachment; filename="Cover Letter.pdf"' },
body: pdf,
});
await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf?token=signed&target=cover-letter"),
"resume-1",
);
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({
id: "resume-1",
userId: "user-1",
target: "cover-letter",
});
});
it("defaults a legacy token without a target to resume", async () => {
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
ok: true,
resumeId: "resume-1",
userId: "user-1",
expiresAt: "2026-06-01T10:10:00.000Z",
});
mocks.createResumePdfDownload.mockResolvedValueOnce({
headers: { "content-disposition": 'attachment; filename="Cover Letter.pdf"' },
body: new File([], "Cover Letter.pdf", { type: "application/pdf" }),
});
await handleResumePdfDownload(new Request("https://example.com/api/resumes/resume-1/pdf?token=legacy"), "resume-1");
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({
id: "resume-1",
userId: "user-1",
target: "resume",
});
});
it("rejects a cover-letter target for a legacy token without one", async () => {
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
ok: true,
resumeId: "resume-1",
userId: "user-1",
expiresAt: "2026-06-01T10:10:00.000Z",
});
const response = await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf?token=legacy&target=cover-letter"),
"resume-1",
);
expect(response.status).toBe(401);
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
});
it("rejects a target that differs from the signed token", async () => {
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
ok: true,
resumeId: "resume-1",
userId: "user-1",
target: "resume",
expiresAt: "2026-06-01T10:10:00.000Z",
});
const response = await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf?token=signed&target=cover-letter"),
"resume-1",
);
expect(response.status).toBe(401);
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({ id: "resume-1", userId: "user-1" });
});
it("rejects missing, invalid, and expired tokens before rendering", async () => {
+5 -9
View File
@@ -30,17 +30,13 @@ export async function handleResumePdfDownload(request: Request, id: string) {
const verification = verifyResumePdfDownloadToken({ resumeId: id, token });
if (!verification.ok) return verification.reason === "expired" ? expiredResponse() : unauthorizedResponse();
const queryTarget = searchParams.get("target");
if (
verification.target
? queryTarget !== null && queryTarget !== verification.target
: queryTarget && queryTarget !== "resume"
)
return unauthorizedResponse();
// Links made before letters left resumes may ask for the resume's cover letter, which is now a letter of its own.
const target = searchParams.get("target");
if (target && target !== "resume")
return new Response("Not found", { status: 404, headers: { "Cache-Control": "private, no-store" } });
try {
const target = verification.target ?? "resume";
const download = await createResumePdfDownload({ id, userId: verification.userId, target });
const download = await createResumePdfDownload({ id, userId: verification.userId });
return new Response(download.body, {
headers: {
+7 -1
View File
@@ -16,6 +16,12 @@ vi.mock("./http/app", () => {
},
};
});
vi.mock("@reactive-resume/auth/config", () => ({
initializeAuth: async () => {
await Promise.resolve();
events.push("auth ready");
},
}));
vi.mock("@hono/node-server", () => ({
serve: () => {
events.push("server listening");
@@ -30,6 +36,6 @@ describe("server startup", () => {
const entry = await import("./index");
expect(events).toEqual([]);
await entry.main();
expect(events).toEqual(["migrations complete", "auth imported", "app created", "server listening"]);
expect(events).toEqual(["migrations complete", "auth imported", "auth ready", "app created", "server listening"]);
});
});
+3 -2
View File
@@ -6,9 +6,10 @@ import { runStartupChecks } from "./startup/checks";
export async function main() {
await runStartupChecks();
// OAuth resource seeding starts when auth is imported, so load the app only
// after migrations have created the provider tables.
// Load and initialize auth only after migrations have created the provider tables.
const { createApp } = await import("./http/app");
const { initializeAuth } = await import("@reactive-resume/auth/config");
await initializeAuth();
// Safety net: Node 24 crashes the whole process on an unhandled rejection. One request's
// stray promise must not take the server down for everyone, so log and keep serving.
+20 -36
View File
@@ -3,7 +3,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { onError } from "@orpc/client";
import { createRouterClient } from "@orpc/server";
import router from "@reactive-resume/api/routers";
import { MCP_TOOL_NAME, registerPrompts, registerResources, registerTools } from "@reactive-resume/mcp";
import {
buildMcpServerInfo,
MCP_TOOL_NAME,
registerPrompts,
registerResources,
registerTools,
} from "@reactive-resume/mcp";
import { appVersion } from "../app-version";
import { getRequestLocale } from "../rpc/locale";
@@ -23,41 +29,19 @@ function createRequestClient(request: Request): RouterClient<typeof router> {
}
export function createMcpServer(request: Request) {
const server = new McpServer(
{
name: "reactive-resume",
version: appVersion,
title: "Reactive Resume",
websiteUrl: "https://rxresu.me",
description:
"Reactive Resume is a free and open-source resume builder. Use this MCP server to interact with your resume using an LLM of your choice.",
icons: [
{
src: "https://rxresu.me/icon/light.svg",
mimeType: "image/svg+xml",
theme: "light",
},
{
src: "https://rxresu.me/icon/dark.svg",
mimeType: "image/svg+xml",
theme: "dark",
},
],
},
{
instructions: [
"You are connected to Reactive Resume over MCP.",
"Authenticate with OAuth (recommended) or an API key (`x-api-key`).",
`Discover resume IDs with \`${MCP_TOOL_NAME.listResumes}\` (not \`resources/list\`).`,
`List distinct tags with \`${MCP_TOOL_NAME.listResumeTags}\`.`,
`Read schema at \`resume://_meta/schema\`; read resume JSON via \`resume://{id}\` or \`${MCP_TOOL_NAME.getResume}\`.`,
`Apply body edits with JSON Patch through \`${MCP_TOOL_NAME.patchResume}\`.`,
`Change name, slug, tags, or public visibility with \`${MCP_TOOL_NAME.updateResume}\` (returns canonical share URL; anonymous access only when \`isPublic\` is true; passwords are managed in the web app only).`,
`Create short-lived authenticated PDF download URLs with \`${MCP_TOOL_NAME.downloadResumePdf}\`; set target to \`cover-letter\` to export a visible cover letter separately.`,
`Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`.`,
].join(" "),
},
);
const server = new McpServer(buildMcpServerInfo(appVersion), {
instructions: [
"You are connected to Reactive Resume over MCP.",
"Authenticate with OAuth (recommended) or an API key (`x-api-key`).",
`Discover resume IDs with \`${MCP_TOOL_NAME.listResumes}\` (not \`resources/list\`).`,
`List distinct tags with \`${MCP_TOOL_NAME.listResumeTags}\`.`,
`Read schema at \`resume://_meta/schema\`; read resume JSON via \`resume://{id}\` or \`${MCP_TOOL_NAME.getResume}\`.`,
`Apply body edits with JSON Patch through \`${MCP_TOOL_NAME.patchResume}\`.`,
`Change name, slug, tags, or public visibility with \`${MCP_TOOL_NAME.updateResume}\` (returns canonical share URL; anonymous access only when \`isPublic\` is true; passwords are managed in the web app only).`,
`Create short-lived authenticated PDF download URLs with \`${MCP_TOOL_NAME.downloadResumePdf}\`; set target to \`cover-letter\` to export a visible cover letter separately.`,
`Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`.`,
].join(" "),
});
const client = createRequestClient(request);
registerResources(server, client);
+69
View File
@@ -0,0 +1,69 @@
import type { StylesheetChange } from "@reactive-resume/api/features/resume/legacy-styles-migration";
import { closeSync, openSync, readFileSync, writeSync } from "node:fs";
import { parseArgs } from "node:util";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { migrateLegacyStyles, restoreLegacyStyles } from "@reactive-resume/api/features/resume/legacy-styles-migration";
import { env } from "@reactive-resume/env/server";
const usage = `Converts resumes and letters still styled by the old style editor (legacy style rules) to Semantic CSS.
Uses DATABASE_URL. Run it once after deploying the version without the legacy renderer.
node apps/server/dist/migrate-legacy-styles.mjs
Dry run: converts every row that needs it in memory and reports the counts. Writes nothing.
node apps/server/dist/migrate-legacy-styles.mjs --apply --backup <file>
Converts and saves. Every replaced stylesheet is appended to <file> (NDJSON) before its row is written.
Safe to run again or after an interruption: converted rows are skipped. Use a new file or the same one.
node apps/server/dist/migrate-legacy-styles.mjs --restore <file>
Puts back the stylesheets recorded in <file>, except on rows whose stylesheet was edited since.
`;
const { values } = parseArgs({
options: {
apply: { type: "boolean", default: false },
backup: { type: "string" },
restore: { type: "string" },
help: { type: "boolean", default: false },
},
});
if (values.help || (values.apply && !values.backup) || (values.restore && (values.apply || values.backup))) {
console.info(usage);
process.exit(values.help ? 0 : 1);
}
// Opened before connecting, so an unwritable path fails before anything changes.
const backup = values.backup ? openSync(values.backup, "a") : undefined;
const pool = new Pool({ connectionString: env.DATABASE_URL, max: 1, connectionTimeoutMillis: 10_000 });
const client = await pool.connect();
const log = (message: string) => console.info(`[${new Date().toISOString()}] ${message}`);
try {
// Finding the rows is one scan per table, which can outlast the database's default statement timeout.
await client.query("SET statement_timeout = 0");
const db = drizzle({ client });
if (values.restore) {
const changes = readFileSync(values.restore, "utf8")
.split("\n")
.filter((line) => line.trim())
.map((line) => JSON.parse(line) as StylesheetChange);
log(`Restoring ${changes.length} stylesheets from ${values.restore}`);
log(`Done: ${JSON.stringify(await restoreLegacyStyles(db, changes))}`);
} else {
log(values.apply ? `Converting, backing up to ${values.backup}` : "Dry run: nothing will be written");
const summary = await migrateLegacyStyles(db, {
apply: values.apply,
log,
...(backup === undefined ? {} : { onChange: (change) => writeSync(backup, `${JSON.stringify(change)}\n`) }),
});
log(`Done: ${JSON.stringify(summary)}`);
}
} finally {
if (backup !== undefined) closeSync(backup);
client.release();
await pool.end();
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { readFile, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
export async function generateOpenApiDocumentation(
async function generateOpenApiDocumentation(
target = fileURLToPath(new URL("../../../../docs/spec.json", import.meta.url)),
) {
const packageJson = JSON.parse(await readFile(new URL("../../../../package.json", import.meta.url), "utf8")) as {
+1 -117
View File
@@ -1,8 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import z from "zod";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
import { writableResumeDataSchema } from "@reactive-resume/schema/resume/write";
// Spec generation reads procedure contracts without executing authentication. Keep the
// provider's resource seeding out of this unit test; real OAuth initialization is covered
@@ -72,43 +69,6 @@ function findImpossibleRequestSchemas(spec: GeneratedSpecView) {
}
describe("generateOpenApiSpec", () => {
it("uses caller-provided application URL and version", async () => {
const spec = await generateSpec();
expect(spec.info).toMatchObject({
title: "Reactive Resume",
version: "9.8.7",
});
expect(spec.servers).toEqual([{ url: "https://rxresu.me/api/openapi" }]);
expect(spec.externalDocs).toEqual({
url: "https://docs.rxresu.me",
description: "Reactive Resume Documentation",
});
}, 15_000);
it("documents the public health endpoint at its actual URL", async () => {
const spec = await generateSpec();
const health = spec.paths?.["/api/health"]?.get;
expect(health).toMatchObject({
operationId: "getHealth",
security: [],
servers: [{ url: "https://rxresu.me" }],
});
for (const status of ["200", "503"]) {
expect(health?.responses?.[status]).toMatchObject({
content: {
"application/json": {
schema: {
required: expect.arrayContaining(["service", "version", "status"]),
properties: { version: { type: "string" } },
},
},
},
});
}
});
it("uses the canonical input-side ResumeData schema in update requests", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
const { $schema: _dialect, ...canonicalInputSchema } = createResumeDataJsonSchema();
@@ -119,87 +79,11 @@ describe("generateOpenApiSpec", () => {
data: { $ref: "#/components/schemas/ResumeData" },
},
});
});
it("accepts legacy input with omitted picture fit in the published request schema", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
expect(spec.components?.schemas?.ResumeData).toMatchObject({
properties: {
picture: { required: expect.not.arrayContaining(["fit"]) },
},
});
});
it("publishes the custom-section type and item correlation", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
const schema = z.fromJSONSchema(spec.components?.schemas?.ResumeData as Parameters<typeof z.fromJSONSchema>[0]);
const mismatched = {
...defaultResumeData,
customSections: [
{
id: "custom-experience",
type: "experience",
title: "Experience",
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [{ id: "summary-item", hidden: false, content: "<p>Not an experience item</p>" }],
},
],
};
expect(schema.safeParse(mismatched).success).toBe(false);
});
it("enforces the same submitted bounds as the published request schema", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
const published = z.fromJSONSchema(spec.components?.schemas?.ResumeData as Parameters<typeof z.fromJSONSchema>[0]);
for (const marginX of [0, 100, -1, 500]) {
const data = structuredClone(defaultResumeData);
data.metadata.page.marginX = marginX;
const expected = marginX === 0 || marginX === 100;
expect(published.safeParse(data).success).toBe(expected);
expect(writableResumeDataSchema.safeParse(data).success).toBe(expected);
}
});
}, 15_000);
it("does not publish impossible request schemas", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
expect(findImpossibleRequestSchemas(spec)).toEqual([]);
});
it("checks every request body media type for impossible schemas", () => {
const spec: GeneratedSpecView = {
paths: {
"/documents": {
post: {
requestBody: {
content: {
"application/json": { schema: { type: "object" } },
"multipart/form-data": { schema: { not: {} } },
},
},
},
},
},
};
expect(findImpossibleRequestSchemas(spec)).toEqual(["POST /documents (multipart/form-data)"]);
});
it("documents imported data as an accepted ResumeData input", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
expect(getRequestSchema(spec, "/resumes/import", "post")).toEqual({
type: "object",
properties: {
data: { $ref: "#/components/schemas/ResumeData" },
},
required: ["data"],
});
});
});
+1 -1
View File
@@ -84,7 +84,7 @@ export async function generateOpenApiSpec({ appUrl, version }: GenerateOpenApiSp
title: "Reactive Resume",
version,
description: "Reactive Resume API",
license: { name: "MIT", url: "https://github.com/amruthpillai/reactive-resume/blob/main/LICENSE" },
license: { name: "MIT", url: "https://github.com/reactive-resume/reactive-resume/blob/main/LICENSE" },
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
},
servers: [{ url: `${appUrl}/api/openapi` }],
+1 -1
View File
@@ -25,7 +25,7 @@ const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
],
});
export async function handleOpenApi(request: Request, trustedClient = "unknown") {
export async function handleOpenApi(request: Request, trustedClient: string) {
if (request.method === "GET" && (request.url.endsWith("/spec.json") || request.url.endsWith("/spec"))) {
return Response.json(await generateOpenApiSpec({ appUrl: env.APP_URL, version: appVersion }));
}
+20
View File
@@ -0,0 +1,20 @@
import { initializeAuth } from "@reactive-resume/auth/config";
import { getPool } from "@reactive-resume/db/client";
import { env } from "@reactive-resume/env/server";
import { runDatabaseMigrations } from "./startup/checks";
if (process.env.VERCEL_ENV === "preview" && process.env.ALLOW_PREVIEW_MIGRATIONS !== "true") {
throw new Error(
"Preview deployment needs an isolated database. Set ALLOW_PREVIEW_MIGRATIONS=true only after connecting one.",
);
}
if (process.env.VERCEL === "1") {
if (env.STORAGE_BACKEND !== "blob")
throw new Error("Vercel requires private Blob storage for direct uploads. Docker supports local, S3, and Blob.");
if (!env.REDIS_URL || !env.ENCRYPTION_SECRET) throw new Error("Vercel requires Redis and ENCRYPTION_SECRET.");
}
await runDatabaseMigrations();
await initializeAuth();
await getPool().end();
+1 -1
View File
@@ -14,7 +14,7 @@ const rpcHandler = new RPCHandler(router, {
],
});
export async function handleRpc(request: Request, trustedClient = "unknown") {
export async function handleRpc(request: Request, trustedClient: string) {
const resHeaders = new Headers();
const { response } = await rpcHandler.handle(request, {
prefix: "/api/rpc",
+2 -2
View File
@@ -1,8 +1,8 @@
import type { Locale } from "@reactive-resume/utils/locale";
import { parse } from "hono/utils/cookie";
import { defaultLocale, isLocale } from "@reactive-resume/utils/locale";
import { getCookie } from "../http/headers";
export function getRequestLocale(request: Request): Locale {
const locale = getCookie(request, "locale");
const locale = parse(request.headers.get("cookie") ?? "", "locale").locale;
return isLocale(locale) ? locale : defaultLocale;
}
+39 -9
View File
@@ -7,6 +7,7 @@ import { migrate } from "drizzle-orm/node-postgres/migrator";
import { Pool } from "pg";
import { env } from "@reactive-resume/env/server";
import { getLocalDataDirectory } from "@reactive-resume/utils/monorepo.node";
import { verifyMigratedSchema } from "./schema-check";
function resolveFromCurrentModule(relativePath: string) {
return fileURLToPath(new URL(relativePath, import.meta.url));
@@ -24,25 +25,54 @@ function resolveWorkspaceFolder(folderName: string): string {
throw new Error(`Could not locate ${folderName} folder relative to ${resolveFromCurrentModule(".")}`);
}
async function runDatabaseMigrations() {
export async function runDatabaseMigrations() {
console.info("Running database migrations...");
const pool = new Pool({ connectionString: env.DATABASE_URL });
const db = drizzle({ client: pool });
const pool = new Pool({
connectionString: env.DATABASE_MIGRATION_URL ?? env.DATABASE_URL,
max: 1,
connectionTimeoutMillis: 10_000,
});
try {
await migrate(db, { migrationsFolder: resolveWorkspaceFolder("migrations") });
console.info("Database migrations completed");
} catch (error) {
console.error("Database migrations failed", { error });
throw error;
const client = await pool.connect();
try {
await client.query("SELECT pg_advisory_lock(721830451)");
const db = drizzle({ client });
try {
await migrate(db, { migrationsFolder: resolveWorkspaceFolder("migrations") });
console.info("Database migrations completed");
} catch (error) {
console.error("Database migrations failed", { error });
throw error;
}
// Post-migration verification is not a migration failure, so it gets its own log
// message. A drifted schema still lets the server boot; STRICT_SCHEMA_CHECK=true
// makes the drift fatal instead.
try {
await verifyMigratedSchema(client);
} catch (error) {
console.error("Database schema verification failed", { error });
if (env.STRICT_SCHEMA_CHECK) throw error;
console.error(
"Continuing with a drifted database schema; set STRICT_SCHEMA_CHECK=true to refuse startup instead.",
);
}
} finally {
try {
await client.query("SELECT pg_advisory_unlock(721830451)");
} finally {
client.release();
}
}
} finally {
await pool.end();
}
}
async function validateLocalStoragePath() {
if (env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY && env.S3_BUCKET) return;
if (env.STORAGE_BACKEND !== "local") return;
const dataDirectory = getLocalDataDirectory(env.LOCAL_STORAGE_PATH);
console.info(`Validating local storage path: ${dataDirectory}`);
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { collectExpectedColumns, verifyMigratedSchema } from "./schema-check";
describe("collectExpectedColumns", () => {
it("collects every column of every schema table", () => {
const expected = collectExpectedColumns();
expect(expected.length).toBeGreaterThan(0);
expect(expected).toContainEqual({ tableName: "ai_providers", columnName: "user_id" });
expect(expected).toContainEqual({ tableName: "user", columnName: "id" });
});
});
describe("verifyMigratedSchema", () => {
it("fails with the table name when every column of a table is missing", async () => {
const rows = collectExpectedColumns()
.filter((e) => e.tableName === "ai_providers")
.map((e) => ({ table_name: e.tableName, column_name: e.columnName }));
const queryable = { query: async () => ({ rows }) };
await expect(verifyMigratedSchema(queryable)).rejects.toThrow('table "ai_providers"');
});
});
+71
View File
@@ -0,0 +1,71 @@
import { is } from "drizzle-orm";
import { getTableConfig, PgTable } from "drizzle-orm/pg-core";
import * as schema from "@reactive-resume/db/schema";
interface SchemaQueryable {
query(text: string, values?: unknown[]): Promise<{ rows: { table_name: string; column_name: string }[] }>;
}
export function collectExpectedColumns() {
const expected: { tableName: string; columnName: string }[] = [];
for (const value of Object.values(schema)) {
if (!is(value, PgTable)) continue;
const config = getTableConfig(value);
for (const column of config.columns) expected.push({ tableName: config.name, columnName: column.name });
}
return expected;
}
// The migration ledger (drizzle.__drizzle_migrations) only records that a migration ran; it
// cannot detect objects that were dropped or lost outside the migrator (a partial restore,
// a manual DROP TABLE, or a recreated "public" schema while the "drizzle" schema survives).
// Comparing the live catalog with the declared schema turns that silent drift into a startup
// failure instead of runtime "relation does not exist" (42P01) errors. The comparison covers
// tables and columns only — indexes, constraints, and enums are intentionally out of scope.
export async function verifyMigratedSchema(queryable: SchemaQueryable): Promise<void> {
const expected = collectExpectedColumns();
if (expected.length === 0) return;
// $1 and $2 are index-aligned: $1[i] is the name of the table expected to contain $2[i].
// Names are qualified as "public.<table>" so the lookup does not follow the connection's
// search_path — migrations always create these tables in the public schema.
const result = await queryable.query(
`select e.table_name, e.column_name
from unnest($1::text[], $2::text[]) as e(table_name, column_name)
where to_regclass('public.' || e.table_name) is null
or not exists (
select 1 from pg_catalog.pg_attribute a
where a.attrelid = to_regclass('public.' || e.table_name)
and a.attname = e.column_name
and a.attnum > 0 and not a.attisdropped
)
order by e.table_name, e.column_name`,
[expected.map((e) => e.tableName), expected.map((e) => e.columnName)],
);
if (result.rows.length === 0) return;
const expectedPerTable = new Map<string, number>();
for (const e of expected) expectedPerTable.set(e.tableName, (expectedPerTable.get(e.tableName) ?? 0) + 1);
const missingByTable = new Map<string, Set<string>>();
for (const row of result.rows) {
const columns = missingByTable.get(row.table_name) ?? new Set<string>();
columns.add(row.column_name);
missingByTable.set(row.table_name, columns);
}
const missing = [...missingByTable.entries()].map(([table, columns]) =>
columns.size === expectedPerTable.get(table)
? `table "${table}"`
: `column(s) ${[...columns].map((column) => `"${table}"."${column}"`).join(", ")}`,
);
throw new Error(
`Database schema does not match the migration ledger: ${missing.join(", ")} ` +
"missing even though all migrations are marked as applied. This usually means the database was " +
"restored from a backup that did not include these objects, or they were dropped outside of " +
"migrations. Restore a consistent backup or recreate the missing objects, then restart the server.",
);
}
-29
View File
@@ -1,29 +0,0 @@
import { describe, expect, it } from "vitest";
import z from "zod";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { handleSchemaJson } from "./schema";
describe("handleSchemaJson", () => {
it("publishes the custom-section type and item correlation", async () => {
const response = handleSchemaJson();
const schema = z.fromJSONSchema((await response.json()) as Parameters<typeof z.fromJSONSchema>[0]);
const mismatched = {
...defaultResumeData,
customSections: [
{
id: "custom-experience",
type: "experience",
title: "Experience",
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [{ id: "summary-item", hidden: false, content: "<p>Not an experience item</p>" }],
},
],
};
expect(schema.safeParse(mismatched).success).toBe(false);
});
});
-69
View File
@@ -1,69 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@reactive-resume/env/server", () => ({
env: {
APP_URL: "https://app.example.com/",
},
}));
const { handleLlms, handleRobots, handleSitemap } = await import("./seo");
describe("SEO static endpoints", () => {
it("generates robots.txt from the normalized app URL", async () => {
const response = handleRobots();
const text = await response.text();
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
expect(text).toContain("User-agent: *");
expect(text).toContain("Allow: /");
expect(text).toContain("Disallow: /api/rpc");
expect(text).toContain("Disallow: /api/auth");
expect(text).toContain("Disallow: /mcp");
expect(text).toContain("Disallow: /.well-known");
expect(text).toContain("Sitemap: https://app.example.com/sitemap.xml");
expect(text).toContain("Sitemap: https://docs.rxresu.me/sitemap.xml");
expect(text).not.toMatch(/GPTBot|ClaudeBot|PerplexityBot|CCBot|ChatGPT-User/);
});
it("generates an app-domain-only sitemap", async () => {
const response = handleSitemap();
const text = await response.text();
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("application/xml; charset=UTF-8");
expect(text).toContain("<loc>https://app.example.com/</loc>");
expect(text).toContain("<loc>https://app.example.com/ats-checker</loc>");
expect(text).not.toContain("docs.rxresu.me");
expect(text).not.toContain("/auth");
expect(text).not.toContain("/dashboard");
expect(text).not.toContain("/builder");
expect(text).not.toContain("/templates");
expect(text).not.toContain("/schema.json");
});
it("generates a lightweight llms.txt product index", async () => {
const response = handleLlms();
const text = await response.text();
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
expect(text).toContain("# Reactive Resume");
expect(text).toContain("- Product: https://app.example.com");
expect(text).toContain("- Documentation: https://docs.rxresu.me");
expect(text).toContain("- Documentation sitemap: https://docs.rxresu.me/sitemap.xml");
expect(text).toContain("- Documentation llms.txt: https://docs.rxresu.me/llms.txt");
expect(text).toContain("- API documentation: https://docs.rxresu.me/api-reference");
expect(text).toContain("- Resume schema: https://app.example.com/schema.json");
expect(text).toContain("- MCP documentation: https://docs.rxresu.me/guides/using-the-mcp-server");
expect(text).toContain("- OpenAPI specification: https://app.example.com/api/openapi/spec.json");
});
it("returns headers without a body for HEAD responses", async () => {
const response = handleLlms({ head: true });
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
expect(await response.text()).toBe("");
});
});
+49 -17
View File
@@ -1,4 +1,6 @@
import { env } from "@reactive-resume/env/server";
import { templateSchema } from "@reactive-resume/schema/templates";
import { getLocaleAlternates } from "@reactive-resume/utils/locale";
const DOCS_URL = "https://docs.rxresu.me";
@@ -34,17 +36,24 @@ export function handleRobots(options?: StaticSeoOptions) {
return textResponse(body, options);
}
// The indexable pages; everything else is the signed-in app or a public resume, both served noindex.
const sitemapPaths = ["/", "/ats-checker"];
export function handleSitemap(options?: StaticSeoOptions) {
const baseUrl = appUrl();
// Each page lists its languages, so every `?locale=` address is discoverable without a sitemap entry of its own.
const urls = sitemapPaths.map((path) => {
const pageUrl = `${baseUrl}${path}`;
const alternates = getLocaleAlternates(pageUrl).map(
({ hreflang, href }) =>
` <xhtml:link rel="alternate" hreflang="${hreflang}" href="${href.replaceAll("&", "&amp;")}"/>`,
);
return [" <url>", ` <loc>${pageUrl}</loc>`, ...alternates, " </url>"].join("\n");
});
const body = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
" <url>",
` <loc>${baseUrl}/</loc>`,
" </url>",
" <url>",
` <loc>${baseUrl}/ats-checker</loc>`,
" </url>",
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">',
...urls,
"</urlset>",
"",
].join("\n");
@@ -59,18 +68,41 @@ export function handleLlms(options?: StaticSeoOptions) {
const body = [
"# Reactive Resume",
"",
"Reactive Resume is an open-source resume builder for creating, managing, and exporting resumes.",
`> Reactive Resume is a free and open-source resume builder. Write a resume in an editor beside a live page, pick one of ${templateSchema.options.length} templates, check that applicant tracking systems can read it, tailor it to a job posting, and share it at a public link or download it. No ads, no tracking, no paid tier; it is funded by donations and released under the MIT License.`,
"",
"## Links",
"## Product",
"",
`- Product: ${baseUrl}`,
`- Documentation: ${DOCS_URL}`,
`- Documentation sitemap: ${DOCS_URL}/sitemap.xml`,
`- Documentation llms.txt: ${DOCS_URL}/llms.txt`,
`- API documentation: ${DOCS_URL}/api-reference`,
`- Resume schema: ${baseUrl}/schema.json`,
`- MCP documentation: ${DOCS_URL}/guides/using-the-mcp-server`,
`- OpenAPI specification: ${baseUrl}/api/openapi/spec.json`,
`- [Homepage](${baseUrl}/): what Reactive Resume does, with answers to common questions.`,
`- [ATS checker](${baseUrl}/ats-checker): a free tool that shows the text applicant tracking systems extract from a resume PDF and what to fix. It runs in the browser; the file is never uploaded.`,
`- [Get started](${baseUrl}/dashboard): create an account and a first resume.`,
"",
"## Facts",
"",
"- Price: free, every feature. Optional donations through GitHub Sponsors and Open Collective.",
"- Export: PDF, Word (DOCX), Markdown and JSON.",
"- Import: PDF, LinkedIn data export, JSON Resume, Reactive Resume JSON; Word files with an AI provider.",
"- Sharing: private by default; a public link or a password-protected link, changeable at any time.",
"- AI: optional, with the user's own API key (OpenAI, Anthropic, Google Gemini, OpenRouter, Ollama and others). Nothing is sent to an AI service without one.",
"- Also includes: cover letters, a job application tracker, version history, passkeys and two-factor authentication.",
"- Languages: the interface is translated into more than 50 languages by volunteers on Crowdin.",
"- Self-hosting: a Docker image, with PostgreSQL and local or S3-compatible storage.",
"",
"## Documentation",
"",
`- [Documentation](${DOCS_URL}): guides for using and self-hosting Reactive Resume.`,
`- [Documentation llms.txt](${DOCS_URL}/llms.txt)`,
`- [Self-hosting with Docker](${DOCS_URL}/self-hosting/docker)`,
`- [API reference](${DOCS_URL}/api-reference)`,
`- [OpenAPI specification](${baseUrl}/api/openapi/spec.json)`,
`- [Resume JSON schema](${baseUrl}/schema.json)`,
`- [MCP server guide](${DOCS_URL}/guides/using-the-mcp-server)`,
"",
"## Community",
"",
"- [Source code on GitHub](https://github.com/reactive-resume/reactive-resume)",
"- [Discord](https://discord.gg/aSyA5ZSxpb)",
"- [Subreddit](https://www.reddit.com/r/reactiveresume)",
"- [Translations on Crowdin](https://crowdin.com/project/reactive-resume)",
"",
].join("\n");
+10 -11
View File
@@ -8,6 +8,7 @@ const envMock = vi.hoisted(() => ({
S3_SECRET_ACCESS_KEY: "test-secret-key",
S3_REGION: "us-east-1",
S3_ENDPOINT: "",
STORAGE_BACKEND: "s3",
S3_BUCKET: "test-bucket",
S3_FORCE_PATH_STYLE: true,
}));
@@ -42,11 +43,6 @@ const server = createServer(async (request, response) => {
response.writeHead(200, { ETag: '"test-etag"' });
return response.end();
}
if (request.method === "DELETE") {
objects.delete(path);
response.writeHead(204);
return response.end();
}
const object = objects.get(path);
if (!object) return fail(404, "NoSuchKey");
response.writeHead(200, { "Content-Type": object.contentType, "Content-Length": object.data.length });
@@ -75,12 +71,6 @@ afterAll(async () => {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
});
it("keeps the ACL-disabled storage health check healthy", async () => {
expect(await storage.healthcheck()).toMatchObject({ status: "healthy", type: "s3" });
expect(requests.map(({ method }) => method)).toEqual(["PUT", "DELETE"]);
expect(objects.size).toBe(0);
});
it("stores images without ACLs and serves them through the signed application proxy", async () => {
const key = "uploads/user-1/pictures/photo.png";
const data = new Uint8Array([137, 80, 78, 71]);
@@ -108,3 +98,12 @@ it("stores private attachments without ACLs while keeping them outside the publi
expect(direct.status).toBe(403);
expect((await storage.read(key))?.data).toEqual(data);
});
it.each(["text/html", "image/svg+xml"])("downloads stored %s uploads instead of rendering them", async (type) => {
const key = "uploads/user-1/pictures/upload.bin";
await storage.write({ key, data: new TextEncoder().encode("<script>alert(1)</script>"), contentType: type });
const response = await handleUpload(new Request(`${envMock.APP_URL}/api/${key}`));
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("application/octet-stream");
expect(response.headers.get("Content-Disposition")).toBe('attachment; filename="upload.bin"');
});
-54
View File
@@ -1,54 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const readMock = vi.fn();
vi.mock("@reactive-resume/api/features/storage", () => ({
getStorageService: () => ({
read: readMock,
}),
}));
vi.mock("@reactive-resume/env/server", () => ({
env: {
APP_URL: "https://example.com",
},
}));
const { handleUpload } = await import("./uploads");
describe("handleUpload", () => {
beforeEach(() => {
readMock.mockReset();
});
it("serves public upload keys", async () => {
readMock.mockResolvedValueOnce({
data: new TextEncoder().encode("image"),
size: 5,
contentType: "image/jpeg",
});
const response = await handleUpload(new Request("https://example.com/api/uploads/user-1/pictures/photo.jpeg"));
expect(response.status).toBe(200);
expect(readMock).toHaveBeenCalledWith("uploads/user-1/pictures/photo.jpeg");
expect(response.headers.get("Content-Type")).toBe("image/jpeg");
expect(response.headers.get("Cross-Origin-Resource-Policy")).toBe("same-site");
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
});
it("does not serve private agent attachment keys through the public uploads route", async () => {
readMock.mockResolvedValueOnce({
data: new TextEncoder().encode("secret"),
size: 6,
contentType: "text/plain",
});
const response = await handleUpload(
new Request("https://example.com/api/uploads/user-1/agent/thread-1/attachment.txt"),
);
expect(response.status).toBe(404);
expect(readMock).not.toHaveBeenCalled();
});
});
+6 -3
View File
@@ -1,7 +1,11 @@
import { createHash } from "node:crypto";
import { basename, extname, normalize } from "node:path";
import { basename, normalize } from "node:path";
import { getStorageService, inferContentType } from "@reactive-resume/api/features/storage";
// Uploads share the app origin and S3/Blob hand back the client-declared type, so only raster
// images render inline. Anything else (HTML, SVG, PDF, unknown) downloads, whatever was stored.
const INLINE_CONTENT_TYPES = new Set(["image/gif", "image/jpeg", "image/png", "image/webp"]);
export async function handleUpload(request: Request) {
const { userId, filePath } = parseRouteParams(request.url);
@@ -16,13 +20,12 @@ export async function handleUpload(request: Request) {
if (!storedFile) return new Response("Not Found", { status: 404 });
const filename = filePath.split("/").pop() ?? filePath;
const ext = extname(filename).toLowerCase();
const contentType = storedFile.contentType ?? inferContentType(filename);
const etag = createEtag(storedFile);
if (isNotModified(request.headers, etag)) return makeNotModifiedResponse(etag);
const shouldForceDownload = [".pdf"].includes(ext);
const shouldForceDownload = !INLINE_CONTENT_TYPES.has(contentType);
const headers = new Headers();
headers.set("Content-Type", shouldForceDownload ? "application/octet-stream" : contentType);
+48 -167
View File
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
env: { APP_URL: "https://rxresu.me", ROOT_RESUME_ID: undefined as string | undefined },
serveStatic: vi.fn((_options?: unknown) => vi.fn()),
serveStatic: vi.fn(() => vi.fn()),
getPublicResumeSocialMeta: vi.fn(),
}));
@@ -29,18 +29,7 @@ vi.mock("@reactive-resume/env/server", () => ({
env: mocks.env,
}));
type StaticOptions = {
onFound?: (
path: string,
context: {
req: { path: string };
header: (name: string, value: string) => void;
},
) => void | Promise<void>;
};
const { handleWebApp } = await import("./web");
const staticOptions = mocks.serveStatic.mock.calls[0]?.[0] as StaticOptions | undefined;
describe("web app fallback classification", () => {
beforeEach(() => {
@@ -50,15 +39,6 @@ describe("web app fallback classification", () => {
mocks.getPublicResumeSocialMeta.mockResolvedValue(null);
});
it("serves the shell for the root app route without noindex", async () => {
const response = await handleWebApp(new Request("https://example.com/"));
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
expect(response.headers.get("X-Robots-Tag")).toBeNull();
expect(await response.text()).toBe("<html>app</html>");
});
it("injects canonical metadata and structured data into tracking-parameter root requests only", async () => {
vi.mocked(fs.readFile).mockResolvedValue(`
<!doctype html>
@@ -78,18 +58,37 @@ describe("web app fallback classification", () => {
const html = await response.text();
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/">');
expect(html).toContain('<link rel="preload" href="/videos/timelapse-v1.webp" as="image" fetchpriority="high">');
expect(html).toContain('<meta property="og:url" content="https://rxresu.me/">');
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/opengraph/banner.jpg">');
expect(html).toContain('id="reactive-resume-structured-data"');
expect(html).toContain('"@type":["SoftwareApplication","WebApplication"]');
expect(html).toContain('"url":"https://rxresu.me/"');
expect(html).toContain('<script type="application/ld+json">');
expect(html).not.toContain("utm_source");
const dashboardResponse = await handleWebApp(new Request("https://example.com/dashboard"));
expect(await dashboardResponse.text()).not.toContain('rel="canonical"');
});
it("serves the homepage prerendered in the requested or saved locale, with hreflang alternates", async () => {
vi.mocked(fs.readFile).mockImplementation((path) => {
const locale = String(path).match(/dist-prerender\/home\/(.+)\.html$/)?.[1];
return Promise.resolve(`<html><head></head><body><div id="app">${locale ?? "shell"}</div></body></html>`);
});
const german = await handleWebApp(new Request("https://example.com/?locale=de-DE"));
const html = await german.text();
expect(html).toContain('<div id="app">de-DE</div>');
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/?locale=de-DE">');
expect(html).toContain('<link rel="alternate" hreflang="x-default" href="https://rxresu.me/">');
expect(german.headers.get("Vary")).toBe("Cookie");
const saved = new Request("https://example.com/", { headers: { cookie: "theme=dark; locale=ar-SA" } });
const savedHtml = await (await handleWebApp(saved)).text();
expect(savedHtml).toContain('<div id="app">ar-SA</div>');
expect(savedHtml).toContain('<link rel="canonical" href="https://rxresu.me/">');
// Only known locales name a file, so the parameter can't point anywhere else.
const unknown = await handleWebApp(new Request("https://example.com/?locale=../../index"));
expect(await unknown.text()).toContain('<div id="app">en-US</div>');
});
describe("the ATS checker page", () => {
const shell = `<html><head><title>Reactive Resume — A free and open-source resume builder</title><meta name="description" content="Marketing copy."></head><body></body></html>`;
@@ -103,60 +102,27 @@ describe("web app fallback classification", () => {
expect(response.headers.get("X-Robots-Tag")).toBeNull();
});
it("replaces the shell metadata with the checker's own", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
it("serves the prerendered page in the requested locale, with its own title on the social cards", async () => {
vi.mocked(fs.readFile).mockImplementation((path) => {
const match = String(path).match(/dist-prerender\/ats-checker\/(.+)\.html$/);
if (!match) return Promise.resolve(shell);
return Promise.resolve(
`<html><head><title>ATS-Prüfung &amp; mehr</title><meta name="description" content="Lesbar?"></head><body><div id="app">${match[1]}</div></body></html>`,
);
});
const html = await (await handleWebApp(new Request("https://example.com/ats-checker"))).text();
const html = await (await handleWebApp(new Request("https://example.com/ats-checker?locale=de-DE"))).text();
expect(html).toContain("<title>ATS Checker - Reactive Resume</title>");
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/ats-checker">');
expect(html).toContain('<meta property="og:url" content="https://rxresu.me/ats-checker">');
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/opengraph/ats-checker.png">');
expect(html).toContain('id="ats-checker-structured-data"');
expect(html).not.toContain("Marketing copy.");
});
it("answers HEAD without a body", async () => {
const response = await handleWebApp(new Request("https://example.com/ats-checker", { method: "HEAD" }));
expect(response.status).toBe(200);
expect(await response.text()).toBe("");
});
it("does not treat the checker path as a public resume owner", async () => {
const response = await handleWebApp(new Request("https://example.com/ats-checker/anything"));
expect(response.status).toBe(404);
expect(mocks.getPublicResumeSocialMeta).not.toHaveBeenCalled();
expect(html).toContain('<div id="app">de-DE</div>');
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/ats-checker?locale=de-DE">');
expect(html).toContain('<meta property="og:title" content="ATS-Prüfung &amp; mehr">');
expect(html).toContain('<meta property="og:locale" content="de_DE">');
});
});
describe("public resume social cards", () => {
const shell = `<html><head><title>Reactive Resume — A free and open-source resume builder</title><meta name="description" content="Marketing copy."></head><body></body></html>`;
it("injects resume-specific social metadata and replaces the shell title", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
mocks.getPublicResumeSocialMeta.mockResolvedValue({
name: "Jane Doe",
title: "Jane Doe — Staff Engineer",
description: "Builds resilient distributed systems.",
template: "azurill",
});
const html = await (await handleWebApp(new Request("https://example.com/jane/resume"))).text();
expect(mocks.getPublicResumeSocialMeta).toHaveBeenCalledWith({ username: "jane", slug: "resume" });
expect(html).toContain("<title>Jane Doe - Reactive Resume</title>");
expect(html).toContain('<meta name="description" content="Builds resilient distributed systems.">');
expect(html).not.toContain("Marketing copy.");
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/jane/resume">');
expect(html).toContain('<meta property="og:type" content="profile">');
expect(html).toContain('<meta property="og:title" content="Jane Doe — Staff Engineer">');
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/opengraph/banner.jpg">');
expect(html).toContain('<meta name="twitter:card" content="summary_large_image">');
expect(html).toContain('<meta name="twitter:image" content="https://rxresu.me/opengraph/banner.jpg">');
});
it("escapes user-authored values so resume content cannot break out of the attribute", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
mocks.getPublicResumeSocialMeta.mockResolvedValue({
@@ -174,42 +140,20 @@ describe("web app fallback classification", () => {
expect(html).toContain('content="Ends with &quot; and &amp; ampersand"');
});
it("serves the plain shell when the resume is not publicly shareable", async () => {
it("keeps replacement patterns in resume text literal", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
mocks.getPublicResumeSocialMeta.mockResolvedValue({
name: "Jane $& $' Doe",
title: "Jane Doe",
description: "Costs $$ and $` nothing",
template: "azurill",
});
const html = await (await handleWebApp(new Request("https://example.com/jane/private"))).text();
const html = await (await handleWebApp(new Request("https://example.com/jane/resume"))).text();
expect(html).toBe(shell);
expect(html).toContain("<title>Jane $&amp; $&#39; Doe - Reactive Resume</title>");
expect(html).toContain('<meta name="description" content="Costs $$ and $` nothing">');
});
it("serves the plain shell when the lookup fails", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
mocks.getPublicResumeSocialMeta.mockRejectedValue(new Error("database unavailable"));
const response = await handleWebApp(new Request("https://example.com/jane/resume"));
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe(shell);
});
});
it("caches versioned homepage media immutably", async () => {
const headers = new Headers();
await staticOptions?.onFound?.("", {
req: { path: "/videos/timelapse-v1.mp4" },
header: (name, value) => headers.set(name, value),
});
expect(headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
const unversionedHeaders = new Headers();
await staticOptions?.onFound?.("", {
req: { path: "/videos/timelapse.mp4" },
header: (name, value) => unversionedHeaders.set(name, value),
});
expect(unversionedHeaders.get("Cache-Control")).toBeNull();
});
it.each(["/", "/alice/resume"])("sets framing and report-only CSP security headers on %s", async (pathname) => {
@@ -232,72 +176,9 @@ describe("web app fallback classification", () => {
expect(await response.text()).toBe("<html>app</html>");
},
);
it("serves noindex shell for public resume shaped routes", async () => {
const response = await handleWebApp(new Request("https://example.com/alice/resume"));
expect(response.status).toBe(200);
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, follow");
expect(await response.text()).toBe("<html>app</html>");
});
it("returns noindex 404 for unknown non-asset routes", async () => {
const response = await handleWebApp(new Request("https://example.com/unknown/extra/path"));
expect(response.status).toBe(404);
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
expect(await response.text()).toBe("Not Found");
expect(fs.readFile).not.toHaveBeenCalled();
});
it.each(["/api/foo", "/mcp/foo", "/uploads/foo"])(
"does not treat reserved two-segment path %s as a public resume",
async (pathname) => {
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
expect(response.status).toBe(404);
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
expect(await response.text()).toBe("Not Found");
expect(fs.readFile).not.toHaveBeenCalled();
},
);
it("returns plain 404 for missing asset-looking paths", async () => {
const response = await handleWebApp(new Request("https://example.com/assets/missing.css"));
expect(response.status).toBe(404);
expect(response.headers.get("X-Robots-Tag")).toBeNull();
expect(await response.text()).toBe("Not Found");
expect(fs.readFile).not.toHaveBeenCalled();
});
it("mirrors fallback status and headers for HEAD without a body", async () => {
const knownResponse = await handleWebApp(new Request("https://example.com/dashboard", { method: "HEAD" }));
const unknownResponse = await handleWebApp(
new Request("https://example.com/unknown/extra/path", { method: "HEAD" }),
);
expect(knownResponse.status).toBe(200);
expect(knownResponse.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
expect(knownResponse.headers.get("X-Robots-Tag")).toBe("noindex, follow");
expect(await knownResponse.text()).toBe("");
expect(unknownResponse.status).toBe(404);
expect(unknownResponse.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
expect(unknownResponse.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
expect(await unknownResponse.text()).toBe("");
});
});
describe("configured root shell", () => {
it.each(["GET", "HEAD"])("serves no-store noindex headers for %s", async (method) => {
mocks.env.ROOT_RESUME_ID = "private-or-missing-id";
const response = await handleWebApp(new Request("https://attacker.example/", { method }));
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, follow");
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
});
it("uses configured canonical root without leaking ID or marketing metadata", async () => {
mocks.env.ROOT_RESUME_ID = "private-or-missing-id";
vi.mocked(fs.readFile).mockResolvedValue(
+196 -123
View File
@@ -1,8 +1,11 @@
import type { Locale } from "@reactive-resume/utils/locale";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { serveStatic } from "@hono/node-server/serve-static";
import { env } from "@reactive-resume/env/server";
import { templateSchema } from "@reactive-resume/schema/templates";
import { defaultLocale, getLocaleAlternates, isLocale, localizedUrl } from "@reactive-resume/utils/locale";
function resolveWebDistPath() {
const candidates = [
@@ -19,6 +22,9 @@ function resolveWebDistPath() {
const staticRoot = resolveWebDistPath();
const indexHtmlPath = `${staticRoot}/index.html`;
// The marketing pages prerendered per locale by the web build (apps/web/vite.config.ts), as <page>/<locale>.html, kept
// beside dist/ so they're never served at an address of their own.
const prerenderRoot = `${staticRoot}-prerender`;
const noindexShellPrefixes = ["/auth", "/dashboard", "/builder", "/agent", "/templates"];
/**
* Marketing pages the SPA owns that search engines should index.
@@ -63,144 +69,146 @@ const BASE_SECURITY_HEADERS = {
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
// `wasm-unsafe-eval` lets the PDF engine (Forme, WebAssembly) start in the browser.
"Content-Security-Policy-Report-Only":
"default-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
"default-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
};
const githubUrl = "https://github.com/reactive-resume/reactive-resume";
// The English copy, for a build without prerendered pages; a prerendered page carries its own locale's title and
// description, and the social cards reuse them.
const ROOT_TITLE = "Reactive Resume — A free and open-source resume builder";
// Keep under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines.
const ROOT_DESCRIPTION =
"Free, open-source resume builder. Create, update, and share your resume, with no ads and no paywall.";
const ROOT_POSTER_PATH = "/videos/timelapse-v1.webp";
const ROOT_FAQ_ITEMS = [
{
question: "Is Reactive Resume really free?",
answer:
"Yes. Reactive Resume is free to use, with no hidden costs, premium tiers, or subscription fees. It's open source, and it will stay free.",
},
{
question: "How is my data protected?",
answer:
"Your data is stored securely and never shared with third parties. If you want full control over it, you can self-host Reactive Resume on your own servers.",
},
{
question: "Can I export my resume to PDF?",
answer: "Yes. One click exports your resume to PDF, with your formatting and styling intact.",
},
{
question: "Is Reactive Resume available in multiple languages?",
answer:
"Yes. Pick your language on the settings page, or with the language switcher in the top right corner. If your language is missing, or the existing translation could be better, you can contribute to the translations on Crowdin.",
},
{
question: "What makes Reactive Resume different from other resume builders?",
answer:
"Reactive Resume is open source, private, and free. It shows no ads, doesn't track what you do, and doesn't lock features behind a paywall.",
},
{
question: "How do I share my resume?",
answer: "Share it with a public URL, put a password on that URL, or download the PDF and send it yourself.",
},
] as const;
const ATS_CHECKER_TITLE = "Free ATS resume checker — Reactive Resume";
const ATS_CHECKER_DESCRIPTION =
"Check whether software can read your resume PDF. Runs entirely in your browser, so your file is never uploaded.";
function createRootSeoMarkup(canonicalUrl: string) {
const origin = new URL(canonicalUrl).origin;
const imageUrl = `${origin}/opengraph/banner.jpg`;
const structuredData = {
type StructuredData = Record<string, unknown>;
/** Who makes Reactive Resume, referenced by id from every page's structured data. */
function organization(origin: string): StructuredData {
return {
"@type": "Organization",
"@id": `${origin}/#organization`,
name: "Reactive Resume",
url: `${origin}/`,
logo: { "@type": "ImageObject", url: `${origin}/pwa-512x512.png`, width: 512, height: 512 },
sameAs: [
githubUrl,
"https://opencollective.com/reactive-resume",
"https://www.reddit.com/r/reactiveresume",
"https://discord.gg/aSyA5ZSxpb",
"https://crowdin.com/project/reactive-resume",
],
};
}
function homepageStructuredData(origin: string): StructuredData {
const rootUrl = `${origin}/`;
return {
"@context": "https://schema.org",
"@graph": [
organization(origin),
{
"@type": "WebSite",
"@id": `${origin}/#website`,
name: "Reactive Resume",
url: canonicalUrl,
url: rootUrl,
publisher: { "@id": `${origin}/#organization` },
},
{
"@type": ["SoftwareApplication", "WebApplication"],
name: "Reactive Resume",
url: canonicalUrl,
url: rootUrl,
description: ROOT_DESCRIPTION,
applicationCategory: "BusinessApplication",
operatingSystem: "Web",
isAccessibleForFree: true,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
codeRepository: "https://github.com/amruthpillai/reactive-resume",
},
{
"@type": "Project",
name: "Reactive Resume",
url: canonicalUrl,
sameAs: ["https://github.com/amruthpillai/reactive-resume"],
},
{
"@type": "FAQPage",
mainEntity: ROOT_FAQ_ITEMS.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: {
"@type": "Answer",
text: item.answer,
},
})),
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
license: `${githubUrl}/blob/main/LICENSE`,
codeRepository: githubUrl,
publisher: { "@id": `${origin}/#organization` },
featureList: [
"Resume editor with a live page preview",
`${templateSchema.options.length} templates`,
"PDF, Word (DOCX), Markdown and JSON export",
"Import from PDF, LinkedIn, JSON Resume and Word",
"ATS readability checker",
"Public or password-protected sharing links",
"Cover letters and a job application tracker",
"Optional AI assistant with your own API key",
"Self-hosting with Docker",
],
},
],
};
}
function atsCheckerStructuredData(origin: string): StructuredData {
return {
"@context": "https://schema.org",
"@graph": [
organization(origin),
{
"@type": "WebApplication",
name: "ATS Checker",
url: `${origin}/ats-checker`,
description: ATS_CHECKER_DESCRIPTION,
applicationCategory: "BusinessApplication",
operatingSystem: "Web",
isAccessibleForFree: true,
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
isPartOf: { "@type": "WebSite", "@id": `${origin}/#website`, name: "Reactive Resume", url: `${origin}/` },
provider: { "@id": `${origin}/#organization` },
},
],
};
}
type PageSeoOptions = {
/** The page's plain address: the canonical for the default locale, and the base of its hreflang alternates. */
canonicalUrl: string;
locale: Locale;
/** A `?locale=` request is canonical for its own language. */
requested: boolean;
/** Already HTML-escaped, as the page's own <title> and description are. */
title: string;
description: string;
imageUrl: string;
structuredData: StructuredData;
};
function createPageSeoMarkup(options: PageSeoOptions) {
const pageUrl = options.requested ? localizedUrl(options.canonicalUrl, options.locale) : options.canonicalUrl;
const alternates = getLocaleAlternates(options.canonicalUrl)
.map(({ hreflang, href }) => `<link rel="alternate" hreflang="${hreflang}" href="${escapeAttribute(href)}">`)
.join("");
return `
<link rel="canonical" href="${canonicalUrl}">
<link rel="preload" href="${ROOT_POSTER_PATH}" as="image" fetchpriority="high">
<link rel="canonical" href="${escapeAttribute(pageUrl)}">
${alternates}
<meta property="og:type" content="website">
<meta property="og:site_name" content="Reactive Resume">
<meta property="og:title" content="${ROOT_TITLE}">
<meta property="og:description" content="${ROOT_DESCRIPTION}">
<meta property="og:url" content="${canonicalUrl}">
<meta property="og:image" content="${imageUrl}">
<meta property="og:locale" content="${options.locale.replace("-", "_")}">
<meta property="og:title" content="${options.title}">
<meta property="og:description" content="${options.description}">
<meta property="og:url" content="${escapeAttribute(pageUrl)}">
<meta property="og:image" content="${options.imageUrl}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${ROOT_TITLE}">
<meta name="twitter:description" content="${ROOT_DESCRIPTION}">
<meta name="twitter:image" content="${imageUrl}">
<script id="reactive-resume-structured-data" type="application/ld+json">${JSON.stringify(structuredData)}</script>
<meta name="twitter:title" content="${options.title}">
<meta name="twitter:description" content="${options.description}">
<meta name="twitter:image" content="${options.imageUrl}">
<script type="application/ld+json">${JSON.stringify(options.structuredData)}</script>
`;
}
const ATS_CHECKER_TITLE = "ATS Checker - Reactive Resume";
// Keep under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines.
const ATS_CHECKER_DESCRIPTION =
"Check whether software can read your resume PDF. Runs entirely in your browser, so your file is never uploaded.";
function createAtsCheckerSeoMarkup(origin: string) {
const canonicalUrl = `${origin}/ats-checker`;
const imageUrl = `${origin}/opengraph/ats-checker.png`;
const structuredData = {
"@context": "https://schema.org",
"@type": "WebApplication",
name: "ATS Checker",
url: canonicalUrl,
description: ATS_CHECKER_DESCRIPTION,
applicationCategory: "BusinessApplication",
operatingSystem: "Web",
isAccessibleForFree: true,
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
isPartOf: { "@type": "WebSite", name: "Reactive Resume", url: `${origin}/` },
/** The title and description a page was built with, still HTML-escaped. */
function readPageMeta(html: string, fallback: { title: string; description: string }) {
return {
title: html.match(/<title>([^<]*)<\/title>/)?.[1] ?? fallback.title,
description: html.match(/<meta name="description" content="([^"]*)"/)?.[1] ?? fallback.description,
};
return `
<link rel="canonical" href="${canonicalUrl}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Reactive Resume">
<meta property="og:title" content="${ATS_CHECKER_TITLE}">
<meta property="og:description" content="${ATS_CHECKER_DESCRIPTION}">
<meta property="og:url" content="${canonicalUrl}">
<meta property="og:image" content="${imageUrl}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${ATS_CHECKER_TITLE}">
<meta name="twitter:description" content="${ATS_CHECKER_DESCRIPTION}">
<meta name="twitter:image" content="${imageUrl}">
<script id="ats-checker-structured-data" type="application/ld+json">${JSON.stringify(structuredData)}</script>
`;
}
// Resume names, headlines, and summaries are user-authored, so they must never reach the served
@@ -258,7 +266,7 @@ export const serveWebDistStatic = serveStatic({
});
function getFallbackResponseHeaders(pathname: string) {
if (pathname === "/" && env.ROOT_RESUME_ID?.trim()) {
if (pathname === "/" && env.ROOT_RESUME_ID) {
return {
"Content-Type": "text/html; charset=UTF-8",
"X-Robots-Tag": "noindex, follow",
@@ -280,6 +288,18 @@ function getFallbackResponseHeaders(pathname: string) {
return null;
}
/**
* A prerendered page's language: a `?locale=` address first (its hreflang alternates), then the visitor's saved
* choice, in the order the app reads them (apps/web/src/libs/locale.ts).
*/
function getPageLocale(request: Request) {
const requested = new URL(request.url).searchParams.get("locale");
if (isLocale(requested)) return { locale: requested, requested: true };
const saved = request.headers.get("cookie")?.match(/(?:^|;\s*)locale=([^;]*)/)?.[1] ?? "";
return { locale: isLocale(saved) ? saved : defaultLocale, requested: false };
}
function notFoundResponse(options: { head?: boolean; noindex?: boolean } = {}) {
const headers = new Headers({ "Content-Type": "text/plain; charset=UTF-8" });
if (options.noindex) headers.set("X-Robots-Tag", "noindex, nofollow");
@@ -290,6 +310,39 @@ function notFoundResponse(options: { head?: boolean; noindex?: boolean } = {}) {
});
}
const withMeta = (html: string, meta: { title: string; description: string }) =>
html
.replace(/<title>[^<]*<\/title>/, `<title>${meta.title}</title>`)
.replace(/<meta\s+name="description"[^>]*>/, `<meta name="description" content="${meta.description}">`);
/** The indexable pages the web build prerenders, by path. */
const prerenderedPages: Record<
string,
{
name: string;
meta: { title: string; description: string };
image: string;
structuredData: (origin: string) => StructuredData;
/** The page when the build has no prerendered copy: the app shell, titled for the page. */
fallback: (shell: string) => string;
}
> = {
"/": {
name: "home",
meta: { title: ROOT_TITLE, description: ROOT_DESCRIPTION },
image: "/opengraph/banner.jpg",
structuredData: homepageStructuredData,
fallback: (shell) => shell,
},
"/ats-checker": {
name: "ats-checker",
meta: { title: ATS_CHECKER_TITLE, description: ATS_CHECKER_DESCRIPTION },
image: "/opengraph/ats-checker.png",
structuredData: atsCheckerStructuredData,
fallback: (shell) => withMeta(shell, { title: ATS_CHECKER_TITLE, description: ATS_CHECKER_DESCRIPTION }),
},
};
// ponytail: GET and HEAD share the same routing logic; method determines body presence
export async function handleWebApp(request: Request) {
const isHead = request.method === "HEAD";
@@ -305,41 +358,61 @@ export async function handleWebApp(request: Request) {
if (isHead) return new Response(null, { status: 200, headers });
const html = await fs.readFile(indexHtmlPath, "utf-8");
const canonicalUrl = new URL("/", env.APP_URL).toString();
if (pathname === "/" && env.ROOT_RESUME_ID?.trim()) {
if (pathname === "/" && env.ROOT_RESUME_ID) {
const canonicalUrl = new URL("/", env.APP_URL).toString();
// Root configuration never discloses a target in the HTML shell. The public API
// gates data and browser metadata; shell requests must not count extra views.
const shell = html
.replace(/<title>[^<]*<\/title>/, "<title>Reactive Resume</title>")
.replace(/<meta\s+name="description"[^>]*>/, '<meta name="description" content="">');
const markup = `<link rel="canonical" href="${escapeAttribute(canonicalUrl)}" data-root-resume-shell><meta name="robots" content="noindex, follow" data-root-resume-shell>`;
return new Response(shell.replace("</head>", `${markup}</head>`), { headers });
return new Response(
shell.replace("</head>", () => `${markup}</head>`),
{ headers },
);
}
if (pathname === "/") {
return new Response(html.replace("</head>", `${createRootSeoMarkup(canonicalUrl)}</head>`), { headers });
}
if (pathname === "/ats-checker") {
const prerendered = prerenderedPages[pathname];
if (prerendered) {
const { locale, requested } = getPageLocale(request);
const origin = new URL(env.APP_URL).origin;
const withTitle = html
.replace(/<title>[^<]*<\/title>/, `<title>${ATS_CHECKER_TITLE}</title>`)
.replace(/<meta\s+name="description"[^>]*>/, `<meta name="description" content="${ATS_CHECKER_DESCRIPTION}">`);
// Without a prerendered page (a build that skipped it), the app renders the page in the browser.
const page = await fs
.readFile(`${prerenderRoot}/${prerendered.name}/${locale}.html`, "utf-8")
.catch(() => prerendered.fallback(html));
const markup = createPageSeoMarkup({
canonicalUrl: new URL(pathname, env.APP_URL).toString(),
locale,
requested,
...readPageMeta(page, prerendered.meta),
imageUrl: `${origin}${prerendered.image}`,
structuredData: prerendered.structuredData(origin),
});
return new Response(withTitle.replace("</head>", `${createAtsCheckerSeoMarkup(origin)}</head>`), { headers });
// The saved locale changes what this address shows, so caches have to key on the cookie.
return new Response(
page.replace("</head>", () => `${markup}</head>`),
{ headers: { ...headers, Vary: "Cookie" } },
);
}
if (isPublicResumePath(pathname)) {
const resumeSeo = await createPublicResumeSeoMarkup(pathname, new URL(env.APP_URL).origin);
if (resumeSeo) {
// The shell's generic title/description are replaced so shares and previews show the resume,
// not the marketing copy baked into index.html.
// not the marketing copy baked into index.html. Function replacers keep `$&`, `$'` etc. in user text literal.
const withTitle = html
.replace(/<title>[^<]*<\/title>/, `<title>${resumeSeo.pageTitle}</title>`)
.replace(/<meta\s+name="description"[^>]*>/, `<meta name="description" content="${resumeSeo.description}">`);
.replace(/<title>[^<]*<\/title>/, () => `<title>${resumeSeo.pageTitle}</title>`)
.replace(
/<meta\s+name="description"[^>]*>/,
() => `<meta name="description" content="${resumeSeo.description}">`,
);
return new Response(withTitle.replace("</head>", `${resumeSeo.markup}</head>`), { headers });
return new Response(
withTitle.replace("</head>", () => `${resumeSeo.markup}</head>`),
{ headers },
);
}
}
+99
View File
@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TRUSTED_IP_HEADERS } from "@reactive-resume/utils/rate-limit";
const mocks = vi.hoisted(() => ({
ipAddress: vi.fn<(request: Request) => string | undefined>(),
waitUntil: vi.fn(),
initializeAuth: vi.fn(),
attachDatabasePool: vi.fn(),
configureAgentStreamLifetime: vi.fn(),
pool: {},
getPool: vi.fn(),
createApp:
vi.fn<
(options: { serveStatic: boolean; trustedClient: (request: Request) => string }) => {
fetch: (request: Request) => Promise<Response>;
}
>(),
handle: vi.fn<(request: Request) => Promise<Response>>(),
}));
vi.mock("@vercel/functions", () => ({
ipAddress: mocks.ipAddress,
waitUntil: mocks.waitUntil,
attachDatabasePool: mocks.attachDatabasePool,
}));
vi.mock("@reactive-resume/api/features/agent/streams", () => ({
configureAgentStreamLifetime: mocks.configureAgentStreamLifetime,
}));
vi.mock("@reactive-resume/auth/config", () => ({ initializeAuth: mocks.initializeAuth }));
vi.mock("@reactive-resume/db/client", () => ({ getPool: mocks.getPool }));
vi.mock("./http/app", () => ({ createApp: mocks.createApp }));
function spoofedRequest() {
return new Request("https://resume.test/api/rpc?batch=1", {
method: "POST",
body: "original RPC body",
headers: {
...Object.fromEntries(TRUSTED_IP_HEADERS.map((header) => [header, "192.0.2.66"])),
"x-forwarded-for": "192.0.2.66, 192.0.2.77",
cookie: "session=original",
authorization: "Bearer original",
"content-type": "application/json",
},
});
}
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mocks.getPool.mockReturnValue(mocks.pool);
mocks.handle.mockResolvedValue(new Response("handled"));
mocks.createApp.mockReturnValue({ fetch: mocks.handle });
});
describe("Vercel adapter", () => {
it("registers platform lifetime hooks and disables filesystem static serving", async () => {
await import("./vercel");
expect(mocks.configureAgentStreamLifetime).toHaveBeenCalledExactlyOnceWith(mocks.waitUntil);
expect(mocks.attachDatabasePool).toHaveBeenCalledExactlyOnceWith(mocks.pool);
expect(mocks.createApp).toHaveBeenCalledExactlyOnceWith({
serveStatic: false,
trustedClient: expect.any(Function),
});
});
it.each(["203.0.113.9", "2001:db8::9"])("replaces all spoofed IP headers with platform IP %s", async (ip) => {
mocks.ipAddress.mockReturnValue(ip);
const { default: adapter } = await import("./vercel");
const request = spoofedRequest();
expect(await (await adapter.fetch(request)).text()).toBe("handled");
expect(mocks.ipAddress).toHaveBeenCalledExactlyOnceWith(request);
const forwarded = mocks.handle.mock.calls[0]?.[0];
if (!forwarded) throw new Error("Expected forwarded request");
for (const header of TRUSTED_IP_HEADERS) {
const expected = ["x-real-ip", "x-forwarded-for"].includes(header.toLowerCase()) ? ip : null;
expect(forwarded.headers.get(header)).toBe(expected);
}
expect(mocks.createApp.mock.calls[0]?.[0].trustedClient(forwarded)).toBe(ip);
expect(forwarded.url).toBe(request.url);
expect(forwarded.method).toBe("POST");
expect(await forwarded.text()).toBe("original RPC body");
expect(forwarded.headers.get("cookie")).toBe("session=original");
expect(forwarded.headers.get("authorization")).toBe("Bearer original");
expect(forwarded.headers.get("content-type")).toBe("application/json");
});
it.each([undefined, "", "invalid-ip", "203.0.113.9, 192.0.2.66"])(
"clears attacker headers when platform IP is missing or invalid: %s",
async (ip) => {
mocks.ipAddress.mockReturnValue(ip);
const { default: adapter } = await import("./vercel");
await adapter.fetch(spoofedRequest());
const forwarded = mocks.handle.mock.calls[0]?.[0];
if (!forwarded) throw new Error("Expected forwarded request");
for (const header of TRUSTED_IP_HEADERS) expect(forwarded.headers.has(header)).toBe(false);
expect(mocks.createApp.mock.calls[0]?.[0].trustedClient(forwarded)).toBe("unknown");
},
);
});
+29
View File
@@ -0,0 +1,29 @@
import { isIP } from "node:net";
import { attachDatabasePool, ipAddress, waitUntil } from "@vercel/functions";
import { configureAgentStreamLifetime } from "@reactive-resume/api/features/agent/streams";
import { initializeAuth } from "@reactive-resume/auth/config";
import { getPool } from "@reactive-resume/db/client";
import { TRUSTED_IP_HEADERS } from "@reactive-resume/utils/rate-limit";
import { createApp } from "./http/app";
configureAgentStreamLifetime(waitUntil);
attachDatabasePool(getPool());
const app = createApp({
serveStatic: false,
trustedClient: (request) => request.headers.get("x-real-ip") ?? "unknown",
});
export default {
async fetch(request: Request) {
await initializeAuth();
const ip = ipAddress(request);
const headers = new Headers(request.headers);
for (const name of TRUSTED_IP_HEADERS) headers.delete(name);
headers.delete("x-real-ip");
if (ip && isIP(ip)) {
headers.set("x-real-ip", ip);
headers.set("x-forwarded-for", ip);
}
return app.fetch(new Request(request, { headers }));
},
};
+58 -4
View File
@@ -8,9 +8,56 @@ const rootPackageJson = JSON.parse(readFileSync(new URL("../../package.json", im
version?: string;
};
// Lambda disables require(ESM) and uses stricter CJS export detection than standalone Node. Vercel's service builder
// also loads external CommonJS packages through pnpm links it leaves out of the Function, so CommonJS dependencies
// (ioredis, react-reconciler) are bundled together with their own dependencies.
const bundledInteropPackages = new Set([
"ioredis",
"@ioredis/commands",
"cluster-key-slot",
"debug",
"ms",
"denque",
"redis-errors",
"standard-as-callback",
"react-reconciler",
"scheduler",
"@uiw/color-convert",
"@babel/runtime",
"sanitize-html",
"htmlparser2",
"domhandler",
"domutils",
"domelementtype",
"dom-serializer",
"entities",
"deepmerge",
"escape-string-regexp",
"is-plain-object",
"parse-srcset",
"postcss",
"nanoid",
"picocolors",
"source-map-js",
"launder",
"dayjs",
]);
const packageNameOf = (id: string) =>
id
.split("/")
.slice(0, id.startsWith("@") ? 2 : 1)
.join("/");
// Matches subpath imports too, such as `react-reconciler/constants.js`.
const shouldBundle = (id: string) =>
id.startsWith("@reactive-resume/") || bundledInteropPackages.has(packageNameOf(id));
const shouldExternalizeThirdParty = (id: string) => {
if (id.startsWith("@reactive-resume/")) return false;
if (id.startsWith("@/") || id.startsWith(".") || id.startsWith("/") || id.startsWith("\0")) return false;
if (shouldBundle(id)) return false;
// Subpath imports (`#…`) are resolved by the workspace package that declares them, so they're bundled with it.
if (id.startsWith("@/") || id.startsWith("#") || id.startsWith(".") || id.startsWith("/") || id.startsWith("\0"))
return false;
return true;
};
@@ -33,7 +80,14 @@ const promptAssetsPlugin: TsdownPlugin = {
};
export default defineConfig({
entry: { index: "src/index.ts" },
entry: {
index: "src/index.ts",
vercel: "src/vercel.ts",
"prepare-deployment": "src/prepare-deployment.ts",
"migrate-legacy-styles": "src/migrate-legacy-styles.ts",
},
// Keep import.meta.url-based asset lookup adjacent to the entrypoints.
outputOptions: { chunkFileNames: "[name]-[hash].mjs" },
format: "esm",
platform: "node",
target: "node24",
@@ -47,7 +101,7 @@ export default defineConfig({
suppressWarnings: [/dynamic import will not move module into another chunk/],
outExtensions: () => ({ js: ".mjs" }),
deps: {
alwaysBundle: [/^@reactive-resume\//],
alwaysBundle: shouldBundle,
neverBundle: shouldExternalizeThirdParty,
},
plugins: [promptAssetsPlugin],
+2
View File
@@ -0,0 +1,2 @@
// Vercel service entrypoint. It must exist before the build, so it re-exports the adapter that tsdown emits.
export { default } from "./dist/vercel.mjs";
+26 -7
View File
@@ -1,9 +1,23 @@
<!doctype html>
<html lang="en" class="dark">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#09090B" />
<meta name="theme-color" content="#F8F7F3" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#100F0C" media="(prefers-color-scheme: dark)" />
<!-- Apply the saved or system theme before first paint, so nothing flashes. Mirrors libs/theme.ts. -->
<script>
(() => {
try {
const match = document.cookie.match(/(?:^|; )theme=([^;]*)/);
const theme = match ? decodeURIComponent(match[1]) : "system";
const dark = theme === "dark" || (theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", dark);
} catch {
// Without cookies or matchMedia the page starts light, and the app corrects it once it loads.
}
})();
</script>
<meta name="application-name" content="Reactive Resume" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
@@ -20,11 +34,12 @@
<title>Reactive Resume — A free and open-source resume builder</title>
</head>
<body>
<!-- Keep #app empty: main.tsx only mounts React when rootElement has no children. -->
<!-- Empty here; the server prerenders the homepage into it (apps/server/src/static/web.ts). -->
<div id="app"></div>
<!-- Branded first paint; hidden once React populates #app (higher-specificity rule below). -->
<div id="initial-loader">
<img src="/icon/dark.svg" width="48" height="48" alt="Reactive Resume" />
<img class="initial-loader__logo-light" src="/icon/light.svg" width="48" height="48" alt="Reactive Resume" />
<img class="initial-loader__logo-dark" src="/icon/dark.svg" width="48" height="48" alt="" />
<div class="initial-loader__spinner"></div>
<span class="initial-loader__sr-only">Loading</span>
</div>
@@ -38,17 +53,21 @@
align-items: center;
justify-content: center;
gap: 24px;
background: #09090b;
background: #f8f7f3;
}
html.dark #initial-loader { background: #100f0c; }
.initial-loader__logo-dark, html.dark .initial-loader__logo-light { display: none; }
html.dark .initial-loader__logo-dark { display: block; }
#app:not(:empty) ~ #initial-loader { display: none; }
.initial-loader__spinner {
width: 24px;
height: 24px;
border: 2px solid rgba(250, 250, 250, 0.2);
border-top-color: #fafafa;
border: 2px solid rgba(28, 27, 21, 0.2);
border-top-color: #1c1b15;
border-radius: 9999px;
animation: app-spin 0.7s linear infinite;
}
html.dark .initial-loader__spinner { border-color: rgba(239, 238, 235, 0.2); border-top-color: #efeeeb; }
.initial-loader__sr-only {
position: absolute;
width: 1px;
+5349 -3243
View File
File diff suppressed because it is too large Load Diff
+5350 -3244
View File
File diff suppressed because it is too large Load Diff
+5349 -3243
View File
File diff suppressed because it is too large Load Diff
+5349 -3243
View File
File diff suppressed because it is too large Load Diff
+5348 -3242
View File
File diff suppressed because it is too large Load Diff
+5361 -3255
View File
File diff suppressed because it is too large Load Diff
+5347 -3241
View File
File diff suppressed because it is too large Load Diff
+5348 -3242
View File
File diff suppressed because it is too large Load Diff
+5347 -3241
View File
File diff suppressed because it is too large Load Diff
+5347 -3241
View File
File diff suppressed because it is too large Load Diff
+5349 -3243
View File
File diff suppressed because it is too large Load Diff
+5347 -3241
View File
File diff suppressed because it is too large Load Diff
+5336 -3230
View File
File diff suppressed because it is too large Load Diff
+5346 -3240
View File
File diff suppressed because it is too large Load Diff
+5356 -3250
View File
File diff suppressed because it is too large Load Diff
+5346 -3240
View File
File diff suppressed because it is too large Load Diff
+5346 -3240
View File
File diff suppressed because it is too large Load Diff
+5348 -3242
View File
File diff suppressed because it is too large Load Diff
+5355 -3249
View File
File diff suppressed because it is too large Load Diff
+5349 -3243
View File
File diff suppressed because it is too large Load Diff
+5354 -3248
View File
File diff suppressed because it is too large Load Diff
+5346 -3240
View File
File diff suppressed because it is too large Load Diff
+5348 -3242
View File
File diff suppressed because it is too large Load Diff
+5350 -3244
View File
File diff suppressed because it is too large Load Diff
+5352 -3246
View File
File diff suppressed because it is too large Load Diff
+5347 -3241
View File
File diff suppressed because it is too large Load Diff
+5347 -3241
View File
File diff suppressed because it is too large Load Diff
+5346 -3240
View File
File diff suppressed because it is too large Load Diff
+5350 -3244
View File
File diff suppressed because it is too large Load Diff
+5361 -3255
View File
File diff suppressed because it is too large Load Diff
+5347 -3241
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More