Compare commits

...
50 Commits
Author SHA1 Message Date
Amruth Pillai 9df2a5287d chore(release): v5.1.4 2026-05-14 15:57:40 +02:00
Amruth Pillai 6d8d8f6e55 feat: add AI agent workspace (#3062)
* chore(ai): remove local AI store now that providers live server-side

The Zustand-based useAIStore has been replaced by the server-side
aiProviders oRPC router (encrypted credentials persisted in DB).
Delete the dead store + tests, drop the ./store export, and remove
zustand/immer deps which are no longer referenced anywhere in
packages/ai/src/.

* feat(agent): archive/delete actions and read-only state for agent threads

- Backend: mark archived threads as read-only in threads.get and reject
  messages.send with CONFLICT when the thread is archived.
- Frontend: render archived threads in the sidebar with muted styling and
  an Archived badge; add a per-thread dropdown menu in the chat header
  with Archive (non-destructive) and Delete (with confirmation); show a
  read-only banner above the message list that disambiguates archived
  vs. missing-resource causes; suppress the Retry and Stop buttons in
  read-only mode.
- Tests: new packages/api/src/services/agent.test.ts covering the
  archived-thread isReadOnly flag and the archived-thread send refusal.

* fix(agent): abort run on archive and verify ownership before deleting thread

- threads.archive: before flipping status, abort any in-flight run controller
  and clear the active-run state on the thread; cleanup failures are logged
  but do not block the status update.
- threads.delete: assert thread ownership via getThread before destructive
  work so an authenticated user cannot wipe another user's attachment rows
  by passing a foreign threadId.

Adds focused tests for both behaviors.

* feat(agent): display patch diffs and surface revert conflicts

Render apply_resume_patch tool messages with a status-aware card (applied/
reverted/conflicted), expandable operation list, and a Revert button that
correctly handles RESUME_VERSION_CONFLICT responses. Adds unit tests for
the inverse-patch builder and the agentService.actions.revert flow.

* chore(agent): remove out-of-scope attachment tests accidentally added in Task 6

The Task 6 commit (73ef1acca) accidentally re-introduced three attachment-
related tests that belong to a separate task:

- `buildAttachmentModelParts > converts text, image, supported binary, and
  unsupported attachments into model parts`
- `agentService.messages.send > persists the user message with file UI parts
  and links selected attachments to it` (was failing — the `ToolLoopAgent`
  mock is not callable as a constructor)
- `agentService.messages.send > rejects attachments that are missing, foreign,
  or already linked before persisting a message`

These were likely re-added during a stash recovery and were not requested
for Task 6, whose scope was limited to the `agentService.actions.revert`
flow. Remove them along with the helpers/fixtures (`buildAttachment`,
`buildActiveThread`, `selectWhereResult`, `selectOrderByResult`) that they
were the only consumers of. `selectLimitResult` is preserved because it is
used by the revert tests.

* chore(agent): configure runtime dependencies

* feat(db): add agent workspace schema

* feat(api): add agent backend services

* feat(web): add agent workspace UI

* chore(agent): remove legacy builder assistant

* test(agent): make agent stream mocks constructible

* chore(web): remove unused resume replacement hook

* feat(api): add unsafe AI base URL flag

* chore(dev): expose local services in compose

* fix(web): normalize resume preview gaps

* feat(api): improve agent tool handling

* feat(web): polish agent workspace UI

* chore: update dependencies

* fix(api,web): address PR review feedback for agent workspace

Security/correctness:
- Restrict AI provider URLs to http/https even in unsafe mode
- Stop exposing Redis on host network by default
- Make .env.local optional and drop app profile in compose.dev.yml
- Store agent attachments with private ACL on S3
- Reset provider test status when provider/model/baseURL changes
- Decouple non-agent AI endpoints from REDIS_URL requirement
- Fix JSON Patch add inverse for existing object members
- Wrap resume patch + agent action insert in db transaction
- Validate partialMessage at runtime and rate-limit attachment uploads
- Add unique index on agent_messages (thread_id, sequence)

UX/bugs:
- Mark agent thread route as ssr: false and guard SSE chunk parsing
- Show config-specific banner only on known configuration error
- Gate AI provider checks behind loading state in resume import
- Fix relative-time formatter blank gap between 45-59 seconds
- Clarify thread delete confirmation message

Polish:
- Raise ENCRYPTION_SECRET minimum to 32 characters
- Bucket AI rate limits by resumeId/threadId/messageId
- Trim form values before submitting AI provider config
- Use single key identifier and nullish-coalesce baseURL display

* fix: address ai agent review feedback

* fix: preserve mobile agent chat state

* docs: add ai agent workspace guides

* feat: introduce design system for Reactive Resume
2026-05-14 15:00:04 +02:00
JamesGoslings 22c60c64b6 fix(fonts): restore legacy local font names via metric-compatible ali… (#3057)
* fix(fonts): restore legacy local font names via metric-compatible aliases

Closes #2989.

In v5.0.x the Puppeteer renderer resolved fonts like 'Times New Roman'
or 'Arial' through the browser's font stack. The v5.1 migration to
@react-pdf/renderer requires every font to be Font.register()-ed; the
legacy local-font names were not carried over, so resumes upgraded
from v5.0.x had their typography silently replaced with IBM Plex Serif,
changing line breaks, page counts and overall layout.

This adds a render-time alias layer mapping the old names to
metric-compatible web fonts already shipped in the webfont list:

  Times New Roman → Tinos
  Cambria         → Tinos
  Arial           → Arimo
  Garamond        → EB Garamond
  Calibri         → Source Sans 3

- packages/fonts:
  - new `legacyFontAliases` map and `resolveLegacyFontAlias` helper.
  - `getFont` falls back to the alias map when the direct lookup misses,
    so any caller that asked 'is this a known family?' now answers
    truthfully for the legacy names.
  - `getFontDisplayName` is intentionally unchanged: the typography
    sidebar keeps showing the user's original choice ('Times New Roman'),
    while the renderer transparently swaps in the alias target.

- packages/pdf/use-register-fonts:
  - `resolvePdfFontFamily` returns the alias target when one applies,
    so `Font.register` runs against the right web font and templates
    receive a family name they can actually render.

Backwards compatible: families that were never aliased (Roboto, IBM
Plex Serif, the standard PDF fonts, ...) take exactly the same code
path as before. The CJK glyph fallback added in #2986 / PR #3013
continues to apply on top of the resolved primary family.

* fix(fonts): use Carlito (not Source Sans 3) as Calibri alias

Per maintainer review feedback: Carlito is metric-compatible with
Calibri, while Source Sans 3 only matches visually. Switching gives
upgraded resumes the same line widths, line breaks and page counts
they had under v5.0.x.

- packages/fonts/webfontlist.json: add Carlito (Google Fonts, weights
  400/700 + italics) so it's a registerable target.
- packages/scripts/fonts/generate.ts: add a getMetricCompatibleFonts
  helper and merge it into the output, mirroring how Computer Modern
  fonts are appended. This way regenerating the list (`pnpm generate`)
  re-emits Carlito automatically and dedupes if it ever enters the
  Google Fonts popularity slice.
- packages/fonts/src/index.ts: alias `Calibri → Carlito`.
- packages/fonts/src/index.test.ts: update alias test cases.
2026-05-14 11:36:15 +02:00
Amruth Pillai affa1d6646 docs: enhance documentation and guides with new features and updates 2026-05-14 03:38:39 +02:00
SirSKillzandAmruth Pillai c71f3b0b92 Feat: Add configurable AI provider base URL flag and update documentation (#3059)
* feat: add FLAG_ALLOW_UNSAFE_AI_BASE_URL for configurable AI provider base URLs

* feat: add FLAG_ALLOW_UNSAFE_AI_BASE_URL documentation

* fix: remove AI_ALLOWED_BASE_URLS from documentation and environment variable reference

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-05-14 03:01:37 +02:00
Amruth Pillai 6c4a4b2aa5 Render public resumes with PDF.js (#3061)
* fix(web): use native pdf viewer for public resumes

* fix(web): render public resumes with pdf.js

* chore: revert vite hook paths

* chore(web): address pdf viewer review
2026-05-14 02:49:43 +02:00
Amruth Pillai 1294d3354a feat(docker): enhance development setup with reactive_resume service and health checks 2026-05-13 15:35:08 +02:00
Claudeamruthpillaianthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com>
42fc78dca1 [WIP] Fix dead link to Using Custom CSS in docs (#3056)
* Initial plan

* docs: remove reference to removed Custom CSS guide

Agent-Logs-Url: https://github.com/amruthpillai/reactive-resume/sessions/82961e42-251b-41da-80ee-7697968566f7

Co-authored-by: amruthpillai <1134738+amruthpillai@users.noreply.github.com>

---------

Co-authored-by: anthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com>
Co-authored-by: amruthpillai <1134738+amruthpillai@users.noreply.github.com>
2026-05-13 11:22:25 +02:00
Amruth Pillai aa7af040fb chore(release): v5.1.3 2026-05-13 09:29:59 +02:00
Amruth Pillai 5f63dc876b feat: new template (scizor) 2026-05-13 09:26:38 +02:00
Kaloian Kozlev 014ceee31f Add DOCX to export formats in README (#3054) 2026-05-13 09:10:30 +02:00
github-actions[bot]andCrowdin Bot 70dff5bf49 [skip ci] chore(i18n): sync translations from crowdin (#3053)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-13 09:10:00 +02:00
Amruth Pillai c5787fe155 chore: translations for a new template 2026-05-13 08:06:31 +02:00
Amruth Pillai 286e165a60 [codex] Hide empty sections from page settings (#3052)
* feat(web): hide empty layout sections

* test: add "scizor" to templates metadata test cases
2026-05-13 01:11:19 +02:00
Amruth Pillai 00dafd0c68 feat: add new resume template "Scizor" 2026-05-13 01:07:35 +02:00
brone1323 d251d602fb fix(pdf): increase headerNameLineHeight to 1.3 to prevent descender clipping (#3050)
All Heading elements apply overflow:hidden via safeTextStyle in primitives.tsx.
At 1.5× heading font size with lineHeight 1.2, the line box is too tight to
fully render descenders (g, p, y, etc.) in the resume header name field,
causing them to appear visually cut off.

Raising headerNameLineHeight from 1.2 to 1.3 adds enough vertical room for
descenders across all 13 templates that share this constant.

Fixes #3042
2026-05-13 01:02:16 +02:00
Amruth Pillai e35ff83911 chore: update dependencies 2026-05-13 00:44:51 +02:00
Amruth Pillai 62b0a1d533 fix(cjk): resolve hyphenation callback with cjk content in resume 2026-05-11 22:04:45 +02:00
Amruth PillaiandCursor Agent 0a8fe05653 Fix split row right content promotion (#3039)
* Fix split row right content promotion

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* refactor: remove MetaLine component and update sections to use Text instead

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-05-11 15:59:26 +02:00
Amruth Pillai de7baa5faf test: add ~500 tests across web, utils, api, import, ai, db, email, auth (#3038)
* test(web): add tests for zustand stores and pure helpers

Cover stores and pure helpers across the builder/dashboard/command-palette
surfaces that previously had 0% coverage:

- command-palette store (open/close, page stack, search clearing, goBack)
- builder assistant-store
- builder sidebar store + parseBuilderLayoutCookie / mapPanelLayoutToBuilderLayout
- builder section store (collapse, toggle, toggleAll)
- builder preview page-layout toggle
- dashboard resume-thumbnail render-size math + cache key
- MCP tool name + annotations invariants

* test(web): cover MCP helpers and template metadata

Add tests for previously 0%-coverage MCP and dialog helpers:

- buildMcpServerCard: server-info, tool catalog vs MCP_TOOL_NAME, prompts,
  resource templates, configuration schema, auth schemes
- registerPrompts (build/improve/review): registration, args schema, resource
  context with interpolated resume id, read-only / no-fabrication directives
- registerResources (resume://{id}, resume://_meta/schema): handler reads via
  oRPC client, error on missing id, schema returns valid JSON
- templates metadata: ids match display names, valid sidebar positions,
  unique image URLs, every entry has tags + description

* test(web): cover sidebar section helpers and layout screens

Add tests for previously near-0%-coverage modules:

- libs/resume/section: getSectionTitle / getSectionIcon return distinct,
  exhaustive results for every sidebar section + cover-letter; icon props
  forwarding; left/right sidebar collections do not overlap.
- layout/loading-screen: spinner + text render.
- layout/error-screen: error message surfaces, Refresh button triggers reset.
- layout/breakpoint-indicator: default + each corner positioning, all
  breakpoint labels rendered, print-hidden class applied.

* test(web): cover preview canvas math and font-weight defaults

Add tests for pure helpers that previously had no direct coverage:

- typography/getNextWeights: prefers 400 + 600 when both are available,
  returns null for unknown families, never produces duplicates, bounded
  to two weights from the 100..900 set.
- preview.shared/normalizeResumePreviewProps: documented defaults +
  pass-through.
- preview.shared/getScaledPreviewPageSize: scaling identity at 1, and
  fractional scaling.
- preview.shared/getPreviewCanvasScale: respects 4x desired scale for
  small pages, honors high devicePixelRatio, clamps to the 16M-pixel
  canvas budget for large pages.

* test(utils): cover DOCX section renderers and html-to-paragraphs

@reactive-resume/utils/resume/docx was previously at ~2.84% statement
coverage despite being load-bearing for the resume DOCX export.

- section-renderers: empty-string / hidden-section / hidden-item branches
  for renderSummary, renderBuiltInSection, and renderCustomSection;
  cover-letter and summary custom-section dispatch; unknown-type fallback;
  setRenderConfig idempotency.
- html-to-docx: whitespace-only short-circuit, multiple top-level blocks,
  h1..h6 paragraph mapping, inline style and link rendering, custom
  font/size/color/linkColor config, ignored script/comment nodes.

* test: cover DOCX builder smoke paths and reactive-resume JSON importer

- utils/resume/docx/builder: buildDocument runs end-to-end against the
  default and sample resume data, both page formats, full-width and
  sidebar layouts, and gracefully degrades with unparseable color or
  empty font family inputs.
- import/reactive-resume-json: ReactiveResumeJSONImporter validates
  malformed JSON, recovers missing built-in sections by appending them
  to page 1 without reordering, and preserves layouts that already
  contain every built-in section.

* test(import): cover JSONResumeImporter parse/convert

JSONResumeImporter (450 lines) was previously at 0% coverage. Add tests
for the public surface:

- Invalid JSON / invalid-shape errors are surfaced.
- basics, summary, picture, education, projects, skills, profiles all
  map to the corresponding ResumeData sections.
- Empty work/education entries (missing key field) are filtered out.
- Highlights become HTML list items in the description field.
- Skill level parsing flows through utils/level.parseLevel.
- formatLocation joins city, region, countryCode with commas.

* test(web): cover query client serializer and home-page animations

- libs/query/client: getQueryClient returns a fresh QueryClient,
  queryKeyHashFn produces stable JSON envelopes for matching keys
  (and distinct strings for different keys), dehydrate/hydrate
  round-trip Date values via the oRPC serializer.
- components/animation/spotlight: overlay container is
  pointer-events-none, both beam groups render, custom
  width/height/translateY/gradient props flow into inline styles.
- components/animation/comet-card: children mount inside the
  perspective wrapper, custom className is merged with the 3D
  baseline classes, glare overlay renders, mouse move/leave
  handlers do not throw.

* test(web): cover Copyright footer

Verify the footer's MIT license link, Amruth Pillai attribution,
external-tab targets, embedded app version (via __APP_VERSION__ stub),
and custom className merging — previously at 0% coverage.

* test(api): cover flags, auth providers, and resume-access cookies

Unlock @reactive-resume/api by mocking @reactive-resume/env/server and
@tanstack/react-start/server. Previously the only services tested were
the standalone AI test and resume-access-policy.

- services/flags: flagsService.getFlags reads disableSignups/disableEmailAuth
  from env (no stale cache).
- services/auth: providers.list always exposes credential + passkey, and
  only adds Google/GitHub/LinkedIn/custom when both id and secret are set;
  custom provider uses OAUTH_PROVIDER_NAME with a 'Custom OAuth' fallback.
- helpers/resume-access: hasResumeAccess validates against signed cookies
  with constant-time comparison; grantResumeAccess writes a 10-minute
  httpOnly cookie with the secure flag matching APP_URL's https-ness.

* test: cover statistics service and email transport via env mocks

- api/services/statistics: github star count succeeds, retries on
  non-OK, falls back to last-known on fetch error / non-positive /
  non-numeric responses; user and resume counts roll up DB count.
- email/src/transport: returns silently with no text/html, logs when
  SMTP is not configured, dispatches via nodemailer with the env
  config when fully wired, renders react elements to html + text
  bodies, swallows transport errors instead of crashing.

* test(api): cover resume-events publish + subscribe

- publishResumeUpdated issues pg_notify with channel and serialized
  event payload.
- subscribeResumeUpdated yields events whose resumeId+userId match
  the subscription, filters out other resumes/users, ignores
  malformed JSON and notifications on other channels, calls
  LISTEN/UNLISTEN and releases the client, and terminates
  immediately if the abort signal fires before iteration starts.

* test(api): cover oRPC auth resolution

resolveUserFromRequestHeaders is the single point where every oRPC
procedure picks up the authenticated user. Test the priority chain:

- x-api-key wins when present and valid
- on invalid api key, falls back to session via auth.api.getSession
- Bearer JWT in Authorization header is verified via verifyOAuthToken
- invalid Bearer falls back to session
- Authorization scheme other than Bearer is ignored entirely
- thrown errors from token verification are logged and swallowed
  (caller still tries session)
- returns null when no auth method succeeds

* test(api): cover storage helpers

inferContentType, isImageFile, processImageForUpload were 0%
coverage despite being on the picture upload path.

- inferContentType maps known image and pdf extensions, is
  case-insensitive, ignores path depth, and falls back to
  application/octet-stream for unknown.
- isImageFile allows only the upload allowlist (gif/png/jpeg/webp)
  and rejects image/svg+xml, application/pdf, and empty strings.
- processImageForUpload short-circuits to the original bytes when
  FLAG_DISABLE_IMAGE_PROCESSING is true, otherwise pipes through
  sharp and returns image/jpeg.

* test(import): broaden v4 importer section-mapping coverage

The existing v4 importer test focused on a single bug (description-only
custom items) and the skill/language level scaling. This new test
exercises the bulk of the v4 → v5 transformation path:

- basics, picture (with border), summary, customFields
- every section's filter-by-required-field invariant (awards needs
  title, certifications needs name, education needs institution,
  experience needs company, volunteer needs organization, etc.)
- experience / education / awards / certifications / references field
  renames between schemas
- language and skill level scaling (v4 0..10 → v5 0..5)

Brings reactive-resume-v4-json from ~66% statement coverage to a
materially higher figure (the bulk of the 410-line transformer body).

* test: cover buildDocx entry and AI configuration store

- utils/resume/docx/index: buildDocx returns a non-empty Blob for both
  default and populated resumes (previously 0% coverage despite being
  the public DOCX entry point).
- ai/store: useAIStore preserves verification status across no-op
  updates, but resets testStatus + enabled whenever provider, model,
  apiKey, or baseURL changes; canEnable is gated to testStatus=success;
  setEnabled(true) is refused unless verified; reset clears every
  field. Brings @reactive-resume/ai from ~72% to materially higher
  coverage.

* test(db): cover resume schema definitions

packages/db was previously at 0% coverage. Smoke-test the public
resume / resume_statistics / resume_analysis tables:

- getTableName matches the SQL identifier used by migrations
- expected columns are present on each table
- defaultResumeData wiring on the data column resolves to a valid
  shape

These are structural assertions that catch accidental renames /
removals without needing a live database connection.

* test(db): cover auth schema tables and relations export

- src/schema/auth: table-driven test for each of the 12 auth tables
  asserting SQL name and presence of the key columns (user/session/
  account/verification/two_factor/passkey/apikey/jwks/oauth_*).
- src/relations: smoke test confirming the relations export is defined.

Brings @reactive-resume/db from 0% to materially higher coverage.

* test(auth): cover getSession isomorphic helper

@reactive-resume/auth was previously at 0% coverage. functions.ts
is the server entry point that other packages call. Mock the auth
config + tanstack/react-start to verify:

- getSession forwards getRequestHeaders() to auth.api.getSession
- returns null when better-auth returns null

* test(web): cover BuilderSidebarEdge

Small presentational component on the builder layout — assert children
mount, left/right positioning class branches, and the sm:flex
mobile-hide behavior.

* test(web): cover section-title-locale resolver cache and hook

The section-title-locale module wraps createSectionTitleResolver with
a per-locale async cache and a React hook for consumers in the
builder. Cover:

- createSectionTitleResolverForLocale returns a usable resolver
- repeated calls for the same locale share a cached promise
- unknown locales fall back through resolveLocale
- useSectionTitleResolver returns null while loading and when no
  locale is passed
- the hook resolves to a function once the async loader settles

* test(web): cover BaseCommandGroup page-stack gating

BaseCommandGroup conditionally renders based on the top of the
command-palette page stack. Tests cover:

- root group renders when no sub-page is active
- root group hides when a sub-page is on top
- sub-page group renders only when its page matches
- mismatched sub-page leaves the group hidden

* test(web): cover ThemeProvider context

- useTheme outside ThemeProvider throws the documented error
- useTheme inside ThemeProvider returns the theme + setTheme +
  toggleTheme helpers

* test(web): cover ConfirmDialogProvider + useConfirm hook

- useConfirm outside provider throws the documented error
- confirm returns a pending promise
- promise resolves false when the Cancel button is clicked
- promise resolves true when the Confirm button is clicked
- works with custom confirmText label

apps/web has its own copy of this hook distinct from
packages/ui (mirrors the existing UI-package tests).

* test(web): cover PromptDialogProvider + usePrompt hook

- usePrompt outside provider throws the documented error
- returns a function when wrapped
- Cancel click resolves the promise to null
- Confirm click resolves to the current input value
- defaultValue option seeds the initial input value

* test(web): cover DashboardHeader

Small presentational header used across dashboard routes — title h1,
icon rendering, className merge, mobile sidebar trigger present and
hidden on md+.

* test(web): cover Create/Import resume cards

Both cards on the resumes dashboard wire a click handler to open
the appropriate dialog via the dialog store:

- CreateResumeCard opens resume.create
- ImportResumeCard opens resume.import

Also asserts the i18n copy strings (icons aside, the cards are
otherwise structural).

* test(web): cover command-palette language sub-page

LanguageCommandPage is a BaseCommandGroup gated on page='language'.
Tests assert:

- it is hidden when 'language' is not the top of the page stack
- when active, it renders a CommandItem per localeMap entry
- documented locale codes (en-US, de-DE, ja-JP) appear

* test(web): cover command-palette theme + preferences sub-pages

- ThemeCommandPage: hidden when 'theme' is not on top, renders Light
  and Dark options when active
- PreferencesCommandGroup: root group renders both Change theme to...
  and Change language to... items; clicking each pushes the
  corresponding page onto the command-palette stack

* test(ai): cover executePatchResume tool

- patchResumeInputSchema rejects empty operations and unknown op
  values; accepts valid replace/add/remove
- executePatchResume returns the applied operations on success
- executePatchResume throws when an operation targets an invalid path
  (passes through the underlying applyResumePatches validation)
- multi-op patches against top-level fields succeed end-to-end

* test(ai): cover sanitize edge branches

Hit the previously-uncovered branches in sanitize.ts:

- numeric 1 coerces to true
- '1' / '0' string shorthand coerces to true/false
- missing item.hidden gets salvaged to false
- empty input causes a non-Zod throw (caught + rethrown with generic message)

* test(ai): cover patch-proposal preview + normalize edge cases

- remove operations surface before-value with after=undefined
- buildResumePatchProposalPreview labels metadata/page paths sanely
- normalizeResumePatchProposals stamps every proposal with baseUpdatedAt
- normalizeResumePatchProposals preserves input order

* test(web): cover getLocaleOptions helper

Locale combobox surface — verify the option list mirrors localeMap
shape, uses locale codes as values, populates label + keywords with
the translated display name, and produces unique values.

* test(web): cover LevelTypeCombobox option mapping

LevelTypeCombobox maps levelDesignSchema.shape.type.options through
the internal getLevelTypeName labeler. Assert all 7 level types are
exposed and that each produces a non-empty label.

* test(web): cover ThemeToggleButton fallback paths

- aria-label flips between 'Switch to light theme' and 'Switch to
  dark theme' based on current theme
- clicking when document.startViewTransition is unavailable
  short-circuits to toggleTheme directly
- prefers-reduced-motion forces the direct toggle path even when
  the view-transition API is available

* test(web): cover NotFoundScreen

Mock the TanStack Router Link so the screen renders standalone, then
assert: documented error heading, routeId is surfaced verbatim, and
the Go Back link points to '..' (parent route).

* test(web): cover InformationSectionBuilder

Stub SectionBase so the donation/info section renders standalone.
Assert: donation prompt copy, OpenCollective CTA link, all 5
external resource links present, and external links target _blank
with rel=noopener.

* test(web): cover NotesSectionBuilder

Mock SectionBase, RichInput, and the resume-draft hooks so the
notes section renders in isolation. Assert: privacy hint copy
renders, RichInput is seeded with metadata.notes, and onChange
proxies through updateResumeData with a draft recipe that mutates
metadata.notes.

* test(web): cover TemplateSectionBuilder

Stub SectionBase and useCurrentResume so the right-sidebar template
section renders standalone. Asserts: current template name in the
heading, template tags rendered as badges, preview image points to
the catalog asset, and clicking the preview opens the
resume.template.gallery dialog.

* test(web): cover ColorPicker preset selection and trigger override

Mock the heavy @uiw/react-color-colorful dependency. Test:

- the trigger swatch reflects the controlled value
- clicking a preset color invokes onChange with an rgba() string
- a custom trigger replaces the default swatch when provided

* test(web): cover ExportSectionBuilder

Mock the heavy export pipelines (buildDocx, createResumePdfBlob,
downloadWithAnchor) and the resume-draft hook to test:

- JSON button packages resume.data as application/json and triggers
  download with the {name}.json filename
- DOCX button awaits buildDocx and downloads .docx
- PDF button awaits createResumePdfBlob and downloads .pdf

* test(web): cover ProfilesSectionBuilder

Stub the resume-draft hooks, SectionBase, SectionItem, and
SectionAddItemButton so the profiles section renders standalone:

- one SectionItem per profile with network as title and username as
  subtitle
- 'Add a new profile' affordance present
- when items.length > 0, the wrapper uses a solid border (not dashed)

* test(web): cover SkillsSectionBuilder

Mirror the profiles test for the skills section — verifies one
SectionItem per skill (name → title, proficiency → subtitle) and
the Add a new skill affordance.

* test(web): bulk-cover 7 left-sidebar section builders

Single test file covers awards, certifications, interests, languages,
publications, references, and volunteer builders. For each:

- one SectionItem rendered with the documented field → title/subtitle
  mapping
  - awards: title → awarder
  - certifications: title → 'issuer • date'
  - interests: name → (no subtitle)
  - languages: language → fluency
  - publications: title → publisher
  - references: name → (no subtitle)
  - volunteer: organization → location
- the 'Add a new {kind}' affordance with the matching copy

Mocks SectionBase, SectionItem, SectionAddItemButton, and the
resume-draft hooks so each builder renders standalone.

* test(web): cover ProjectsSectionBuilder buildSubtitle

The projects section is the only left-sidebar builder with a
composite subtitle. Tests three branches of its inline
buildSubtitle helper:

- period + website.label → joined with ' • '
- period only → just the period
- empty period + whitespace-only website.label → returns undefined

* test(web): cover Education + Experience section builders

- Education: school → title, degree → subtitle, add-new affordance
- Experience: position → subtitle when set; falls back to '1 role' /
  'N roles' (lingui plural) when position empty and roles[] present;
  add-new affordance

* test(web): cover CountUp animated number renderer

- default aria attributes (aria-live=polite, aria-atomic=true)
- initial textContent seeds to 'from' (up) or 'to' (down) value
- separator option formats with grouping
- decimal places are preserved when from/to are fractional
- aria-hidden=true strips aria-live + aria-atomic
- custom className is applied to the rendered span

* test(web): cover TextMaskEffect SVG renderer

- supplied text renders in every visible <text> layer
- aria-hidden + aria-label forwarded onto the root svg
- mouse enter/move/leave handlers don't throw
- custom className merges into the svg's class attribute

* test(web): cover URLInput prefix handling

- displayed input strips the https:// prefix so users only edit the
  meaningful portion
- editing re-adds the prefix on the way back through onChange
- pre-prefixed input is preserved
- cleared input emits an empty url (no prefix forced)
- hideLabelButton=true removes the popover trigger; default keeps it

* test(web): cover GithubStarsButton

Mocks useQuery + the CountUp animation so the button renders
standalone. Asserts:

- anchor points at the project repo with rel=noopener + target=_blank
- aria-label is the no-count copy when star count is undefined
- CountUp renders only once the count loads
- aria-label includes the localized count once data arrives

* test(web): cover ui/Combobox trigger label rendering

The shared Combobox wraps base-ui's combobox primitives. Smoke-test
the trigger label resolution:

- placeholder shows when nothing is selected
- selected option's label renders in the trigger
- multi-select default values render all labels
- empty options array renders the placeholder without crashing

* test(web): cover IconPicker trigger rendering

Stub react-window's Grid so happy-dom can render the picker without
layout-measurement deps. Verify:

- trigger renders an <i class='ph-{value}'> for the current value
- changing value updates the trigger icon class
- the picker emits a trigger button

* test(web): cover ChipInput add/dedupe/description behaviors

- existing chips render as Badges
- Enter adds the typed value to the chip list
- comma also commits the typed value
- duplicate input is dropped (onChange not called with a longer list)
- empty / whitespace-only input is dropped
- hideDescription removes the keyboard hint <kbd>; default keeps it

* test(web): cover StatisticsSectionBuilder

Mock useQuery + useParams + section-base so the right-sidebar
statistics section renders standalone:

- returns null content while the query is loading
- shows the private-resume hint when isPublic=false
- shows views/downloads counters and labels when isPublic=true
- includes 'Last viewed' timestamp copy when lastViewedAt is set

* test(web): cover TemplateGalleryDialog selection flow

- title + intro copy render
- one tile per template (>= 14)
- currently-selected template tile carries the ring-highlight
- clicking a different tile triggers updateResumeData with a recipe
  that sets metadata.template to the chosen template id

* test(web): cover Prefooter home-page section

- community tagline heading renders
- community-thanks paragraph renders
- the decorative TextMaskEffect renders an svg

* test(web): cover home-page Footer

- Resources and Community headings render
- documented resource links (Documentation, Sponsorships, Source
  Code, Changelog) all appear in the rendered output
- documented community links (Report an issue, Translations,
  Subreddit, Discord) all appear
- social anchors point at GitHub, LinkedIn, and X (Twitter)
- Copyright sub-component surfaces the app version via __APP_VERSION__

* test(web): cover home-page Header navigation

Mock TanStack Router Link + child components so the header renders
standalone. Verify:

- homepage anchor (/ link) carries the documented aria-label
- dashboard anchor points to /dashboard
- ThemeToggleButton and GithubStarsButton both mount
- the <nav> landmark is labeled 'Main navigation'

* chore: fix linter warnings
2026-05-11 14:25:10 +02:00
Amruth Pillai 48555f58e5 Merge branch 'main' of github.com:amruthpillai/reactive-resume 2026-05-11 13:56:01 +02:00
Amruth Pillai 0daf868cd4 fix(knip): remove duplicate export 2026-05-11 13:55:56 +02:00
github-actions[bot]andCrowdin Bot e574d4005f [skip ci] chore(i18n): sync translations from crowdin (#3037)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-11 13:54:49 +02:00
Amruth Pillai fda4e500b3 feat(toast): add non-invasive, dismissible donation banner 2026-05-11 13:50:32 +02:00
Amruth Pillai adfc9b527b docs: map existing codebase
Adds .planning/codebase/ with parallel-mapper outputs covering stack,
integrations, architecture, structure, conventions, testing, and concerns.
2026-05-11 11:16:33 +02:00
Amruth Pillai 71aadbd73d docs: update AGENTS.md with detailed codebase structure, environment setup, and common commands 2026-05-11 09:42:58 +02:00
Amruth Pillai 0713cf20d4 fix: filter invalid/empty section items and experience roles 2026-05-11 09:27:14 +02:00
Amruth Pillai 334ea48bc7 fix: enhance rich text handling in PDF generation
- Bump @tanstack/react-form version to 1.32.0 in package.json.
- Refactor rich-input component to simplify highlight configuration.
- Improve rich text HTML normalization to handle <mark> elements and apply styles correctly in PDF output.
- Update global CSS for WYSIWYG to adjust paragraph and list margins.
2026-05-11 09:04:42 +02:00
Amruth PillaiandCursor Agent 69c23211a0 Fix award title and date layout (#3029)
* Fix award title date layout

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* Keep award date inline across templates

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-05-11 05:54:51 +02:00
Amruth Pillai 143aaa741b fix(lapras): adjust lapras border color to fixed gray 2026-05-11 01:11:17 +02:00
Amruth Pillai e4cc6a8e57 docs: add react-pdf-html prose spacing design 2026-05-11 01:00:46 +02:00
Amruth Pillai 92a0e3ddb8 feat: implement resume preview loading enhancements and add tests for preview components 2026-05-11 00:59:16 +02:00
Amruth Pillai 4ebe9e5a67 fix: enhance resume schema validation and improve slugify function for CJK input 2026-05-11 00:27:47 +02:00
Amruth Pillai 0abee1048c fix(docs): update healthcheck test script on docs, resolves #3027 2026-05-11 00:09:07 +02:00
Amruth Pillai 83a407bc10 fix: add special hyphenation callback for Chinese script in PDF font registration 2026-05-10 23:54:37 +02:00
Amruth Pillai 02973a1eb1 fix: correct path for app source files in globals.css 2026-05-10 23:35:46 +02:00
Amruth Pillai 2e04e71f4a fix: update build process to ensure clean output for web app 2026-05-10 23:27:06 +02:00
Amruth Pillai 978cbaf1f3 fix: run knip --fix to add unlisted dependencies 2026-05-10 22:12:35 +02:00
Amruth Pillai 3cd228bd84 chore: update postcss to version 8.5.14 and sort font weights in typography components 2026-05-10 22:09:37 +02:00
Amruth Pillai 64ac3ff328 fix: revert to default hyphenation method 2026-05-10 21:50:27 +02:00
Amruth Pillai 846b7856a7 Merge branch 'main' of github.com:amruthpillai/reactive-resume 2026-05-10 20:46:31 +02:00
Amruth Pillai 7a60a42a04 chore: migrate from jsdom to happy-dom for testing environment 2026-05-10 20:46:28 +02:00
github-actions[bot]andCrowdin Bot b0de64ad13 [skip ci] chore(i18n): sync translations from crowdin (#3026)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-10 20:20:08 +02:00
Amruth Pillai b321e01658 Merge branch 'main' into feat/increase-test-coverage
# Conflicts:
#	packages/ui/src/components/input-group.test.tsx
2026-05-10 20:05:00 +02:00
Amruth Pillai 6a01207b6b test: add unit and component tests across the monorepo
Adds ~1000 tests to bring the previously-untested packages and apps
under coverage:

- packages/utils — string, color, date, file, level, locale, sanitize,
  field, html, network-icons, rate-limit, url, url-security, monorepo,
  resume/patch, resume/docx/link-utils, style helpers (~97% on the
  testable utility files)
- packages/ui — 28 component test files plus the use-controlled-state,
  use-mobile, use-confirm, use-prompt hooks (95% statements)
- packages/pdf — shared template helpers (filtering, columns, picture,
  metrics, section-links, page-size, rich-text-html, section-title)
- packages/schema — resumeDataSchema, page, templates, default
- packages/fonts — expanded coverage on font helpers
- packages/ai — resume sanitize and extraction template
- packages/api — resume-access-policy
- apps/web — error-message, locale, theme, pwa, dialogs/store, and
  the resume/move-item / section-actions / make-section-item helpers

Adds jsdom polyfills (ResizeObserver, IntersectionObserver,
scrollIntoView, matchMedia) and an explicit React Testing Library
cleanup hook to vitest.setup.ts so portal- and overlay-based components
work without per-test setup.
2026-05-10 20:00:07 +02:00
Amruth Pillai 2f6a8904e4 fix: enforce hasPassword to be a boolean 2026-05-10 19:56:50 +02:00
Amruth Pillai 56c9eb2ff4 fix(contact-list): resolves an issue where website label was uneditable 2026-05-10 19:30:09 +02:00
Amruth Pillai 33103536ae fix: fallback for cjk fonts when italic font style not available 2026-05-10 17:35:32 +02:00
Amruth PillaiandCursor Agent a93e7bd190 docs: add AGENTS.md with Cursor Cloud development instructions (#3024)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-05-10 13:34:19 +02:00
github-actions[bot]andCrowdin Bot 4cd4b8c193 [skip ci] chore(i18n): sync translations from crowdin (#3023)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-10 13:27:59 +02:00
447 changed files with 64198 additions and 16734 deletions
+16 -1
View File
@@ -77,6 +77,15 @@ S3_ENDPOINT="http://localhost:8333"
S3_BUCKET="reactive-resume"
S3_FORCE_PATH_STYLE="true"
# --- AI Agent Workspace (optional) ---
# Required only for the authenticated /agent workspace and saved AI providers.
REDIS_URL="redis://localhost:6379"
ENCRYPTION_SECRET="change-me-to-a-secure-agent-secret-in-production"
# Optional Cloudflare Browser Run credentials used as a URL extraction fallback for agent web access.
CLOUDFLARE_ACCOUNT_ID=""
CLOUDFLARE_API_TOKEN=""
# --- Feature Flags ---
# This flag disables new signups, both on the web app and the server.
FLAG_DISABLE_SIGNUPS="false"
@@ -89,6 +98,12 @@ FLAG_DISABLE_EMAIL_AUTH="false"
# This is useful if you are using a machine with limited resources, like a Raspberry Pi.
FLAG_DISABLE_IMAGE_PROCESSING="false"
# Allows AI providers to be configured with any base URL, including http:// and
# private/loopback addresses (e.g. http://localhost:11434 for a local Ollama instance).
# WARNING: Enabling this on a multi-tenant deployment is a Server-Side Request Forgery (SSRF)
# risk. Only enable this on a trusted, single-tenant self-hosted instance.
FLAG_ALLOW_UNSAFE_AI_BASE_URL="false"
# --- Others ---
# Google Cloud API Key (optional)
# For font-list generation tooling.
@@ -98,4 +113,4 @@ GOOGLE_CLOUD_API_KEY=""
# Crowdin (optional)
# For translation tooling.
CROWDIN_PROJECT_ID=""
CROWDIN_API_TOKEN=""
CROWDIN_PERSONAL_TOKEN=""
+1 -1
View File
@@ -61,7 +61,7 @@
"guid": "open-collective",
"type": "payment-provider",
"description": "Open Collective",
"address": "https://opencollective.com/reactive-resume"
"address": "https://opencollective.com/reactive-resume/donate"
}
]
}
+1
View File
@@ -53,6 +53,7 @@ body:
- Onyx
- Pikachu
- Rhyhorn
- Scizor
validations:
required: false
+3 -1
View File
@@ -1,6 +1,7 @@
// @ts-check
const betaPackages = ["drizzle-orm", "drizzle-kit", "drizzle-zod"];
const betaPackages = ["drizzle-zod"];
const rcPackages = ["drizzle-orm", "drizzle-kit"];
/** @type {import('npm-check-updates').RunOptions} */
module.exports = {
@@ -10,6 +11,7 @@ module.exports = {
packageManager: "pnpm",
target: (packageName) => {
if (betaPackages.includes(packageName)) return "@beta";
if (rcPackages.includes(packageName)) return "@rc";
return "latest";
},
};
+332
View File
@@ -0,0 +1,332 @@
<!-- refreshed: 2026-05-11 -->
# Architecture
**Analysis Date:** 2026-05-11
## System Overview
Reactive Resume is a single full-stack web application running as one Node.js process on port 3000. The web app is a TanStack Start application (Vite + React 19 + Nitro server) packaged in a pnpm/Turborepo monorepo. All API surface area, server-side rendering, file uploads, OAuth, OpenAPI, MCP, and PWA assets are served from the same process. Internal packages are consumed as TypeScript source through `package.json` `exports` maps that point directly at `src` files; there is no per-package `dist` output to depend on.
```text
┌────────────────────────────────────────────────────────────────────────────┐
│ Browser (React 19) │
│ TanStack Router · TanStack Query · Zustand stores · React PDF view │
│ `apps/web/src/router.tsx` · `apps/web/src/routes/__root.tsx` │
└──────────┬─────────────────────────────────────────────────────┬───────────┘
│ HTTP / SSR hydration │ /api/rpc (RPCLink)
▼ ▼
┌────────────────────────────────────────────────────────────────────────────┐
│ Nitro server entry (TanStack Start) │
│ `apps/web/src/server.ts` · Nitro plugins (`apps/web/plugins/`) │
├──────────────────────────┬─────────────────────────────────────────────────┤
│ File-based routes │ Route `server.handlers` blocks │
│ `apps/web/src/routes/*` │ `api/rpc.$.ts` · `api/auth.$.ts` · `api/health.ts`│
│ │ `api/openapi.$.ts` · `api/uploads/$userId.$.ts`│
│ │ `mcp/index.ts` · `[.]well-known/*` · `schema.json.ts`│
└──────────┬───────────────┴──────────────────────────────┬─────────────────┘
│ in-process router client │ HTTP handler
▼ ▼
┌────────────────────────────────────────────────────────────────────────────┐
│ oRPC routers │
│ `packages/api/src/routers/{ai,auth,flags,resume,statistics,storage}.ts` │
│ Procedures: `publicProcedure` / `protectedProcedure` │
│ Middleware: `packages/api/src/middleware/rate-limit/index.ts` │
└──────────┬─────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────────────┐
│ Services & helpers │
│ `packages/api/src/services/{resume,ai,auth,storage,flags,statistics}.ts` │
│ `packages/api/src/helpers/{resume-access,resume-access-policy}.ts` │
│ `packages/api/src/services/resume-events.ts` (Postgres LISTEN/NOTIFY) │
│ `packages/auth/src/config.ts` (Better Auth) │
└──────────┬──────────────────────────────────────┬──────────────────────────┘
│ Drizzle ORM │ S3 / local FS
▼ ▼
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ PostgreSQL (via `pg`) │ │ Storage (S3 or `<workspace>/data`)│
│ `packages/db/src/client.ts` │ │ `packages/api/src/services/storage.ts`│
│ schema: `packages/db/src/schema/*`│ │ served by `routes/uploads/$userId.$.tsx`│
│ migrations: `migrations/` │ └──────────────────────────────────┘
└──────────────────────────────────┘
```
## Component Responsibilities
| Component | Responsibility | File |
|-----------|----------------|------|
| Web app shell | TanStack Start app, route tree, SSR/CSR boundary, PWA, builder UI | `apps/web/src/router.tsx`, `apps/web/src/routes/__root.tsx` |
| Server entry | Nitro fetch handler wrapping `react-start/server-entry` | `apps/web/src/server.ts` |
| Migration plugin | Walks up to repo root, runs Drizzle migrations on boot | `apps/web/plugins/1.migrate.ts` |
| Storage plugin | Validates `<workspace>/data` writability when S3 is unused | `apps/web/plugins/2.storage.ts` |
| oRPC router root | Aggregates all sub-routers exposed at `/api/rpc` and OpenAPI | `packages/api/src/routers/index.ts` |
| oRPC context | Header-based auth resolution, `publicProcedure`/`protectedProcedure` | `packages/api/src/context.ts` |
| Resume service | CRUD, patch (RFC 6902), password, lock, statistics, analysis, events | `packages/api/src/services/resume.ts` |
| Storage service | S3 + local FS abstraction, image processing via `sharp` | `packages/api/src/services/storage.ts` |
| Resume access policy | Owner/viewer/redaction rules for `getBySlug` and statistics | `packages/api/src/helpers/resume-access-policy.ts` |
| Resume events | Postgres `LISTEN/NOTIFY` channel for live updates | `packages/api/src/services/resume-events.ts` |
| Auth | Better Auth config, OAuth provider, passkey, 2FA, API keys, JWKS | `packages/auth/src/config.ts` |
| Database | Drizzle client singleton, schema, generated migrations | `packages/db/src/client.ts`, `packages/db/src/schema/*.ts`, `migrations/` |
| Schema | Zod resume/page/template models shared across web + API + PDF + MCP | `packages/schema/src/resume/data.ts`, `packages/schema/src/templates.ts` |
| PDF rendering | React PDF `Document`, font registration, 14 template implementations | `packages/pdf/src/document.tsx`, `packages/pdf/src/templates/index.ts`, `packages/pdf/src/hooks/use-register-fonts.ts` |
| Shared UI | Base UI / shadcn-style component library and hooks | `packages/ui/src/components/*.tsx`, `packages/ui/src/hooks/*.tsx` |
| MCP server | Model Context Protocol server backed by oRPC routers | `apps/web/src/routes/mcp/index.ts`, `apps/web/src/routes/mcp/-helpers/*` |
## Pattern Overview
**Overall:** Modular monolith. One deployable web app + a constellation of source-only TypeScript packages communicating through typed `package.json` `exports`. Browser ↔ server communication uses **oRPC** (typed RPC with REST/OpenAPI generation) instead of REST/tRPC. SSR and client share the same router/queryClient via TanStack Start.
**Key Characteristics:**
- Source-consumed workspace packages (no per-package `dist` build) keep types end-to-end.
- The oRPC router is mounted twice: as a Fetch handler at `/api/rpc` and as an in-process `createRouterClient` for SSR/server functions, with identical types on both paths.
- Browser-only code (React PDF preview, PDF.js, canvas) is isolated behind explicit `.browser.tsx` files and `ssr: false` / `ssr: "data-only"` route opts to keep SSR bundles small and safe.
- Postgres is both the data store and the event bus (`pg_notify` channel `resume_updated` for live builder sync).
- Shared concerns (auth, theme, locale, feature flags, oRPC client, query client) are loaded once and passed through TanStack Router `context` rather than fetched per-route.
## Layers
**Routes (`apps/web/src/routes/`):**
- Purpose: File-based TanStack Router routes; some are pure UI, others embed `server.handlers` blocks that act as HTTP endpoints.
- Location: `apps/web/src/routes/`
- Contains: Page components, route loaders, server-only API handlers, layout wrappers.
- Depends on: `packages/api/routers` (mounted at `/api/rpc`), `packages/auth/config` (mounted at `/api/auth`), `packages/db/client`, `packages/api/services/*`.
- Used by: TanStack Router (route tree is regenerated into `apps/web/src/routeTree.gen.ts`).
**oRPC API layer (`packages/api/src/routers/`):**
- Purpose: Public typed contract for the browser, in-process callers, and OpenAPI/MCP consumers.
- Location: `packages/api/src/routers/{ai,auth,flags,resume,statistics,storage}.ts`, aggregated in `packages/api/src/routers/index.ts`.
- Contains: Procedure definitions, Zod input/output schemas, REST metadata, rate-limit middleware bindings.
- Depends on: `packages/api/src/context.ts`, `packages/api/src/dto/*`, `packages/api/src/services/*`.
- Used by: `apps/web/src/routes/api/rpc.$.ts` (RPCHandler), `apps/web/src/routes/api/openapi.$.ts` (OpenAPIHandler), `apps/web/src/libs/orpc/client.ts` (isomorphic client), MCP tools in `apps/web/src/routes/mcp/-helpers/tools.ts`.
**Services / business logic (`packages/api/src/services/`):**
- Purpose: All cross-cutting business rules — resume CRUD, statistics, AI orchestration, storage, feature flags, auth helpers, event publishing.
- Location: `packages/api/src/services/*.ts`
- Contains: Side-effecting functions with explicit `userId`-style inputs. No request/response coupling.
- Depends on: `packages/db/client`, `packages/db/schema`, `packages/auth/config`, `packages/schema/resume/*`, `packages/utils/*`, `packages/email/transport`, AI SDKs.
- Used by: Routers, MCP helpers, the `/api/health` route.
**Persistence (`packages/db/`):**
- Purpose: Drizzle ORM client + schema definitions; the only place SQL is written.
- Location: `packages/db/src/client.ts`, `packages/db/src/schema/{auth,resume,index}.ts`, `packages/db/src/relations.ts`.
- Contains: Postgres `Pool` singleton (stored on `globalThis.__pool` to survive HMR), `drizzle()` client, table definitions, relations.
- Depends on: `pg`, `drizzle-orm`, `packages/env/server`.
- Migrations: Generated by `drizzle-kit` into the repo-root `migrations/` directory (see `packages/db/drizzle.config.ts`) and applied at startup by `apps/web/plugins/1.migrate.ts`.
**Auth (`packages/auth/`):**
- Purpose: Better Auth instance with email/password, OAuth (Google/GitHub/LinkedIn + generic), passkey, 2FA, API keys, JWKS, and a JWT-issuing OAuth provider for MCP clients.
- Location: `packages/auth/src/config.ts`, `packages/auth/src/functions.ts`, `packages/auth/src/types.ts`.
- Mounted at: `apps/web/src/routes/api/auth.$.ts` (delegates `request → auth.handler(request)` after sanitizing OAuth params).
- Verifies tokens for MCP at `apps/web/src/routes/mcp/index.ts` via `verifyOAuthToken`.
**PDF rendering (`packages/pdf/`):**
- Purpose: React PDF document and 14 visual templates (named after Pokémon).
- Location: `packages/pdf/src/document.tsx` mounts `getTemplatePage(template)`; templates live under `packages/pdf/src/templates/<name>/`.
- Shared template primitives: `packages/pdf/src/templates/shared/{filtering,rich-text,sections,primitives,picture,page-size,columns}.ts(x)`.
- Fonts: `packages/pdf/src/hooks/use-register-fonts.ts` owns React PDF font registration, standard PDF font handling, CJK fallback stacks, and global hyphenation.
- Consumed by: Web builder preview (`apps/web/src/components/resume/preview*.tsx`, `pdf-canvas.tsx`), public resume page (`apps/web/src/routes/$username/$slug.tsx`), and the OpenAPI `/resumes/{id}/download` procedure (`apps/web/src/routes/api/-helpers/resume-pdf.ts`).
**Shared schemas (`packages/schema/`):**
- Purpose: Zod source of truth for resume data, templates, page settings, and AI analysis.
- Location: `packages/schema/src/resume/{data,default,sample,analysis}.ts`, `packages/schema/src/templates.ts`, `packages/schema/src/page.ts`, `packages/schema/src/icons.ts`.
- Used by: API DTOs (`packages/api/src/dto/resume.ts`), DB column typing (`packages/db/src/schema/resume.ts`), import package, PDF rendering, web forms, MCP tool descriptions, and the public JSON schema at `/schema.json`.
**Shared UI (`packages/ui/`):**
- Purpose: Headless/styled component library (Base UI + shadcn-style) used by `apps/web`.
- Location: `packages/ui/src/components/*.tsx`, `packages/ui/src/hooks/*.tsx`.
- Examples: `dialog`, `dropdown-menu`, `command`, `resizable`, `sonner`, `tooltip`, `form`, `direction`.
- Styles: `packages/ui/src/styles/globals.css` (Tailwind v4 entry).
**Support packages:**
- `packages/utils` — small focused helpers (`color`, `date`, `field`, `file`, `html`, `level`, `locale`, `network-icons`, `rate-limit`, `sanitize`, `string`, `style`, `url`, plus Node-only `monorepo.node`, `url-security.node`, and `resume/{docx,patch}`).
- `packages/env` — `@t3-oss/env-core` server schema; `dotenv` loads the repo-root `.env` (`packages/env/src/server.ts`).
- `packages/email` — `nodemailer` transport + `react-email` templates (`packages/email/src/transport.ts`, `packages/email/src/templates/*.tsx`).
- `packages/import` — converters for JSON Resume, Reactive Resume v3/v4 JSON (`packages/import/src/*.tsx`).
- `packages/ai` — Zustand store, AI prompts (`packages/ai/src/prompts/*.md`), patch-resume tool, sanitize/extraction helpers consumed by the AI router.
- `packages/fonts` — generated Google Fonts metadata (`packages/fonts/src/webfontlist.json`, `packages/fonts/src/index.ts`).
- `packages/scripts` — repo-level scripts (`packages/scripts/database/reset.ts`, `packages/scripts/fonts/generate.ts`).
- `packages/config` — shared TypeScript/Vitest base configs (`packages/config/tsconfig.base.json`, `packages/config/vitest.config.ts`).
- `packages/runtime-externals` — declares `bcrypt`, `sharp`, `@aws-sdk/client-s3` so they remain runtime-only (externalized in `apps/web/vite.config.ts`).
## Data Flow
### Primary Request Path (browser RPC call)
1. Browser route uses `orpc.resume.getById.queryOptions(...)` from `apps/web/src/libs/orpc/client.ts:84` to fetch data.
2. The isomorphic oRPC client (`apps/web/src/libs/orpc/client.ts:28-47`) creates an `RPCLink` pointing at `${window.location.origin}/api/rpc` with `credentials: "include"` and a `BatchLinkPlugin`.
3. Request hits the file route `apps/web/src/routes/api/rpc.$.ts`, where `RPCHandler` (line 9) dispatches with `BatchHandlerPlugin`, `RequestHeadersPlugin`, and `StrictGetMethodPlugin`.
4. `publicProcedure` (`packages/api/src/context.ts:79`) resolves the user from headers (`x-api-key` → bearer JWT via JWKS → Better Auth session cookie).
5. `protectedProcedure` (`packages/api/src/context.ts:90`) rejects unauthenticated callers with `ORPCError("UNAUTHORIZED")`.
6. Router handler in `packages/api/src/routers/resume.ts` calls into `packages/api/src/services/resume.ts`, which queries Drizzle (`packages/db/src/client.ts:32`).
7. Response is serialized back through oRPC and consumed by TanStack Query / the route component.
### SSR / Server-Side Path
1. During SSR, `apps/web/src/libs/orpc/client.ts:13-27` short-circuits the HTTP path via `createRouterClient(router, { context: async () => ({ locale, reqHeaders }) })`.
2. The same `publicProcedure`/`protectedProcedure` middleware runs in-process — no socket hop — but still resolves auth from the original request headers via `getRequestHeaders()` from `@tanstack/react-start/server`.
3. Route loaders (e.g. `apps/web/src/routes/builder/$resumeId/route.tsx:39-44`) populate the query cache via `context.queryClient.ensureQueryData(orpc.resume.getById.queryOptions(...))`.
### Resume Live-Update Flow
1. `subscribe` procedure in `packages/api/src/routers/resume.ts:76` returns an async generator.
2. On any mutation, `packages/api/src/services/resume-events.ts:37` calls `pg_notify('resume_updated', JSON.stringify(event))`.
3. Subscribing clients receive `resume.updated` SSE-style events via oRPC streaming (`apps/web/src/libs/orpc/client.ts:51-82`, `streamClient`).
4. The builder route consumes them through `useResumeUpdateSubscription` (`apps/web/src/components/resume/builder-resume-draft.ts`).
### PDF Download Path
1. Web client requests `GET /api/openapi/resumes/{id}/download` (oRPC OpenAPI handler at `apps/web/src/routes/api/openapi.$.ts`).
2. `downloadResumePdfProcedure` in `apps/web/src/routes/api/-helpers/resume-pdf.ts` loads the resume, renders `ResumeDocument` from `packages/pdf/src/document.tsx`, persists to storage via `getStorageService()`, and returns/streams the PDF.
### Public Resume Path
1. `apps/web/src/routes/$username/$slug.tsx` uses `ssr: "data-only"` — server fetches `resume.getBySlug` but renders the React PDF preview only on the client.
2. `packages/api/src/helpers/resume-access-policy.ts` enforces visibility, redacts non-public fields, and throws `NEED_PASSWORD` for password-protected resumes (the route then redirects to `/auth/resume-password`).
**State Management:**
- Server cache: TanStack Query (`apps/web/src/libs/query/client.ts`) wrapped by oRPC's `createTanstackQueryUtils`.
- Local UI state: Zustand stores under `apps/web/src/routes/builder/$resumeId/-store/{section,sidebar}.ts`, `apps/web/src/dialogs/store.ts`, `apps/web/src/components/command-palette/store.ts`, `apps/web/src/components/resume/builder-resume-draft.ts`.
- Router context: `theme`, `locale`, `session`, `flags`, `queryClient`, `orpc` are computed once in `apps/web/src/router.tsx` (and again in the root route `beforeLoad`) and reused by descendants.
- Cookies: builder layout (`BUILDER_LAYOUT_COOKIE_NAME`), theme, and locale are persisted via `getCookie`/`setCookie` server functions.
## Key Abstractions
**oRPC procedures (`os.$context<ORPCContext>()`):**
- Purpose: Typed RPC procedures that double as REST endpoints and MCP tool surfaces.
- Examples: `publicProcedure` and `protectedProcedure` in `packages/api/src/context.ts:79`/`:90`.
- Pattern: `procedure.route({...openapi metadata}).input(zodSchema).use(rateLimitMiddleware).output(zodSchema).handler(async ({ context, input }) => ...)`.
**Drizzle tables:**
- Purpose: Strongly-typed Postgres schema; `.jsonb()` columns are typed against Zod-derived TypeScript (`ResumeData`, `StoredResumeAnalysis`).
- Examples: `packages/db/src/schema/resume.ts` (resume, resumeStatistics, resumeAnalysis), `packages/db/src/schema/auth.ts` (Better Auth tables).
- Pattern: `pg.pgTable("name", { ... }, (t) => [pg.index().on(...), pg.unique().on(...)])`.
**Template pages (React PDF):**
- Purpose: One `TemplatePage` component per visual template, mapped by name in `packages/pdf/src/templates/index.ts`.
- Examples: `packages/pdf/src/templates/azurill/AzurillPage.tsx`, `packages/pdf/src/templates/onyx/OnyxPage.tsx`.
- Pattern: `(props: { page: LayoutPage; pageIndex: number }) => JSX`, consuming `RenderProvider` from `packages/pdf/src/context.tsx`.
**Base UI components:**
- Purpose: Headless primitives styled with Tailwind v4 and exported as composable parts.
- Examples: `packages/ui/src/components/dialog.tsx`, `packages/ui/src/components/command.tsx`, `packages/ui/src/components/resizable.tsx`.
- Imported via deep paths: `import { Dialog } from "@reactive-resume/ui/components/dialog";`.
**TanStack Router file routes:**
- Purpose: Page components, loaders, and optional `server.handlers` blocks per file.
- Examples: `apps/web/src/routes/builder/$resumeId/route.tsx`, `apps/web/src/routes/api/rpc.$.ts`.
- Pattern: `export const Route = createFileRoute("/path")({ component, loader, beforeLoad, server: { handlers: { GET, POST } }, ssr });`.
## Entry Points
**Vite + Nitro build entry:**
- Location: `apps/web/vite.config.ts`
- Triggers: `pnpm dev`, `pnpm build`. Wires TanStack Start, Tailwind v4, Lingui (i18n), Nitro plugins, and the Vite PWA plugin.
- Externals: `bcrypt`, `sharp`, `@aws-sdk/client-s3` (declared in `packages/runtime-externals`).
**Server fetch entry:**
- Location: `apps/web/src/server.ts`
- Responsibilities: Wraps `@tanstack/react-start/server-entry` and substitutes `srvx`'s `FastResponse` as the global `Response`.
**Nitro startup plugins:**
- `apps/web/plugins/1.migrate.ts` — resolves the repo-root `migrations/` folder, opens its own `pg.Pool`, runs Drizzle migrations on boot, then closes the pool.
- `apps/web/plugins/2.storage.ts` — when S3 env vars are absent, ensures the local storage directory is writable before serving requests.
**Router entry:**
- Location: `apps/web/src/router.tsx`
- Responsibilities: Builds `queryClient`, loads `theme`/`locale`/`session`/`flags` in parallel, creates the TanStack Router with router context, registers SSR query integration.
**Root route:**
- Location: `apps/web/src/routes/__root.tsx`
- Responsibilities: HTML shell, providers (`I18nProvider`, `ThemeProvider`, `HotkeysProvider`, `DirectionProvider`, `TooltipProvider`, `ConfirmDialogProvider`, `PromptDialogProvider`), PWA head/scripts, `DialogManager`, `CommandPalette`, `Toaster`.
**HTTP endpoints (route `server.handlers`):**
- `apps/web/src/routes/api/rpc.$.ts` — `/api/rpc/*` oRPC handler (browser RPC).
- `apps/web/src/routes/api/auth.$.ts` — `/api/auth/*` Better Auth handler (with OAuth payload sanitization).
- `apps/web/src/routes/api/health.ts` — `/api/health` JSON probe (db + storage with timeouts).
- `apps/web/src/routes/api/openapi.$.ts` — `/api/openapi/*` OpenAPI handler + spec.
- `apps/web/src/routes/api/uploads/$userId.$.ts` and `apps/web/src/routes/uploads/$userId.$.tsx` — signed/etagged static file serving from storage.
- `apps/web/src/routes/mcp/index.ts` — `/mcp` Model Context Protocol server (OAuth-protected).
- `apps/web/src/routes/[.]well-known/*` — `/.well-known/oauth-authorization-server`, `/.well-known/oauth-protected-resource`, `/.well-known/openid-configuration`, `/.well-known/mcp` discovery documents.
- `apps/web/src/routes/schema[.]json.ts` — `/schema.json` public JSON Schema for `ResumeData`.
**Builder + public resume entry points:**
- `apps/web/src/routes/builder/$resumeId/route.tsx` — authenticated builder shell (header + resizable left/right sidebars + artboard outlet + assistant).
- `apps/web/src/routes/builder/$resumeId/index.tsx` — `ssr: false`; lazy-loaded `PreviewPage` running React PDF/canvas only in the browser.
- `apps/web/src/routes/$username/$slug.tsx` — `ssr: "data-only"`; public/shared resume view (with password gating via `NEED_PASSWORD` ORPCError).
## Architectural Constraints
- **Single Node process:** Everything (web, oRPC, auth, MCP, OpenAPI, file serving) runs in one Node 24 process on port 3000. No separate API service.
- **Source-only workspace packages:** `package.json` `exports` point at `src/*.ts(x)`; do not assume `dist` output exists. The Vite build externalizes `bcrypt`, `sharp`, `@aws-sdk/client-s3` (`apps/web/vite.config.ts:55`).
- **Globals on `globalThis` for DB pool:** `packages/db/src/client.ts:8-11` caches the `pg.Pool` and Drizzle client on `globalThis.__pool` / `globalThis.__drizzle` to survive HMR reloads.
- **Browser-only PDF rendering:** PDF.js, `@react-pdf/renderer` canvas, and the builder preview must stay off the SSR path. Use `.browser.tsx` suffix files and `ssr: false` (builder preview) or `ssr: "data-only"` (public resume).
- **Generated route tree:** `apps/web/src/routeTree.gen.ts` is regenerated by TanStack Router tooling; never edit by hand.
- **Migrations folder location:** `drizzle-kit` writes to `../../migrations` from `packages/db/drizzle.config.ts`, so all migration directories live at the repo root, not inside the package.
- **DATABASE_URL not auto-loaded for drizzle-kit:** `pnpm db:migrate` / `pnpm db:generate` require `DATABASE_URL` exported in the shell; only the runtime Node code loads `.env` via `packages/env/src/server.ts`.
- **Rate limiting is production-only:** `packages/api/src/middleware/rate-limit/index.ts:5` and the Better Auth config gate rate limits on `process.env.NODE_ENV === "production"`.
- **OAuth audience binding:** `verifyOAuthToken` (`packages/auth/src/config.ts:40`) only accepts JWTs whose `aud` matches `${APP_URL}` (with/without trailing slash) or `${APP_URL}/mcp`.
- **Postgres LISTEN/NOTIFY coupling:** Live resume updates depend on a single shared `pg.Pool` (`packages/db/src/client.ts:13`); scaling beyond one process requires replacing `pg_notify` with a broker.
## Anti-Patterns
### Ad-hoc fetching of router context
**What happens:** Components separately calling `getSession()`, `getTheme()`, or `getLocale()` instead of reading them from TanStack Router context.
**Why it's wrong:** The root route's `beforeLoad` already loads these in parallel (`apps/web/src/routes/__root.tsx:82-93`) and exposes them via `Route.useRouteContext()`; duplicating the calls causes extra round-trips during SSR and triggers locale reloads.
**Do this instead:** Use `Route.useRouteContext()` or read from a parent route loader, as `apps/web/src/routes/__root.tsx:101` does for theme/locale.
### Direct DB or storage imports in client code
**What happens:** Importing `@reactive-resume/db/client` or `@reactive-resume/api/services/storage` from a non-route component.
**Why it's wrong:** Pulls `pg`, `sharp`, `bcrypt`, `@aws-sdk/client-s3` into the client bundle (which Vite externalizes — the build will fail or break at runtime).
**Do this instead:** Call the corresponding oRPC procedure from `packages/api/src/routers/*`. Server-only imports belong inside route `server.handlers` blocks or `.server.tsx` files like `apps/web/src/libs/resume/pdf-document.server.tsx`.
### PDF/canvas code in shared modules
**What happens:** Importing `@react-pdf/renderer` or PDF.js from a file that participates in SSR.
**Why it's wrong:** These libraries crash under Node SSR (canvas/DOM dependencies).
**Do this instead:** Keep browser code in `*.browser.tsx` / `pdf-canvas.tsx` and gate with `ssr: false` (e.g. `apps/web/src/routes/builder/$resumeId/index.tsx:6`) or `ssr: "data-only"` (e.g. `apps/web/src/routes/$username/$slug.tsx:40`).
### Bypassing the resume access policy
**What happens:** Reading resume rows directly from Drizzle in a public procedure without applying redaction.
**Why it's wrong:** Leaks owner-only fields (password hash, private flags, statistics) and breaks the password-gate flow that depends on `NEED_PASSWORD` errors.
**Do this instead:** Route through `packages/api/src/helpers/resume-access-policy.ts` (`assertCanView`, `redactResumeForViewer`, `shouldCountForStatistics`) as `packages/api/src/services/resume.ts` does.
### Hand-editing generated files
**What happens:** Modifying `apps/web/src/routeTree.gen.ts` or migration SQL after Drizzle writes it.
**Why it's wrong:** Edits are overwritten on the next `tanstack-router` regen or migration; data drift between snapshots and SQL breaks future migrations.
**Do this instead:** Add a route file under `apps/web/src/routes/`, or change the Drizzle schema in `packages/db/src/schema/*.ts` and run `pnpm db:generate`.
## Error Handling
**Strategy:** Typed errors via `ORPCError` codes; HTTP responses for low-level handlers; route-level `defaultErrorComponent` and `onError` for UI fallbacks.
**Patterns:**
- Procedures throw `ORPCError("UNAUTHORIZED"|"NOT_FOUND"|"NEED_PASSWORD"|...)` or use `.errors({ ... })` to declare typed application errors (e.g. `RESUME_SLUG_ALREADY_EXISTS`, `RESUME_VERSION_CONFLICT` in `packages/api/src/routers/resume.ts`).
- The OAuth bearer / API key / session resolvers in `packages/api/src/context.ts:14-55` swallow verification errors and log via `console.warn` so unauthenticated requests fall through to the next strategy.
- `apps/web/src/routes/api/rpc.$.ts` and `apps/web/src/routes/api/openapi.$.ts` install `onError` interceptors that log every server error with a tag (`[oRPC Server]`, `[OpenAPI]`).
- Route-level `onError` (e.g. `apps/web/src/routes/$username/$slug.tsx:24`) translates `NEED_PASSWORD` into a redirect.
- Top-level UI fallbacks: `apps/web/src/components/layout/error-screen.tsx`, `loading-screen.tsx`, `not-found-screen.tsx` (wired in `apps/web/src/router.tsx:30-32`).
- Healthcheck uses a 1.5s timeout helper (`apps/web/src/routes/api/health.ts:22-34`) so a stuck dependency cannot stall the probe.
## Cross-Cutting Concerns
**Logging:** `console.info`/`console.warn`/`console.error` with bracketed prefixes (e.g. `[oRPC Server]`, `[Healthcheck]`, `[oRPC client]`). No external log shipping is wired.
**Validation:** Zod 4 everywhere — Drizzle column types (`packages/db/src/schema/resume.ts`), oRPC input/output schemas, AI tool inputs, environment variables (`packages/env/src/server.ts`), and the public `/schema.json` route.
**Authentication:** Better Auth in `packages/auth/src/config.ts` (Drizzle adapter, email/password + OAuth + passkey + 2FA + admin + API keys + JWT + generic OAuth + dynamic client registration + custom `oauthProvider` for MCP). The unified resolver in `packages/api/src/context.ts:64` is the only place that decides which credential wins.
**Internationalization:** Lingui — `apps/web/lingui.config.ts`, `apps/web/locales/*.po`, `apps/web/src/libs/locale.ts`, RTL toggling in `__root.tsx`.
**Theming:** `apps/web/src/libs/theme.ts` (cookie-backed), `apps/web/src/components/theme/provider.tsx` (`next-themes`).
**Rate limiting:** `@orpc/experimental-ratelimit` with an in-memory ratelimiter from `packages/api/src/middleware/rate-limit/index.ts`. Trusted IP headers come from `packages/utils/src/rate-limit.ts`.
**Feature flags:** Server-resolved at boot (`packages/api/src/routers/flags.ts` and `packages/api/src/services/flags.ts`), then carried in router context via `apps/web/src/router.tsx:20`.
---
*Architecture analysis: 2026-05-11*
+393
View File
@@ -0,0 +1,393 @@
# Codebase Concerns
**Analysis Date:** 2026-05-11
## TODO / FIXME / HACK / XXX Comments
A repo-wide grep for `TODO`, `FIXME`, `HACK`, and `XXX` markers across `apps/web/src/**` and `packages/**` returned **zero hits** in source code. The team appears to track follow-ups in PRs/issues rather than inline. Two referenced issues remain anchored in inline comments:
- `packages/pdf/src/hooks/use-register-fonts.ts:22` — references issue `#2986` (CJK glyph-level font fallback).
- `packages/pdf/src/hooks/use-register-fonts.ts:103` — references issue `#2986` again for CJK textkit substitution.
These are stable design references, not unresolved debt — included for traceability only.
The remaining inline-comment "concerns" found during exploration sit in the **Anti-debt narrative** below, derived from code shape rather than comment markers.
---
## High Severity
### Security: SMTP-disabled fallback logs full email bodies (incl. verification/reset links)
When `SMTP_HOST` / `SMTP_USER` / `SMTP_PASS` / `SMTP_FROM` are not all set, the email transport logs the entire payload — including `text` and `html` bodies — to `stdout`.
- File: `packages/email/src/transport.ts:59-66`
```ts
console.info("SMTP not configured; skipping email send.", {
to: payload.to,
subject: payload.subject,
text: payload.text,
html: payload.html,
});
```
**Impact:**
- Password reset, email verification, and email-change confirmation URLs (which are credential-equivalent bearer tokens) are written to server logs whenever SMTP is not fully configured.
- In any shared-log / log-shipping environment (Docker, Kubernetes, journald, cloud logging) this is a credential leak.
- The README and `AGENTS.md:101` describe this as a dev convenience; nothing prevents an operator from running production with partial SMTP config.
**Fix approach:**
1. Add a server-startup assertion that, if `NODE_ENV === "production"`, `isSmtpEnabled()` must be true.
2. Redact `text` / `html` from the `console.info` call — log only `to` and `subject`.
3. Document the production SMTP requirement in `.env.example` alongside `AUTH_SECRET`.
### Security: rate limiting is silently disabled in non-production
Both the oRPC rate-limit middleware and Better Auth's rate-limit config gate on `process.env.NODE_ENV === "production"`. Anything that does not set `NODE_ENV=production` at runtime (default `node` invocation, custom Docker entrypoints that forget to set it, self-hosters running `pnpm start` without `NODE_ENV`) runs with **all rate limiting disabled**, including:
- `/sign-in/email`, `/sign-up/email`, password-reset, OAuth `register`/`authorize`/`token` (`packages/auth/src/config.ts:30, :69-78, :249-251, :388-390`)
- Resume password verification, AI calls, PDF export, storage uploads/deletes, resume mutations (`packages/api/src/middleware/rate-limit/index.ts:5, :75`)
**Impact:** brute-force-friendly. A self-hosted deployment that omits `NODE_ENV=production` is wide open on auth and resume-password endpoints.
**Fix approach:**
- Either default `isRateLimitEnabled = true` and provide a `RATE_LIMIT_DISABLED` opt-out env var, or assert `NODE_ENV === "production"` at boot when not running tests.
### Security: rate limiter is in-process memory only — broken under horizontal scaling
`MemoryRatelimiter` is used for every oRPC rate limit (`packages/api/src/middleware/rate-limit/index.ts:59-66`). Better Auth's `rateLimit` and `apiKey.rateLimit` blocks (`packages/auth/src/config.ts:248-251, :387-391`) similarly do not configure a distributed store.
**Impact:** Running >1 Node instance behind a load balancer multiplies the effective rate limit by the instance count. Brute-force attacks bypass the limit by retrying until they hit a different replica.
**Fix approach:** Swap `MemoryRatelimiter` for a Redis-backed ratelimiter (the package supports it via `@orpc/experimental-ratelimit`) once a redis dependency is acceptable. Until then, document the single-instance constraint in the deployment docs.
### Security: file upload accepts arbitrary MIME with image processing disabled
`uploadFile` (`packages/api/src/routers/storage.ts:42-71`) only runs the sharp image-processing pipeline for files where `isImageFile(file.type)` returns true based on the **client-supplied `file.type`**, and even that pipeline is skipped if `FLAG_DISABLE_IMAGE_PROCESSING=true` (`packages/api/src/services/storage.ts:96-101`).
**Impact:**
- A client can claim `content-type: text/html` (or any non-image MIME) on a 10 MB upload, and the file is stored verbatim into `uploads/{userId}/pictures/...` with the user-claimed content type.
- Served back from `apps/web/src/routes/uploads/$userId.$.tsx`. `X-Content-Type-Options: nosniff` is set (`:159`) and the storage route forces `application/octet-stream` only for `.pdf` (`:39, :148-153`), so a stored HTML body could be served as HTML if the inferred extension matches. The picture key always ends in `.jpeg` (`storage.ts:60`), which mitigates this in the picture path, but there is no MIME allow-list enforced at upload time.
- With `FLAG_DISABLE_IMAGE_PROCESSING` on, the same applies to genuine images (no resize/strip-metadata path), so EXIF data is preserved unredacted.
**Fix approach:**
1. Add a strict allow-list at the router boundary (`packages/api/src/routers/storage.ts:9`) — `z.file().mime(["image/png", "image/jpeg", "image/webp", "image/gif"])` plus magic-byte validation.
2. Reject upload if claimed MIME does not match the sharp-detected MIME; do not fall back to client claim.
3. Surface a startup warning when `FLAG_DISABLE_IMAGE_PROCESSING=true` so it is not enabled in production unknowingly.
### Tech Debt: web app has near-zero test coverage
- `apps/web/src` has **11 test files** across **~224 source files** (~5%) — `find apps/web/src -name "*.test.*"`.
- `apps/web/src/routes` has **1 test file** across **125 route files** (`apps/web/src/routes/builder/$resumeId/-components/donation-toast.test.tsx`).
- Total repo: ~93 test files / ~385 source files (~24%). Most coverage lives in `packages/ui`, `packages/utils`, `packages/pdf/src/templates/shared`, and `packages/api` (5 tests).
**Impact:**
- Refactors to routes, builder shell, sidebar forms, resume preview wiring, MCP tools, and uploads handler land without a regression net.
- Particularly thin: `apps/web/src/routes/api/**` (rpc, auth, openapi, mcp, uploads) and `apps/web/src/routes/builder/$resumeId/**`.
**Fix approach:** Phase-by-phase, add server-handler integration tests for `api/health.ts`, `api/auth.$.ts` (registration-validation path), `uploads/$userId.$.tsx` (path traversal), and at least smoke tests around the builder store in `apps/web/src/components/resume/builder-resume-draft.ts`.
---
## Medium Severity
### Fragile: PDF.js / canvas SSR boundary is enforced by convention only
The SSR/CSR split for the resume preview is hand-maintained:
- `apps/web/src/components/resume/preview.tsx:14` returns `null` until `useIsClient()` resolves and then lazy-loads the browser bundle.
- `apps/web/src/components/resume/preview.browser.tsx:2` imports `@react-pdf/renderer` (`pdf`).
- `apps/web/src/components/resume/pdf-canvas.tsx:1, :4, :9` imports PDF.js types/runtime and sets a module-level `GlobalWorkerOptions.workerSrc`.
- `apps/web/src/routes/templates/$.tsx:1` imports `PDFViewer` at the **top level** but the route component itself bails on `!isClient`. Top-level import means the bundle reaches the SSR chunk; correctness relies on the import being tree-shaken away when SSR runs.
- `apps/web/src/routes/dashboard/resumes/-components/cards/resume-thumbnail.tsx` uses dynamic `await import("pdfjs-dist")` — a different convention from `pdf-canvas.tsx`'s static import.
- SSR mode hints: `apps/web/src/routes/builder/$resumeId/index.tsx:4` (`ssr: false`), `apps/web/src/routes/$username/$slug.tsx` (`ssr: "data-only"`).
**Impact:** A future contributor adding a static PDF.js import inside a SSR-rendered route component will break SSR with a `window is not defined` error at build/run time. There is no lint rule or build-time guard enforcing this.
**Fix approach:**
1. Document the boundary explicitly at the top of `apps/web/src/components/resume/preview.tsx` and `pdf-canvas.tsx` (currently only described in `AGENTS.md:35`).
2. Consider a Vite SSR-externals config that aborts the build if `pdfjs-dist` / `@react-pdf/renderer` is reachable from an SSR-eligible route.
3. Switch `apps/web/src/routes/templates/$.tsx:1` to a dynamic `import("@react-pdf/renderer")` inside the `useIsClient()` branch for consistency with the rest of the preview pipeline.
### Fragile: `getStorageService()` is captured at module load in router
`packages/api/src/routers/storage.ts:7` calls `getStorageService()` at module top level. Because `apps/web/src/routes/api/rpc.$.ts` constructs a new `RPCHandler` on every request, the router's storage reference is fixed for the lifetime of the Node process.
**Impact:** Switching backends (e.g. flipping S3 vars on at runtime) requires a process restart, which is normally fine — but the singleton is also held inside Nitro's HMR boundary in dev, so config changes during `pnpm dev` need a full restart (not just a save).
**Fix approach:** Call `getStorageService()` inside each handler instead, or invalidate the cached service when env values change.
### Fragile: `RPCHandler` instantiated per-request
`apps/web/src/routes/api/rpc.$.ts:8-16` creates a new `RPCHandler` (with plugins) for every incoming request. Each instance re-walks the router tree and re-constructs plugin pipelines.
**Impact:** Measurable per-request cost on high-RPS dashboards; not catastrophic but unnecessary.
**Fix approach:** Move `new RPCHandler(...)` out of the handler and reuse a module-level instance; only `getLocale()` should run per-request.
### Performance: PDF preview regenerates entire PDF on every change
`apps/web/src/components/resume/preview.browser.tsx:103-131` debounces PDF generation by 100ms and calls `pdf(resumeDocument).toBlob()` on every resume change. For multi-page resumes with images, this re-renders the full document and re-loads it into PDF.js.
**Impact:** Builder feels sluggish on slower hardware when typing into fields; CPU spikes on each keystroke after debounce.
**Mitigation in place:** `UPDATE_DEBOUNCE_MS = 100`, crossfade between staged/active layers (`:20-80`).
**Fix approach:** Long-term, switch to incremental rendering or page-level memoization keyed by section hash. Short-term, raise debounce to 200–300 ms while typing.
### Performance: font registration cost
`packages/pdf/src/hooks/use-register-fonts.ts:18, :86` keeps a module-level `registeredFontVariants` Set keyed by `family:weight:style`. Per-resume registration calls `Font.register` once per (family × weight × italic × CJK-fallback) combination, with web-font fetches resolved through `getWebFontSource`. This Set never expires — if many resumes with different typography are previewed in one session, the registered-font count grows for the page lifetime.
**Impact:** Memory grows in the builder for sessions that switch typography frequently. Not a leak in the GC sense, but bounded only by the size of the registered-font universe.
**Fix approach:** Acceptable for current usage. Document the cap and reconsider if typography switching becomes more common.
### Performance: large source files hint at oversized modules
Top offenders by line count (excluding generated/test fixtures):
| Lines | File |
|------:|------|
| 1535 | `packages/schema/src/icons.ts` (static data) |
| 1088 | `apps/web/src/routes/builder/$resumeId/-components/assistant.tsx` |
| 900 | `packages/pdf/src/templates/shared/sections.tsx` |
| 789 | `apps/web/src/components/input/rich-input.tsx` |
| 785 | `packages/import/src/reactive-resume-v4-json.tsx` |
| 685 | `packages/ui/src/components/sidebar.tsx` |
| 556 | `apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx` |
| 543 | `packages/api/src/services/resume.ts` |
`assistant.tsx` (1088 lines) and `sections.tsx` (900 lines) are particularly likely to accumulate further complexity without splitting. `rich-input.tsx` (789 lines) is the rich-text editor — likely justifies its size but has zero direct tests.
**Fix approach:** Split `assistant.tsx` along tool boundaries; extract per-section renderers from `sections.tsx` if any individual section grows further.
### Security: `verifyPassword` rate limit keyed by `username:slug:ip`
`packages/api/src/middleware/rate-limit/index.ts:77-83` keys the resume-password limiter on `resume-password:{username}:{slug}:{clientKey}` where `clientKey` is the client IP. The window/max is 5 attempts per 10 minutes (`packages/utils/src/rate-limit.ts:43`). This is reasonable, but the global Better Auth global rule for `/two-factor/verify-otp` is also 5 per 600s. An attacker on a botnet (different IPs) is not blocked at the resource level — each IP gets its own 5/10min budget against the same resume.
**Impact:** Limited but present brute-force surface on password-protected public resumes.
**Fix approach:** Add a per-resume global cap on top of the per-IP limit (e.g. 50/hour per `username:slug` regardless of IP).
### Security: `as string` cast on password hash
`packages/api/src/services/resume.ts:487` casts `resume.password` to `string` after a `isNotNull(schema.resume.password)` WHERE clause. The cast is correct in context, but if a future refactor drops the `isNotNull` guard the cast silently allows `null` through `bcrypt.compare`.
**Fix approach:** Replace `as string` with a runtime `if (!resume.password) throw new ORPCError(...)` check.
### Security: trust of `TRUSTED_IP_HEADERS` is unconditional
`packages/utils/src/rate-limit.ts:1-7` defines a list of trusted IP headers (`CF-Connecting-IP`, `True-Client-IP`, `X-Forwarded-For`, etc.) that the rate limiter and Better Auth (`packages/auth/src/config.ts:276`) honour from any caller.
**Impact:** If the app is deployed without a proxy that strips client-supplied versions of these headers, any client can spoof their rate-limit identity by setting `X-Forwarded-For: 1.2.3.4`.
**Fix approach:** Document that operators **must** terminate at a trusted proxy (Cloudflare, nginx, Caddy) that strips inbound `X-Forwarded-For` / `X-Real-IP`. Optionally, add a `TRUST_PROXY` env flag and only honour those headers when set.
### Fragile: `cachedTransport` in `email/transport.ts` ignores env mutation
`packages/email/src/transport.ts:21-37` caches the nodemailer transport on first use. If SMTP creds change at runtime (e.g. credential rotation in a deployed instance), the cached transport keeps using the stale credentials until the process restarts.
**Fix approach:** Detect cred changes and rebuild, or document the restart-on-rotation behaviour.
### Storage gotcha: statistics cache is filesystem-bound even with S3 configured
`packages/api/src/services/statistics.ts:21-52` caches user/resume/star counts as files in `getLocalDataDirectory(env.LOCAL_STORAGE_PATH)` regardless of whether S3 is enabled.
**Impact:** When S3 is configured and `LOCAL_STORAGE_PATH` is on ephemeral storage (e.g. container scratch), the cache is recreated on every redeploy — meaning a cold start always queries the DB / GitHub API rather than re-using the cache. Also breaks horizontal scaling — each replica has its own cache file.
**Fix approach:** Cache via the configured storage service (`getStorageService`) instead of raw `fs`, or move to an in-memory + TTL cache.
---
## Low Severity
### Tech Debt: generated file you must not edit
`apps/web/src/routeTree.gen.ts` (983 lines, `eslint-disable`, `@ts-nocheck` at top) is auto-generated by TanStack Router. It is correctly excluded from Biome (`biome.json:14`) and noted in `AGENTS.md:31`.
**Action required of contributors:** Never hand-edit. Regenerate by running `pnpm dev` (TanStack tooling watches `apps/web/src/routes/**`).
### Tech Debt: dev workflow — `pnpm check` is write-capable
`package.json:20` defines `"check": "biome check --write --unsafe ."`. Running `pnpm check` will modify files. The lefthook pre-commit (`lefthook.yml:7-9`) runs the same command on staged files only, and `stage_fixed: true` re-stages them.
**Impact:** Surprise file modifications when a contributor runs `pnpm check` expecting a non-mutating audit.
**Fix approach:** Add a parallel `pnpm check:ci` (no `--write`) for inspection, and document the distinction (already covered in `AGENTS.md:103`).
### Tech Debt: dev gotcha — `drizzle-kit` does not auto-load `.env`
`packages/db/drizzle.config.ts:8` reads `process.env.DATABASE_URL || ""`. The `@reactive-resume/env` package auto-loads `.env` via dotenv (`packages/env/src/server.ts:9-11`), but `drizzle-kit` runs as a separate process that does not import `@reactive-resume/env`.
**Impact:** Fresh `pnpm db:migrate` silently fails with an empty connection string unless `DATABASE_URL` is exported in the shell. Documented in `AGENTS.md:58, :79-80`.
**Fix approach:** Either import the env package from `drizzle.config.ts` to inherit the `.env` load, or wrap the script with a one-liner that exports `DATABASE_URL`.
### Tech Debt: Nitro plugin auto-runs migrations on dev/prod boot
`apps/web/plugins/1.migrate.ts:23-40` runs migrations on every Nitro startup. This is convenient but couples app-boot health to migration health.
**Impact:**
- A bad migration takes down the whole web service on boot, not just future migration runs.
- Two app instances starting concurrently both call `migrate()`; Drizzle's `migrations` table uses transactions to avoid duplicate apply, but the race adds startup latency.
- For prod self-hosters, there is no "boot-without-migrate" knob.
**Fix approach:** Gate on an env flag (e.g. `RUN_MIGRATIONS_ON_BOOT=true`, default true) and document running `pnpm db:migrate` separately in zero-downtime deploys.
### Tech Debt: S3 toggle is all-or-nothing
`packages/api/src/services/storage.ts:336` and `apps/web/plugins/2.storage.ts:7`: storage backend selection requires all three of `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_BUCKET` to be set. Setting two of three silently falls back to local storage.
**Impact:** Operator misconfigures S3 (e.g. forgets `S3_BUCKET`), app silently writes to local FS — uploads disappear on next container restart on ephemeral disks.
**Fix approach:** Treat "any S3 var set" as "S3 intended" — throw on partial config rather than silently downgrading.
### Security: no global CSP / X-Frame-Options on HTML responses
A repo-wide grep for `Content-Security-Policy`, `X-Frame-Options`, `Strict-Transport-Security` finds them only in the uploads route (`apps/web/src/routes/uploads/$userId.$.tsx:159-165`) and the schema JSON route (`apps/web/src/routes/schema[.]json.ts`). The HTML shell (`apps/web/src/routes/__root.tsx`) sets no CSP, no HSTS, no `Permissions-Policy`, no `Referrer-Policy`.
**Impact:**
- No clickjacking protection on the resume builder or public resume pages — they can be framed by any origin.
- No CSP means an XSS through rich-text rendering (`packages/utils/src/sanitize.*`, `packages/pdf/src/templates/shared/rich-text-html.ts`) has no defence-in-depth.
**Fix approach:** Add a Nitro response hook that sets `Content-Security-Policy`, `Strict-Transport-Security`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-Content-Type-Options: nosniff`, and `X-Frame-Options: SAMEORIGIN` on all HTML responses. Validate that the CSP allows `pdf.worker.min.mjs`, `@react-pdf/renderer` font fetches, and the configured S3/SeaweedFS origin.
### Security: OAuth dynamic-client registration allows unauthenticated callers
`packages/auth/src/config.ts:395-401` enables `allowDynamicClientRegistration: true` and `allowUnauthenticatedClientRegistration: true` on the oauthProvider plugin. The comment (`:397-399`) explicitly states this is required for MCP onboarding (RFC 7591) and that the phishing vector is closed by the redirect-URI allowlist in `hooks.before` (`:253-271`) and `apps/web/src/routes/api/auth.$.ts:97-111`.
**Impact:** Anyone can register an OAuth client. The protection depends entirely on the redirect-URI allowlist correctness in `parseAllowedHostList(env.OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS)` and `isAllowedOAuthRedirectUri`.
**Fix approach:** Already mitigated in code; the residual risk is operator misconfiguration of `OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS`. Add a startup warning when `OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS` is empty (i.e. allowlist is APP_URL-only).
### Tech Debt: log noise on auth fallthrough
`packages/api/src/context.ts:25, :37, :53` calls `console.warn` for every failed Bearer / session / API-key validation. In an unauthenticated user flow that hits the same route via Bearer-not-present → session-cookie path, no warning fires, but in an actual token-mismatch path the warning fires on every request.
**Impact:** Log volume in production when API keys expire or rotate. Logs may leak token shape information indirectly via repeated warnings.
**Fix approach:** Drop to `console.debug` (which is no-op in default Node), or rate-limit the warning per token-hash.
### Tech Debt: `getStorageService()` cached service singleton in module-load order
`packages/api/src/services/storage.ts:343-350` caches the service at first call. Combined with the `packages/api/src/routers/storage.ts:7` top-level call, if storage env vars are not present at module-load time (e.g. `.env` not loaded yet) the local backend is wired in permanently for the process.
**Fix approach:** Lazy-validate env on first use, not at module load.
### Anti-pattern: empty catch swallows preview generation errors
`apps/web/src/components/resume/preview.browser.tsx:120`:
```ts
} catch {}
```
A failed PDF generation in the builder preview is silently swallowed. The crossfade machinery keeps showing the previous preview, masking template bugs or runtime errors from contributors during development.
**Fix approach:** At minimum, `console.error` the failure (matching the pattern used in `pdf-canvas.tsx:68`). Better: surface a toast or banner so failed-preview state is visible.
### Anti-pattern: `console.warn`/`error` lacks structure
Every error log in `packages/api/src` uses `console.warn`/`console.error` with positional args (e.g. `services/resume.ts:158, :293, :363`, `context.ts:25, :37, :53`). There is no centralised logger, no structured fields, no request correlation ID.
**Fix approach:** Introduce a minimal logger (pino-light or a thin wrapper) with `level`, `event`, `userId`, `requestId` fields. Already partly done in the healthcheck (`apps/web/src/routes/api/health.ts:65, "[Healthcheck]"`).
### Fragile: in-process pub/sub for resume update events
`packages/api/src/services/resume-events.ts` (the subscribe path consumed by `packages/api/src/routers/resume.ts:99-103`'s `subscribeResumeUpdates`) is in-process. Two web replicas will not see each other's resume update events.
**Impact:** Multi-tab / multi-device builder sync across replicas does not work behind a load balancer.
**Fix approach:** Redis pub/sub or Postgres `LISTEN/NOTIFY` once distributed deployment becomes a target.
---
## Migration & Schema Concerns
### Migration count is small but each is unversioned in app
- 13 migration files in `migrations/` (`migrations/20260114102228_*` through `migrations/20260507144406_*`).
- The Nitro plugin runs every pending migration on every boot (`apps/web/plugins/1.migrate.ts:23-40`).
- There is no "down" / rollback story. Drizzle-kit generates forward-only migrations.
**Action:** Standard for drizzle workflows. Document that downgrade requires manual SQL.
### Schema notes
- `packages/db/src/schema/resume.ts:24` stores `password` as plaintext column type `text`, but content is always bcrypt-hashed at write (`packages/api/src/services/resume.ts:453`). Column name is misleading — should be `password_hash`. Renaming requires a non-trivial migration.
---
## Dependency Risk
### Pre-1.0 / preview dependencies in production critical path
| Package | Version | Risk |
|---|---|---|
| `drizzle-orm` | `1.0.0-beta.22` | Beta. Breaking changes possible until 1.0.0 stable. (`packages/api/package.json`, `packages/auth/package.json`, `packages/db/package.json`, `apps/web/package.json`) |
| `drizzle-kit` | `1.0.0-beta.22` | Beta migrator. Snapshot format may change. (`packages/db/package.json`) |
| `drizzle-zod` | `1.0.0-beta.14-a36c63d` | Beta + specific commit hash — version drift risk. (`packages/api/package.json`) |
| `nitro` | `3.0.260429-beta` | Beta server runtime in the critical path. (`apps/web/package.json`) |
| `@typescript/native-preview` | `7.0.0-dev.20260510.1` | Dev build of `tsgo`. All packages use it for `typecheck`. |
| `typescript` | `^6.0.3` | TS 6 — recent major. |
| `vite` | `^8.0.11` | Vite 8 — recent major. |
| `react` / `react-dom` | `^19.2.6` | React 19. |
| `@orpc/experimental-ratelimit` | `^1.14.2` | Explicitly experimental in the package name. (`packages/api/package.json`) |
| `@tanstack/react-start` | `^1.167.65` | TanStack Start is pre-1.x semver but not labelled beta. |
| `better-auth` | `1.6.10` (exact) | Pinned exact, not `^`. Manual upgrade required for security patches. |
**Impact:** Library upgrades in this stack are high-risk; the team must follow each upstream's release notes closely. The `BETA` / `DEV` versions also affect lockfile churn.
**Fix approach:**
- Set up Renovate / Dependabot for the beta packages specifically, so security patches are caught.
- Run `pnpm knip` and `pnpm dlx npm-check-updates` regularly (both are devDependencies, `package.json:34, :40`).
### Several heavy native dependencies are externalised at build time
`apps/web/vite.config.ts:55-57` externals `bcrypt`, `sharp`, `@aws-sdk/client-s3`. `packages/runtime-externals` is the workspace package that wraps these. Knip is configured to ignore them (`knip.json:14`).
**Risk:** Operators must ensure these are installed at runtime (covered in `Dockerfile`). Self-hosters using a Node base image without build-essentials may hit `bcrypt` build failures.
**Fix approach:** Mostly documented; consider switching `bcrypt` → `bcryptjs` (pure JS) to remove a build dependency.
---
## Cross-Cutting Hard-to-Find Logic
These are non-obvious surfaces that consumers of this map should know about before changing related code:
1. **PDF section filtering** — `packages/pdf/src/templates/shared/filtering.ts:25-60` decides which resume sections render in PDFs based on hidden flags and required title fields. Per-template visual exceptions live in each template directory; cross-template visual changes go here. Noted in `AGENTS.md:44`.
2. **React-PDF font registration & CJK fallback stack** — `packages/pdf/src/hooks/use-register-fonts.ts:68-133`. Owns standard-PDF-font handling (`isStandardPdfFontFamily`), CJK glyph-level fallback (#2986), and global hyphenation callback. Module-level registration cache (`:18`). Noted in `AGENTS.md:45`.
3. **Resume access policy** — `packages/api/src/helpers/resume-access-policy.ts`. Single source of truth for owner-vs-viewer redaction (`name` and `metadata.notes` stripped for non-owners), `NOT_FOUND`-vs-`FORBIDDEN` choice (not-found used to avoid existence disclosure), and self-view statistics exclusion. Owner-only mutations rely on SQL `WHERE userId =` clauses, not this policy — drift between SQL guards and policy is silent.
4. **Resume password cookie** — `packages/api/src/helpers/resume-access.ts`. Cookie name `resume_access_{resumeId}`, signed value is `sha256(resumeId:passwordHash)`, TTL 10 minutes, `httpOnly`, `sameSite: lax`, `secure` only when `APP_URL` starts with `https`. Forgetting to set `APP_URL=https://...` in production drops the secure flag.
5. **OAuth `authorize` request sanitization** — `apps/web/src/routes/api/auth.$.ts:6-51` strips control chars from OAuth parameters and decodes broken-but-decodable redirect URIs before passing to Better Auth. Easy to bypass if a new OAuth flow is added that does not route through this handler.
6. **Dynamic client registration coercion to public client** — `apps/web/src/routes/api/auth.$.ts:53-80` forces `token_endpoint_auth_method = "none"` for unauthenticated registrations (specifically for Claude.ai's MCP onboarding quirk). This is non-standard behaviour buried in a request preprocessor.
7. **Builder draft sync** — `apps/web/src/components/resume/builder-resume-draft.ts:46-100` manages per-resume zustand stores keyed by resume id, with debounced patch-and-resync. Failure to clean up `runtimes` Map on resume close = subscription/timer leaks.
---
## Test Coverage Gaps (Priority Map)
| Area | Source files | Test files | Priority |
|------|-------------:|-----------:|----------|
| `apps/web/src/routes/**` | 125 | 1 | **High** — covers all server handlers and the builder shell |
| `apps/web/src/routes/api/**` (rpc/auth/uploads/openapi/mcp) | ~10 | 0 | **High** — security-sensitive route handlers |
| `apps/web/src/components/resume/**` | 5 | 2 | Medium |
| `apps/web/src/dialogs/**` | ~30 | 1 (`store.test.ts`) | Medium |
| `packages/api/src/routers/**` | 7 | 0 (DTO test only) | **High** — auth-protected procedures |
| `packages/api/src/services/**` | 8 | 1 (`ai.test.ts`) | High |
| `packages/auth/src/**` | 3 | 0 | **High** — central auth config never directly tested |
| `packages/email/src/**` | ~5 | 0 | Medium |
| `packages/import/src/**` | several importers | 1 (v4) | Medium |
| `packages/pdf/src/templates/shared/**` | ~20 | 11 | Low (well covered) |
---
*Concerns audit: 2026-05-11*
+238
View File
@@ -0,0 +1,238 @@
# Coding Conventions
**Analysis Date:** 2026-05-11
This document is the canonical short-form reference for code style, structure, and feature-boundary rules in the Reactive Resume monorepo. The authoritative long-form reference lives in `AGENTS.md` at the repo root — when in doubt, defer to it.
## Tooling Stack
- **Formatter + linter:** Biome 2.x (`biome.json`). Pre-commit hook (`lefthook.yml`) runs `biome check --write --unsafe` on staged JS/TS/JSON files.
- **Type checker:** `tsgo --noEmit` (the `@typescript/native-preview` TS implementation) in every workspace package and `apps/web`. Use `pnpm typecheck` at the root or `pnpm --filter <pkg> typecheck` per package.
- **TypeScript base config:** `packages/config/tsconfig.base.json`, consumed by `tsconfig.json` at root and per-package `tsconfig.json` files.
- **Commit hook:** commitlint with `@commitlint/config-conventional` (`commitlint.config.cjs`). `body-max-line-length` is disabled.
- **Internal CLI:** `pnpm check` is **write-capable** (`biome check --write --unsafe .`). Use `biome check .` (no `--write`) for read-only inspection.
## Biome Configuration (`biome.json`)
**Formatter:**
- `lineWidth: 120`
- `indentStyle: "tab"`
- `javascript.formatter.quoteStyle: "double"`
- CSS parser has `tailwindDirectives: true`
**Linter rules (notable):**
- `recommended: true`
- `suspicious.noExplicitAny: "error"` — `any` is forbidden
- `suspicious.noArrayIndexKey: "off"`
- `correctness.useExhaustiveDependencies: "info"` (not an error)
- `style.useImportType: { level: "on", options: { style: "separatedType" } }` — `import type` is required when an import is type-only and must be on its own line (not inlined per-specifier)
- `style.noInferrableTypes: "error"` — drop redundant annotations like `const x: number = 1`
- `style.noUselessElse: "error"`
- `style.useSelfClosingElements: "error"`
- `style.useSingleVarDeclarator: "error"`
- `style.noParameterAssign: "error"`
- `style.useDefaultParameterLast: "error"`
- `nursery.useSortedClasses: { level: "warn", fix: "safe", functions: ["clsx", "cva", "cn"] }` — Tailwind class strings inside these wrappers are sorted automatically
**Import organization:** Biome's `assist.actions.source.organizeImports` is `on`, grouped in this order (`biome.json` lines 32-39):
1. Type-only imports (`{ "type": true }`)
2. Node built-ins (`":NODE:"`, excluding Bun)
3. Vitest + Testing Library (`vitest`, `vitest/**`, `@testing-library/**`)
4. External npm packages (`:PACKAGE:`, excluding `@reactive-resume/**`)
5. Internal workspace packages (`@reactive-resume/**`)
6. App-local aliases / relative paths (`:ALIAS:`, `:PATH:`)
A representative file that demonstrates the order: `packages/api/src/services/resume.ts` (type imports → node-free externals → `@reactive-resume/*` → relatives).
**Biome ignores:** `**/.turbo`, `**/.output`, `**/.vercel`, `**/.wrangler`, `**/coverage`, `**/reports`, `**/routeTree.gen.ts`.
## TypeScript Conventions
**Strictness flags** in `packages/config/tsconfig.base.json` are intentionally aggressive:
- `strict: true`
- `verbatimModuleSyntax: true` (pairs with Biome's `useImportType` enforcement)
- `exactOptionalPropertyTypes: true`
- `noUncheckedIndexedAccess: true`
- `noUncheckedSideEffectImports: true`
- `noUnusedLocals: true`, `noUnusedParameters: true`
- `noFallthroughCasesInSwitch: true`
- `isolatedModules: true`, `moduleResolution: "bundler"`
- `target: "ESNext"`, `module: "ESNext"`
**Type-only imports:** Always use `import type { … }` on its own line when an import is only used in type positions. See `packages/api/src/services/resume.ts:1-5` and `packages/api/src/context.ts:1-2`.
**`any`:** Banned by Biome (`noExplicitAny: "error"`). Use `unknown` and narrow, or use a discriminated union.
**Path aliases (web app only):** `apps/web/tsconfig.json` declares:
- `@/*` → `./src/*`
- `@reactive-resume/ui/*` → `../../packages/ui/src/*` (build-time alias for direct source resolution)
Internal packages do NOT use `@/*` aliases — they import siblings via relative paths and cross-package code via the `@reactive-resume/*` export maps.
## Package Export Conventions (source-consumed packages)
**Internal packages export `src` files directly via `package.json` `exports`.** There is no per-package build step; consumers pick up the TS source through bundler/vitest resolution. Do not assume any `dist/` output.
Sample (`packages/utils/package.json`):
```json
{
"name": "@reactive-resume/utils",
"type": "module",
"exports": {
"./color": "./src/color.ts",
"./date": "./src/date.ts",
"./resume/docx": "./src/resume/docx/index.ts",
"./resume/patch": "./src/resume/patch.ts",
"./url-security.node": "./src/url-security.node.ts"
}
}
```
**Conventions when adding cross-package exports:**
- Use **explicit subpath exports**, not wildcards in `@reactive-resume/utils`/`@reactive-resume/db`. Some packages (`@reactive-resume/api`) do use wildcards like `"./services/*"` — match the style of the package you're editing.
- Filenames ending in `.node.ts` (e.g. `packages/utils/src/url-security.node.ts`, `packages/utils/src/monorepo.node.ts`) are reserved for Node-only code that must not be imported from the browser bundle.
- Filename suffixes `.browser.tsx` (e.g. `apps/web/src/components/resume/preview.browser.tsx`) mark code that must stay out of SSR paths.
## React & Web App Conventions
**Routing:** TanStack Router with file-based routes under `apps/web/src/routes`.
- Each route file calls `createFileRoute("/path")({ … })` and exports `Route`. Example: `apps/web/src/routes/auth/login.tsx:18`.
- `apps/web/src/routeTree.gen.ts` is generated — never hand-edit it (also Biome-ignored).
- Server-only handlers live in `server.handlers` blocks on routes like `apps/web/src/routes/api/rpc.$.ts`, `apps/web/src/routes/api/auth.$.ts`, `apps/web/src/routes/api/health.ts`.
- Public resume route `apps/web/src/routes/$username/$slug.tsx` is `ssr: "data-only"`; nested builder preview is `ssr: false`.
**Router context** (`apps/web/src/router.tsx`) provides `queryClient`, `orpc`, `theme`, `locale`, `session`, and `flags`. Read them via `Route.useRouteContext()` rather than refetching.
**Components:**
- Functional components only. React 19.
- File naming: **kebab-case** for files and directories (e.g. `apps/web/src/components/command-palette/`, `packages/ui/src/components/alert-dialog.tsx`, `apps/web/src/dialogs/api-key/create.tsx`).
- Test file: `<name>.test.ts(x)` colocated with the implementation (e.g. `packages/ui/src/components/button.tsx` + `packages/ui/src/components/button.test.tsx`).
- shadcn/Base UI primitive components in `packages/ui/src/components/*.tsx` are exported via the `./components/*` subpath. Hooks via `./hooks/*`.
**Tailwind class strings:** Always wrap in `clsx`, `cva`, or `cn` so Biome's `useSortedClasses` rule can sort them safely.
## oRPC Conventions (`packages/api`)
- **Procedures:** Build new procedures with `publicProcedure` or `protectedProcedure` from `packages/api/src/context.ts:79-99`. `protectedProcedure` adds the authenticated `User` to context and throws `ORPCError("UNAUTHORIZED")` otherwise; prefer it for anything authenticated.
- **Routers** live in `packages/api/src/routers/*.ts` and are composed in `packages/api/src/routers/index.ts` (`ai`, `auth`, `flags`, `resume`, `statistics`, `storage`). Each router file may export sub-routers internally (see `tagsRouter`, `statisticsRouter`, `analysisRouter`, `updatesRouter` in `packages/api/src/routers/resume.ts`).
- **Business logic** belongs in `packages/api/src/services/*.ts`. Handlers must stay thin: validate input, call a service, return its output. Example pattern: `packages/api/src/routers/resume.ts:24-26` calls `resumeService.tags.list(...)`.
- **DTOs / IO schemas** live in `packages/api/src/dto/*.ts` and are imported as `resumeDto.<op>.input` / `resumeDto.<op>.output`.
- **Errors:** Declare typed errors with `.errors({ CODE: { message, status } })` on the procedure (see `packages/api/src/routers/resume.ts:282-291`). Throw `new ORPCError("CODE")` inside services. The web side translates codes to user-facing strings in `apps/web/src/libs/error-message.ts`.
- **Route metadata:** Every procedure declares `.route({ method, path, tags, operationId, summary, description, successDescription })` so the OpenAPI/MCP endpoints stay accurate.
- **Rate limiting:** Apply via `.use(resumeMutationRateLimit)` / `.use(resumePasswordRateLimit)` from `packages/api/src/middleware/rate-limit`.
- **Web exposure:** Routers are mounted at `/api/rpc` by `apps/web/src/routes/api/rpc.$.ts`. The isomorphic client lives at `apps/web/src/libs/orpc/client.ts` — server-side calls use the in-process router client and browser calls hit `/api/rpc` with credentials.
## Drizzle Conventions (`packages/db`)
- **Schema location:** `packages/db/src/schema/*.ts`. Tables exported from `packages/db/src/schema/index.ts`.
- **Client:** `packages/db/src/client.ts`, exported as `@reactive-resume/db/client`.
- **Migrations:** Generated to **`migrations/` at the repo root** by `drizzle-kit generate`. Use `pnpm db:generate` after schema changes.
- **`DATABASE_URL` handling:** `drizzle-kit` does **not** auto-load `.env`. Always export `DATABASE_URL` in the shell (or prefix the command) before running `pnpm db:generate` / `pnpm db:migrate`.
- **Runtime migration:** `apps/web/plugins/1.migrate.ts` runs migrations on Nitro startup, so `pnpm db:migrate` is mostly used for first-time setup or debugging.
- **Patterns observed in `packages/db/src/schema/resume.ts`:**
- Primary keys use `pg.text("id").$defaultFn(() => generateId())` (UUIDv7 from `@reactive-resume/utils/string`).
- `createdAt` / `updatedAt` use `withTimezone: true`, `.defaultNow()`, and `$onUpdate(() => new Date())`.
- JSONB columns get `.$type<T>()` for end-to-end typing.
- Composite uniques/indexes are declared in the table's tuple callback.
- Foreign keys use `onDelete: "cascade"`.
## Schema-First Change Workflow
When changing resume data shape, propagate in this order (per `AGENTS.md`):
1. **`packages/schema/src/resume/*.ts`** — Zod schemas and types (entry point).
2. **`packages/api/src/dto/*.ts`** — API DTOs that re-use those schemas.
3. **`packages/import/src/*.tsx`** — importers (`json-resume`, `reactive-resume-json`, `reactive-resume-v4-json`).
4. **`packages/pdf/src/templates/**`** — PDF rendering for every template (`azurill`, `bronzor`, `chikorita`, `ditgar`, `ditto`, `gengar`, `glalie`, `kakuna`, `lapras`, `leafish`, `meowth`, `onyx`, `pikachu`, `rhyhorn`, `scizor`). Shared filtering: `packages/pdf/src/templates/shared/filtering.ts`.
5. **`apps/web/src/`** — builder forms and any consumer hooks.
Adding/renaming a template requires changes in `packages/schema/src/templates.ts`, `packages/pdf/src/templates/index.ts`, the template directory `packages/pdf/src/templates/<name>/`, and static previews under `apps/web/public/templates/{jpg,pdf}/`.
## Error Handling Patterns
- **Services:** Throw `new ORPCError("CODE")` (e.g. `NOT_FOUND`, `UNAUTHORIZED`, custom `RESUME_LOCKED`). Example: `packages/api/src/services/resume.ts:54`.
- **Routers:** Declare expected codes via `.errors({ … })` so callers get typed error narrowing.
- **Auth helpers** in `packages/api/src/context.ts:14-55` catch verification errors and `console.warn(...)` rather than throwing, returning `null` so the caller can fall through to the next auth method.
- **Web side:** `apps/web/src/libs/error-message.ts` exposes `getReadableErrorMessage`, `getOrpcErrorMessage`, and `getResumeErrorMessage` for translating raw errors into UI-safe strings. Pair with `sonner` toasts (see `apps/web/src/routes/auth/login.tsx:45-64`).
## Logging
- No dedicated logging framework. Use `console.warn` / `console.error` for diagnostic output, scoped tightly (see `packages/api/src/context.ts:25`).
- Server logs are also where dev-mode email verification links surface when SMTP is unconfigured.
## i18n & Translations (Lingui)
- **Library:** `@lingui/core`, `@lingui/react` with the babel macro plugin enabled in `apps/web/vitest.config.ts` and Vite config.
- **Config:** `apps/web/lingui.config.ts` — source locale `en-US`, pseudo locale `zu-ZA`, 50+ supported locales. Catalogs live in `apps/web/locales/{locale}.po`.
- **Usage:**
- Import macros: `import { t } from "@lingui/core/macro"` and `import { Trans } from "@lingui/react/macro"` (`apps/web/src/routes/auth/login.tsx:1-2`).
- Wrap displayed text in `<Trans>...</Trans>` for JSX or `` t`...` `` / `t({ message, comment })` for strings.
- Provide `comment:` for ambiguous fallback strings (see `login.tsx:57-60`).
- **Extraction:** `pnpm lingui:extract` (turbo task in `apps/web`). Crowdin sync runs via `.github/workflows/crowdin-sync.yml`.
## Comments Policy
Comments stay short and explain **why**, not what. Patterns observed:
- One-line `//` comments before a non-obvious decision (`vitest.setup.ts:5-7` explains why `cleanup()` is registered manually; `packages/utils/src/monorepo.node.test.ts:11` explains the `realpathSync` call).
- JSDoc/TSDoc only on cross-package public functions where intent matters (e.g. `packages/api/src/context.ts:57-63` documents `resolveUserFromRequestHeaders`).
- Inline `/* @__PURE__ */` annotations on `$onUpdate(() => new Date())` in Drizzle schemas (`packages/db/src/schema/resume.ts:36`).
- No commented-out code in commits.
## Git Hooks & Commit Style
**Lefthook (`lefthook.yml`):**
```yaml
pre-commit:
parallel: true
jobs:
- name: lint and format
glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}"
run: pnpm biome check --write --unsafe --no-errors-on-unmatched --files-ignore-unknown=true {staged_files}
stage_fixed: true
commit-msg:
jobs:
- name: commitlint
run: pnpm commitlint --edit {1}
```
The pre-commit hook **rewrites staged files** with Biome fixes (`stage_fixed: true`). Run `pnpm check` before staging to avoid surprises.
**Commit messages:** Conventional Commits (`commitlint.config.cjs` extends `@commitlint/config-conventional`). Examples from `git log`:
- `feat: implement an AI chat window for agentic resume building`
- `fix(pdf): register CJK fallback font so Chinese/Japanese/Korean text renders correctly`
- `fix(lapras): adjust lapras border color to fixed gray`
- `chore: migrate from jsdom to happy-dom for testing environment`
- `docs: update AGENTS.md with detailed codebase structure`
- `test: add unit and component tests across the monorepo`
- `chore(release): v5.1.2`
Allowed types: `feat`, `fix`, `chore`, `docs`, `test`, `refactor`, `style`, `perf`, `build`, `ci`, `revert`. Scope is optional and lowercase. `body-max-line-length` is disabled, so long PR bodies are fine.
## CI Workflows
- **`.github/workflows/autofix.yml`** runs on every PR and push to `main`: `pnpm install --frozen-lockfile` → `pnpm knip --fix` (prune unused deps) → `pnpm check` (Biome) → `autofix-ci/action` opens fix commits.
- **`.github/workflows/docker-build.yml`** is `workflow_dispatch` only, builds multi-arch Docker images.
- **`.github/workflows/crowdin-sync.yml`** syncs translation catalogs.
- There is no CI workflow that runs `pnpm test` today. Tests run locally via `pnpm test` / `pnpm test:ci` and through turbo's `test:agent` reporter for agent-driven runs.
## Function & Module Design
- **Function size:** Most service functions stay under ~40 lines. Bigger flows (e.g. `resumeService.patch`) are decomposed into helpers in `packages/api/src/helpers/*` and `packages/api/src/services/resume-events.ts`.
- **Parameters:** Service helpers consistently take a single object argument (`async ({ id, userId })`) rather than positional args. See `packages/api/src/services/resume.ts:27,41,65`.
- **Return values:** Services return plain typed objects; routers shape the response via `.output(schema)` so Zod validates at the boundary.
- **Module boundaries:**
- Cross-package imports must go through declared `exports` subpaths. Reaching into `packages/<x>/src/internal-file` directly is not allowed.
- `packages/utils` exports are narrowly scoped — if you need a new helper for another package, add a new explicit subpath in `packages/utils/package.json`.
- `packages/runtime-externals` and `packages/scripts` are support packages; avoid importing them into runtime code.
---
*Convention analysis: 2026-05-11*
+239
View File
@@ -0,0 +1,239 @@
# External Integrations
**Analysis Date:** 2026-05-11
## APIs & External Services
**AI Providers (user-supplied API keys, called per-request from `packages/api/src/services/ai.ts`):**
- OpenAI — Default base URL `https://api.openai.com/v1` (`packages/ai/src/types.ts`)
- SDK: `@ai-sdk/openai ^3.0.63` via `createOpenAI(...).chat(model)`
- API key supplied per-call from the resume's `ai` config; not read from server env
- Anthropic — Default base URL `https://api.anthropic.com/v1`
- SDK: `@ai-sdk/anthropic ^3.0.76` via `createAnthropic(...).languageModel(model)`
- Google Gemini — Default base URL `https://generativelanguage.googleapis.com/v1beta`
- SDK: `@ai-sdk/google ^3.0.71` via `createGoogleGenerativeAI(...).languageModel(model)`
- Vercel AI Gateway — Default base URL `https://ai-gateway.vercel.sh/v3/ai`
- SDK: `ai ^6.0.177` via `createGateway(...).languageModel(model)`
- OpenRouter — Default base URL `https://openrouter.ai/api/v1`
- SDK: `@ai-sdk/openai-compatible ^2.0.47` via `createOpenAICompatible({ name: "openrouter", ... })`
- Ollama — Default base URL `https://ollama.com/api`
- SDK: `ollama-ai-provider-v2 ^3.5.0` via `createOllama(...)`
- Security: `packages/api/src/services/ai.ts` `resolveBaseUrl` rejects non-HTTPS URLs, credentialed URLs, and private/loopback hosts (via `packages/utils/src/url-security.node.ts`).
- File-input AI calls are capped at 10MB (`MAX_AI_FILE_BYTES`).
**OAuth Identity Providers (server-side env-configured, optional):**
- Google — `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` (config in `packages/auth/src/config.ts`).
- GitHub — `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`.
- LinkedIn — `LINKEDIN_CLIENT_ID` / `LINKEDIN_CLIENT_SECRET`.
- Custom Generic OAuth — `OAUTH_PROVIDER_NAME`, `OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET`, plus either `OAUTH_DISCOVERY_URL` or all three of `OAUTH_AUTHORIZATION_URL`/`OAUTH_TOKEN_URL`/`OAUTH_USER_INFO_URL`. Scopes from `OAUTH_SCOPES` (defaults `openid profile email`). Wired via Better Auth's `genericOAuth` plugin.
- Trusted providers for account linking (`packages/auth/src/config.ts`): `google`, `github`, `linkedin`.
**Translation / Localization:**
- Crowdin — `CROWDIN_PROJECT_ID`, `CROWDIN_PERSONAL_TOKEN`. Config in `crowdin.yml`; pull request automation labelled `l10n`. Source catalog `apps/web/locales/en-US.po`.
**Font Catalog Tooling:**
- Google Fonts Developer API — `GOOGLE_CLOUD_API_KEY` consumed by `packages/scripts/fonts/generate.ts` hitting `https://www.googleapis.com/webfonts/v1/webfonts`. Output committed at `packages/fonts/src/webfontlist.json`.
## Data Storage
**Databases:**
- PostgreSQL — Required relational database.
- Connection env: `DATABASE_URL` (validated as `postgres(ql)://...` in `packages/env/src/server.ts`)
- Client: `drizzle-orm 1.0.0-beta.22` with `pg ^8.20.0` Pool, instantiated in `packages/db/src/client.ts` (singleton via `globalThis.__pool` / `globalThis.__drizzle`).
- Schema location: `packages/db/src/schema/index.ts` (auth + resume tables) with `packages/db/src/relations.ts`.
- Migrations: generated by `drizzle-kit` into repo-root `migrations/` (e.g. `20260507144406_fast_nova/`). Generator config at `packages/db/drizzle.config.ts`.
- Auto-migration on app start: `apps/web/plugins/1.migrate.ts` runs `drizzle-orm/node-postgres/migrator` against the resolved migrations folder.
- `drizzle-kit` does NOT auto-load `.env` — `DATABASE_URL` must be exported before running `pnpm db:generate` / `pnpm db:migrate` (see `AGENTS.md` "Important" callout).
- Health probe: `packages/api/src/services/storage.ts` healthcheck + `apps/web/src/routes/api/health.ts` runs `SELECT 1` through Drizzle (1.5s timeout).
**File Storage (selected at runtime in `packages/api/src/services/storage.ts`):**
- S3-compatible object storage when all three of `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_BUCKET` are set.
- Client: `@aws-sdk/client-s3 ^3.1045.0` (`S3Client` constructed in `S3StorageService`).
- Options: `S3_REGION` (default `us-east-1`), `S3_ENDPOINT` (for non-AWS), `S3_FORCE_PATH_STYLE`.
- Tested compatible backends: AWS S3 and SeaweedFS (see `compose.dev.yml` / `compose.yml`).
- All writes use `ACL: "public-read"` (consumed publicly from `/uploads/$userId/$` route, with path validation).
- Local filesystem fallback (`LocalStorageService`) used when any of the three S3 vars is missing.
- Path: `LOCAL_STORAGE_PATH` if set (must be absolute); otherwise `<workspace>/data` in dev or `/app/data` in the Docker image.
- Validated at boot by `apps/web/plugins/2.storage.ts` (mkdir + access check).
- Key layout (built in `packages/api/src/services/storage.ts`):
- `uploads/{userId}/pictures/{timestamp}.jpeg`
- `uploads/{userId}/screenshots/{resumeId}/{timestamp}.jpeg`
- `uploads/{userId}/pdfs/{resumeId}/{timestamp}.pdf`
- Public read route: `apps/web/src/routes/uploads/$userId.$.tsx` (ETag, content-type sniffing, path traversal protection).
- Image preprocessing: `sharp ^0.34.5` resizes to 800x800 and re-encodes JPEG at quality 80, unless `FLAG_DISABLE_IMAGE_PROCESSING=true`.
**Caching:**
- No external cache (Redis/Memcached) configured.
- In-process rate limiter: `@orpc/experimental-ratelimit` `MemoryRatelimiter` (`packages/api/src/middleware/rate-limit/index.ts`).
- Workbox precache in the service worker (PWA) — `apps/web/vite.config.ts` `VitePWA` setup (skipWaiting, clientsClaim, cleanupOutdatedCaches).
## Authentication & Identity
**Auth Provider:** Better Auth `1.6.10`, configured in `packages/auth/src/config.ts`.
- Mounted as a TanStack Start server handler at `/api/auth/*` via `apps/web/src/routes/api/auth.$.ts`.
- Drizzle adapter: `@better-auth/drizzle-adapter` bound to `db` + schema (provider `pg`).
- Trusted origins: `http://localhost:3000`, `http://127.0.0.1:3000`, and the normalized origin of `APP_URL`.
- Secure cookies enabled automatically when `APP_URL` is `https://`.
- Trusted IP headers (used by both Better Auth `advanced.ipAddress.ipAddressHeaders` and the oRPC rate limiter): `CF-Connecting-IP`, `CF-Connecting-IPv6`, `True-Client-IP`, `X-Forwarded-For`, `X-Real-IP` (`packages/utils/src/rate-limit.ts`).
- Telemetry: explicitly disabled.
**Email/Password:**
- Enabled unless `FLAG_DISABLE_EMAIL_AUTH=true`.
- Password hash: `bcrypt` (cost 10) via `hash` / `compare` from `bcrypt ^6.0.0`.
- Min 8, max 64 char passwords.
- Email verification: sent on signup using `VerifyEmail` template from `@reactive-resume/email/templates/auth`.
- Reset password: `sendResetPassword` -> `ResetPasswordEmail` template.
- Email change: `sendChangeEmailConfirmation` -> `VerifyEmailChange` template.
**Better Auth Plugins (in `packages/auth/src/config.ts`):**
- `jwt()` — JWKS published at `/api/auth/jwks` (also referenced by `verifyOAuthToken`).
- `admin()` — Admin operations.
- `passkey()` (`@better-auth/passkey ^1.6.10`) — WebAuthn passkeys.
- `genericOAuth({ config })` — Driven by `OAUTH_*` env vars when configured.
- `twoFactor({ issuer: "Reactive Resume" })` — TOTP + backup codes; UI routes `/auth/verify-2fa` and `/auth/verify-2fa-backup`.
- `apiKey()` (`@better-auth/api-key ^1.6.10`) — Header `x-api-key`; `enableSessionForAPIKeys: true`; per-key rate limit 1000 req/hour.
- `oauthProvider()` (`@better-auth/oauth-provider ^1.6.10`) — Makes this app act as an OAuth 2.1 authorization server for MCP clients. Allows dynamic and unauthenticated client registration (RFC 7591) but redirect URIs are gated by an allowlist in the auth `hooks.before` middleware and `OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS`. Audiences whitelist: `${APP_URL}`, `${APP_URL}/`, `${APP_URL}/mcp`, `${APP_URL}/mcp/`.
- `username()` — Normalized lowercase usernames (`^[a-z0-9._-]+$`, length 3–64).
- `dash({ apiKey: BETTER_AUTH_API_KEY })` — Better Auth Dashboard (only when `BETTER_AUTH_API_KEY` is set).
**Social Providers:**
- Google, GitHub, LinkedIn — Each activates only when both `*_CLIENT_ID` and `*_CLIENT_SECRET` env vars are set (`packages/auth/src/config.ts`). `disableImplicitSignUp: true`; account linking enabled.
**Session / Access in API context:**
- `packages/api/src/context.ts` exposes `protectedProcedure` (referenced in `AGENTS.md`) for authenticated procedures.
- oRPC middleware reads request headers via `RequestHeadersPlugin` (`apps/web/src/routes/api/rpc.$.ts`, `apps/web/src/routes/api/openapi.$.ts`).
**API Key Auth for HTTP / MCP:**
- `x-api-key` header recognised by Better Auth API Key plugin.
- OpenAPI spec at `/api/openapi/spec.json` declares `apiKey` security scheme (`apps/web/src/routes/api/openapi.$.ts`).
- MCP server first tries OAuth Bearer (`Authorization: Bearer ...`), then falls back to `x-api-key` (`apps/web/src/routes/mcp/index.ts`).
## Monitoring & Observability
**Error Tracking:**
- None — no Sentry/Datadog/Rollbar SDK present.
- Errors are logged via `console.error` (oRPC interceptors `onError` in `apps/web/src/routes/api/rpc.$.ts` and `apps/web/src/routes/api/openapi.$.ts`).
**Health Checks:**
- `GET /api/health` — `apps/web/src/routes/api/health.ts` returns JSON with DB and storage status; 503 if unhealthy. Used by Docker `HEALTHCHECK`.
**Logs:**
- `console.info`/`console.warn`/`console.error` only. SMTP failures, missing config, sanitization diagnostics, and migration progress are all written to stdout/stderr.
## CI/CD & Deployment
**Hosting / Distribution:**
- Self-hosted Docker image — `Dockerfile` builds final image around `node:24-slim`, expose port 3000, default `LOCAL_STORAGE_PATH=/app/data`. Multi-stage uses `turbo prune` for both the web app and the `runtime-externals` package (which carries `bcrypt`, `sharp`, `@aws-sdk/client-s3` as native deps).
- Container labels point to `https://rxresu.me` and `https://docs.rxresu.me`.
**CI Pipeline:**
- Not present in this worktree (no `.github/workflows/` enumerated here). Scripts produce CI-friendly Vitest reports (`reports/vitest-junit.xml`, `reports/vitest-results.json`) via the `test:ci` task.
**Local Orchestration:**
- `compose.dev.yml` — `postgres:latest`, `seaweedfs:latest`, `seaweedfs_create_bucket` (uses `quay.io/minio/mc:latest`).
- `compose.yml` — Same services plus `reactive_resume` app container, networks `data_network` / `storage_network`.
**Git Hooks:**
- Lefthook (`lefthook.yml`) — `pre-commit` runs `biome check --write --unsafe` on staged JS/TS/JSON files; `commit-msg` runs `commitlint --edit`.
## Environment Configuration
**Required env vars (validated by Zod in `packages/env/src/server.ts`):**
- `APP_URL` — http(s) URL, used for auth base URL, OAuth audiences, OG metadata, and public upload URLs.
- `DATABASE_URL` — `postgres(ql)://...`.
- `AUTH_SECRET` — Better Auth signing secret (`openssl rand -hex 32`).
**Optional auth env vars:**
- `BETTER_AUTH_API_KEY` — Enables Better Auth Dashboard plugin.
- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`.
- `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`.
- `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`.
- `OAUTH_PROVIDER_NAME`, `OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET`, `OAUTH_DISCOVERY_URL`, `OAUTH_AUTHORIZATION_URL`, `OAUTH_TOKEN_URL`, `OAUTH_USER_INFO_URL`, `OAUTH_SCOPES`, `OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS`.
**Optional SMTP env vars (all required together to enable real sends):**
- `SMTP_HOST`, `SMTP_PORT` (default 587), `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_SECURE` (default false). Fallback in `packages/email/src/transport.ts`: log to console with subject/body when SMTP is not fully configured.
**Optional storage env vars:**
- `LOCAL_STORAGE_PATH` — Must be absolute when set.
- `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_REGION` (default `us-east-1`), `S3_ENDPOINT`, `S3_BUCKET`, `S3_FORCE_PATH_STYLE` (default false).
**Optional feature flags:**
- `FLAG_DISABLE_SIGNUPS` — Disables signups across all providers.
- `FLAG_DISABLE_EMAIL_AUTH` — Disables email/password auth and verification flows.
- `FLAG_DISABLE_IMAGE_PROCESSING` — Skips Sharp resize/encode (useful on resource-constrained hardware).
**Optional tooling env vars:**
- `CROWDIN_PROJECT_ID` and `CROWDIN_PERSONAL_TOKEN` for Crowdin translation sync.
- `GOOGLE_CLOUD_API_KEY` — For `packages/scripts/fonts/generate.ts`.
**Secrets location:**
- `.env` at the repo root (gitignored). `.env.example` provides documented defaults. Existence noted: `.env.local`, `.env.production` files present locally (contents not read; may contain secrets).
- Turbo cache invalidation tied to env vars via `turbo.json` `globalEnv` whitelist.
## Webhooks & Callbacks
**Incoming:**
- OAuth provider callbacks served by Better Auth: `/api/auth/oauth2/callback/{providerId}` (e.g. `/api/auth/oauth2/callback/custom` configured in `packages/auth/src/config.ts`).
- OAuth Authorization Server endpoints (when this app acts as an OAuth provider for MCP clients): handled by Better Auth `oauthProvider` plugin under `/api/auth/oauth2/*`. Login/consent pages at `/auth/oauth` (`apps/web/src/routes/auth/oauth.ts`).
- `.well-known` discovery routes:
- `apps/web/src/routes/[.]well-known/oauth-authorization-server.ts` and `.../oauth-authorization-server.$.ts`
- `apps/web/src/routes/[.]well-known/oauth-protected-resource.ts` and `.../oauth-protected-resource.$.ts`
- `apps/web/src/routes/[.]well-known/openid-configuration.ts`
- `apps/web/src/routes/[.]well-known/mcp/server-card[.]json.ts`
- Generic catch-all: `apps/web/src/routes/[.]well-known/$.ts`
- MCP Streamable HTTP transport: `apps/web/src/routes/mcp/index.ts` (uses `WebStandardStreamableHTTPServerTransport`). Authenticates via OAuth Bearer or `x-api-key`.
**Outgoing:**
- AI provider HTTPS requests (see "AI Providers" above) — initiated per user request from `packages/api/src/services/ai.ts`.
- SMTP outbound (`packages/email/src/transport.ts`) for verification, password reset, and email-change confirmations.
- S3 PUT/GET/DELETE/LIST through `@aws-sdk/client-s3` when S3 storage is configured.
- Crowdin and Google Fonts requests only from CI / scripts, not from the runtime web app.
## API Endpoints Exposed by the App
| Endpoint | Handler | Purpose |
|----------|---------|---------|
| `/api/health` | `apps/web/src/routes/api/health.ts` | Liveness/readiness JSON (DB + storage). |
| `/api/auth/*` | `apps/web/src/routes/api/auth.$.ts` | Better Auth handler (sessions, OAuth, passkey, JWKS, etc.). |
| `/api/rpc/*` | `apps/web/src/routes/api/rpc.$.ts` | oRPC server (`packages/api/src/routers/index.ts`: `ai`, `auth`, `flags`, `resume`, `statistics`, `storage`). |
| `/api/openapi/*` | `apps/web/src/routes/api/openapi.$.ts` | OpenAPI-style REST wrapper of the oRPC router; spec at `/api/openapi/spec.json`. Auth via `x-api-key`. |
| `/mcp` | `apps/web/src/routes/mcp/index.ts` | MCP Streamable HTTP server (`@modelcontextprotocol/sdk`). |
| `/uploads/{userId}/...` | `apps/web/src/routes/uploads/$userId.$.tsx` | Public read of stored images/PDFs with ETag + path validation. |
| `/schema.json` | `apps/web/src/routes/schema[.]json.ts` | Resume JSON schema. |
| `/.well-known/...` | `apps/web/src/routes/[.]well-known/*` | OAuth/OIDC/MCP discovery documents. |
## Rate Limiting
**Better Auth (production only, see `packages/auth/src/config.ts` `isRateLimitEnabled`):**
- Global default: 60 req / 60s.
- `/sign-in/email`: 5/60s; `/sign-up/email`: 3/60s.
- `/request-password-reset`, `/send-verification-email`: 3/600s.
- `/two-factor/verify-otp`, `/verify-totp`, `/verify-backup-code`: 5/600s.
- `/is-username-available`: 20/60s.
- OAuth provider endpoints: `register` 5/60s, `authorize` 30/60s, `token` 20/60s, `introspect` 60/60s, `revoke` 30/60s, `userinfo` 60/60s.
- API keys: 1000 req / hour per key.
- Source: `packages/utils/src/rate-limit.ts`.
**oRPC procedure-level (in-memory, production only, see `packages/api/src/middleware/rate-limit/index.ts`):**
- `resumePassword`: 5 / 10min.
- `pdfExport`: 5 / 60s.
- `aiRequest`: 20 / 60s.
- `jobsSearch`: 30 / 60s.
- `jobsTestConnection`: 10 / 60s.
- `storageUpload`: 20 / 60s.
- `storageDelete`: 30 / 60s.
- `resumeMutations`: 60 / 60s.
- Keys are derived from authenticated user id when present, otherwise from the first trusted-IP header or a UA+language fingerprint.
## Optional Services Declared in Compose Files
| Service | Image | Purpose | File |
|---------|-------|---------|------|
| `postgres` | `postgres:latest` | Required PostgreSQL DB | `compose.dev.yml`, `compose.yml` |
| `seaweedfs` | `chrislusf/seaweedfs:latest` | Optional S3-compatible storage for dev/prod | `compose.dev.yml`, `compose.yml` |
| `seaweedfs_create_bucket` | `quay.io/minio/mc:latest` | One-shot init that creates `reactive-resume` bucket | `compose.dev.yml`, `compose.yml` |
| `reactive_resume` | Built from `Dockerfile` | The app itself in prod compose | `compose.yml` |
---
*Integration audit: 2026-05-11*
+210
View File
@@ -0,0 +1,210 @@
# Technology Stack
**Analysis Date:** 2026-05-11
## Languages
**Primary:**
- TypeScript `^6.0.3` — All workspace code under `apps/web/src` and `packages/*/src`. Typechecked with the experimental TS native compiler `@typescript/native-preview` (`7.0.0-dev.20260510.1`) via `tsgo --noEmit`.
- TSX (React 19) — UI components in `apps/web/src`, `packages/ui/src/components`, `packages/pdf/src/templates`, and email templates in `packages/email/src/templates`.
**Secondary:**
- JavaScript (ESM, `"type": "module"`) — A handful of config files such as `commitlint.config.cjs` and `apps/web/postcss.config` style snippets.
- JSON / JSONC — `package.json`, `tsconfig.json`, `biome.json`, `turbo.json`, `knip.json`, `apps/web/components.json`, `packages/schema/schema.json`, locale and webfont metadata.
- SQL — Drizzle-generated migrations under `migrations/` (e.g. `migrations/20260507144406_fast_nova/`).
- YAML — `compose.yml`, `compose.dev.yml`, `lefthook.yml`, `crowdin.yml`, `pnpm-workspace.yaml`.
- Markdown — `AGENTS.md`, `README.md`, `SECURITY.md`, `LICENSE`, docs under `docs/`.
## Runtime
**Environment:**
- Node.js `24` — Pinned in `Dockerfile` via `ARG NODE_VERSION=24`. Single Node process exposes the web app on port `3000`.
- Browser runtime — React 19 SSR + hydration via TanStack Start. PWA service worker registered from `apps/web/vite.config.ts`.
**Package Manager:**
- pnpm `11.0.9` — Pinned in `package.json` `packageManager` field (with integrity hash). Managed via Corepack (`corepack enable`) per `AGENTS.md`.
- Workspace topology: `pnpm-workspace.yaml` includes `apps/*` and `packages/*`.
- Lockfile: `pnpm-lock.yaml` is present and committed at repo root.
- `allowBuilds` in `pnpm-workspace.yaml`: `bcrypt`, `esbuild`, `lefthook`, `msw`, `sharp`.
- `postcss` is pinned by an override to `^8.5.14` in `pnpm-workspace.yaml`.
**Build Orchestrator:**
- Turborepo `^2.9.12` — `turbo.json` defines tasks (`build`, `dev`, `typecheck`, `test`, `db:generate`, `db:migrate`, `db:studio`, `lingui:extract`) and the `globalEnv` whitelist for cache invalidation.
- The Docker build also pins `turbo@2.9.9` via `pnpm dlx` for the pruner stages.
## Frameworks
**Core (apps/web):**
- React `^19.2.6` with `react-dom ^19.2.6` (from `apps/web/package.json`).
- TanStack Start `^1.167.65` — Full-stack framework wiring Vite, Nitro, and React Router. Server handlers live in route files under `apps/web/src/routes`.
- TanStack Router `^1.169.2` — File-based router. `apps/web/src/routeTree.gen.ts` is generated.
- TanStack React Query `^5.100.9` with SSR bridge `@tanstack/react-router-ssr-query ^1.166.12`.
- TanStack React Form `^1.32.0` and React Hotkeys `^0.10.0`.
- Vite `^8.0.11` (rolldown-based) — `apps/web/vite.config.ts` orchestrates plugins.
- Nitro `3.0.260429-beta` — Server framework used through `nitro/vite`. Plugins at `apps/web/plugins/1.migrate.ts` and `apps/web/plugins/2.storage.ts` run on Nitro startup.
- `srvx ^0.11.15` — HTTP server runtime used by Nitro/TanStack Start.
**RPC / API:**
- oRPC `^1.14.2` family — `@orpc/server`, `@orpc/client`, `@orpc/openapi`, `@orpc/json-schema`, `@orpc/zod`, `@orpc/tanstack-query`, `@orpc/experimental-ratelimit`.
- `@modelcontextprotocol/sdk ^1.29.0` — MCP server exposed at `/mcp` via `apps/web/src/routes/mcp/index.ts`.
**Auth:**
- Better Auth `1.6.10` — Core auth framework configured in `packages/auth/src/config.ts`.
- Better Auth plugins: `@better-auth/api-key ^1.6.10`, `@better-auth/drizzle-adapter ^1.6.10`, `@better-auth/infra ^0.2.6` (dashboard), `@better-auth/oauth-provider ^1.6.10`, `@better-auth/passkey ^1.6.10`, plus built-ins (`admin`, `jwt`, `twoFactor`, `username`, `genericOAuth`).
- `jose ^6.2.3` — JWT verification for OAuth tokens (MCP authentication).
- `bcrypt ^6.0.0` — Password hashing (10 rounds in `packages/auth/src/config.ts`).
**Database & ORM:**
- Drizzle ORM `1.0.0-beta.22` with PostgreSQL driver `pg ^8.20.0` (node-postgres).
- `drizzle-kit 1.0.0-beta.22` — Migration tooling. Config at `packages/db/drizzle.config.ts` (dialect `postgresql`, schema `./src/schema/index.ts`, out `../../migrations`).
- `drizzle-zod 1.0.0-beta.14-a36c63d` — Schema-derived Zod validators in `packages/api`.
**PDF / Rendering:**
- `@react-pdf/renderer ^4.5.1` with types `@react-pdf/types ^2.11.1` — Used by `packages/pdf/src/document.tsx` and all templates under `packages/pdf/src/templates/<name>/`.
- `pdfjs-dist 5.7.284` — Client-side PDF rendering and parsing (browser-only paths).
- `react-pdf-html ^2.1.5`, `node-html-parser ^7.1.0`, `phosphor-icons-react-pdf ^0.1.3`, `cjk-regex ^3.4.0` — PDF helpers and CJK fallback.
- Font registration owned by `packages/pdf/src/hooks/use-register-fonts.ts`; webfont catalog at `packages/fonts/src/webfontlist.json`.
**UI / Styling:**
- Base UI / shadcn-style components — `@base-ui/react ^1.4.1`, `shadcn ^4.7.0` (CLI), components live in `packages/ui/src/components/*.tsx`. Config at `apps/web/components.json` (style `base-nova`, base color `zinc`, icon library `phosphor`).
- Tailwind CSS `^4.3.0` via `@tailwindcss/vite ^4.3.0` and `@tailwindcss/postcss ^4.3.0`. Plugin `@tailwindcss/typography ^0.5.19`. Global stylesheet at `packages/ui/src/styles/globals.css`.
- PostCSS `^8.5.14` and `tw-animate-css ^1.4.0`.
- `class-variance-authority ^0.7.1`, `clsx ^2.1.1`, `tailwind-merge ^3.6.0`.
- Icons: `@phosphor-icons/react ^2.1.10`, `@phosphor-icons/web ^2.1.2`.
- Fonts: `@fontsource-variable/ibm-plex-sans ^5.2.8` (shipped with `packages/ui`).
- Theming: `next-themes ^0.4.6`.
- Animation: `motion ^12.38.0`.
- Toasts: `sonner ^2.0.7`.
- Command palette: `cmdk ^1.1.1`.
- Misc UI: `react-resizable-panels ^4.11.0`, `react-window ^2.2.7`, `react-zoom-pan-pinch ^4.0.3`, `qrcode.react ^4.2.0`, `@uiw/color-convert ^2.10.1`, `@uiw/react-color-colorful ^2.10.1`.
**Drag & Drop and Editing:**
- `@dnd-kit/core ^6.3.1`, `@dnd-kit/sortable ^10.0.0`, `@dnd-kit/utilities ^3.2.2`.
- TipTap `^3.23.1` editor with `starter-kit`, `pm`, `react`, plus extensions `color`, `highlight`, `table`, `text-align`, `text-style`.
**State / Validation / Patterns:**
- Zustand `^5.0.13` — Client and AI state stores (`packages/ai/src/store.ts`).
- Immer `^11.1.8`.
- Zod `^4.4.3` — Validators across schema, api, env, ai, import, utils packages.
- `ts-pattern ^5.9.0` — Pattern matching in API services.
- `es-toolkit ^1.46.1` and `fuse.js ^7.3.0`.
**Internationalization (i18n):**
- Lingui `^6.0.1` family — `@lingui/core`, `@lingui/react`, `@lingui/cli`, `@lingui/format-po`, `@lingui/vite-plugin`, `@lingui/babel-plugin-lingui-macro`.
- Babel transformer chain via `@rolldown/plugin-babel ^0.2.3` and `babel-plugin-macros ^3.1.0`.
- Locale config at `apps/web/lingui.config.ts` (source locale `en-US`, ~55 target locales, pseudo-locale `zu-ZA`).
- Translation catalogs at `apps/web/locales/{locale}.po`. Crowdin sync configured in `crowdin.yml`.
**Email:**
- React Email `^6.1.1` with `@react-email/ui ^6.1.1` — Templates in `packages/email/src/templates/auth.tsx`.
- Nodemailer `^8.0.7` SMTP transport — `packages/email/src/transport.ts`.
**AI:**
- Vercel AI SDK `ai ^6.0.177` and `@ai-sdk/react ^3.0.179`.
- Provider SDKs: `@ai-sdk/openai ^3.0.63`, `@ai-sdk/anthropic ^3.0.76`, `@ai-sdk/google ^3.0.71`, `@ai-sdk/openai-compatible ^2.0.47`, `ollama-ai-provider-v2 ^3.5.0`.
- Supported providers enumerated at `packages/ai/src/types.ts`: `openai`, `anthropic`, `gemini`, `vercel-ai-gateway`, `openrouter`, `ollama`.
- JSON patch / repair: `fast-json-patch ^3.1.1`, `jsonrepair ^3.14.0`, `deepmerge-ts ^7.1.5`.
**Storage:**
- AWS SDK v3 — `@aws-sdk/client-s3 ^3.1045.0` used in `packages/api/src/services/storage.ts`. Marked external in the rolldown build (see `apps/web/vite.config.ts`) and isolated into `packages/runtime-externals` for Docker.
**Image Processing:**
- Sharp `^0.34.5` — Used in `packages/api/src/services/storage.ts` `processImageForUpload`. Disabled when `FLAG_DISABLE_IMAGE_PROCESSING` is true.
**Document Generation / Import:**
- `docx ^9.6.1` — DOCX export utilities in `packages/utils/src/resume/docx/`.
- JSON Resume importers in `packages/import` (`json-resume`, `reactive-resume-json`, `reactive-resume-v4-json`).
**HTML Sanitization:**
- `dompurify ^3.4.2` — Used in `packages/utils/src/sanitize.ts`.
- `@sindresorhus/slugify ^3.0.0`, `unique-names-generator ^4.7.1`, `uuid ^14.0.0`.
**PWA:**
- `vite-plugin-pwa ^1.3.0` — Workbox-based service worker, manifest defined in `apps/web/src/libs/pwa`.
**Testing:**
- Vitest `^4.1.5` with `@vitest/coverage-v8 ^4.1.5` (provider `v8`).
- Shared config in `vitest.shared.ts`; per-package configs at `<pkg>/vitest.config.ts`.
- DOM: `happy-dom ^20.9.0` (`disableJavaScriptFileLoading`, `disableCSSFileLoading`, navigation disabled in `vitest.shared.ts`).
- Testing Library: `@testing-library/react ^16.3.2`, `@testing-library/dom ^10.4.1`, `@testing-library/jest-dom ^6.9.1`, `@testing-library/user-event ^14.6.1`.
- Tests are co-located in each package's `src/**/*.{test,spec}.{ts,tsx}` and discovered automatically.
**Lint / Format / Tooling:**
- Biome `^2.4.15` — Sole linter + formatter (`biome.json`: tabs, double quotes, line width 120, organized import groups, `useSortedClasses` for `clsx`/`cva`/`cn`). Pre-commit runs through Lefthook.
- Lefthook `^2.1.6` — Git hooks defined in `lefthook.yml` (`biome check --write --unsafe` on staged JS/TS/JSON files; `commitlint --edit` on commit messages).
- Commitlint `^21.0.0` with `@commitlint/config-conventional` (see `commitlint.config.cjs`).
- Knip `^6.12.2` — Dead-code/unused-deps detection (`knip.json`).
- `npm-check-updates ^22.1.1`.
- `tsx ^4.21.0` — Used by `packages/scripts` for ad hoc TS scripts.
## Key Dependencies
**Critical:**
- `@tanstack/react-start ^1.167.65` — App framework boundary; route server handlers depend on it.
- `@orpc/server ^1.14.2` — Type-safe RPC; backbone of `/api/rpc` and `/api/openapi`.
- `better-auth 1.6.10` — Identity, sessions, OAuth provider, MCP auth.
- `drizzle-orm 1.0.0-beta.22` + `pg ^8.20.0` — All DB access.
- `@react-pdf/renderer ^4.5.1` — Resume PDF generation.
- `@aws-sdk/client-s3 ^3.1045.0` — S3-compatible object storage.
- `@modelcontextprotocol/sdk ^1.29.0` — MCP server endpoint.
- `ai ^6.0.177` + `@ai-sdk/*` — Resume AI features (analysis, parsing, chat).
**Infrastructure:**
- `vite ^8.0.11` + `nitro 3.0.260429-beta` — Build and server runtime.
- `turbo ^2.9.12` — Workspace task graph and caching.
- `dotenv ^17.4.2` — Loaded by `packages/env/src/server.ts` for app/server code (drizzle-kit does NOT auto-load).
- `@t3-oss/env-core ^0.13.11` — Server env validation in `packages/env/src/server.ts`.
## Configuration
**TypeScript:**
- Root `tsconfig.json` extends `@reactive-resume/config/tsconfig.base.json` (`packages/config/tsconfig.base.json`).
- Each package has its own `tsconfig.json` and runs `tsgo --noEmit`.
- Apps/packages export source from `src/*.ts` via package.json `exports` — no `dist/` artifacts unless explicitly built (e.g. `apps/web/.output`).
**Build:**
- `apps/web/vite.config.ts` orchestrates `tailwindcss`, `tanstackStart`, `viteReact`, `lingui`, `babel` (Lingui macro preset), `nitro` (with `1.migrate.ts` and `2.storage.ts` plugins), and `VitePWA`.
- Rolldown externals: `bcrypt`, `sharp`, `@aws-sdk/client-s3` (kept out of the client/server bundle and provided by `packages/runtime-externals` in Docker).
- Output: `apps/web/.output/server/index.mjs` (Nitro), public assets in `apps/web/.output/public`.
**Database:**
- Drizzle config: `packages/db/drizzle.config.ts` — `dialect: "postgresql"`, schema in `packages/db/src/schema/index.ts`, migrations written to repo-root `migrations/`.
- Client: `packages/db/src/client.ts` exposes singleton `db` plus `Pool` via `pg`, using `env.DATABASE_URL`.
- Startup auto-migration: `apps/web/plugins/1.migrate.ts` runs `drizzle-orm/node-postgres/migrator` against the resolved `migrations/` folder.
**Environment:**
- Server env contract validated by Zod in `packages/env/src/server.ts` (via `@t3-oss/env-core`).
- `dotenv` auto-loads root `.env` from `packages/env/src/server.ts` using `findWorkspaceRoot()`.
- Required: `APP_URL`, `DATABASE_URL`, `AUTH_SECRET`.
- Cache invalidation env vars listed in `turbo.json` `globalEnv`.
- `.env.example` present at repo root; `.env.local` and `.env.production` exist locally (contents not read — may contain secrets).
**Linting / Formatting:**
- `biome.json` — Tabs, 120-col, double quotes, sorted Tailwind classes for `clsx|cva|cn`, organized import groups (`type` imports first, then Node built-ins, test packages, third-party, `@reactive-resume/**`, then aliases/relative).
- `lefthook.yml` — Pre-commit Biome on staged files; commit-msg Commitlint.
- `commitlint.config.cjs` — Conventional commits.
- `knip.json` — Workspace-level ignores for `runtime-externals` external deps.
**Container / Compose:**
- `Dockerfile` (multi-stage): `base` (Node 24 slim + corepack), `pruner` (turbo prune), `builder` (frozen lockfile install + `pnpm turbo run build --filter=web --force`), `runtime-pruner` + `runtime-deps` (deploy `@reactive-resume/runtime-externals` with native deps), final `runtime` image running `node .output/server/index.mjs`, HEALTHCHECK on `/api/health`.
- `compose.dev.yml` — Dev `postgres:latest` + `seaweedfs:latest` (S3 emulator) + `seaweedfs_create_bucket` init container using `quay.io/minio/mc:latest`.
- `compose.yml` — Same services plus the app container `reactive_resume` built from `Dockerfile`, with `data_network`/`storage_network` and S3 env defaults pointing at SeaweedFS.
## Platform Requirements
**Development:**
- Node.js 24, pnpm 11.0.9 (via Corepack), Docker (for Postgres / SeaweedFS).
- Repo-local `data/` directory for local-storage mode (auto-created by `apps/web/plugins/2.storage.ts`).
- `LOCAL_STORAGE_PATH` must be absolute when set.
- SMTP optional; without it `packages/email/src/transport.ts` logs the email to console.
**Production:**
- Single Node 24 process on port 3000 (`apps/web/.output/server/index.mjs`).
- Official Docker image listed in `Dockerfile` labels (`org.opencontainers.image.url=https://rxresu.me`).
- Production uses S3-compatible storage when all three of `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_BUCKET` are set; otherwise falls back to local filesystem under `/app/data` (per Dockerfile `ENV LOCAL_STORAGE_PATH=/app/data`).
- HEALTHCHECK polls `GET /api/health` (handler at `apps/web/src/routes/api/health.ts` checks DB and storage with a 1.5s timeout).
- Rate limiting only active when `NODE_ENV=production` (see `packages/auth/src/config.ts` and `packages/api/src/middleware/rate-limit/index.ts`).
---
*Stack analysis: 2026-05-11*
+394
View File
@@ -0,0 +1,394 @@
# Codebase Structure
**Analysis Date:** 2026-05-11
## Directory Layout
```text
reactive-resume/
├── apps/
│ └── web/ # Sole deployable application (TanStack Start / Vite / Nitro)
│ ├── plugins/ # Nitro startup plugins (run on `pnpm dev` / `pnpm start`)
│ ├── public/ # Static assets (PWA, opengraph, templates, screenshots)
│ ├── locales/ # Lingui `.po` translation catalogs (~40 locales)
│ ├── src/
│ │ ├── components/ # Reusable web components grouped by feature
│ │ ├── dialogs/ # Global dialog system + dialog implementations
│ │ ├── hooks/ # App-level React hooks
│ │ ├── libs/ # Browser/server glue (auth, orpc, query, resume, theme, locale, pwa)
│ │ ├── routes/ # File-based TanStack Router routes (UI + `server.handlers`)
│ │ ├── router.tsx # Router factory (builds query client, context, SSR query integration)
│ │ ├── routeTree.gen.ts # GENERATED — do not hand-edit
│ │ ├── server.ts # Nitro fetch entry (wraps `react-start/server-entry`)
│ │ └── index.css # Tailwind v4 entry
│ ├── components.json # shadcn config
│ ├── lingui.config.ts # i18n extract config
│ ├── vite.config.ts # Vite + TanStack Start + Nitro + PWA + Lingui
│ └── vitest.config.ts
├── packages/
│ ├── ai/ # AI prompts, Zustand store, patch-resume tool
│ ├── api/ # oRPC routers, services, helpers, DTOs, rate-limit middleware
│ ├── auth/ # Better Auth config and helpers
│ ├── config/ # Shared tsconfig + vitest base configs
│ ├── db/ # Drizzle client + schema (Postgres)
│ ├── email/ # Nodemailer transport + react-email templates
│ ├── env/ # `@t3-oss/env-core` server env (auto-loads root `.env`)
│ ├── fonts/ # Google Fonts metadata
│ ├── import/ # JSON Resume / Reactive Resume v3/v4 importers
│ ├── pdf/ # React PDF document, fonts, 14 templates, shared primitives
│ ├── runtime-externals/ # Declares bcrypt, sharp, @aws-sdk/client-s3 as runtime-only
│ ├── schema/ # Zod schemas (resume data, templates, page, analysis, icons)
│ ├── scripts/ # Standalone tsx scripts (db reset, font generation)
│ ├── ui/ # Shared Base UI + shadcn-style components
│ └── utils/ # Small focused helpers (color, date, html, sanitize, etc.)
├── migrations/ # Drizzle-generated SQL + snapshots (one folder per migration)
├── docs/ # Project documentation
├── skills/ # Internal agent skill definitions
├── data/ # Local-FS storage root (when S3 is unset) — gitignored runtime data
├── .vite-hooks/ # Lefthook-managed git hooks (commit-msg, pre-commit)
├── .github/ # GitHub Actions / issue templates
├── .claude/ # Project-local Claude/Codex agent assets
├── .codex/
├── .planning/ # GSD planning + codebase maps (this directory)
├── .vscode/
├── compose.dev.yml # Postgres + SeaweedFS for local dev
├── compose.yml # Production-shaped compose
├── Dockerfile # Node 24 multi-stage build
├── AGENTS.md # Canonical agent guide (symlinked from CLAUDE.md)
├── README.md
├── SECURITY.md
├── LICENSE
├── package.json # Root scripts (turbo run …) + workspace devDeps
├── pnpm-workspace.yaml # `apps/*` + `packages/*`
├── pnpm-lock.yaml
├── turbo.json # Pipeline + globalEnv allowlist
├── biome.json # Biome (lint + format) config
├── knip.json # Dead-code/dep analysis config
├── lefthook.yml # Git hooks
├── commitlint.config.cjs
├── crowdin.yml # Translation sync config
├── tsconfig.json # Root TS solution config
├── vitest.shared.ts / vitest.setup.ts # Shared vitest config and global setup
├── .ncurc.cjs # npm-check-updates ignore list
├── .env.example # Documented env vars
└── .gitignore / .dockerignore
```
## Directory Purposes
**`apps/web/`:**
- Purpose: The only deployed app — TanStack Start / React 19 / Vite / Nitro.
- Contains: Routes, components, dialogs, hooks, libs, server entry, build config.
- Key files: `apps/web/vite.config.ts`, `apps/web/src/router.tsx`, `apps/web/src/server.ts`, `apps/web/src/routes/__root.tsx`, `apps/web/plugins/1.migrate.ts`, `apps/web/plugins/2.storage.ts`.
**`apps/web/src/routes/`:**
- Purpose: TanStack Router file-based route tree. Each file is either a UI route, a server-only endpoint (`server.handlers`), or both.
- Notable subtrees:
- `__root.tsx` — global HTML shell, providers, head/meta, PWA scripts.
- `_home/` — public marketing layout (`route.tsx`, `index.tsx`, `-sections/{hero,features,faq,testimonials,...}.tsx`).
- `auth/` — login/register/2FA/password flows and OAuth callback (`oauth.ts`).
- `dashboard/` — authenticated dashboard (`route.tsx`, `index.tsx`, `resumes/`, `settings/{profile,preferences,api-keys,authentication,integrations,job-search,danger-zone}.tsx`, `-components/{header,sidebar,functions}.{ts,tsx}`).
- `builder/$resumeId/` — resume builder (`route.tsx` shell, `index.tsx` browser-only preview, `-components/`, `-sidebar/{left,right}/`, `-store/{section,sidebar}.ts`).
- `$username/$slug.tsx` — public/shared resume route (`ssr: "data-only"`).
- `api/` — server-only endpoints (`rpc.$.ts`, `auth.$.ts`, `health.ts`, `openapi.$.ts`, `uploads/$userId.$.ts`, `-helpers/resume-pdf.ts`).
- `mcp/` — Model Context Protocol server (`index.ts`, `-helpers/{tools,resources,prompts,mcp-server-card,mcp-tool-names,tool-annotations}.ts`).
- `[.]well-known/` — OAuth/OIDC/MCP discovery documents.
- `uploads/$userId.$.tsx` — etag/security-validated file serving.
- `templates/$.tsx` — template preview/download.
- `schema[.]json.ts` — `/schema.json` endpoint.
**`apps/web/src/components/`:**
- Purpose: Reusable, app-scoped components organized by domain.
- Subdirectories:
- `animation/` — Motion-driven UI (`comet-card`, `count-up`, `spotlight`, `text-mask`).
- `command-palette/` — Cmd-K UI (`index.tsx`, `store.ts`, `pages/`).
- `input/` — Custom inputs (`chip-input`, `color-picker`, `github-stars-button`, `icon-picker`, `rich-input`, `url-input`).
- `layout/` — Error/loading/not-found screens, breakpoint indicator.
- `level/`, `locale/`, `theme/`, `typography/` — combobox/toggle utilities for those concerns.
- `resume/` — Builder preview surface (`preview.tsx`, `preview.browser.tsx`, `preview.shared.tsx`, `pdf-canvas.tsx`, `builder-resume-draft.ts`).
- `ui/` — App-level UI extensions (`combobox.tsx`, `copyright.tsx`).
- `user/` — User dropdown menu.
**`apps/web/src/libs/`:**
- Purpose: Glue between UI and external systems.
- Contents:
- `auth/{client.ts,session.ts}` — Better Auth browser client + SSR session getter.
- `orpc/client.ts` — Isomorphic oRPC client (in-process server + RPCLink browser).
- `query/client.ts` — TanStack Query client factory.
- `resume/` — Section, PDF, and ordering helpers (`make-section-item.ts`, `move-item.ts`, `section-actions.ts`, `section-title.ts`, `section-title-locale.ts`, `pdf-document.tsx`, `pdf-document.server.tsx`, `section.tsx`).
- `theme.ts`, `locale.ts`, `pwa.ts`, `error-message.ts`, `tanstack-form.tsx` — direct app utilities.
**`apps/web/src/dialogs/`:**
- Purpose: Global imperative dialog system.
- Contents: `manager.tsx` (renders open dialogs), `store.ts` (Zustand store), then domain dialogs under `auth/`, `resume/{sections,template,index.tsx,import.tsx}`, `api-key/`.
**`apps/web/src/hooks/`:**
- Purpose: Web-app hooks. (Shared cross-package hooks live in `packages/ui/src/hooks/`.)
- Contents: `use-confirm.tsx`, `use-controlled-state.tsx`, `use-form-blocker.tsx`, `use-mobile.tsx`, `use-prompt.tsx`, `use-sync-form-values.ts`.
**`apps/web/plugins/`:**
- Purpose: Nitro startup plugins.
- Contents: `1.migrate.ts` (runs Drizzle migrations on boot), `2.storage.ts` (validates the local data dir when S3 isn't configured).
**`apps/web/public/`:**
- Purpose: Static assets served at the site root.
- Notable subdirs: `templates/{jpg,pdf}/` (per-template previews), `screenshots/` (PWA + marketing), `opengraph/`, `icon/`, `logo/`, `fonts/`, `photos/`, `sounds/`, `videos/`. Top-level files include `favicon.{ico,svg}`, `apple-touch-icon-180x180.png`, `manifest.webmanifest`, `pwa-{64,192,512}x.png`, `maskable-icon-512x512.png`, `robots.txt`, `sitemap.xml`, `funding.json`.
**`apps/web/locales/`:**
- Purpose: Lingui `.po` translation catalogs (~40 languages, `en-US` is source).
- Synced via `crowdin.yml` and `pnpm lingui:extract`.
**`packages/ai/`:**
- Purpose: AI prompt assets, patch-resume tool, sanitize/extraction helpers.
- Key paths: `packages/ai/src/prompts/{chat,analyze-resume,docx-parser,pdf-parser}-*.md`, `packages/ai/src/store.ts`, `packages/ai/src/tools/{patch-resume,patch-proposal}.ts`, `packages/ai/src/resume/{extraction-template,sanitize}.ts`.
**`packages/api/`:**
- Purpose: Server-side business logic exposed over oRPC. The only place oRPC procedures live.
- Key paths: `packages/api/src/context.ts` (auth context + `publicProcedure`/`protectedProcedure`), `packages/api/src/routers/{ai,auth,flags,resume,statistics,storage,index}.ts`, `packages/api/src/services/{resume,resume-events,storage,ai,auth,flags,statistics}.ts`, `packages/api/src/dto/resume.ts`, `packages/api/src/helpers/{resume-access,resume-access-policy}.ts`, `packages/api/src/middleware/rate-limit/index.ts`.
**`packages/auth/`:**
- Purpose: Better Auth instance and types reused by web routes and API context.
- Key paths: `packages/auth/src/config.ts` (Drizzle adapter, OAuth/passkey/2FA/api-key/JWT/MCP OAuth provider), `packages/auth/src/functions.ts`, `packages/auth/src/types.ts`.
**`packages/config/`:**
- Purpose: Shared tsconfig and vitest base configs consumed via workspace devDep.
- Key paths: `packages/config/tsconfig.base.json`, `packages/config/vitest.config.ts`.
**`packages/db/`:**
- Purpose: Drizzle client and schema. Migration tooling but **migrations live in repo-root `migrations/`** (`packages/db/drizzle.config.ts` → `out: "../../migrations"`).
- Key paths: `packages/db/src/client.ts` (singleton `pg.Pool` + `drizzle()` on `globalThis`), `packages/db/src/schema/{auth,resume,index}.ts`, `packages/db/src/relations.ts`.
**`packages/email/`:**
- Purpose: SMTP transport + react-email templates.
- Key paths: `packages/email/src/transport.ts`, `packages/email/src/templates/{auth,reset-password,verify-email,verify-email-change}.tsx`.
**`packages/env/`:**
- Purpose: Type-safe server environment variables. Auto-loads the repo-root `.env` for app/server callers (drizzle-kit must export `DATABASE_URL` manually).
- Key paths: `packages/env/src/server.ts`.
**`packages/fonts/`:**
- Purpose: Generated Google Fonts metadata used by the typography combobox and React PDF font registration.
- Key paths: `packages/fonts/src/index.ts`, `packages/fonts/src/webfontlist.json` (regenerated by `packages/scripts/fonts/generate.ts`).
**`packages/import/`:**
- Purpose: Importers that convert external resume formats into the shared `ResumeData` schema.
- Key paths: `packages/import/src/{json-resume,reactive-resume-json,reactive-resume-v4-json}.tsx`.
**`packages/pdf/`:**
- Purpose: React PDF rendering — the same code paths used by the browser preview and the server-side PDF download.
- Key paths: `packages/pdf/src/document.tsx`, `packages/pdf/src/context.tsx`, `packages/pdf/src/section-title.ts`, `packages/pdf/src/hooks/use-register-fonts.ts`, `packages/pdf/src/templates/index.ts`, `packages/pdf/src/templates/<name>/<Name>Page.tsx` (14 templates: `azurill`, `bronzor`, `chikorita`, `ditgar`, `ditto`, `gengar`, `glalie`, `kakuna`, `lapras`, `leafish`, `meowth`, `onyx`, `pikachu`, `rhyhorn`, `scizor`), shared primitives under `packages/pdf/src/templates/shared/` (`filtering.ts`, `rich-text.tsx`, `sections.tsx`, `primitives.tsx`, `picture.ts`, `page-size.ts`, `columns.ts`, `metrics.ts`, `meta-line.tsx`, `contact.ts`, `contact-item.tsx`, `level-display.tsx`, `section-links.ts`, `rich-text-html.ts`, `rich-text-spacing.ts`, `styles.ts`, `types.ts`, `context.tsx`).
**`packages/runtime-externals/`:**
- Purpose: Vendors `bcrypt`, `sharp`, `@aws-sdk/client-s3` so they stay runtime-only (Vite externalizes them in `apps/web/vite.config.ts:55`).
- No `src/` — `package.json` is the entire surface.
**`packages/schema/`:**
- Purpose: Source-of-truth Zod schemas for resume data, templates, page settings, AI analysis, and icon catalog.
- Key paths: `packages/schema/src/resume/{data,default,sample,analysis}.ts`, `packages/schema/src/templates.ts`, `packages/schema/src/page.ts`, `packages/schema/src/icons.ts`.
**`packages/scripts/`:**
- Purpose: Standalone tsx scripts; no exports.
- Key paths: `packages/scripts/database/reset.ts` (`pnpm --filter @reactive-resume/scripts db:reset`), `packages/scripts/fonts/generate.ts` (`pnpm --filter @reactive-resume/scripts fonts:generate`).
**`packages/ui/`:**
- Purpose: Shared component library (Base UI primitives + shadcn-style wrappers, Tailwind v4 styles).
- Key paths: `packages/ui/src/components/{dialog,dropdown-menu,command,resizable,form,tooltip,sonner,…}.tsx`, `packages/ui/src/hooks/{use-confirm,use-controlled-state,use-mobile,use-prompt}.tsx`, `packages/ui/src/styles/globals.css`.
**`packages/utils/`:**
- Purpose: Pure utility functions used everywhere. Each helper has its own export path; do not import internal files.
- Key paths: `packages/utils/src/{color,date,field,file,html,level,locale,network-icons,rate-limit,sanitize,string,style,url}.ts`, Node-only `packages/utils/src/{monorepo.node,url-security.node}.ts`, and `packages/utils/src/resume/{docx/index.ts,patch.ts}`.
**`migrations/`:**
- Purpose: Drizzle-generated migration directories (one per migration), kept at the repo root.
- Layout: each `YYYYMMDDhhmmss_<adjective>_<noun>/` contains `migration.sql` (the SQL Drizzle will apply) and `snapshot.json` (Drizzle's internal state).
- Applied by `apps/web/plugins/1.migrate.ts` on every server boot, and manually by `pnpm db:migrate`. Generated by `pnpm db:generate` (which writes here because of `out: "../../migrations"` in `packages/db/drizzle.config.ts`). Do not hand-edit `migration.sql`/`snapshot.json`.
**`data/` (and `apps/web/data/`):**
- Purpose: Default local-filesystem storage root when S3 vars are unset. `<workspace>/data` is validated/created at boot by `apps/web/plugins/2.storage.ts`. Override with `LOCAL_STORAGE_PATH` (must be absolute).
- `data/statistics/` and `apps/web/data/statistics/` exist as runtime byproducts; treat as gitignored runtime state.
**`.vite-hooks/`:**
- Purpose: Lefthook-managed git hooks (`pre-commit` runs `biome check`, `commit-msg` runs commitlint). Configured by `lefthook.yml` and `commitlint.config.cjs`.
**`.planning/`:**
- Purpose: GSD planning artifacts.
- Subdirectories: `.planning/codebase/` (this directory — analysis docs).
## Key File Locations
**Entry Points:**
- `apps/web/src/server.ts` — Nitro fetch entry.
- `apps/web/src/router.tsx` — Router factory.
- `apps/web/src/routes/__root.tsx` — Root route + global providers.
- `apps/web/plugins/1.migrate.ts` — Migration on boot.
- `apps/web/plugins/2.storage.ts` — Local storage validation on boot.
**Configuration:**
- `apps/web/vite.config.ts` — Vite + TanStack Start + Nitro + PWA + Lingui.
- `turbo.json` — Pipeline + `globalEnv` allowlist.
- `pnpm-workspace.yaml` — Workspaces (`apps/*` + `packages/*`).
- `biome.json` — Lint + format rules.
- `knip.json` — Dead-code analysis config.
- `lefthook.yml` — Git hooks.
- `packages/db/drizzle.config.ts` — Drizzle migration config (writes to `../../migrations`).
- `packages/env/src/server.ts` — Server env schema + `.env` loader.
- `apps/web/lingui.config.ts` — i18n extract config.
- `compose.dev.yml` / `compose.yml` — Postgres + SeaweedFS (dev) and prod-shaped compose.
**Core API:**
- `packages/api/src/routers/index.ts` — Router root.
- `packages/api/src/context.ts` — Auth resolver + procedure factories.
- `packages/api/src/services/resume.ts` — Resume CRUD/patch/lock/password/duplication.
- `packages/api/src/services/storage.ts` — S3 + local FS storage abstraction.
- `packages/api/src/helpers/resume-access-policy.ts` — Visibility/redaction policy.
**Core data:**
- `packages/db/src/schema/resume.ts` — Resume, statistics, analysis tables.
- `packages/db/src/schema/auth.ts` — Better Auth tables.
- `packages/db/src/client.ts` — Singleton `pg.Pool` + `drizzle()` client.
- `packages/schema/src/resume/data.ts` — Canonical Zod schema for `ResumeData`.
- `packages/schema/src/templates.ts` — Enum of template names.
**Core PDF:**
- `packages/pdf/src/document.tsx` — `ResumeDocument` root component.
- `packages/pdf/src/templates/index.ts` — Template registry.
- `packages/pdf/src/hooks/use-register-fonts.ts` — Font registration + CJK fallbacks.
- `packages/pdf/src/templates/shared/filtering.ts` — Shared section filtering.
**Auth:**
- `packages/auth/src/config.ts` — Better Auth instance.
- `apps/web/src/routes/api/auth.$.ts` — `/api/auth/*` handler with OAuth sanitization.
- `apps/web/src/libs/auth/{client.ts,session.ts}` — Browser client + SSR session helper.
**Testing:**
- `vitest.shared.ts`, `vitest.setup.ts` — Repo-wide setup (Testing Library, jest-dom, happy-dom env).
- `packages/config/vitest.config.ts` — Reusable Vitest base.
- `apps/web/vitest.config.ts` — Web app Vitest config.
## Naming Conventions
**Files:**
- Source files: `kebab-case.ts` / `kebab-case.tsx` (e.g. `resume-access-policy.ts`, `command-palette.tsx`).
- Component PascalCase is reserved for component names *inside* files; filenames stay kebab-case (e.g. `Button` exported from `packages/ui/src/components/button.tsx`).
- PDF template components are the one PascalCase exception: `packages/pdf/src/templates/azurill/AzurillPage.tsx` (kept that way because the directory name doubles as the template enum value).
- Tests sit next to their subject: `foo.ts` ↔ `foo.test.ts`, `foo.tsx` ↔ `foo.test.tsx`.
- Browser-only modules use a `.browser.tsx` suffix (e.g. `apps/web/src/components/resume/preview.browser.tsx`).
- Server-only modules use a `.server.tsx` suffix when they live next to browser counterparts (e.g. `apps/web/src/libs/resume/pdf-document.server.tsx`).
- Generated files use `.gen.ts` (e.g. `apps/web/src/routeTree.gen.ts`).
- Node-only utilities use a `.node.ts` suffix (e.g. `packages/utils/src/monorepo.node.ts`, `packages/utils/src/url-security.node.ts`).
**Routes (TanStack Router file conventions):**
- `$param.tsx` — dynamic path segment (e.g. `apps/web/src/routes/$username/$slug.tsx`).
- `$.tsx` — splat (matches the rest of the path; used for `api/rpc/$`, `api/auth/$`, `[.]well-known/$`).
- `_layout/` (underscore prefix) — pathless layout group (e.g. `apps/web/src/routes/_home/`).
- `-folder/` (dash prefix) — colocated, non-route helpers (`-components/`, `-sidebar/`, `-store/`, `-sections/`, `-helpers/`). The router ignores these.
- `[.]well-known` — escaped folder name for paths starting with a dot.
- `name[.]json.ts` — escaped dot in a route filename (used for `/schema.json`).
- `route.tsx` — layout/wrapper route at a directory level; `index.tsx` — index route inside it.
**Directories:**
- `apps/<app>` and `packages/<name>` — kebab-case workspace members.
- `packages/<name>/src/<subdomain>/<file>.ts` — every public path in `package.json` exports points into `src/`.
**Package names:** All workspace packages are scoped under `@reactive-resume/*` (`api`, `auth`, `db`, `ui`, etc.). The web app is just `web`.
## Where to Add New Code
**New oRPC procedure:**
- Define in `packages/api/src/routers/<domain>.ts`, register it on the exported router map, and add corresponding business logic to `packages/api/src/services/<domain>.ts`.
- If authenticated, prefer `protectedProcedure` from `packages/api/src/context.ts`.
- If it mutates resumes, attach `resumeMutationRateLimit` from `packages/api/src/middleware/rate-limit/index.ts`.
- Define input/output Zod schemas in `packages/api/src/dto/<domain>.ts` when reused.
**New database table or column:**
- Add the table/column to `packages/db/src/schema/<file>.ts`, update `packages/db/src/relations.ts` if needed, re-export from `packages/db/src/schema/index.ts`.
- Run `DATABASE_URL=... pnpm db:generate` to write a migration directory under `migrations/`.
- Apply with `DATABASE_URL=... pnpm db:migrate` (or just start the app — `apps/web/plugins/1.migrate.ts` runs them on boot).
**New resume field or section:**
- Update Zod first in `packages/schema/src/resume/data.ts` (and `default.ts` / `sample.ts` if applicable).
- Adjust API DTOs (`packages/api/src/dto/resume.ts`), importers (`packages/import/src/*.tsx`), PDF templates and shared primitives (`packages/pdf/src/templates/...`), and the builder forms under `apps/web/src/routes/builder/$resumeId/-sidebar/`.
**New resume template:**
- Add to the template enum in `packages/schema/src/templates.ts`.
- Implement the page component at `packages/pdf/src/templates/<name>/<Name>Page.tsx` and register it in `packages/pdf/src/templates/index.ts`.
- Drop static previews into `apps/web/public/templates/jpg/<name>.jpg` and `apps/web/public/templates/pdf/<name>.pdf`.
**New web route:**
- Add the file under `apps/web/src/routes/...`. Use `$param.tsx` for dynamic segments, `_layout/` for pathless groups, and `-folder/` for colocated helpers.
- The route tree regenerates into `apps/web/src/routeTree.gen.ts` — do not hand-edit.
- For browser-only sub-routes, set `ssr: false` (see `apps/web/src/routes/builder/$resumeId/index.tsx`) or `ssr: "data-only"` (see `apps/web/src/routes/$username/$slug.tsx`).
**New server-only HTTP endpoint:**
- Add a route file (typically under `apps/web/src/routes/api/`) and export `Route = createFileRoute(...)({ server: { handlers: { GET/POST/ANY: handler } } })`.
- Import server-only deps (`@reactive-resume/db/client`, `@reactive-resume/api/services/*`) only inside the handler module so they stay out of the client bundle.
**New shared component:**
- App-only: `apps/web/src/components/<group>/<name>.tsx` (with colocated test if behaviour is non-trivial).
- Reused across packages: `packages/ui/src/components/<name>.tsx` (export via deep path `@reactive-resume/ui/components/<name>`).
**New shared utility:**
- Add to `packages/utils/src/<topic>.ts` and an explicit `"./<topic>": "./src/<topic>.ts"` entry in `packages/utils/package.json` `exports`. Never import private files across packages.
**New dialog:**
- Implement under `apps/web/src/dialogs/<group>/<name>.tsx`.
- Register with the dialog store and ensure `apps/web/src/dialogs/manager.tsx` renders it.
**New AI prompt or tool:**
- Prompts go in `packages/ai/src/prompts/<name>.md` (re-exported via `packages/ai/src/prompts.ts`).
- Tools go in `packages/ai/src/tools/<name>.ts` and are exposed through the AI router in `packages/api/src/routers/ai.ts`.
**New MCP tool/resource/prompt:**
- Register in `apps/web/src/routes/mcp/-helpers/{tools,resources,prompts}.ts`; reuse existing oRPC services for actual logic.
## Special Directories
**`apps/web/src/routes/`:**
- Purpose: Source for the file-based route tree.
- Generated artifact: `apps/web/src/routeTree.gen.ts`.
- Committed: Yes (the tree file is committed; regenerated by TanStack Router tooling on dev/build).
**`migrations/`:**
- Purpose: Drizzle-generated SQL + snapshots.
- Generated: Yes (by `pnpm db:generate`).
- Committed: Yes — do not hand-edit, but always commit new migration folders.
**`apps/web/.output/`:**
- Purpose: Nitro/Vite production build output (server + client + PWA assets).
- Generated: Yes (by `pnpm build`).
- Committed: No (gitignored).
**`apps/web/locales/`:**
- Purpose: Lingui `.po` catalogs.
- Generated: `en-US.po` is the source; other locales are synced via Crowdin (`crowdin.yml`).
- Committed: Yes.
**`data/` and `apps/web/data/`:**
- Purpose: Default local storage root when S3 is unset (`<workspace>/data`).
- Generated: Yes (at runtime by `apps/web/plugins/2.storage.ts` or service calls).
- Committed: No (gitignored runtime state).
**`.turbo/`, `.pnpm-store/`, `node_modules/`:**
- Purpose: Tool caches and dependency stores.
- Committed: No.
**`.vite-hooks/`:**
- Purpose: Lefthook-installed git hooks (`commit-msg`, `pre-commit`).
- Committed: Yes (so contributors get hooks automatically), but the underlying behavior is defined by `lefthook.yml`.
**`packages/runtime-externals/`:**
- Purpose: Marks `bcrypt`, `sharp`, `@aws-sdk/client-s3` as runtime dependencies that Vite externalizes.
- No source files — package.json is the entire contract.
## Files / Locations to Avoid Hand-Editing
- `apps/web/src/routeTree.gen.ts` — regenerated by TanStack Router tooling.
- `migrations/<timestamp>_<name>/migration.sql` and `snapshot.json` — generated by `drizzle-kit`. Add a new migration via `pnpm db:generate` instead of editing past ones.
- `pnpm-lock.yaml` — managed by pnpm. Update via `pnpm install`.
- `apps/web/.output/`, `apps/web/coverage/`, `packages/*/coverage/`, `packages/*/reports/` — build/test artifacts.
- `apps/web/locales/*.po` (except `en-US.po`) — synced from Crowdin per `crowdin.yml`.
- `apps/web/public/screenshots/`, `apps/web/public/opengraph/`, `apps/web/public/templates/{jpg,pdf}/` — regenerated assets; replace files wholesale rather than diff-editing.
---
*Structure analysis: 2026-05-11*
+253
View File
@@ -0,0 +1,253 @@
# Testing Patterns
**Analysis Date:** 2026-05-11
## Test Framework
**Runner:** Vitest 4.x.
- Root devDependency in `package.json`: `"vitest": "^4.1.5"`, `"@vitest/coverage-v8": "^4.1.5"`.
- Every workspace package and `apps/web` has its own `vitest.config.ts` that delegates to the shared factory at `vitest.shared.ts`.
**DOM environment:** `happy-dom` 20.x (migrated from jsdom in commit `7a60a42a0`). Default test environment per `vitest.shared.ts:19` is `"node"`; per-package configs opt into browser-like envs.
**Assertion / testing libraries** (root `package.json` devDependencies):
- `@testing-library/react` ^16.3.2
- `@testing-library/dom` ^10.4.1
- `@testing-library/jest-dom` ^6.9.1 — registered globally in `vitest.setup.ts:1`
- `@testing-library/user-event` ^14.6.1
**Run commands** (root `package.json`):
```bash
pnpm test # turbo run test → vitest run --passWithNoTests in every package
pnpm test:coverage # turbo run test:coverage → adds --coverage flag
pnpm test:ci # adds GitHub Actions, JSON, and JUnit reporters
pnpm test:agent # agent-friendly reporter + JSON output for agentic runs
```
Per-package commands (uniform across all packages, see `packages/api/package.json:13-19`):
```bash
pnpm --filter @reactive-resume/utils test
pnpm --filter @reactive-resume/api test
pnpm --filter web test
```
Vitest test paths are package-relative when filtering: `pnpm --filter @reactive-resume/utils test -- src/string.test.ts`.
## Shared Vitest Configuration
`vitest.shared.ts` exports `createVitestProjectConfig({ name, dirname, environment, plugins })`. Highlights:
- `root: dirname` — each package runs in isolation.
- `envDir: workspaceRoot` — `.env` at repo root is loaded for every package.
- `resolve: { tsconfigPaths: true }` — TS path aliases resolve from the package's own `tsconfig.json`.
- `setupFiles: [./vitest.setup.ts]` — global hooks applied to all projects.
- `include: ["src/**/*.{test,spec}.?(c|m)[jt]s?(x)"]` — both `.test.*` and `.spec.*` are picked up.
- `exclude: ["node_modules", "dist", ".output", "coverage", "reports"]`.
- `pool: "threads"`, `isolate: false` — fast threaded execution with shared module state inside a worker.
- `passWithNoTests: true` — packages without tests don't fail CI.
- `environmentOptions.happyDOM` disables JS/CSS file loading and navigation for safety.
Coverage is configured directly in `vitest.shared.ts:49-56`:
- Provider: `v8`
- Output: `./coverage` per package
- Reporters: `text`, `text-summary`, `json-summary`, `json`, `lcov`, `html`
- Include: `src/**/*.{ts,tsx}`
- Exclude: `src/**/*.{test,spec}.*`, `src/**/*.d.ts`, `src/routeTree.gen.ts`
- `reportOnFailure: true`
No global coverage thresholds are enforced (no `thresholds: {...}` block). Per-package coverage HTML lives under each package's `coverage/` directory after `pnpm test:coverage`.
## Global Setup (`vitest.setup.ts`)
Applied to every project:
1. `import "@testing-library/jest-dom/vitest"` — registers `toBeInTheDocument`, `toHaveAttribute`, etc.
2. `afterEach(() => cleanup())` — explicit RTL cleanup (Vitest doesn't expose `afterEach` globally without `test.globals: true`).
3. Polyfills for jsdom/happy-dom gaps: `ResizeObserver`, `IntersectionObserver`, `Element.prototype.scrollIntoView`, `window.matchMedia` (used by `cmdk`, Base UI, `next-themes`).
## Per-Package Vitest Configs
All `vitest.config.ts` files reuse the shared factory. Notable variants:
- **Node default** (`packages/utils/vitest.config.ts`, `packages/api/vitest.config.ts`, `packages/db/vitest.config.ts`, `packages/schema/vitest.config.ts`, `packages/ai/vitest.config.ts`, `packages/email/vitest.config.ts`, `packages/fonts/vitest.config.ts`, `packages/env/vitest.config.ts`, `packages/auth/vitest.config.ts`, `packages/import/vitest.config.ts`, `packages/pdf/vitest.config.ts`, `packages/config/vitest.config.ts`) — `environment: "node"`.
- **DOM** (`packages/ui/vitest.config.ts:7`) — `environment: "happy-dom"` for component tests.
- **Web app** (`apps/web/vitest.config.ts`) — `environment: "node"` plus Vite plugins to mirror dev: `@tailwindcss/vite`, `@lingui/vite-plugin` (with `linguiTransformerBabelPreset`), and `@rolldown/plugin-babel`. Individual web tests opt into the DOM via the `@vitest-environment happy-dom` file-level comment.
Per-test environment overrides (declared at the top of the file as `// @vitest-environment happy-dom` or in a `/** @vitest-environment happy-dom */` block):
- `packages/utils/src/sanitize.test.ts`
- `packages/utils/src/file.test.ts`
- `apps/web/src/components/resume/preview.browser.test.tsx`
- `apps/web/src/components/resume/preview.shared.test.tsx`
- `apps/web/src/components/typography/combobox.test.tsx`
## Test File Organization
**Location:** Co-located with implementation. `foo.ts` lives next to `foo.test.ts` (or `foo.test.tsx` for React).
**Naming:**
- `<name>.test.ts` — Node/pure logic (e.g. `packages/utils/src/string.test.ts`).
- `<name>.test.tsx` — JSX/component tests (e.g. `packages/ui/src/components/button.test.tsx`).
- `<name>.node.test.ts` — Node-only modules whose implementation is also `.node.ts` (e.g. `packages/utils/src/url-security.node.test.ts`, `packages/utils/src/monorepo.node.test.ts`).
- No `.spec.*` files in the repo today, but the include pattern supports them.
**Test counts (current):** 127 `*.test.ts` files + 98 `*.test.tsx` files across `packages/` and `apps/web/src/`.
**Hot spots (where coverage is densest):**
- `packages/utils/src/*.test.ts` — string, color, html, date, level, locale, sanitize, rate-limit, field, file, network-icons, style, url, url-security.node, monorepo.node, plus `resume/patch.test.ts`.
- `packages/ui/src/components/*.test.tsx` — ~30+ component tests (button, dialog, alert, badge, card, combobox, command, popover, scroll-area, sidebar, switch, tabs, textarea, toggle, tooltip, etc.).
- `packages/pdf/src/templates/shared/*.test.ts` — columns, section-links, rich-text, metrics, picture, filtering.
- `packages/pdf/src/section-title.test.ts` and `packages/pdf/src/hooks/use-register-fonts.test.ts`.
- `packages/api/src/{dto,helpers,services}/*.test.ts` — `dto/resume`, `helpers/resume-access-policy`, `services/ai`.
- `packages/schema/src/{templates,page}.test.ts` and `packages/schema/src/resume/{data,default}.test.ts`.
- `packages/ai/src/{tools,resume}/*.test.ts` — patch-proposal, sanitize, extraction-template.
- `packages/import/src/reactive-resume-v4-json.test.ts`, `packages/fonts/src/index.test.ts`.
- `apps/web/src/libs/{pwa,locale,theme,error-message}.test.ts`, `apps/web/src/dialogs/store.test.ts`, `apps/web/src/components/resume/preview.{browser,shared}.test.tsx`, `apps/web/src/components/typography/combobox.test.tsx`.
## Test Structure Patterns
**Idiomatic skeleton** (from `packages/utils/src/string.test.ts:1-21` and `packages/api/src/helpers/resume-access-policy.test.ts:1-17`):
```typescript
import { describe, expect, it } from "vitest";
import { thingUnderTest } from "./thing";
describe("thingUnderTest", () => {
it("returns X for Y", () => {
expect(thingUnderTest(input)).toBe(expected);
});
it("returns Z for empty input", () => {
expect(thingUnderTest("")).toBe("");
});
});
```
**Patterns observed:**
- Top-level `describe` per exported function; nested `describe` blocks group behaviors.
- One assertion focus per `it` — short, declarative names ("returns X", "throws Y when Z", "does not mutate the input").
- Negative cases are first-class — every helper has tests for `null`, empty string, unknown shapes, etc.
- `it.each([...] as const)("variant=%s renders without throwing", (variant) => {...})` for matrix tests over discriminated union variants (see `packages/ui/src/components/button.test.tsx:55-79`).
- Setup with `beforeEach` / `afterEach` is used only when needed (timers, temp dirs, store reset). Example: `apps/web/src/dialogs/store.test.ts:4-13` resets a Zustand store between tests with `useDialogStore.setState(...)`.
- Temp directories use `fs.mkdtempSync` + `realpathSync` and are torn down in `afterEach` (`packages/utils/src/monorepo.node.test.ts:7-17`).
- Fake timers via `vi.useFakeTimers()` / `vi.advanceTimersByTime(300)` for animation/transition assertions (`apps/web/src/dialogs/store.test.ts:54-65`).
## Mocking
**Library:** Vitest's built-in `vi` (no Jest). Used sparingly — most tests cover pure functions.
**Patterns:**
- `vi.fn()` for callback assertions: `expect(onClick).toHaveBeenCalledOnce()` (`packages/ui/src/components/button.test.tsx:32-37`).
- `vi.fn().mockResolvedValue(true)` for async handlers (`apps/web/src/dialogs/store.test.ts:86-93`).
- `vi.mock("module-path", () => ({ ... }))` for replacing modules. Heaviest example: `apps/web/src/components/resume/preview.browser.test.tsx:25-81` mocks `@react-pdf/renderer`, `@/libs/resume/pdf-document`, `./builder-resume-draft`, and `./pdf-canvas` so the preview component can be exercised without React PDF.
- `vi.hoisted(() => ({ ... }))` to share mutable state with hoisted `vi.mock` factories (`apps/web/src/components/resume/preview.browser.test.tsx:8-12`). This is required because `vi.mock` calls are hoisted above imports.
- Spies via `vi.spyOn` are rare; mock modules are preferred so the real implementation stays out of scope.
**Test data / fixtures:**
- No central `fixtures/` directory. Tests build minimal objects inline or extend canonical defaults from the schema package:
- `import { defaultResumeData } from "@reactive-resume/schema/resume/default"` (used by `packages/api/src/helpers/resume-access-policy.test.ts:2`).
- `import { sampleResumeData } from "@reactive-resume/schema/resume/sample"` (used by `apps/web/src/components/resume/preview.browser.test.tsx:5`).
- Local helper builders are inlined per test file (e.g. the `resumeDataWithPageCount` helper at `apps/web/src/components/resume/preview.browser.test.tsx:14-23`).
## React Component Testing
**Render + query** via Testing Library (`packages/ui/src/components/button.test.tsx`):
```typescript
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
render(<Button onClick={onClick}>Click</Button>);
await userEvent.click(screen.getByRole("button"));
```
**Accessibility-first queries:** `getByRole("button", { name: "..." })` is the default; `aria-label` is asserted explicitly (`packages/ui/src/components/button.test.tsx:81-84`).
**Slot / data-attr conventions:** Components expose `data-slot` for shadcn/Base UI slotting and tests assert it (`button.test.tsx:22-25`).
**Async UI:** Use `waitFor` from `@testing-library/react` and `await userEvent.*`. The `cleanup()` afterEach in `vitest.setup.ts` ensures DOM doesn't leak between tests despite `isolate: false`.
## Reporters
Per-package `package.json` scripts (uniform pattern, e.g. `packages/api/package.json:15-18`):
- `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:agent` → `vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests`
The `agent` reporter is a Vitest 4 feature optimized for LLM-driven runs; `pnpm test:agent` is the canonical script to use when an agent needs structured pass/fail data.
JUnit + JSON outputs land in `reports/` inside each package (Biome-ignored).
## CI
- **`.github/workflows/autofix.yml`** — runs on every PR and push to `main`. It runs `pnpm knip --fix` and `pnpm check`. **It does NOT run `pnpm test`.** Tests are not currently gating CI.
- **`.github/workflows/docker-build.yml`** — `workflow_dispatch` only; builds multi-arch images. No test step.
- **`.github/workflows/crowdin-sync.yml`** — translation sync only.
Tests are run locally (`pnpm test`) or by agents via `pnpm test:agent` / `pnpm test:ci`. No PR is currently blocked by a failing test on GitHub Actions.
## Test Types
- **Unit tests** — Dominant. Pure functions in `packages/utils`, `packages/schema`, `packages/pdf/src/templates/shared`, `packages/api/src/{helpers,dto}` are exercised in isolation.
- **Component tests** — `packages/ui/src/components/*.test.tsx` and a handful in `apps/web/src/components/`. Driven by `@testing-library/react` + `happy-dom` (file-level override) or `packages/ui`'s package-level `environment: "happy-dom"`.
- **Integration tests** — None against the live Drizzle client or the running TanStack Start server. `apps/web/src/components/resume/preview.browser.test.tsx` is the closest, mocking React PDF and exercising the preview pipeline end-to-end in happy-dom.
- **E2E / browser tests** — Not present. No Playwright, Cypress, or `vitest --browser` config.
## Common Patterns
**Async error testing:**
```typescript
expect(() => assertCanView({ userId: "u1", isPublic: false }, null)).toThrow();
try {
assertCanView({ userId: "u1", isPublic: false }, null);
expect.unreachable();
} catch (error: unknown) {
expect((error as { code?: string }).code).toBe("NOT_FOUND");
}
```
(See `packages/api/src/helpers/resume-access-policy.test.ts:29-44`.)
**Immutability assertions:**
```typescript
const before = JSON.stringify(resume);
redactResumeForViewer(resume, false);
expect(JSON.stringify(resume)).toBe(before);
```
(See `packages/api/src/helpers/resume-access-policy.test.ts:86-93`.)
**Time-sensitive logic:** UUIDv7 ordering is verified with a `setTimeout` and a string compare instead of mocking time (`packages/utils/src/string.test.ts:15-20`).
## Coverage Gaps Worth Flagging
These areas have implementation but no `*.test.*` files alongside them today — agents adding features here should consider adding tests.
- **`packages/email/src/transport.ts` and templates** — no tests. SMTP transport is untested.
- **`packages/env/src/server.ts`** — no tests for env-var schema validation.
- **`packages/auth/`** — no tests; Better Auth config and helpers are uncovered.
- **`packages/api/src/services/{resume,storage,statistics,auth,flags,resume-events}.ts`** — only `ai.ts` has a service-level test (`ai.test.ts`). Most resume mutation flow logic is exercised only indirectly through `helpers/resume-access-policy.test.ts` and `dto/resume.test.ts`.
- **`packages/api/src/routers/*`** — no router-level tests. End-to-end oRPC procedure behavior (auth + rate limit + service composition) is not asserted.
- **`packages/api/src/middleware/rate-limit/*`** — no tests.
- **`packages/db/src/schema/*`** — no tests (schema correctness is implicit, but no migration roundtrip tests).
- **`packages/scripts/`** — no tests.
- **`packages/runtime-externals/`** — no tests.
- **`apps/web/src/routes/**`** — route handlers (`api/rpc.$.ts`, `api/auth.$.ts`, `api/health.ts`, `uploads/...`, `mcp/...`, `auth/oauth.ts`) and most builder UI under `routes/builder/$resumeId` are uncovered.
- **`apps/web/src/dialogs/**`** — only `store.test.ts` covers the dialog store. Individual dialog components (resume create/update/import, two-factor, api-key) are uncovered.
- **`packages/pdf/src/templates/{azurill,bronzor,...}`** — individual template renderers have no tests; only the shared primitives in `templates/shared/` are covered.
When adding tests in any of these areas, follow the colocation rule (`foo.ts` ↔ `foo.test.ts`) and reuse `defaultResumeData` / `sampleResumeData` from `@reactive-resume/schema/resume/*` instead of inventing new fixtures.
---
*Testing analysis: 2026-05-11*
+107
View File
@@ -0,0 +1,107 @@
# AGENTS.md
## Cursor Cloud specific instructions
### Overview
Reactive Resume is a pnpm monorepo (Turborepo) with a single full-stack web app at `apps/web` (TanStack Start / React 19 / Vite) and ~15 internal packages under `packages/`. It runs as a single Node.js process on port 3000.
Internal packages are source-consumed through `package.json` export maps that point 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`). Use `nvm install 24 && nvm use 24` if needed.
- **Docker** is required to run PostgreSQL. Start it with `sudo dockerd &` if the daemon isn't running.
- **pnpm 11.0.9** is managed via corepack (`corepack enable`).
### Codebase map
- `apps/web` is the only app. It owns TanStack Start routes, Vite/Nitro config, PWA setup, oRPC client wiring, route-level server handlers, and the resume builder UI.
- `packages/api` contains oRPC routers, services, DTOs, storage, resume access policy, statistics, AI services, and rate limiting. The web route `apps/web/src/routes/api/rpc.$.ts` exposes these routers at `/api/rpc`.
- `packages/auth` contains Better Auth config, auth helper functions, and exported auth types. The web route `apps/web/src/routes/api/auth.$.ts` delegates to `auth.handler`.
- `packages/db` contains the Drizzle client and schema. Migration files live at the repo root in `migrations/`.
- `packages/env` defines server environment validation and auto-loads the root `.env` for app/server code.
- `packages/schema` contains Zod schemas and typed resume/page/template models.
- `packages/pdf` contains the React PDF document, font registration, shared template primitives, and template implementations.
- `packages/ui` contains shared Base UI/shadcn-style components and hooks.
- `packages/fonts`, `packages/email`, `packages/import`, `packages/ai`, `packages/utils`, `packages/scripts`, `packages/config`, and `packages/runtime-externals` provide focused support surfaces. Prefer their existing exports over adding cross-package shortcuts.
### Web app conventions
- Routes are file-based under `apps/web/src/routes`. Do not hand-edit `apps/web/src/routeTree.gen.ts`; it is generated by TanStack Router tooling.
- Server-only route handlers use route `server.handlers` blocks, for example `api/rpc.$.ts`, `api/auth.$.ts`, `api/health.ts`, uploads, schema, OpenAPI, MCP, and `.well-known` routes.
- `apps/web/src/router.tsx` initializes router context with `queryClient`, `orpc`, `theme`, `locale`, `session`, and `flags`. Reuse route context where possible instead of refetching these concerns ad hoc.
- The builder shell lives under `apps/web/src/routes/builder/$resumeId`. The nested preview route is client-only (`ssr: false`), while the public resume route `apps/web/src/routes/$username/$slug.tsx` uses `ssr: "data-only"`.
- Browser-only resume preview code is split across `apps/web/src/components/resume/preview.tsx`, `preview.browser.tsx`, `pdf-canvas.tsx`, and shared helpers. Keep PDF.js/canvas/browser APIs out of SSR paths.
- The isomorphic oRPC client is in `apps/web/src/libs/orpc/client.ts`; server calls use an in-process router client and browser calls use `/api/rpc` with credentials included.
### Package and feature boundaries
- Add new API procedures in `packages/api/src/routers/*` and keep business logic in `packages/api/src/services/*` or helpers. Prefer `protectedProcedure` from `packages/api/src/context.ts` for authenticated procedures.
- Add database columns/tables in `packages/db/src/schema/*`, then generate root-level migrations with `pnpm db:generate`.
- Add or change resume data shape in `packages/schema/src/resume/*` first, then update API DTOs, importers, PDF rendering, and web forms that consume that shape.
- Add or rename templates in all relevant places: `packages/schema/src/templates.ts`, `packages/pdf/src/templates/index.ts`, template source under `packages/pdf/src/templates/<name>/`, and static previews under `apps/web/public/templates/{jpg,pdf}`.
- Shared PDF section filtering lives in `packages/pdf/src/templates/shared/filtering.ts`. Keep template-specific visual exceptions in the owning template directory unless multiple templates need the same behavior.
- `packages/pdf/src/hooks/use-register-fonts.ts` owns React PDF font registration, standard PDF font handling, CJK fallback stacks, and global hyphenation behavior.
- `packages/utils` has narrowly exported helpers. If another package needs a utility, add an explicit export path instead of importing private files.
### Database
PostgreSQL runs via Docker Compose:
```
sudo docker compose -f compose.dev.yml up -d postgres
```
The dev default connection string is `postgresql://postgres:postgres@localhost:5432/postgres`.
**Important**: `drizzle-kit` (used by `pnpm db:migrate`) reads `DATABASE_URL` from `process.env` directly — it does **not** auto-load the `.env` file. You must `export DATABASE_URL=...` before running migration commands, or set it in your shell profile.
The web app also runs migrations during Nitro startup via `apps/web/plugins/1.migrate.ts`. Manual `pnpm db:migrate` is mainly for first setup, migration debugging, or applying migrations without starting the app.
### Environment
Copy `.env.example` to `.env`. The three required variables are:
- `APP_URL` (default `http://localhost:3000`)
- `DATABASE_URL` (default `postgresql://postgres:postgres@localhost:5432/postgres`)
- `AUTH_SECRET` (any non-empty string)
S3/SeaweedFS is optional. If `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, and `S3_BUCKET` are all set, the app uses S3-compatible storage. The checked-in `.env.example` sets SeaweedFS defaults, so either start the `seaweedfs` compose service too or comment out those S3 vars to use local filesystem storage under `<workspace>/data`. `LOCAL_STORAGE_PATH` must be absolute when set.
### Common commands
| Task | Command |
|------|---------|
| Install deps | `pnpm install` |
| Start Postgres only | `sudo docker compose -f compose.dev.yml up -d postgres` |
| Start Postgres + SeaweedFS | `sudo docker compose -f compose.dev.yml up -d postgres seaweedfs seaweedfs_create_bucket` |
| Generate migrations | `DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" pnpm db:generate` |
| Run migrations | `DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" pnpm db:migrate` |
| Dev server | `pnpm dev` (starts on port 3000) |
| Web dev server only | `pnpm dev:web` |
| Lint/format | `pnpm check` (Biome) |
| Tests | `pnpm test` (Vitest) |
| Build | `pnpm build` |
| Typecheck | `pnpm typecheck` |
For focused validation, prefer package filters before repo-wide commands, for example:
```
pnpm --filter web typecheck
pnpm --filter @reactive-resume/pdf test
pnpm --filter @reactive-resume/api test
```
Vitest test paths are package-relative when running through `pnpm --filter <package> test -- <path>`.
### Gotchas
- The dev server (`pnpm dev`) auto-runs migrations on startup via Nitro, so `pnpm db:migrate` is only strictly needed for first-time setup or after pulling new migration files.
- Email sending requires SMTP config; without it, emails are logged to console. This is fine for dev — the app still functions, but email verification links appear in server logs.
- The `lefthook.yml` pre-commit hook runs `biome check` on staged files. Run `pnpm check` before committing to avoid hook failures.
- `pnpm check` is write-capable (`biome check --write --unsafe .`). Call that out when using it, and use narrower Biome commands if you need a non-mutating inspection.
- Biome uses tabs, double quotes, line width 120, organized import groups, and sorted Tailwind classes for `clsx`, `cva`, and `cn`.
- Most packages use `tsgo --noEmit` for typechecking and `vitest run --passWithNoTests` for tests.
- There may be unrelated local edits in the worktree. Inspect `git status --short` first and avoid reverting files you did not touch.
- **New env vars require a `turbo.json` entry.** Turborepo 2.x runs in strict env mode by default — it filters out env vars that are not listed in `globalEnv` (or task-level `env`/`passThroughEnv`). Any new environment variable added to `packages/env/src/server.ts` must also be added to the `globalEnv` array in `turbo.json`, or the variable will be `undefined` inside child processes at runtime even if it is correctly set in the OS/container environment.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+347
View File
@@ -0,0 +1,347 @@
---
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.
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"
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
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
---
## 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.
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 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.
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").
## Colors
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.
- **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.
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.
## 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.
- **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.
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.
Font rendering uses `antialiased` (grayscale AA) and `proportional-nums` across the board for clean rendering and properly spaced numerals in dates and phone numbers.
## Layout
### Builder (Three-Panel Workspace)
The core builder uses a resizable three-panel layout powered by `react-resizable-panels`:
- **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).
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).
### Dashboard
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.
## Components
### Buttons
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):
- **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.
Size scale: `xs` (28px), `sm` (32px), `default` (36px), `lg` (40px), plus `icon` variants at each size for square icon-only buttons.
### Cards
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`).
### 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.
## Internationalization
The app supports 40+ locales including RTL languages (Arabic, Hebrew, Persian, Urdu, Uyghur, Yiddish). i18n is not an afterthought — it shapes layout decisions:
**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.
**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`).
+1 -1
View File
@@ -25,7 +25,7 @@ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
pnpm install --frozen-lockfile
COPY --from=pruner /app/out/full/ ./
RUN pnpm turbo run build --filter=web
RUN rm -rf apps/web/.output && pnpm turbo run build --filter=web --force
FROM base AS runtime-pruner
COPY . .
+42
View File
@@ -0,0 +1,42 @@
# syntax=docker/dockerfile:1.7
ARG NODE_VERSION=24
FROM node:${NODE_VERSION}-slim AS dev
WORKDIR /app
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
PNPM_HOME="/pnpm" \
PATH="/pnpm:$PATH" \
NODE_ENV=development \
TURBO_TELEMETRY_DISABLED=1
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY apps/web/package.json ./apps/web/package.json
COPY packages/ai/package.json ./packages/ai/package.json
COPY packages/api/package.json ./packages/api/package.json
COPY packages/auth/package.json ./packages/auth/package.json
COPY packages/config/package.json ./packages/config/package.json
COPY packages/db/package.json ./packages/db/package.json
COPY packages/email/package.json ./packages/email/package.json
COPY packages/env/package.json ./packages/env/package.json
COPY packages/fonts/package.json ./packages/fonts/package.json
COPY packages/import/package.json ./packages/import/package.json
COPY packages/pdf/package.json ./packages/pdf/package.json
COPY packages/runtime-externals/package.json ./packages/runtime-externals/package.json
COPY packages/schema/package.json ./packages/schema/package.json
COPY packages/scripts/package.json ./packages/scripts/package.json
COPY packages/ui/package.json ./packages/ui/package.json
COPY packages/utils/package.json ./packages/utils/package.json
RUN --mount=type=cache,id=reactive-resume-dev-pnpm-store,target=/pnpm/store,sharing=locked \
pnpm install --frozen-lockfile
COPY . .
EXPOSE 3000/tcp
CMD ["pnpm", "run", "dev:web"]
+7 -3
View File
@@ -21,7 +21,7 @@
<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>
<a href="https://github.com/sponsors/AmruthPillai"><img src="https://img.shields.io/github/sponsors/AmruthPillai?style=flat-square&label=sponsors" alt="Sponsors" /></a>
<a href="https://opencollective.com/reactive-resume"><img src="https://img.shields.io/opencollective/backers/reactive-resume?style=flat-square&label=donations" alt="Donations" /></a>
<a href="https://opencollective.com/reactive-resume/donate"><img src="https://img.shields.io/opencollective/backers/reactive-resume?style=flat-square&label=donations" alt="Donations" /></a>
</p>
</div>
@@ -36,7 +36,7 @@ Built with privacy as a core principle, Reactive Resume gives you complete owner
**Resume Building**
- Real-time preview as you type
- Multiple export formats (PDF, JSON)
- Multiple export formats (PDF, JSON, DOCX)
- Drag-and-drop section ordering
- Custom sections for any content type
- Rich text editor with formatting support
@@ -130,6 +130,10 @@ Built with privacy as a core principle, Reactive Resume gives you complete owner
<img src="apps/web/public/templates/jpg/meowth.jpg" alt="Meowth" width="150" />
<br /><sub><b>Meowth</b></sub>
</td>
<td align="center">
<img src="apps/web/public/templates/jpg/scizor.jpg" alt="Scizor" width="150" />
<br /><sub><b>Scizor</b></sub>
</td>
</tr>
</table>
@@ -208,7 +212,7 @@ Reactive Resume is and always will be free and open-source. If it has helped you
<a href="https://github.com/sponsors/AmruthPillai">
<img src="https://img.shields.io/badge/GitHub%20Sponsors-Support-ea4aaa?style=flat-square&logo=github-sponsors" alt="GitHub Sponsors" />
</a>
<a href="https://opencollective.com/reactive-resume">
<a href="https://opencollective.com/reactive-resume/donate">
<img src="https://img.shields.io/badge/Open%20Collective-Contribute-7FADF2?style=flat-square&logo=open-collective" alt="Open Collective" />
</a>
</p>
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+492 -203
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+493 -204
View File
File diff suppressed because it is too large Load Diff
+469 -180
View File
File diff suppressed because it is too large Load Diff
+29 -27
View File
@@ -4,7 +4,7 @@
"type": "module",
"private": true,
"scripts": {
"build": "vite build",
"build": "rm -rf .output && vite build",
"dev": "vite dev",
"serve": "vite preview",
"start": "node .output/server/index.mjs",
@@ -16,24 +16,24 @@
"lingui:extract": "lingui extract --clean --overwrite"
},
"dependencies": {
"@ai-sdk/react": "^3.0.179",
"@ai-sdk/react": "^3.0.184",
"@base-ui/react": "^1.4.1",
"@better-auth/api-key": "^1.6.10",
"@better-auth/infra": "^0.2.6",
"@better-auth/oauth-provider": "^1.6.10",
"@better-auth/passkey": "^1.6.10",
"@better-auth/api-key": "^1.6.11",
"@better-auth/infra": "^0.2.8",
"@better-auth/oauth-provider": "^1.6.11",
"@better-auth/passkey": "^1.6.11",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lingui/core": "^6.0.1",
"@lingui/react": "^6.0.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@orpc/client": "^1.14.2",
"@orpc/json-schema": "^1.14.2",
"@orpc/openapi": "^1.14.2",
"@orpc/server": "^1.14.2",
"@orpc/tanstack-query": "^1.14.2",
"@orpc/zod": "^1.14.2",
"@orpc/client": "^1.14.3",
"@orpc/json-schema": "^1.14.3",
"@orpc/openapi": "^1.14.3",
"@orpc/server": "^1.14.3",
"@orpc/tanstack-query": "^1.14.3",
"@orpc/zod": "^1.14.3",
"@phosphor-icons/react": "^2.1.10",
"@react-pdf/renderer": "^4.5.1",
"@reactive-resume/ai": "workspace:*",
@@ -48,26 +48,27 @@
"@reactive-resume/ui": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-form": "^1.31.0",
"@tanstack/react-form": "^1.32.0",
"@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-query": "^5.100.9",
"@tanstack/react-query": "^5.100.10",
"@tanstack/react-router": "^1.169.2",
"@tanstack/react-router-ssr-query": "^1.166.12",
"@tanstack/react-start": "^1.167.65",
"@tiptap/extension-color": "^3.23.1",
"@tiptap/extension-highlight": "^3.23.1",
"@tiptap/extension-table": "^3.23.1",
"@tiptap/extension-text-align": "^3.23.1",
"@tiptap/extension-text-style": "^3.23.1",
"@tiptap/pm": "^3.23.1",
"@tiptap/react": "^3.23.1",
"@tiptap/starter-kit": "^3.23.1",
"@tiptap/extension-color": "^3.23.4",
"@tiptap/extension-highlight": "^3.23.4",
"@tiptap/extension-table": "^3.23.4",
"@tiptap/extension-text-align": "^3.23.4",
"@tiptap/extension-text-style": "^3.23.4",
"@tiptap/pm": "^3.23.4",
"@tiptap/react": "^3.23.4",
"@tiptap/starter-kit": "^3.23.4",
"@types/js-cookie": "^3.0.6",
"@uiw/color-convert": "^2.10.1",
"@uiw/react-color-colorful": "^2.10.1",
"better-auth": "1.6.10",
"ai": "^6.0.182",
"better-auth": "1.6.11",
"cmdk": "^1.1.1",
"drizzle-orm": "1.0.0-beta.22",
"drizzle-orm": "1.0.0-rc.2",
"es-toolkit": "^1.46.1",
"fuse.js": "^7.3.0",
"immer": "^11.1.8",
@@ -78,7 +79,8 @@
"qrcode.react": "^4.2.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-resizable-panels": "^4.11.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.11.1",
"react-window": "^2.2.7",
"react-zoom-pan-pinch": "^4.0.3",
"sonner": "^2.0.7",
@@ -98,12 +100,12 @@
"@types/pg": "^8.20.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260510.1",
"@typescript/native-preview": "7.0.0-dev.20260514.1",
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-macros": "^3.1.0",
"nitro": "3.0.260429-beta",
"typescript": "^6.0.3",
"vite": "^8.0.11",
"vite": "^8.0.13",
"vite-plugin-pwa": "^1.3.0"
}
}
+1 -1
View File
@@ -61,7 +61,7 @@
"guid": "open-collective",
"type": "payment-provider",
"description": "Open Collective",
"address": "https://opencollective.com/reactive-resume"
"address": "https://opencollective.com/reactive-resume/donate"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.
@@ -0,0 +1,56 @@
// @vitest-environment happy-dom
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { CometCard } from "./comet-card";
describe("CometCard", () => {
it("renders its children inside the perspective wrapper", () => {
const { getByText } = render(
<CometCard>
<span>card body</span>
</CometCard>,
);
expect(getByText("card body")).toBeInTheDocument();
});
it("merges custom className into the wrapper", () => {
const { container } = render(
<CometCard className="extra-class">
<span>x</span>
</CometCard>,
);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.className).toContain("extra-class");
expect(wrapper.className).toContain("perspective-distant");
expect(wrapper.className).toContain("transform-3d");
});
it("renders a glare overlay positioned absolutely with mix-blend-overlay", () => {
const { container } = render(
<CometCard>
<span>x</span>
</CometCard>,
);
const glare = container.querySelector("[class*='mix-blend-overlay']") as HTMLElement | null;
expect(glare).not.toBeNull();
expect(glare?.className).toContain("pointer-events-none");
});
it("does not throw when mouse enters / moves over / leaves the card", () => {
const { container } = render(
<CometCard>
<span>x</span>
</CometCard>,
);
const tiltable = container.querySelector("[class*='will-change-transform']") as HTMLElement;
expect(tiltable).toBeTruthy();
expect(() => {
fireEvent.mouseMove(tiltable, { clientX: 100, clientY: 50 });
fireEvent.mouseMove(tiltable, { clientX: 0, clientY: 0 });
fireEvent.mouseLeave(tiltable);
}).not.toThrow();
});
});
@@ -0,0 +1,52 @@
// @vitest-environment happy-dom
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { CountUp } from "./count-up";
describe("CountUp", () => {
it("renders an aria-live=polite span by default (announced to screen readers)", () => {
const { container } = render(<CountUp to={1000} />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.getAttribute("aria-live")).toBe("polite");
expect(span.getAttribute("aria-atomic")).toBe("true");
});
it("seeds textContent to the 'from' value when counting up", () => {
const { container } = render(<CountUp from={0} to={100} />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.textContent).toBe("0");
});
it("seeds textContent to the 'to' value when direction is down", () => {
const { container } = render(<CountUp from={0} to={100} direction="down" />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.textContent).toBe("100");
});
it("formats with the separator when one is supplied", () => {
const { container } = render(<CountUp from={1234} to={2345} separator="," />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.textContent).toBe("1,234");
});
it("preserves decimal places when from / to are fractional", () => {
const { container } = render(<CountUp from={1.25} to={3.75} />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.textContent).toBe("1.25");
});
it("strips aria-live and aria-atomic when aria-hidden is set", () => {
const { container } = render(<CountUp to={100} aria-hidden="true" />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.getAttribute("aria-hidden")).toBe("true");
expect(span.getAttribute("aria-live")).toBeNull();
expect(span.getAttribute("aria-atomic")).toBeNull();
});
it("accepts a custom className", () => {
const { container } = render(<CountUp to={100} className="custom-class" />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.className).toContain("custom-class");
});
});
@@ -0,0 +1,45 @@
// @vitest-environment happy-dom
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Spotlight } from "./spotlight";
describe("Spotlight", () => {
it("renders a non-pointer-events overlay container", () => {
const { container } = render(<Spotlight />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.className).toContain("pointer-events-none");
expect(wrapper.className).toContain("absolute");
});
it("renders both left and right beam groups by default", () => {
const { container } = render(<Spotlight />);
// Outer wrapper > two animated beam containers
const beamGroups = container.firstChild?.childNodes;
expect(beamGroups?.length).toBe(2);
});
it("applies the provided width / height / smallWidth to inline styles", () => {
const { container } = render(<Spotlight width={500} height={800} smallWidth={120} translateY={-100} />);
const inlineStyles = Array.from(container.querySelectorAll<HTMLDivElement>("[style]")).map(
(el) => el.getAttribute("style") ?? "",
);
const allStyles = inlineStyles.join("|");
expect(allStyles).toContain("width: 500px");
expect(allStyles).toContain("height: 800px");
expect(allStyles).toContain("width: 120px");
expect(allStyles).toContain("translateY(-100px)");
});
it("uses the supplied gradient strings as background values", () => {
const customFirst = "radial-gradient(red, blue)";
const { container } = render(<Spotlight gradientFirst={customFirst} />);
const matched = Array.from(container.querySelectorAll<HTMLDivElement>("[style]")).filter(
(el) => el.style.background.includes("red") && el.style.background.includes("blue"),
);
expect(matched.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,47 @@
// @vitest-environment happy-dom
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { TextMaskEffect } from "./text-mask";
const renderMask = (props: React.ComponentProps<typeof TextMaskEffect>) => render(<TextMaskEffect {...props} />);
describe("TextMaskEffect", () => {
it("renders the supplied text in all visible text layers", () => {
const { container } = renderMask({ text: "Hello World" });
const texts = container.querySelectorAll("text");
expect(texts.length).toBeGreaterThanOrEqual(2);
for (const el of texts) {
expect(el.textContent).toBe("Hello World");
}
});
it("forwards aria-hidden onto the root svg", () => {
const { container } = renderMask({ text: "X", "aria-hidden": "true" });
const svg = container.querySelector("svg") as SVGSVGElement;
expect(svg.getAttribute("aria-hidden")).toBe("true");
});
it("renders an aria-label on the root svg", () => {
const { container } = renderMask({ text: "X" });
const svg = container.querySelector("svg") as SVGSVGElement;
expect(svg.getAttribute("aria-label")).toBe("Text mask effect");
});
it("does not throw on mouse-enter / mouse-move / mouse-leave interactions", () => {
const { container } = renderMask({ text: "X" });
const svg = container.querySelector("svg") as SVGSVGElement;
expect(() => {
fireEvent.mouseEnter(svg);
fireEvent.mouseMove(svg, { clientX: 50, clientY: 25 });
fireEvent.mouseLeave(svg);
}).not.toThrow();
});
it("merges custom className into the svg", () => {
const { container } = renderMask({ text: "X", className: "custom-class" });
const svg = container.querySelector("svg") as SVGSVGElement;
expect(svg.getAttribute("class") ?? "").toContain("custom-class");
});
});
@@ -0,0 +1,53 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { Command } from "@reactive-resume/ui/components/command";
import { useCommandPaletteStore } from "../store";
import { BaseCommandGroup } from "./base";
const renderInCommand = (ui: React.ReactNode) => render(<Command>{ui}</Command>);
const resetStore = () => {
useCommandPaletteStore.setState({ open: false, search: "", pages: [] });
};
afterEach(resetStore);
describe("BaseCommandGroup", () => {
it("renders children at the root (no page prop) when the page stack is empty", () => {
renderInCommand(<BaseCommandGroup heading="Root">child-text</BaseCommandGroup>);
expect(screen.getByText("child-text")).toBeInTheDocument();
});
it("does NOT render when the page stack tops a different page than its `page` prop", () => {
useCommandPaletteStore.setState({ pages: ["other"] });
const { container } = renderInCommand(
<BaseCommandGroup page="settings" heading="Settings">
<span>x</span>
</BaseCommandGroup>,
);
// Nothing rendered besides the Command shell itself.
expect(container.textContent).toBe("");
});
it("renders when the top of the page stack matches its `page` prop", () => {
useCommandPaletteStore.setState({ pages: ["settings"] });
renderInCommand(
<BaseCommandGroup page="settings" heading="Settings">
settings-children
</BaseCommandGroup>,
);
expect(screen.getByText("settings-children")).toBeInTheDocument();
});
it("does NOT render the root group when there is a sub-page on top", () => {
useCommandPaletteStore.setState({ pages: ["settings"] });
const { container } = renderInCommand(
<BaseCommandGroup heading="Root">
<span>root-text</span>
</BaseCommandGroup>,
);
expect(container.textContent).toBe("");
});
});
@@ -1,6 +1,7 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
ChatCircleDotsIcon,
GearIcon,
HouseSimpleIcon,
KeyIcon,
@@ -44,6 +45,16 @@ export function NavigationCommandGroup() {
<Trans>Resumes</Trans>
</CommandItem>
<CommandItem
disabled={!session}
keywords={[t`Agent`, t`Artificial Intelligence`]}
value="navigation.agent"
onSelect={() => onNavigate("/agent")}
>
<ChatCircleDotsIcon />
<Trans>Agent</Trans>
</CommandItem>
<CommandItem
disabled={!session}
keywords={[t`Settings`]}
@@ -0,0 +1,52 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { Command } from "@reactive-resume/ui/components/command";
import { useCommandPaletteStore } from "../../store";
vi.mock("@/components/theme/provider", () => ({
useTheme: () => ({ setTheme: vi.fn(), theme: "light", toggleTheme: vi.fn() }),
}));
const { PreferencesCommandGroup } = await import("./index");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
afterEach(() => {
useCommandPaletteStore.setState({ open: false, search: "", pages: [] });
});
const renderGroup = () =>
render(
<I18nProvider i18n={i18n}>
<Command>
<PreferencesCommandGroup />
</Command>
</I18nProvider>,
);
describe("PreferencesCommandGroup", () => {
it("renders 'Change theme to...' and 'Change language to...' at the root", () => {
renderGroup();
expect(screen.getByText("Change theme to...")).toBeInTheDocument();
expect(screen.getByText("Change language to...")).toBeInTheDocument();
});
it("pushes 'theme' onto the page stack when the theme item is selected", () => {
renderGroup();
const item = screen.getByText("Change theme to...");
fireEvent.click(item);
expect(useCommandPaletteStore.getState().pages).toContain("theme");
});
it("pushes 'language' onto the page stack when the language item is selected", () => {
renderGroup();
fireEvent.click(screen.getByText("Change language to..."));
expect(useCommandPaletteStore.getState().pages).toContain("language");
});
});
@@ -0,0 +1,59 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { Command } from "@reactive-resume/ui/components/command";
import { localeMap } from "@/libs/locale";
import { useCommandPaletteStore } from "../../store";
import { LanguageCommandPage } from "./language";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
afterEach(() => {
useCommandPaletteStore.setState({ open: false, search: "", pages: [] });
});
const renderPage = () =>
render(
<I18nProvider i18n={i18n}>
<Command>
<LanguageCommandPage />
</Command>
</I18nProvider>,
);
describe("LanguageCommandPage", () => {
it("does NOT render when the page stack does not have 'language' on top", () => {
renderPage();
// localeMap codes shouldn't appear because BaseCommandGroup gating is off.
expect(screen.queryByText("en-US")).toBeNull();
});
it("renders one CommandItem for every entry in localeMap when active", () => {
useCommandPaletteStore.setState({ pages: ["language"] });
renderPage();
const expectedCount = Object.keys(localeMap).length;
expect(expectedCount).toBeGreaterThan(0);
// Each locale value is rendered in the inline font-mono span.
for (const code of Object.keys(localeMap).slice(0, 5)) {
expect(screen.getByText(code)).toBeInTheDocument();
}
});
it("includes the documented set of locales (sample check)", () => {
useCommandPaletteStore.setState({ pages: ["language"] });
renderPage();
// Spot-check a couple of common locales.
for (const code of ["en-US", "de-DE", "ja-JP"]) {
if (code in localeMap) {
expect(screen.getByText(code)).toBeInTheDocument();
}
}
});
});
@@ -0,0 +1,45 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { Command } from "@reactive-resume/ui/components/command";
import { useCommandPaletteStore } from "../../store";
vi.mock("@/components/theme/provider", () => ({
useTheme: () => ({ setTheme: vi.fn(), theme: "light", toggleTheme: vi.fn() }),
}));
const { ThemeCommandPage } = await import("./theme");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
afterEach(() => {
useCommandPaletteStore.setState({ open: false, search: "", pages: [] });
});
const renderPage = () =>
render(
<I18nProvider i18n={i18n}>
<Command>
<ThemeCommandPage />
</Command>
</I18nProvider>,
);
describe("ThemeCommandPage", () => {
it("is hidden when 'theme' is not on top of the page stack", () => {
renderPage();
expect(screen.queryByText("Light theme")).toBeNull();
});
it("renders Light + Dark options when active", () => {
useCommandPaletteStore.setState({ pages: ["theme"] });
renderPage();
expect(screen.getByText("Light theme")).toBeInTheDocument();
expect(screen.getByText("Dark theme")).toBeInTheDocument();
});
});
@@ -0,0 +1,118 @@
import { afterEach, describe, expect, it } from "vitest";
import { useCommandPaletteStore } from "./store";
const reset = () => {
useCommandPaletteStore.setState({ open: false, search: "", pages: [] });
};
afterEach(reset);
describe("useCommandPaletteStore", () => {
it("starts closed with empty search and no pages", () => {
const state = useCommandPaletteStore.getState();
expect(state.open).toBe(false);
expect(state.search).toBe("");
expect(state.pages).toEqual([]);
});
it("setOpen(true) opens the palette without touching other fields", () => {
useCommandPaletteStore.setState({ search: "foo", pages: ["page1"] });
useCommandPaletteStore.getState().setOpen(true);
const state = useCommandPaletteStore.getState();
expect(state.open).toBe(true);
expect(state.search).toBe("foo");
expect(state.pages).toEqual(["page1"]);
});
it("setOpen(false) resets the entire store back to initial state", () => {
useCommandPaletteStore.setState({ open: true, search: "foo", pages: ["page1"] });
useCommandPaletteStore.getState().setOpen(false);
const state = useCommandPaletteStore.getState();
expect(state.open).toBe(false);
expect(state.search).toBe("");
expect(state.pages).toEqual([]);
});
it("setSearch updates only the search field", () => {
useCommandPaletteStore.getState().setSearch("query");
expect(useCommandPaletteStore.getState().search).toBe("query");
});
it("pushPage appends a page and clears search", () => {
useCommandPaletteStore.setState({ search: "leftover" });
useCommandPaletteStore.getState().pushPage("settings");
const state = useCommandPaletteStore.getState();
expect(state.pages).toEqual(["settings"]);
expect(state.search).toBe("");
});
it("pushPage stacks multiple pages in order", () => {
const { pushPage } = useCommandPaletteStore.getState();
pushPage("a");
pushPage("b");
pushPage("c");
expect(useCommandPaletteStore.getState().pages).toEqual(["a", "b", "c"]);
});
it("peekPage returns the top page (or undefined when empty)", () => {
expect(useCommandPaletteStore.getState().peekPage()).toBeUndefined();
useCommandPaletteStore.setState({ pages: ["a", "b"] });
expect(useCommandPaletteStore.getState().peekPage()).toBe("b");
});
it("popPage removes the last page and clears search", () => {
useCommandPaletteStore.setState({ pages: ["a", "b"], search: "x" });
useCommandPaletteStore.getState().popPage();
const state = useCommandPaletteStore.getState();
expect(state.pages).toEqual(["a"]);
expect(state.search).toBe("");
});
it("reset clears every state field", () => {
useCommandPaletteStore.setState({ open: true, search: "x", pages: ["a"] });
useCommandPaletteStore.getState().reset();
expect(useCommandPaletteStore.getState()).toMatchObject({ open: false, search: "", pages: [] });
});
describe("goBack", () => {
it("clears search first if present, leaving pages and open untouched", () => {
useCommandPaletteStore.setState({ open: true, search: "text", pages: ["a"] });
useCommandPaletteStore.getState().goBack();
const state = useCommandPaletteStore.getState();
expect(state.search).toBe("");
expect(state.pages).toEqual(["a"]);
expect(state.open).toBe(true);
});
it("pops the top page when no search and pages exist", () => {
useCommandPaletteStore.setState({ open: true, search: "", pages: ["a", "b"] });
useCommandPaletteStore.getState().goBack();
const state = useCommandPaletteStore.getState();
expect(state.pages).toEqual(["a"]);
expect(state.open).toBe(true);
});
it("closes the palette when no search and no pages", () => {
useCommandPaletteStore.setState({ open: true, search: "", pages: [] });
useCommandPaletteStore.getState().goBack();
expect(useCommandPaletteStore.getState().open).toBe(false);
});
});
});
@@ -0,0 +1,84 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { ChipInput } from "./chip-input";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
const renderInput = (props: Partial<React.ComponentProps<typeof ChipInput>> = {}) =>
render(
<I18nProvider i18n={i18n}>
<ChipInput defaultValue={[]} onChange={vi.fn()} {...props} />
</I18nProvider>,
);
describe("ChipInput", () => {
it("renders the supplied chips as Badges", () => {
renderInput({ defaultValue: ["alpha", "beta", "gamma"] });
expect(screen.getByText("alpha")).toBeInTheDocument();
expect(screen.getByText("beta")).toBeInTheDocument();
expect(screen.getByText("gamma")).toBeInTheDocument();
});
it("adds a chip on Enter, calling onChange with the new list", () => {
const onChange = vi.fn();
renderInput({ defaultValue: ["a"], onChange });
const input = document.querySelector("input") as HTMLInputElement;
fireEvent.change(input, { target: { value: "b" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith(["a", "b"]);
});
it("adds a chip on comma keypress", () => {
const onChange = vi.fn();
renderInput({ defaultValue: [], onChange });
const input = document.querySelector("input") as HTMLInputElement;
fireEvent.change(input, { target: { value: "new-tag" } });
fireEvent.keyDown(input, { key: "," });
expect(onChange).toHaveBeenCalledWith(["new-tag"]);
});
it("does not add a duplicate chip", () => {
const onChange = vi.fn();
renderInput({ defaultValue: ["a"], onChange });
const input = document.querySelector("input") as HTMLInputElement;
fireEvent.change(input, { target: { value: "a" } });
fireEvent.keyDown(input, { key: "Enter" });
// chips set should remain ["a"]; onChange not invoked with the same array.
const callsAddingA = onChange.mock.calls.filter((args) => Array.isArray(args[0]) && args[0].length > 1);
expect(callsAddingA.length).toBe(0);
});
it("does not add an empty / whitespace-only chip", () => {
const onChange = vi.fn();
renderInput({ defaultValue: ["a"], onChange });
const input = document.querySelector("input") as HTMLInputElement;
fireEvent.change(input, { target: { value: " " } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).not.toHaveBeenCalled();
});
it("hides the description copy when hideDescription is true", () => {
const { container } = renderInput({ defaultValue: ["a"], hideDescription: true });
// We don't know the exact translated text, just confirm no <Kbd> hint banner is rendered.
expect(container.querySelector("kbd")).toBeNull();
});
it("shows the description copy by default", () => {
const { container } = renderInput({ defaultValue: ["a"] });
expect(container.querySelector("kbd")).not.toBeNull();
});
});
@@ -0,0 +1,61 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
vi.mock("@uiw/react-color-colorful", () => ({
default: () => null,
}));
const { ColorPicker } = await import("./color-picker");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
const renderPicker = (props: React.ComponentProps<typeof ColorPicker> = {}) =>
render(
<I18nProvider i18n={i18n}>
<ColorPicker {...props} />
</I18nProvider>,
);
describe("ColorPicker", () => {
it("renders a trigger swatch reflecting the current value", () => {
const { container } = renderPicker({ defaultValue: "rgba(231, 0, 11, 1)" });
const trigger = container.querySelector("[style*='background-color']") as HTMLElement | null;
expect(trigger).not.toBeNull();
// happy-dom serializes both the input rgba string and the rgb representation,
// depending on alpha; just confirm the color values surface.
const bg = trigger?.getAttribute("style") ?? "";
expect(bg).toContain("231");
expect(bg).toContain("11");
});
it("calls onChange when a preset color is clicked", () => {
const onChange = vi.fn();
renderPicker({ defaultValue: "rgba(0, 0, 0, 1)", onChange });
// Open the popover by clicking the trigger swatch.
const triggerSwatch = document.querySelector("[style*='background-color']") as HTMLElement;
fireEvent.click(triggerSwatch);
// Locate any preset button (they have aria-label='Use color rgba(...)').
const presetBtn = screen.getAllByRole("button", { name: /Use color rgba\(/ })[0];
fireEvent.click(presetBtn);
expect(onChange).toHaveBeenCalled();
expect(onChange.mock.calls[0]?.[0]).toMatch(/^rgba\(/);
});
it("renders a custom trigger when provided", () => {
renderPicker({
defaultValue: "rgba(0, 0, 0, 1)",
trigger: <button type="button">custom trigger</button>,
});
expect(screen.getByText("custom trigger")).toBeInTheDocument();
});
});
@@ -0,0 +1,66 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
const queryResult = vi.hoisted(() => ({ data: undefined as number | undefined }));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => queryResult,
}));
vi.mock("@/libs/orpc/client", () => ({
orpc: { statistics: { github: { getStarCount: { queryOptions: () => ({}) } } } },
}));
vi.mock("../animation/count-up", () => ({
CountUp: ({ to }: { to: number }) => <span data-testid="count-up">{to.toLocaleString()}</span>,
}));
const { GithubStarsButton } = await import("./github-stars-button");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
beforeEach(() => {
queryResult.data = undefined;
});
const renderButton = () =>
render(
<I18nProvider i18n={i18n}>
<GithubStarsButton />
</I18nProvider>,
);
describe("GithubStarsButton", () => {
it("renders an anchor pointing at the project repo with rel=noopener and target=_blank", () => {
renderButton();
const link = screen.getByRole("button") as HTMLAnchorElement;
expect(link.href).toBe("https://github.com/amruthpillai/reactive-resume");
expect(link.target).toBe("_blank");
expect(link.rel).toBe("noopener");
});
it("uses the no-count aria-label when star count hasn't loaded yet", () => {
renderButton();
const link = screen.getByRole("button") as HTMLAnchorElement;
expect(link.getAttribute("aria-label")).toBe("Star us on GitHub (opens in new tab)");
});
it("does not render a CountUp when star count is undefined", () => {
renderButton();
expect(screen.queryByTestId("count-up")).toBeNull();
});
it("renders a CountUp + announces the star count when available", () => {
queryResult.data = 12345;
renderButton();
expect(screen.getByTestId("count-up").textContent).toBe("12,345");
const link = screen.getByRole("button") as HTMLAnchorElement;
expect(link.getAttribute("aria-label")).toContain("12,345 stars");
});
});
@@ -0,0 +1,74 @@
// @vitest-environment happy-dom
import { render } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
// react-window's <Grid> uses ResizeObserver / IntersectionObserver heavily and
// expects measurable layouts that happy-dom can't provide. Stub it with a simple
// pass-through that renders the first row of cells via the supplied cellComponent.
vi.mock("react-window", () => ({
Grid: ({
rowCount,
columnCount,
cellComponent: CellComponent,
cellProps,
}: {
rowCount: number;
columnCount: number;
cellComponent: React.ComponentType<
{ rowIndex: number; columnIndex: number; style: React.CSSProperties } & Record<string, unknown>
>;
cellProps: Record<string, unknown>;
}) => {
const cells: React.ReactNode[] = [];
for (let r = 0; r < Math.min(rowCount, 1); r++) {
for (let c = 0; c < columnCount; c++) {
cells.push(<CellComponent key={`${r}-${c}`} rowIndex={r} columnIndex={c} style={{}} {...cellProps} />);
}
}
return <div data-testid="grid">{cells}</div>;
},
}));
const { IconPicker } = await import("./icon-picker");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
const renderPicker = (props: Partial<React.ComponentProps<typeof IconPicker>> = {}) =>
render(
<I18nProvider i18n={i18n}>
<IconPicker value="globe" onChange={vi.fn()} {...props} />
</I18nProvider>,
);
describe("IconPicker", () => {
it("renders a trigger button containing the current icon", () => {
const { container } = renderPicker();
const i = container.querySelector("i.ph") as HTMLElement;
expect(i.className).toContain("ph-globe");
});
it("changes the trigger icon when value prop changes", () => {
const { container, rerender } = renderPicker({ value: "globe" });
const before = container.querySelector("i.ph")?.className ?? "";
expect(before).toContain("ph-globe");
rerender(
<I18nProvider i18n={i18n}>
<IconPicker value="star" onChange={vi.fn()} />
</I18nProvider>,
);
const after = container.querySelector("i.ph")?.className ?? "";
expect(after).toContain("ph-star");
});
it("renders the picker button as a single icon-size button", () => {
const { container } = renderPicker();
expect(container.querySelectorAll("button").length).toBeGreaterThanOrEqual(1);
});
});
+1 -5
View File
@@ -86,11 +86,7 @@ const extensions = [
}),
TextStyle,
Color,
Highlight.configure({
HTMLAttributes: {
class: "rounded-md px-0.5 py-px",
},
}),
Highlight.configure(),
TextAlign.configure({ types: ["heading", "paragraph", "listItem"] }),
TableKit.configure(),
];
@@ -0,0 +1,85 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { URLInput } from "./url-input";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
const renderInput = (value: { url: string; label: string }, onChange = vi.fn(), hideLabelButton = false) =>
render(
<I18nProvider i18n={i18n}>
<URLInput value={value} onChange={onChange} hideLabelButton={hideLabelButton} />
</I18nProvider>,
);
describe("URLInput", () => {
it("strips the https:// prefix in the visible input value", () => {
renderInput({ url: "https://example.com/path", label: "" });
const input = screen.getByRole("textbox") as HTMLInputElement;
expect(input.value).toBe("example.com/path");
});
it("renders the raw value when no prefix is present", () => {
renderInput({ url: "no-prefix.example", label: "" });
const input = screen.getByRole("textbox") as HTMLInputElement;
expect(input.value).toBe("no-prefix.example");
});
it("adds https:// prefix on edit when not already present", () => {
const onChange = vi.fn();
renderInput({ url: "https://example.com", label: "" }, onChange);
const input = screen.getByRole("textbox") as HTMLInputElement;
fireEvent.change(input, { target: { value: "new.example" } });
expect(onChange).toHaveBeenCalledWith({
url: "https://new.example",
label: "",
});
});
it("keeps already-prefixed URLs intact on edit", () => {
const onChange = vi.fn();
renderInput({ url: "https://example.com", label: "" }, onChange);
const input = screen.getByRole("textbox") as HTMLInputElement;
fireEvent.change(input, { target: { value: "https://other.example" } });
expect(onChange).toHaveBeenCalledWith({
url: "https://other.example",
label: "",
});
});
it("emits an empty url string when cleared", () => {
const onChange = vi.fn();
renderInput({ url: "https://example.com", label: "" }, onChange);
const input = screen.getByRole("textbox") as HTMLInputElement;
fireEvent.change(input, { target: { value: "" } });
expect(onChange).toHaveBeenCalledWith({
url: "",
label: "",
});
});
it("hides the label button when hideLabelButton=true", () => {
const { container } = renderInput({ url: "https://example.com", label: "" }, vi.fn(), true);
// PopoverTrigger is rendered as a button; its absence means hideLabelButton worked.
const buttons = container.querySelectorAll("button");
expect(buttons.length).toBe(0);
});
it("renders the label button by default", () => {
const { container } = renderInput({ url: "https://example.com", label: "" });
const buttons = container.querySelectorAll("button");
expect(buttons.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,51 @@
// @vitest-environment happy-dom
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { BreakpointIndicator } from "./breakpoint-indicator";
const getWrapper = (container: HTMLElement) => container.firstChild as HTMLElement;
describe("BreakpointIndicator", () => {
it("defaults to bottom-right positioning when no position is supplied", () => {
const { container } = render(<BreakpointIndicator />);
const wrapper = getWrapper(container);
expect(wrapper.className).toContain("bottom-0");
// bottom-right path: top branch sets the "bottom-0", right path sets "inset-e-0"
expect(wrapper.className).toContain("inset-e-0");
});
it("renders all breakpoint labels (one is visible at each viewport)", () => {
const { container } = render(<BreakpointIndicator />);
const text = container.textContent ?? "";
for (const label of ["XS", "SM", "MD", "LG", "XL", "2XL", "3XL", "4XL"]) {
expect(text).toContain(label);
}
});
it("uses top-left classes for top-left position", () => {
const { container } = render(<BreakpointIndicator position="top-left" />);
const wrapper = getWrapper(container);
expect(wrapper.className).toContain("top-0");
expect(wrapper.className).toContain("inset-s-0");
});
it("uses top-right classes for top-right position", () => {
const { container } = render(<BreakpointIndicator position="top-right" />);
const wrapper = getWrapper(container);
expect(wrapper.className).toContain("top-0");
expect(wrapper.className).toContain("inset-e-0");
});
it("uses bottom-left classes for bottom-left position", () => {
const { container } = render(<BreakpointIndicator position="bottom-left" />);
const wrapper = getWrapper(container);
expect(wrapper.className).toContain("bottom-0");
expect(wrapper.className).toContain("inset-s-0");
});
it("hides the indicator from print stylesheets", () => {
const { container } = render(<BreakpointIndicator />);
expect(getWrapper(container).className).toContain("print:hidden");
});
});
@@ -0,0 +1,45 @@
// @vitest-environment happy-dom
import type { ErrorComponentProps } from "@tanstack/react-router";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { ErrorScreen } from "./error-screen";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
const renderError = (overrides: { error?: Error; reset?: () => void } = {}) => {
const props: ErrorComponentProps = {
error: overrides.error ?? new Error("boom"),
reset: overrides.reset ?? vi.fn(),
};
return render(
<I18nProvider i18n={i18n}>
<ErrorScreen {...props} />
</I18nProvider>,
);
};
describe("ErrorScreen", () => {
it("shows the error message provided", () => {
renderError({ error: new Error("Network is down") });
expect(screen.getByText("Network is down")).toBeInTheDocument();
});
it("shows the user-facing error heading", () => {
renderError();
expect(screen.getByText("An error occurred while loading the page.")).toBeInTheDocument();
});
it("calls reset when the Refresh button is clicked", () => {
const reset = vi.fn();
renderError({ reset });
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
expect(reset).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,35 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { LoadingScreen } from "./loading-screen";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
describe("LoadingScreen", () => {
it("renders a spinner and the loading text", () => {
render(
<I18nProvider i18n={i18n}>
<LoadingScreen />
</I18nProvider>,
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
it("fills the viewport (fixed inset-0)", () => {
const { container } = render(
<I18nProvider i18n={i18n}>
<LoadingScreen />
</I18nProvider>,
);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.className).toContain("fixed");
expect(wrapper.className).toContain("inset-0");
});
});
@@ -0,0 +1,47 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
// `Link` from tanstack/react-router requires a Router context. Stub it out with
// a plain anchor so we can render the screen in isolation.
vi.mock("@tanstack/react-router", () => ({
Link: ({ children, to, ...rest }: React.PropsWithChildren<{ to: string }>) => (
<a href={typeof to === "string" ? to : "#"} {...rest}>
{children}
</a>
),
}));
const { NotFoundScreen } = await import("./not-found-screen");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
const renderScreen = (routeId = "/missing") =>
render(
<I18nProvider i18n={i18n}>
<NotFoundScreen isNotFound routeId={routeId as never} />
</I18nProvider>,
);
describe("NotFoundScreen", () => {
it("renders the documented error heading", () => {
renderScreen();
expect(screen.getByText("An error occurred while loading the page.")).toBeInTheDocument();
});
it("displays the routeId that triggered the not-found", () => {
renderScreen("/dashboard/missing-page");
expect(screen.getByText("/dashboard/missing-page")).toBeInTheDocument();
});
it("renders a Go Back link", () => {
renderScreen();
const link = screen.getByRole("link", { name: /go back/i });
expect(link.getAttribute("href")).toBe("..");
});
});
@@ -0,0 +1,44 @@
// @vitest-environment happy-dom
import { render } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
// Capture the options the Combobox receives so we can introspect them.
const captured = vi.hoisted(() => ({
options: undefined as Array<{ value: string; label: string }> | undefined,
}));
vi.mock("@/components/ui/combobox", () => ({
Combobox: (props: { options: Array<{ value: string; label: string }> }) => {
captured.options = props.options;
return null;
},
}));
const { LevelTypeCombobox } = await import("./combobox");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
describe("LevelTypeCombobox", () => {
it("renders one option per level type from levelDesignSchema", () => {
render(<LevelTypeCombobox />);
expect(captured.options?.length).toBe(7);
const values = captured.options?.map((o) => o.value);
expect(values).toEqual(
expect.arrayContaining(["hidden", "circle", "square", "rectangle", "rectangle-full", "progress-bar", "icon"]),
);
});
it("produces a human-readable label for each level type", () => {
render(<LevelTypeCombobox />);
for (const opt of captured.options ?? []) {
expect(opt.label).toBeTruthy();
expect(typeof opt.label).toBe("string");
}
});
});
@@ -0,0 +1,79 @@
// @vitest-environment happy-dom
import { render } from "@testing-library/react";
import { beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { LevelDisplay } from "./display";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
describe("LevelDisplay", () => {
it("renders nothing when level is 0", () => {
const { container } = render(<LevelDisplay type="circle" icon="star" level={0} />);
expect(container.firstChild).toBeNull();
});
it("renders nothing when type is hidden", () => {
const { container } = render(<LevelDisplay type="hidden" icon="star" level={3} />);
expect(container.firstChild).toBeNull();
});
it("renders nothing when icon is empty (regardless of type)", () => {
const { container } = render(<LevelDisplay type="icon" icon="" level={3} />);
expect(container.firstChild).toBeNull();
});
it("renders 5 segments for progress-bar type", () => {
const { container } = render(<LevelDisplay type="progress-bar" icon="star" level={3} />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.children).toHaveLength(5);
});
it("marks first N segments as active for progress-bar", () => {
const { container } = render(<LevelDisplay type="progress-bar" icon="star" level={3} />);
const wrapper = container.firstChild as HTMLElement;
const activeStates = Array.from(wrapper.children).map((el) => (el as HTMLElement).dataset.active);
expect(activeStates).toEqual(["true", "true", "true", "false", "false"]);
});
it("renders icons for icon type", () => {
const { container } = render(<LevelDisplay type="icon" icon="star" level={2} />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.children).toHaveLength(5);
expect(wrapper.querySelectorAll("i").length).toBe(5);
expect(wrapper.querySelector("i")?.className).toContain("ph-star");
});
it("dims inactive icons for icon type", () => {
const { container } = render(<LevelDisplay type="icon" icon="star" level={1} />);
const wrapper = container.firstChild as HTMLElement;
const icons = wrapper.querySelectorAll("i");
expect(icons[0]?.className).not.toContain("opacity-40");
expect(icons[4]?.className).toContain("opacity-40");
});
it("renders square segments for circle/rectangle/rectangle-full types", () => {
for (const type of ["circle", "rectangle", "rectangle-full"] as const) {
const { container } = render(<LevelDisplay type={type} icon="star" level={2} />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.children).toHaveLength(5);
const activeStates = Array.from(wrapper.children).map((el) => (el as HTMLElement).dataset.active);
expect(activeStates).toEqual(["true", "true", "false", "false", "false"]);
}
});
it("includes an aria-label describing the level", () => {
const { container } = render(<LevelDisplay type="circle" icon="star" level={4} />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.getAttribute("role")).toBe("img");
expect(wrapper.getAttribute("aria-label")).toContain("4");
});
it("merges extra className into the wrapper", () => {
const { container } = render(<LevelDisplay type="circle" icon="star" level={1} className="extra" />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.className).toContain("extra");
});
});
@@ -0,0 +1,36 @@
// @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { localeMap } from "@/libs/locale";
import { getLocaleOptions } from "./combobox";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
describe("getLocaleOptions", () => {
it("returns one option per entry in localeMap", () => {
const options = getLocaleOptions();
expect(options).toHaveLength(Object.keys(localeMap).length);
});
it("uses the locale code as the value", () => {
const options = getLocaleOptions();
const values = options.map((opt) => opt.value);
expect(values).toContain("en-US");
expect(values).toContain("de-DE");
});
it("populates label and keywords with the same translated string", () => {
const options = getLocaleOptions();
const enUS = options.find((opt) => opt.value === "en-US");
expect(enUS?.label).toBeTruthy();
expect(enUS?.keywords).toEqual([enUS?.label]);
});
it("uses unique values for every option", () => {
const values = getLocaleOptions().map((opt) => opt.value);
expect(new Set(values).size).toBe(values.length);
});
});
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Locale } from "@reactive-resume/utils/locale";
import type { SingleComboboxProps } from "@/components/ui/combobox";
import type { Locale } from "@/libs/locale";
import { i18n } from "@lingui/core";
import { useLingui } from "@lingui/react";
import { Combobox } from "@/components/ui/combobox";

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