Compare commits

...
56 Commits
Author SHA1 Message Date
Amruth Pillai c288675b16 Release v5.2.9 (#3382)
* feat(ats): add ATS checker and replace resume analysis

Adds a public, browser-only ATS checker at /ats-checker and an ATS Check
section in the builder's right sidebar. PDFs are parsed locally: text
extraction, reading order, contact and date recovery, section detection,
and file-level readability are scored deterministically, with evidence
cited per finding and skipped checks reported rather than counted as
passes.

Removes the AI-scored resume analysis it supersedes: the resume_analysis
table (dropped via migration), the get_resume_analysis MCP tool, and
POST /ai/analyze-resume. The replacement, POST /ai/ats-review, reviews
extracted resume text and returns qualitative feedback with no score.

Also bumps the version to 5.2.9 and adds the changelog entry.

* chore(deps): bump workspace dependencies

* fix(ats-checker): keep negation inside each 'what this does not do' bullet

The three bullets were bare fragments whose negation came from the
section heading, which translators never see. A dozen locales rendered
them as affirmative assertions or imperatives, so the page claimed the
checker enforces a one-page rule and predicts rejection -- the opposite
of the source, and directly contradicted by the sentence beside it.

Each bullet now carries its own negation, so the polarity cannot be
lost in translation. Re-extracted and refilled across all 53 target
locales.
2026-08-27 03:37:01 +02:00
Santhi PrakashandAmruth Pillai e065a10824 fix(pdf): resolve bold text weight from the family's bold face (#3335)
* fix(pdf): resolve bold text weight from the family's bold face

Bold text (<strong>, rich-text bold, template bold styles) previously
rendered at the last stored body weight, which is ambiguous: families
are commonly stored as ["400","600"] (the typography picker's default
pairing), so bold rendered at SemiBold — nearly indistinguishable from
Regular for faces like Open Sans (#3310).

Add resolveBoldFontWeight() to the fonts package: keep a deliberate
stored bold-class choice (>= 700), else prefer the family's true Bold
face ("700"), else the heaviest >= 600 face; return null so callers
keep their existing fallback when the family has no bold-class face.

Wire it through use-register-fonts, the shared base-template-styles
builder, base-styles and the Scizor template. Default body IBM Plex
Serif ["400","500"] now renders bold at 700 (base-reset-fidelity
expectation updated accordingly).

Fixes #3310

* fix(pdf): register bold fallback faces for CJK glyph substitution

When resolveBoldFontWeight maps stored weights like ["400","600"] to the
family's 700 face, register that weight on each PDF fallback font too so
glyph-level substitution keeps bold glyphs instead of snapping to 600.

Also reorder @reactive-resume/fonts imports per Biome convention.

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-27 03:16:46 +02:00
Santhi Prakashgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>Amruth Pillaiautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
b47f805321 fix(pdf): render emoji via a Noto Emoji script fallback (#3351)
* fix(pdf): render emoji via a Noto Emoji script fallback

Emoji in resume content (flags, globe, pictographs) rendered as mojibake
in the preview and PDF export because the per-codepoint fallback chain
registered no emoji-capable font: every font in the stack lacked the
glyphs, so layout fell through to single-byte standard-font encoding —
each UTF-16 code unit truncated to its low byte (#3321).

Follows the #2986/#3190 script-fallback pattern: detect emoji content
(regional indicators unioned with Extended_Pictographic), map it to the
monochrome Noto Emoji web font (TrueType glyf outlines, PDF-embeddable),
and register it in the fallback stack for both serif and sans stacks.
Out-of-range weight requests alias to the nearest served weight (300-700)
so registration never falls back to the preview subset.

* fix(pdf): detect keycap emoji via the combining enclosing keycap

Greptile review on #3351: keycap sequences like 1\uFE0F\u20E3 carry no
regional indicator and no Extended_Pictographic codepoint, so they
bypassed the emoji detector and rendered garbled — the exact class of
bug #3321 fixes. Union U+20E3 into the detector; every valid keycap
sequence contains it.

* Update packages/utils/src/locale.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* [autofix.ci] apply automated fixes

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-27 03:15:34 +02:00
Santhi Prakash 2761bd6715 fix(fonts): register Vazirmatn in webfont catalog for JSON imports (#3331)
* fix(fonts): register Vazirmatn in webfont catalog for JSON imports

Imported resumes can set typography.fontFamily to Vazirmatn, but the
popularity-sorted Google Fonts slice omits it so PDF registration fell
back to IBM Plex Serif and Persian/Arabic glyphs stacked or tofu (#3098).

Add Vazirmatn as a locale-coverage manual entry (same pattern as Carlito)
and cover catalog resolution with unit tests.

* test(pdf): keep Vazirmatn as primary family for fa-IR registration

Prove JSON-imported Vazirmatn is handed to Font.register instead of
being rewritten to IBM Plex Serif (#3098).

* fix(fonts): address CodeRabbit review on Vazirmatn catalog

Assert getWebFontSource resolves files["400"] for Vazirmatn instead of
only matching the preview fallback, and split the font-generation log
line to satisfy the 120-column Biome limit.
2026-08-27 03:15:31 +02:00
Santhi Prakash a416d01112 fix(pdf): constrain bullet list content width within horizontal margin (#3367)
Change richListItemContent from flex: initial to flex: 1 with minWidth: 0.
This makes the content area fill remaining space after the marker and
columnGap, so text wraps within the user-set margin instead of overflowing.

Fixes #3336
2026-08-27 03:15:28 +02:00
github-actions[bot]andCrowdin Bot 7fac6f29c0 Sync Translations from Crowdin (#3381)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-26 16:25:56 +02:00
Amruth Pillai d3dddf229b Update .gitignore and AGENTS.md 2026-08-26 14:23:42 +00:00
Amruth Pillai 3c195dc3f8 Release v5.2.8 (#3375)
Upgrades to Better Auth 1.7, expands Custom Styles coverage of item headers, and adds a human-approval step to the AI agent's resume edits.

Breaking for self-hosters using a custom OAuth provider: the callback path changes from /api/auth/oauth2/callback/custom to /api/auth/callback/custom, and installs using OAUTH_DISCOVERY_URL need one additional UPDATE after upgrading. Both are documented in docs/self-hosting/sso.mdx.

- Better Auth 1.7, with the account issuer migration and the jwks alg/crv columns the 1.7 jwt plugin requires
- Agent edits gated behind an approval step, with crash-safe runs and context pruning
- item-header now covers every section header row on every template; adds the item-header-row part
- Fixes provider unlinking, auth error messages, and version conflicts on freshly created resumes
- New /auth/error page, translated across all 53 target locales
- DeepSeek Harness plugin moved into packages/dsh-plugin
- Dependency bumps across the workspace
2026-08-24 21:44:16 +02:00
github-actions[bot]andCrowdin Bot 3221afda9d Sync Translations from Crowdin (#3365)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-20 10:38:31 +02:00
Amruth Pillai 8ce899a04b feat(agent): omit resume documents from the copied conversation json 2026-08-20 09:40:57 +02:00
Amruth Pillai 39f36b4ac5 fix(resume): guard patch versions in the transaction, not in sql
Postgres defaultNow() stores microseconds while JS Dates are millisecond-truncated, so the SQL equality guard matched zero rows on freshly created resumes and every guarded agent patch failed with a permanent version conflict. The SELECT ... FOR UPDATE lock plus the in-transaction ms-precision check already provide the guarantee; drop the SQL predicate. Verified A/B against a live database.
2026-08-20 09:28:31 +02:00
Amruth Pillai 39590eaff6 fix(auth): allow unlinking providers after the session ages past a day (#3364)
Better Auth guards `/unlink-account` with `freshSessionMiddleware`, which
rejects any session whose `createdAt` is older than `freshAge` (one day by
default). Sessions here last a week and there is no re-authentication flow to
refresh that timestamp, so disconnecting a provider failed with
`SESSION_NOT_FRESH` for every user who signed in more than a day ago.

Disable the freshness gate, and teach `getReadableErrorMessage` to read plain
error objects: Better Auth client errors are `{ code, message, status }`
objects rather than `Error` instances, so every auth toast was collapsing to
its generic fallback instead of showing the real reason.
2026-08-20 08:20:18 +02:00
Amruth Pillai c8081ac2fe feat(agent): adopt AI SDK v7 — crash safety, context pruning, HITL approvals (#3362)
* docs(adr): propose agent AI SDK v7 adoption plan

* fix(ai): bind analyzeResume through aiService in service test

The test destructured analyzeResume as a named export that does not exist; main was red.

* test(agent): keep pure ai helpers real via spread-actual mock factory

* feat(agent): add run guards, patch version guard, run wall-clock timeout

* feat(agent): validate UI messages at the send boundary

* feat(agent): crash-safe draft-row persistence and server-side cancellation

* feat(agent): reap stale run claims at boot, on send, and on thread open

* feat(agent): fresh-document patch output and tiered context pruning

* feat(ai): shared agent tool contracts and message metadata schema

* feat(agent): add per-thread review-patches setting with update endpoint

* feat(agent): gate resume patches behind hmac-signed tool approval

* feat(agent): merge question answers and approval decisions before run claim

* feat(agent): approval ui with composed auto-send and fixture-driven tests

* feat(agent): usage metadata, tool activity cards, smoother streaming

* feat(agent): tool-call repair, input examples, structured step logging

* chore(i18n): translate new agent workspace strings across all locales

* fix(agent): gate stale-run draft cancellation on winning the claim clear

Snapshot streaming drafts before the conditional clear and skip the flip entirely when another reaper or a replacement run already cleared the claim. Also address review nits in eleven locale catalogs.

* fix(agent): flip reaped drafts only when their snapshotted state is unchanged

* fix(agent): address review findings across run lifecycle, context budget, and approval flow

- bind patches to the revision the model read via signed baseUpdatedAt
- claim the run before consuming a continuation; recorded-but-unexecuted approvals retry as pending continuations
- keep run ownership on stop() until cancellation persists; preserve the claim for the reaper when final persistence fails
- estimate tokens without serializing binary attachments (tokenx) and enforce the budget by dropping oldest whole turns
- mark crash-recovered patch results as snapshot boundaries; strip /data prefixes at execution time
- retry failed continuations without regenerate; mount a single AgentChat; disable response controls on read-only threads; freeze review toggle during runs (client+server)
- accumulate usage across continuations and match the SDK's nested usage shape; label-form token strings; reorderable source label; accessible note field; state-neutral web-search label

* chore(i18n): translate revised agent strings across all locales

* fix(agent): harden baseUpdatedAt validation and address review follow-ups

- bundle tokenx in the server runtime dependencies (e2e boot failure)
- strict ISO schema for baseUpdatedAt plus loud executor rejection of unparseable values
- it-IT source label consistency (Fonte)
- prove penultimate-turn retention in the context pruning test

* chore(deps): exempt tokenx from knip for the externalized server bundle
2026-08-20 08:06:53 +02:00
autofix-ci[bot] dbbab6fd76 [autofix.ci] apply automated fixes 2026-08-19 03:49:17 +00:00
Emanuele Tonello 8acde4c1ac fix(ai): provide current date to resume analysis (#3353) 2026-08-19 05:48:31 +02:00
Amruth Pillai 4d53a6d1de fix(stylesheet): apply item-header to every header row on every template (#3357)
`SectionItemHeader` only rendered its own box when a template opted into
`mainItemHeaderBorder` (only Ditgar did). Everywhere else it walked the
header children and attached the resolved `item-header` style to the first
descendant that happened to be a literal `View` or `InlineItemHeader`.

Sections whose header starts with anything else — certifications, awards,
projects, publications, references — matched nothing, so the style was
silently dropped; stacked headers such as experience matched only their
first row, so a second row went unstyled.

The header now always renders its own `Div`, so `item-header` covers the
whole header row of every section on every template. `Div` rather than
`View` keeps the base row gap the rows used to inherit from the item box,
and Ditgar keeps its tight header via `rowGap: 0` on its own
`sectionItemHeader` slot, so rendered output is unchanged apart from the
newly styled rows. `mainItemHeaderBorder` is now dead and removed.

Fixes #3349
2026-08-19 02:24:38 +02:00
Amruth Pillai ab811b5f10 Merge branch 'main' of github.com:amruthpillai/reactive-resume 2026-08-18 20:42:47 +02:00
Amruth Pillai 65618a82a0 feat/dsh plugin (#3356)
* docs: remove .superpowers

* feat(dsh-plugin): bring the DeepSeek Harness plugin into the monorepo

Moves dsh-plugin-reactive-resume out of its own repository and into
packages/dsh-plugin. It stays a published, public npm package — the only
one here — but now builds, typechecks, tests, and lints under the same
turbo tasks as everything else.

The move pays for itself in the drift guard. Standalone, the plugin kept a
generated snapshot of the tool names scraped from the live server card at
https://rxresu.me, plus a weekly CI job to notice when that snapshot went
stale. Sitting next to packages/mcp, it reads MCP_TOOL_NAME directly, so a
tool rename breaks the prompt guide on the same pull request instead of
days later. The snapshot, the fetch script, and the scheduled job are gone.

packages/mcp gains a ./tool-names export so that import goes through the
public export map rather than another workspace's src.

Also flips autoInstallPeers off. The DeepSeek Harness rc packages declare
peers that are host-supplied and, in one case
(@deepseek-ai/dsh-type-meta), not published at all, so auto-install 404s
the whole workspace. Turning it off drops only optional peers elsewhere;
@neodrag/core was the single hard peer that had been arriving implicitly,
and it is now declared where it is used. Full typecheck and test suites
pass, and pnpm peers check reports nothing new beyond the pre-existing
drizzle-orm range mismatch.

Tests move from test/ to colocated src/*.test.ts and the build output from
lib/ to dist/ to match repository conventions.

* fix(dsh-plugin): ship a bundle manifest and target the current Harness

`dsh plugin add` warned that the package "declares no dsh.bundle — installed
as a plain dependency, not a profile layer", and it was right. Every other
Harness plugin, in-box and third-party, ships a cordis.patch.yml and points
dsh.bundle.patch at it; that declaration is what joins a package to a
profile's bundle stack. Without it the package installed and then sat inert,
and the README's hand-written insert row was a workaround for the gap rather
than the intended way in.

The peer ranges were also a generation behind. They asked for
@deepseek-ai/dsh-mcp-client and dsh-system-prompt at ^0.0.1-rc.1, which
cannot match the 0.1.0-rc.6 a current harness ships, so the plugin could
never have resolved against the thing it targets. Both APIs are unchanged
across the bump — StreamableHttpConfig still takes the same six fields and
PromptSection still takes name/order/text — so this is a range correction,
not a migration.

That bump pays for itself elsewhere. The old generation peer-depended on
@deepseek-ai/dsh-type-meta, which was never published, and working around
that 404 is why merging this package turned autoInstallPeers off for the
whole repository and pulled @neodrag/core in by hand. The new generation
dropped that peer and publishes every other one, so both changes are
reverted and pnpm-workspace.yaml is back to what it was.

Because a bundle patch mounts the plugin the moment it is installed, a
required apiKey would fail config validation and take the profile down
before the user ever had a chance to mint a key. It now defaults to empty
and apply() warns and mounts nothing, matching how dsh-honcho-memory
handles the same problem.

Verified by packing the tarball and installing it into a clean project with
default pnpm settings: it resolves, imports, and reports its exports.
2026-08-18 20:42:42 +02:00
Amruth Pillai 6f0c727770 docs: remove .superpowers 2026-08-18 19:50:36 +02:00
Amruth Pillai ebcaa4729f fix(stylesheet): stop item header titles overlapping the date under nowrap (#3355) 2026-08-18 17:47:23 +02:00
Amruth Pillai f14e120b00 Update star history chart links in README 2026-08-18 04:26:28 +02:00
Amruth Pillai d9da31e7bc fix(ci): pass issue_number when labeling new issues
context.issue spreads to { owner, repo, number }, but Octokit v9 requires
issue_number. The request hit /repos/.../issues//labels and returned 404,
so no opened issue was ever labeled.
2026-08-18 04:04:21 +02:00
github-actions[bot]andCrowdin Bot 128916b9a0 [skip ci] chore(i18n): sync translations from crowdin (#3346)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-18 03:50:35 +02:00
Amruth Pillai 00be67f702 feat(stylesheet): expose the item header row to Semantic CSS (#3345)
Section item headers render the title and its trailing date inside a shared
split row styled with `flex-wrap: wrap`. When the title is long the date wraps
onto its own line and left-aligns instead of staying pinned right, which reads
as inconsistent down a list of certifications.

That row had no selector. It is a bare View, so it never reached the semantic
tree: `item-header` matches the box around the row, and the title and date are
text nodes that layout properties do not apply to. There was no stylesheet that
could reach it.

Expose it as `template-part[name="item-header-row"]`, shared by every template
because the row comes from the shared section components. Awards,
certifications, projects and publications are covered; experience, education
and volunteer stack two rows and hand their headers to the
`inline-item-header-*` parts on some templates, so they are left alone.

Readers can now write:

    @version 1;
    template-part[name="item-header-row"] { flex-wrap: nowrap; }
2026-08-18 00:29:30 +02:00
Amruth Pillai 5392728f22 chore(ui): drop the orphaned next-themes dependency
The sonner wrapper was the only consumer of next-themes; the Base UI toast
that replaced it does not use the hook. knip flagged it as unused, and CI's
`knip --fix` step removed it and then failed `pnpm check` against a lockfile
that still listed it.
2026-08-17 23:08:33 +02:00
Amruth Pillai 0b0b4ef13b chore(release): v5.2.7
Bumps the version and adds the changelog entry for the changes since v5.2.6.
2026-08-17 22:54:57 +02:00
Amruth Pillai 24c15cd8cd chore(i18n): fill missing translations
Fills the 25 strings added this cycle by the toast migration, the account page
rename, the Custom Styles status labels and the job posting auto-fill, across
all 53 target catalogs. The zu-ZA pseudo-locale is intentionally left empty.
2026-08-17 22:54:57 +02:00
Amruth Pillai 6e3853fe13 chore(i18n): extract catalogs
Picks up the strings added and removed by the toast migration, the account page
rename and the autofill change.
2026-08-17 22:32:34 +02:00
Amruth Pillai b080fcddad docs: document intent skill loading
Adds the @tanstack/intent skill lookup step to AGENTS.md so agents check for a
matching local skill before editing files.
2026-08-17 22:32:33 +02:00
Amruth Pillai 9dc2aade46 chore(deps): update dependencies
Routine version bumps across the workspace. The @react-pdf/textkit patch is
renamed to drop the pinned version so it survives the next bump.
2026-08-17 22:32:33 +02:00
Amruth Pillai e2554c9be8 chore(ui): drop the sonner dependency
Every call site now uses the Base UI toast, so the sonner wrapper and its test
go with it.
2026-08-17 22:32:33 +02:00
Amruth Pillai eedf2faf02 feat(agent): let the assistant ask clarifying questions
Adds the questionnaire and empty-state primitives and renders the
ask_user_question tool call inline in the chat, so the agent can offer choices
instead of guessing when a request is ambiguous.
2026-08-17 22:32:33 +02:00
Amruth Pillai da2f1f8244 refactor(applications): autofill from a pasted posting instead of a URL
Fetching an arbitrary job URL server side meant owning SSRF defence, redirect
and size limits, and per-site scraping quirks. The autofill tool now takes only
pasted text, so the URL input, the fetch path and its MCP annotation are gone.

The sheet gates the call behind a tested AI provider and a minimum paste length
so a stray snippet does not spend an AI call.
2026-08-17 22:32:32 +02:00
Amruth Pillai 7a14b0dfbc refactor(settings): rename the danger zone page to account
The page now holds account-level actions rather than only destructive ones, so
it is reachable at /dashboard/settings/account and presented with a neutral
icon in the sidebar and command palette.
2026-08-17 22:32:32 +02:00
Amruth Pillai 23ceee2148 refactor(web): move toast call sites to the new component
Swaps sonner's toast.success/error/loading/dismiss for the new toast.add({ type,
description }) and toast.close across dialogs, auth pages, the builder, the
dashboard and the applications views. Behaviour is unchanged.
2026-08-17 22:32:32 +02:00
Amruth Pillai 170550ed59 feat(ui): add a Base UI toast component
Adds the toast primitive that replaces sonner, along with its design-sync card
mapping. Nothing consumes it yet; the call sites move over next.
2026-08-17 22:32:31 +02:00
Amruth Pillai ac062bbcbd test: cap turbo concurrency so suites stop timing out
Turbo defaults to ten concurrent tasks and each vitest sizes its pool to the
core count, so a ten-core machine ran roughly a hundred workers and a 1.6s test
blew its 15s budget. Different suites failed on every run. At concurrency four
the whole repo passed five runs straight with no wall-clock cost.
2026-08-17 22:19:52 +02:00
Amruth Pillai bfdd29f941 test(server): generate the OpenAPI spec once per suite
Each case rebuilt the whole spec, which walks every router and resume JSON
schema. The first case already carried a raised 15s timeout and still timed out
on a loaded machine. The spec is deterministic and only read here, so build it
once: the file drops from over 15s to 1.86s.
2026-08-17 22:19:52 +02:00
Amruth Pillai e8508e6d03 test: isolate test files to stop cross-file mock leakage
Without isolation the files in a worker share one module registry, so a
vi.mock of @reactive-resume/env/server in one file leaked into another and
whichever file imported the module first won. Measured on a clean cache,
isolate: false failed four of four whole-repo runs; with isolation, none.
2026-08-17 22:19:52 +02:00
Amruth Pillai 60d0440763 test: seed the required server env vars for every suite
Units that transitively import the validated server env threw at import time
whenever no .env was present, taking out packages/auth and packages/api. Seeding
the three required variables in the shared setup fixes every current and future
caller in one place. Real values still win.
2026-08-17 22:19:52 +02:00
Amruth Pillai f4bf6887b9 test(stylesheet): complete system variables at the end of the prefix
The case passed cursor position 5 into "--resume-", which lands mid-token and
reads as a selector context, so it received the selector list. Every other case
in the file uses source.length.
2026-08-17 22:19:52 +02:00
Amruth Pillai 817d4ef971 test(stylesheet): correct the malformed declaration offset
The expected offset disagreed with its own line and column: line 2 column 17 is
offset 28, which is where `red` starts. Offset 31 pointed at `; }`. The sibling
UTF-16 case in the same file already used the correct arithmetic.

Anchors the offset to the source it must point at so it cannot drift again.
2026-08-17 22:19:52 +02:00
Amruth Pillai 7c7dbaf21d fix(agent): keep the chat composer focused while streaming
Disabling the textarea for the duration of a response made the browser blur it,
so the caret left the composer on every send and had to be clicked back. send()
already ignores calls mid-stream, so Enter stays a no-op and type-ahead works.
2026-08-17 22:19:52 +02:00
Amruth Pillai 762b999d1e fix(agent): key chat message parts by index
Every step-start part serialises to the same JSON, so the content-derived key
collided for any multi-step assistant message and React warned about duplicate
keys on each incoming chunk. Two identical text parts collided the same way.

Parts are append-only and never reordered by the AI SDK, so the index is stable.
2026-08-17 22:19:52 +02:00
Amruth Pillai 9d0dc36706 feat(seo): render social card metadata for public resumes
Public resume pages only produced their OpenGraph and Twitter tags client side,
so a shared link had no card at all. The server now injects them into the shell
and swaps in the resume's own title and description.

The lookup is scoped to public, password-free resumes and deliberately avoids
resumeService.getBySlug: that counts a view and would expose a protected
resume's summary to an unauthenticated crawler. User-authored values are escaped
before they reach the HTML, and any lookup failure falls back to the plain shell.

getResumeSocialMeta is shared with the client route head so the two cannot drift.
2026-08-17 22:19:52 +02:00
Amruth Pillai d0fa9ae8da fix(seo): shorten the meta description for mobile search results
The 131 character description overflowed the three line snippet Google renders
on mobile. The replacement is 114 characters and keeps the same claims.
2026-08-17 22:19:52 +02:00
Amruth Pillai 1e23a453a0 fix(seo): declare Twitter card tags with name attributes
X reads twitter:* meta tags from the name attribute, not property, so the card
validator reported twitter:title and twitter:description as missing. Also adds
the og:type tag the root head was never emitting.
2026-08-17 22:19:52 +02:00
Amruth Pillai 36c35c9bd5 fix(seo): serve the root request through the web app handler
The static middleware was mounted ahead of the web app fallback, and Hono's
serveStatic resolves "/" to the directory and returns dist/index.html verbatim.
handleWebApp never ran for the root route, so the OpenGraph, Twitter, canonical
and JSON-LD markup it injects was missing in production - fetching
https://rxresu.me/ as Twitterbot returned zero og: tags.

Route "/" explicitly before the static middleware so the injection runs.
2026-08-17 22:19:52 +02:00
github-actions[bot]andCrowdin Bot 0c7c3ac4c4 [skip ci] chore(i18n): sync translations from crowdin (#3330)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-17 10:20:08 +02:00
Amruth Pillai 9509b5bc2e refactor(stylesheet): move Semantic CSS to the browser (#3329) 2026-08-16 16:50:27 +02:00
github-actions[bot]andCrowdin Bot f848e57436 Sync Translations from Crowdin (#3328)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-16 12:46:45 +02:00
a4bc2693be fix(ai): bound the provider test and explain why it failed (#3319)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-16 12:45:00 +02:00
github-actions[bot]andCrowdin Bot 104e954b77 Sync Translations from Crowdin (#3327)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-16 11:57:33 +02:00
Kaushik NandClaude Opus 5 118f3679a3 fix(lefthook): run the conflict-marker check on Windows (#3320)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 11:55:30 +02:00
Amruth Pillai 6c1280dca9 chore(github): organize issue triage (#3325) 2026-08-16 10:59:02 +02:00
Amruth Pillai 8affc567e3 fix: show non-expiring API keys (#3324) 2026-08-16 10:58:55 +02:00
579 changed files with 139682 additions and 28516 deletions
-55
View File
@@ -1,55 +0,0 @@
# design-sync notes — @reactive-resume/ui
Syncs to Claude Design project **Reactive Resume** (`3c0f6556-050a-41e5-9886-c3f1ea950517`).
## Repo shape / build
- `@reactive-resume/ui` is **source-consumed** (pnpm workspace, no `dist`, exports point at `src/components/*.tsx`). Runs in the converter's **synth-entry mode** (no `--entry`).
- `buildCmd` = `node .design-sync/build-css.mjs`. That one script does three things, all required before every converter run:
1. Creates the workspace **self-symlink** `packages/ui/node_modules/@reactive-resume/ui -> ../../../ui` (pnpm doesn't self-install it; the converter resolves the DS as `node_modules/<pkg>` and esbuild needs it for `@reactive-resume/ui/components/*` self-imports).
2. Emits real **`.d.ts`** to `packages/ui/dist/types` via `tsc -p packages/ui/tsconfig.emit.json`. Without this, synth-entry mode gives weak `{[key]: unknown}` prop contracts; with it the converter's `findTypesRoot` picks up `dist/types` and every component gets real props (variant/size unions, inherited Base UI props).
3. Compiles Tailwind v4 `globals.css` → self-contained `packages/ui/.ds-compiled.css` (`cfg.cssEntry`): inlines the IBM Plex Sans latin variable woff2 as a data-URI and strips all other `@font-face` (extra scripts + the Phosphor icon web font, which previews don't use — components render Phosphor as inline React SVGs). This is why previews are fully styled with tokens + brand font and there are zero dangling font URLs.
- CSS entry scans `.design-sync/tw-entry.css` which `@import`s globals.css and adds `@source "./previews/*.tsx"` so utility classes used in authored previews are compiled. **Preview layout wrappers use inline styles** anyway (so subagents needn't recompile the shared CSS); only component-level utility classes need the recompile.
## Card scope
- The package exports **202 symbols** (39 primary components + 163 compound sub-parts). User chose **~40 primary cards**: `cfg.componentSrcMap` nulls the 163 sub-parts. All 202 stay importable from `window.RRUI` (the bundle exports everything regardless of the card list), so previews compose sub-parts (`RRUI.DialogContent`, etc.) freely.
- Multi-primary files represented by one card: `combobox.tsx`→ComboboxRoot, `form.tsx`→FormItem, `resizable.tsx`→ResizableGroup, `sonner.tsx`→Toaster.
## Preview authoring conventions (calibrated on Button / Alert / Dialog)
- Import naturally: `import { Button } from "@reactive-resume/ui/components/button"` — converter rule 2 redirects any exported-component module to `window.RRUI`, and sub-parts resolve too.
- Icons: `@phosphor-icons/react` with the `*Icon` suffix (e.g. `PlusIcon`, `TrashIcon`, `WarningIcon`). Bundles into the preview.
- Base UI compose pattern: `render={<Button variant="outline" />}` on `*.Trigger` / `*.Close` etc.
- Layout wrappers: inline `style={{ display:"flex", gap, padding }}` — not Tailwind (keeps fan-out from needing CSS recompiles).
- **Overlays** (Dialog, and expect the same for AlertDialog/Sheet/Popover/HoverCard/DropdownMenu/ContextMenu/Tooltip/Command-dialog): render open via `defaultOpen`, and set `cfg.overrides.<Name> = {cardMode:"single", primaryStory:"<export>", viewport:"WxH"}`. Use viewport width ≥ 640 so `sm:` breakpoint styles (e.g. horizontal dialog footer) engage — Dialog uses `760x440`.
- Realistic resume-app content (resumes, sections, publish/export/share), never foo/bar.
## Component composition notes (from the authoring wave)
- **Real `.d.ts` contracts require the barrel** (see build step 2 + `publishConfig.types`). Base UI prop names differ from Radix/native: Switch `defaultChecked`+`size`; Toggle `defaultPressed`+`variant`+`size`; Slider `defaultValue` array (`[n]` single / `[a,b]` range). Use uncontrolled `default*` props in previews to avoid controlled-without-onChange warnings.
- **BrandIcon renders the app's own logo/icon** (`variant="logo"|"icon"`), NOT a social/brand-slug icon. It `<img src>`s `/logo/*.svg` + `/icon/*.svg`, which the preview server (serving `ds-bundle/`) 404s. The BrandIcon preview inlines the real `apps/web/public/{logo,icon}/light.svg` as base64 `src` overrides (component spreads `{...props}` after its own `src`, so the override wins).
- **Overlays** handled by the orchestrator with `cfg.overrides` (cardMode single + primaryStory Open + viewport): Dialog, AlertDialog, Sheet, Popover, Tooltip, HoverCard, DropdownMenu, ContextMenu, ComboboxRoot. Command renders **inline** (cmdk, no overlay); Sidebar uses `collapsible="none"` to render inline (default offcanvas is fixed-positioned); Toaster fires a `duration:Infinity` toast on mount.
- **Providers composed in-preview** (no cfg.provider): Tooltip→TooltipProvider, Sidebar→SidebarProvider, MessageScroller→MessageScrollerProvider (+ explicit container height — Root is `size-full min-h-0` and collapses otherwise), FormItem carries its own context.
- **Accordion** opens statically via `defaultValue={[...itemValues]}` (the `--accordion-panel-height` warn is a non-issue — panels measure fine). **Tabs** via `defaultValue`. **ScrollArea/ResizableGroup/InputGroup** need an inline container height/width. **Separator** vertical needs an explicit height.
- Chat/attachment components (Attachment, Bubble, Message, MessageScroller, Marker) are all used only in `apps/web/src/routes/agent/-components/agent-chat.tsx` — the canonical composition source.
## Build/verify gotchas (learned the hard way)
- **A full `package-build` takes ~3-4 minutes** — not a hang. `@phosphor-icons/react` is a giant barrel, so each icon-importing preview costs ~10-20s of esbuild parse, and 30+ authored previews compile serially. Always run it in a real background task (not a 120s-capped foreground shell) and wait for completion.
- **Do NOT add a barrel `index.d.ts` + `publishConfig.types`** to get rich props for inline-param-typed components: it makes ts-morph resolve all 200+ inline Base UI param types and hangs the build for many minutes. Tried and reverted. Result: components with a named `<Name>Props` source type (Button) get real props; the rest get honest `{[key]: unknown}`.
- **Base UI menu Labels must be inside a Group**: `DropdownMenuLabel`/`ContextMenuLabel` throw `MenuGroupContext is missing` unless wrapped in `DropdownMenuGroup`/`ContextMenuGroup`. Same likely for other `*Label`/`*GroupLabel` menu parts.
- **`[RENDER_THIN]` (height 0px) is benign for fixed-position overlays** (Dialog, AlertDialog, Sheet): the content is `position:fixed` so it measures 0 in normal flow, but `rootEmpty:false` and the screenshot is correct. Confirmed via review sheets — not a failure.
- **`[GRID_OVERFLOW]` wide** → `cfg.overrides.<Name> = {cardMode:"column"}` applied to: Accordion, Attachment, Bubble, FormItem, InputGroup, Marker, Message, ResizableGroup, Tabs, Textarea. Toaster (portal escape) → `{cardMode:"single", primaryStory:"Notification"}`.
## Known render warns (triaged, not failures)
- `[TOKENS_MISSING]`: `--active-tab-{top,left,height,width}` (Base UI tab indicator sets these at runtime), `--accordion-panel-height` (Base UI accordion runtime), `--tw` (Tailwind internal), plus app-level `--resume-preview-page-gap` / `--page-primary-color` (defined by apps/web, not this package). All expected absent from the shipped stylesheet — components set them at runtime. Do not chase.
- `--font-heading` is referenced (DialogTitle `font-heading`) but not defined in the UI package tokens (app-level). Falls back to `--font-body` (IBM Plex). Cosmetic only.
- Unauthored primitives render near-empty floor cards (`[RENDER_BLANK]` for empty Button/Input/etc.) — resolved once authored.
## Re-sync risks
- `packages/ui/dist/types`, `packages/ui/.ds-compiled.css`, `packages/ui/.ds-tw-raw.css`, and the self-symlink are all gitignored build artifacts regenerated by `buildCmd` — always run `node .design-sync/build-css.mjs` before the converter/driver.
- The inlined IBM Plex font path in `build-css.mjs` is pinned to `@fontsource-variable/ibm-plex-sans/files/ibm-plex-sans-latin-wght-normal.woff2`; if that dep moves, the font inline breaks (previews fall back to system sans).
- `tsconfig.emit.json` is committed; if the package adds a real build later, prefer pointing the converter at that dist and drop the emit step.
-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/env node
// design-sync CSS build: compile the UI package's Tailwind v4 globals.css to
// static CSS, then make it self-contained for preview rendering by inlining the
// IBM Plex Sans (latin) variable webfont as a data-URI and dropping the other
// @font-face rules (extra scripts + the Phosphor icon font, which previews
// don't use — components render Phosphor as inline React SVGs).
//
// Output: packages/ui/.ds-compiled.css (cfg.cssEntry, bounded to the package)
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const repo = resolve(here, "..");
// pnpm doesn't self-install the workspace package into its own node_modules,
// but the design-sync converter resolves the DS as node_modules/<pkg>. Create
// the self-symlink so PKG_DIR resolves and esbuild finds @reactive-resume/ui/*
// self-imports. Mirrors the sibling symlinks pnpm already writes (utils, config).
const selfLink = resolve(repo, "packages/ui/node_modules/@reactive-resume/ui");
if (!existsSync(selfLink)) symlinkSync("../../../ui", selfLink);
// Emit real .d.ts declarations (the package is source-consumed with no build).
// The converter's findTypesRoot picks up dist/types, giving components real
// prop contracts (variant/size unions, inherited Base UI props) instead of the
// weak `{[key]: unknown}` synth-entry fallback.
execFileSync(resolve(repo, "node_modules/.bin/tsc"), ["-p", "tsconfig.emit.json"], {
cwd: resolve(repo, "packages/ui"),
stdio: "inherit",
});
// NOTE: a barrel index.d.ts + publishConfig.types was tried to give the prop
// extractor an entry for components with inline param types — but resolving all
// 200+ inline Base UI param types through ts-morph's checker hangs the build
// (many minutes). Reverted. Components with a named <Name>Props source type
// (e.g. Button) still extract real props from dist/types; the rest fall back to
// the honest `{[key]: unknown}` contract, with usage carried by the preview +
// .prompt.md. See .design-sync/NOTES.md "Re-sync risks".
const cli = resolve(repo, ".ds-sync/node_modules/.bin/tailwindcss");
const entry = resolve(here, "tw-entry.css");
const tmp = resolve(repo, "packages/ui/.ds-tw-raw.css");
const out = resolve(repo, "packages/ui/.ds-compiled.css");
const font = resolve(
repo,
"packages/ui/node_modules/@fontsource-variable/ibm-plex-sans/files/ibm-plex-sans-latin-wght-normal.woff2",
);
execFileSync(cli, ["-i", entry, "-o", tmp], { stdio: "inherit" });
let css = readFileSync(tmp, "utf8");
css = css.replace(/@font-face\s*\{[^}]*\}/g, ""); // drop all shipped @font-face
const b64 = readFileSync(font).toString("base64");
const face = `@font-face{font-family:"IBM Plex Sans Variable";font-style:normal;font-weight:100 700;font-display:swap;src:url(data:font/woff2;base64,${b64}) format("woff2-variations")}\n`;
writeFileSync(out, face + css);
console.error(` build-css: wrote ${out} (${(Buffer.byteLength(face + css) / 1024).toFixed(0)} KB, font inlined)`);
-256
View File
@@ -1,256 +0,0 @@
{
"projectId": "3c0f6556-050a-41e5-9886-c3f1ea950517",
"pkg": "@reactive-resume/ui",
"globalName": "RRUI",
"shape": "package",
"buildCmd": "node .design-sync/build-css.mjs",
"tsconfig": "tsconfig.json",
"cssEntry": ".ds-compiled.css",
"componentSrcMap": {
"AccordionContent": null,
"AccordionItem": null,
"AccordionTrigger": null,
"AlertAction": null,
"AlertDescription": null,
"AlertDialogAction": null,
"AlertDialogCancel": null,
"AlertDialogContent": null,
"AlertDialogDescription": null,
"AlertDialogFooter": null,
"AlertDialogHeader": null,
"AlertDialogMedia": null,
"AlertDialogOverlay": null,
"AlertDialogPortal": null,
"AlertDialogTitle": null,
"AlertDialogTrigger": null,
"AlertTitle": null,
"AttachmentAction": null,
"AttachmentActions": null,
"AttachmentContent": null,
"AttachmentDescription": null,
"AttachmentGroup": null,
"AttachmentMedia": null,
"AttachmentTitle": null,
"AttachmentTrigger": null,
"AvatarBadge": null,
"AvatarFallback": null,
"AvatarGroup": null,
"AvatarGroupCount": null,
"AvatarImage": null,
"BubbleContent": null,
"BubbleGroup": null,
"BubbleReactions": null,
"ButtonGroupSeparator": null,
"ButtonGroupText": null,
"ComboboxChip": null,
"ComboboxChips": null,
"ComboboxChipsInput": null,
"ComboboxClear": null,
"ComboboxCollection": null,
"ComboboxContent": null,
"ComboboxEmpty": null,
"ComboboxGroup": null,
"ComboboxInput": null,
"ComboboxItem": null,
"ComboboxLabel": null,
"ComboboxList": null,
"ComboboxSeparator": null,
"ComboboxTrigger": null,
"ComboboxValue": null,
"CommandDialog": null,
"CommandEmpty": null,
"CommandGroup": null,
"CommandInput": null,
"CommandItem": null,
"CommandList": null,
"CommandSeparator": null,
"CommandShortcut": null,
"ContextMenuCheckboxItem": null,
"ContextMenuContent": null,
"ContextMenuGroup": null,
"ContextMenuItem": null,
"ContextMenuLabel": null,
"ContextMenuPortal": null,
"ContextMenuRadioGroup": null,
"ContextMenuRadioItem": null,
"ContextMenuSeparator": null,
"ContextMenuShortcut": null,
"ContextMenuSub": null,
"ContextMenuSubContent": null,
"ContextMenuSubTrigger": null,
"ContextMenuTrigger": null,
"DialogClose": null,
"DialogContent": null,
"DialogDescription": null,
"DialogFooter": null,
"DialogHeader": null,
"DialogOverlay": null,
"DialogPortal": null,
"DialogTitle": null,
"DialogTrigger": null,
"DropdownMenuCheckboxItem": null,
"DropdownMenuContent": null,
"DropdownMenuGroup": null,
"DropdownMenuItem": null,
"DropdownMenuLabel": null,
"DropdownMenuPortal": null,
"DropdownMenuRadioGroup": null,
"DropdownMenuRadioItem": null,
"DropdownMenuSeparator": null,
"DropdownMenuShortcut": null,
"DropdownMenuSub": null,
"DropdownMenuSubContent": null,
"DropdownMenuSubTrigger": null,
"DropdownMenuTrigger": null,
"FormControl": null,
"FormDescription": null,
"FormLabel": null,
"FormMessage": null,
"HoverCardContent": null,
"HoverCardTrigger": null,
"InputGroupAddon": null,
"InputGroupButton": null,
"InputGroupInput": null,
"InputGroupText": null,
"InputGroupTextarea": null,
"KbdGroup": null,
"MarkerContent": null,
"MarkerIcon": null,
"MessageAvatar": null,
"MessageContent": null,
"MessageFooter": null,
"MessageGroup": null,
"MessageHeader": null,
"MessageScrollerButton": null,
"MessageScrollerContent": null,
"MessageScrollerItem": null,
"MessageScrollerProvider": null,
"MessageScrollerViewport": null,
"PopoverContent": null,
"PopoverDescription": null,
"PopoverHeader": null,
"PopoverTitle": null,
"PopoverTrigger": null,
"ResizablePanel": null,
"ResizableSeparator": null,
"ScrollBar": null,
"SheetClose": null,
"SheetContent": null,
"SheetDescription": null,
"SheetFooter": null,
"SheetHeader": null,
"SheetTitle": null,
"SheetTrigger": null,
"SidebarContent": null,
"SidebarFooter": null,
"SidebarGroup": null,
"SidebarGroupAction": null,
"SidebarGroupContent": null,
"SidebarGroupLabel": null,
"SidebarHeader": null,
"SidebarInput": null,
"SidebarInset": null,
"SidebarMenu": null,
"SidebarMenuAction": null,
"SidebarMenuBadge": null,
"SidebarMenuButton": null,
"SidebarMenuItem": null,
"SidebarMenuSkeleton": null,
"SidebarMenuSub": null,
"SidebarMenuSubButton": null,
"SidebarMenuSubItem": null,
"SidebarProvider": null,
"SidebarRail": null,
"SidebarSeparator": null,
"SidebarTrigger": null,
"TabsContent": null,
"TabsIndicator": null,
"TabsList": null,
"TabsTrigger": null,
"TooltipContent": null,
"TooltipProvider": null,
"TooltipTrigger": null
},
"overrides": {
"Dialog": {
"cardMode": "single",
"primaryStory": "Open",
"viewport": "760x440"
},
"AlertDialog": {
"cardMode": "single",
"primaryStory": "Open",
"viewport": "640x460"
},
"Sheet": {
"cardMode": "single",
"primaryStory": "Open",
"viewport": "760x480"
},
"Popover": {
"cardMode": "single",
"primaryStory": "Open",
"viewport": "420x340"
},
"Tooltip": {
"cardMode": "single",
"primaryStory": "Open",
"viewport": "360x260"
},
"HoverCard": {
"cardMode": "single",
"primaryStory": "Open",
"viewport": "440x320"
},
"DropdownMenu": {
"cardMode": "single",
"primaryStory": "Open",
"viewport": "440x360"
},
"ContextMenu": {
"cardMode": "single",
"primaryStory": "Open",
"viewport": "440x340"
},
"ComboboxRoot": {
"cardMode": "single",
"primaryStory": "Open",
"viewport": "420x360"
},
"Accordion": {
"cardMode": "column"
},
"Attachment": {
"cardMode": "column"
},
"Bubble": {
"cardMode": "column"
},
"FormItem": {
"cardMode": "column"
},
"InputGroup": {
"cardMode": "column"
},
"Marker": {
"cardMode": "column"
},
"Message": {
"cardMode": "column"
},
"ResizableGroup": {
"cardMode": "column"
},
"Tabs": {
"cardMode": "column"
},
"Textarea": {
"cardMode": "column"
},
"Toaster": {
"cardMode": "single",
"primaryStory": "Notification"
}
},
"readmeHeader": ".design-sync/conventions.md"
}
-60
View File
@@ -1,60 +0,0 @@
# Reactive Resume UI — how to build with it
This is `@reactive-resume/ui`: a shadcn-style React component library built on **Base UI**
primitives and **Tailwind CSS v4**. Every component is real upstream code, bundled to the
`window.RRUI` global; the 39 cards are the primary components, but all their compound
sub-parts (e.g. `DialogContent`, `AccordionItem`, `SidebarMenuButton`) are also on `RRUI`.
## Setup & wrapping
- **No global provider is required.** All design tokens live on `:root` in `styles.css` (loaded
for you), so components are styled out of the box. For dark mode, add `class="dark"` to a
wrapping element — the same tokens flip to their dark values.
- **A few components need their own provider — wrap only where you use them:**
- `Tooltip*` → wrap in `RRUI.TooltipProvider`.
- `Sidebar*` → wrap in `RRUI.SidebarProvider`.
- `MessageScroller*` → wrap in `RRUI.MessageScrollerProvider` and give it a bounded height.
- Form fields → `RRUI.FormItem` provides the field context for `FormLabel`/`FormControl`/`FormMessage`.
- **Compose compound components** from their parts, e.g. `Dialog` = `DialogTrigger` + `DialogContent`
(+ `DialogHeader`/`DialogTitle`/`DialogDescription`/`DialogFooter`). Overlay parts (Dialog, Sheet,
Popover, DropdownMenu, ContextMenu, Tooltip, HoverCard) render into a portal. Menu labels must sit
inside a `*Group` (`DropdownMenuGroup`, `ContextMenuGroup`).
- Icons come from `@phosphor-icons/react` (the `*Icon` suffix, e.g. `PlusIcon`).
## Styling idiom — Tailwind utilities on semantic tokens
Components style themselves; for **your own** layout and surfaces, use Tailwind utility classes
bound to the design system's **semantic color tokens** (never raw hex — these adapt to light/dark):
| Purpose | Utilities |
|---|---|
| Surfaces | `bg-background`, `bg-card`, `bg-popover`, `bg-muted`, `bg-sidebar` |
| Brand / actions | `bg-primary` + `text-primary-foreground`, `bg-secondary` + `text-secondary-foreground` |
| Accents / hover | `bg-accent` + `text-accent-foreground`, `hover:bg-muted` |
| Danger | `bg-destructive`, `text-destructive` |
| Text | `text-foreground` (primary), `text-muted-foreground` (secondary) |
| Borders / focus | `border`, `border-input`, `ring-ring`, `outline-ring` |
| Radius | `rounded-md`, `rounded-lg` (driven by `--radius`) |
Each token is also a CSS variable (`var(--primary)`, `var(--muted-foreground)`, `var(--border)`,
`var(--radius)`, `--font-body` = IBM Plex Sans) if you need it in inline styles.
## Where the truth lives
- **Styling:** `styles.css` and its `@import` closure (`_ds_bundle.css` = component styles; the
token definitions on `:root`/`.dark`). Read these before inventing a class or color.
- **Per component:** `components/<group>/<Name>/<Name>.prompt.md` (usage) and `<Name>.d.ts` (props —
variant/size unions where a named type exists; some fall back to a permissive shape).
## Idiomatic snippet
```jsx
// A confirm action, styled with the DS's own tokens for the surrounding layout.
<div className="flex flex-col gap-3 rounded-lg border bg-card p-4">
<p className="text-sm text-muted-foreground">Publish this resume to your public profile?</p>
<div className="flex justify-end gap-2">
<RRUI.Button variant="outline">Cancel</RRUI.Button>
<RRUI.Button>Publish</RRUI.Button>
</div>
</div>
```
-52
View File
@@ -1,52 +0,0 @@
import type * as React from "react";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
const wrap: React.CSSProperties = { width: 420, padding: 16 };
// Open by default so the panel content is visible in the card (Base UI accordion
// is uncontrolled via defaultValue, matching item `value` props).
export const Sections = () => (
<div style={wrap}>
<Accordion defaultValue={["experience"]}>
<AccordionItem value="experience">
<AccordionTrigger>Work Experience</AccordionTrigger>
<AccordionContent>
<p>Senior Product Designer · Framer — 2021 to Present</p>
<p>Led the redesign of the onboarding flow, lifting activation by 24% across web and mobile.</p>
</AccordionContent>
</AccordionItem>
<AccordionItem value="education">
<AccordionTrigger>Education</AccordionTrigger>
<AccordionContent>
<p>B.Des in Interaction Design · Rhode Island School of Design</p>
</AccordionContent>
</AccordionItem>
<AccordionItem value="skills">
<AccordionTrigger>Skills</AccordionTrigger>
<AccordionContent>
<p>Figma, prototyping, design systems, user research, and front-end handoff.</p>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
export const MultipleOpen = () => (
<div style={wrap}>
<Accordion multiple defaultValue={["summary", "certifications"]}>
<AccordionItem value="summary">
<AccordionTrigger>Professional Summary</AccordionTrigger>
<AccordionContent>
<p>Full-stack engineer with eight years shipping resilient TypeScript services and design systems.</p>
</AccordionContent>
</AccordionItem>
<AccordionItem value="certifications">
<AccordionTrigger>Certifications</AccordionTrigger>
<AccordionContent>
<p>AWS Solutions Architect · Professional</p>
<p>Certified Kubernetes Administrator</p>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
-38
View File
@@ -1,38 +0,0 @@
import type * as React from "react";
import { InfoIcon, WarningIcon } from "@phosphor-icons/react";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
import { Button } from "@reactive-resume/ui/components/button";
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 16, padding: 16, maxWidth: 540 };
export const Default = () => (
<div style={wrap}>
<Alert>
<InfoIcon />
<AlertTitle>Resume saved</AlertTitle>
<AlertDescription>Your changes were saved automatically and synced to your account.</AlertDescription>
</Alert>
</div>
);
export const Destructive = () => (
<div style={wrap}>
<Alert variant="destructive">
<WarningIcon />
<AlertTitle>Export failed</AlertTitle>
<AlertDescription>We couldn't generate your PDF. Check your connection and try again.</AlertDescription>
</Alert>
</div>
);
export const WithAction = () => (
<div style={wrap}>
<Alert>
<AlertTitle>Unsaved changes</AlertTitle>
<AlertDescription>You have edits that haven't been published to your public resume yet.</AlertDescription>
<AlertAction>
<Button size="sm">Publish</Button>
</AlertAction>
</Alert>
</div>
);
-34
View File
@@ -1,34 +0,0 @@
import { WarningIcon } from "@phosphor-icons/react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@reactive-resume/ui/components/alert-dialog";
// Overlay — rendered open (defaultOpen). cfg.overrides.AlertDialog pins
// cardMode: single + viewport (content is fixed-positioned, centred).
export const Open = () => (
<AlertDialog defaultOpen>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia>
<WarningIcon />
</AlertDialogMedia>
<AlertDialogTitle>Delete this resume?</AlertDialogTitle>
<AlertDialogDescription>
“Software Engineer” and its entire version history will be permanently removed. This action can’t be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive">Delete resume</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
-84
View File
@@ -1,84 +0,0 @@
import type * as React from "react";
import { DownloadSimpleIcon, FileDocIcon, FilePdfIcon, TrashIcon, WarningIcon } from "@phosphor-icons/react";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
} from "@reactive-resume/ui/components/attachment";
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 12, padding: 16, width: 360 };
export const WithActions = () => (
<div style={wrap}>
<Attachment>
<AttachmentMedia>
<FilePdfIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>Ansel_Bradford_Resume.pdf</AttachmentTitle>
<AttachmentDescription>248 KB · PDF</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Download">
<DownloadSimpleIcon />
</AttachmentAction>
<AttachmentAction aria-label="Remove">
<TrashIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
</div>
);
export const States = () => (
<div style={wrap}>
<Attachment size="sm" state="uploading">
<AttachmentMedia>
<FileDocIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>cover-letter.docx</AttachmentTitle>
<AttachmentDescription>Uploading…</AttachmentDescription>
</AttachmentContent>
</Attachment>
<Attachment size="sm" state="error">
<AttachmentMedia>
<WarningIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>portfolio-2024.zip</AttachmentTitle>
<AttachmentDescription>Upload failed · file too large</AttachmentDescription>
</AttachmentContent>
</Attachment>
</div>
);
export const Group = () => (
<div style={{ padding: 16, width: 360 }}>
<AttachmentGroup>
<Attachment orientation="vertical" size="sm">
<AttachmentMedia>
<FilePdfIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>Resume.pdf</AttachmentTitle>
<AttachmentDescription>248 KB</AttachmentDescription>
</AttachmentContent>
</Attachment>
<Attachment orientation="vertical" size="sm">
<AttachmentMedia>
<FileDocIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>cover-letter.docx</AttachmentTitle>
<AttachmentDescription>19 KB</AttachmentDescription>
</AttachmentContent>
</Attachment>
</AttachmentGroup>
</div>
);
-71
View File
@@ -1,71 +0,0 @@
import type * as React from "react";
import { CheckIcon } from "@phosphor-icons/react";
import {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
} from "@reactive-resume/ui/components/avatar";
const row: React.CSSProperties = { display: "flex", alignItems: "center", gap: 16, padding: 20 };
export const Fallback = () => (
<div style={row}>
<Avatar>
<AvatarFallback>AP</AvatarFallback>
</Avatar>
<Avatar>
<AvatarFallback>JD</AvatarFallback>
</Avatar>
<Avatar>
<AvatarFallback>MK</AvatarFallback>
</Avatar>
</div>
);
export const WithStatus = () => (
<div style={row}>
<Avatar>
<AvatarFallback>AP</AvatarFallback>
<AvatarBadge>
<CheckIcon weight="bold" />
</AvatarBadge>
</Avatar>
<Avatar size="lg">
<AvatarFallback>SR</AvatarFallback>
<AvatarBadge />
</Avatar>
</div>
);
export const Sizes = () => (
<div style={row}>
<Avatar size="sm">
<AvatarFallback>AP</AvatarFallback>
</Avatar>
<Avatar size="default">
<AvatarFallback>AP</AvatarFallback>
</Avatar>
<Avatar size="lg">
<AvatarFallback>AP</AvatarFallback>
</Avatar>
</div>
);
export const Group = () => (
<div style={row}>
<AvatarGroup>
<Avatar>
<AvatarFallback>AP</AvatarFallback>
</Avatar>
<Avatar>
<AvatarFallback>JD</AvatarFallback>
</Avatar>
<Avatar>
<AvatarFallback>MK</AvatarFallback>
</Avatar>
<AvatarGroupCount>+5</AvatarGroupCount>
</AvatarGroup>
</div>
);
-40
View File
@@ -1,40 +0,0 @@
import type * as React from "react";
import { CheckCircleIcon, PencilSimpleIcon, SparkleIcon } from "@phosphor-icons/react";
import { Badge } from "@reactive-resume/ui/components/badge";
const row: React.CSSProperties = { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 10, padding: 20 };
export const Variants = () => (
<div style={row}>
<Badge>Default</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="destructive">Destructive</Badge>
<Badge variant="outline">Outline</Badge>
</div>
);
export const StatusLabels = () => (
<div style={row}>
<Badge variant="secondary">
<CheckCircleIcon weight="fill" data-icon="inline-start" />
Published
</Badge>
<Badge variant="outline">
<PencilSimpleIcon data-icon="inline-start" />
Draft
</Badge>
<Badge>
<SparkleIcon weight="fill" data-icon="inline-start" />
Pro
</Badge>
<Badge variant="destructive">Expired</Badge>
</div>
);
export const Counts = () => (
<div style={row}>
<Badge>12</Badge>
<Badge variant="secondary">New</Badge>
<Badge variant="outline">v5.2</Badge>
</div>
);
File diff suppressed because one or more lines are too long
-48
View File
@@ -1,48 +0,0 @@
import type * as React from "react";
import { Bubble, BubbleContent, BubbleGroup, BubbleReactions } from "@reactive-resume/ui/components/bubble";
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 8, padding: 16, width: 420 };
export const Conversation = () => (
<div style={wrap}>
<BubbleGroup>
<Bubble align="end">
<BubbleContent>Can you make my summary sound more senior without exaggerating?</BubbleContent>
</Bubble>
<Bubble variant="muted" align="start">
<BubbleContent>
I tightened it to lead with scope and outcomes. Want me to mirror that tone in your experience bullets too?
</BubbleContent>
</Bubble>
<Bubble align="end">
<BubbleContent>Yes, keep it concise.</BubbleContent>
</Bubble>
</BubbleGroup>
</div>
);
export const Variants = () => (
<div style={wrap}>
<Bubble variant="default" align="end">
<BubbleContent>Applied 3 edits to your resume.</BubbleContent>
</Bubble>
<Bubble variant="tinted" align="start">
<BubbleContent>I emphasized measurable launch outcomes in your last role.</BubbleContent>
</Bubble>
<Bubble variant="outline" align="start">
<BubbleContent>Draft saved — publish when you're ready.</BubbleContent>
</Bubble>
<Bubble variant="destructive" align="start">
<BubbleContent>Couldn't reach the AI provider. Retry?</BubbleContent>
</Bubble>
</div>
);
export const WithReactions = () => (
<div style={{ padding: 24, width: 420 }}>
<Bubble variant="secondary" align="start">
<BubbleContent>Rewrote your headline to target a Senior Product Manager role.</BubbleContent>
<BubbleReactions>👍 2</BubbleReactions>
</Bubble>
</div>
);
-51
View File
@@ -1,51 +0,0 @@
import type * as React from "react";
import { ArrowRightIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react";
import { Button } from "@reactive-resume/ui/components/button";
const row: React.CSSProperties = { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 12, padding: 16 };
export const Variants = () => (
<div style={row}>
<Button>Save changes</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Delete</Button>
<Button variant="link">Learn more</Button>
</div>
);
export const Sizes = () => (
<div style={row}>
<Button size="xs">Extra small</Button>
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
</div>
);
export const WithIcons = () => (
<div style={row}>
<Button>
<PlusIcon /> Add section
</Button>
<Button variant="outline">
Continue <ArrowRightIcon />
</Button>
<Button variant="destructive">
<TrashIcon /> Remove
</Button>
<Button size="icon" variant="outline" aria-label="Add section">
<PlusIcon />
</Button>
</div>
);
export const Disabled = () => (
<div style={row}>
<Button disabled>Saving…</Button>
<Button variant="outline" disabled>
Disabled
</Button>
</div>
);
-67
View File
@@ -1,67 +0,0 @@
import type * as React from "react";
import {
AlignCenterHorizontalIcon,
AlignLeftIcon,
AlignRightIcon,
ArrowClockwiseIcon,
ArrowCounterClockwiseIcon,
TextBIcon,
TextItalicIcon,
TextUnderlineIcon,
} from "@phosphor-icons/react";
import { Button } from "@reactive-resume/ui/components/button";
import { ButtonGroup, ButtonGroupSeparator, ButtonGroupText } from "@reactive-resume/ui/components/button-group";
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 16, padding: 16 };
export const Formatting = () => (
<div style={wrap}>
<ButtonGroup>
<Button variant="outline" size="icon" aria-label="Bold">
<TextBIcon />
</Button>
<Button variant="outline" size="icon" aria-label="Italic">
<TextItalicIcon />
</Button>
<Button variant="outline" size="icon" aria-label="Underline">
<TextUnderlineIcon />
</Button>
<ButtonGroupSeparator />
<Button variant="outline" size="icon" aria-label="Align left">
<AlignLeftIcon />
</Button>
<Button variant="outline" size="icon" aria-label="Align center">
<AlignCenterHorizontalIcon />
</Button>
<Button variant="outline" size="icon" aria-label="Align right">
<AlignRightIcon />
</Button>
</ButtonGroup>
<ButtonGroup>
<Button variant="outline">
<ArrowCounterClockwiseIcon /> Undo
</Button>
<Button variant="outline">
<ArrowClockwiseIcon /> Redo
</Button>
</ButtonGroup>
</div>
);
export const WithText = () => (
<div style={wrap}>
<ButtonGroup>
<ButtonGroupText>Zoom</ButtonGroupText>
<Button variant="outline">50%</Button>
<Button variant="outline">100%</Button>
<Button variant="outline">150%</Button>
</ButtonGroup>
<ButtonGroup orientation="vertical">
<Button variant="outline">Export PDF</Button>
<Button variant="outline">Export DOCX</Button>
<Button variant="outline">Copy link</Button>
</ButtonGroup>
</div>
);
-30
View File
@@ -1,30 +0,0 @@
import {
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxRoot,
} from "@reactive-resume/ui/components/combobox";
const skills = ["TypeScript", "React", "Node.js", "GraphQL", "PostgreSQL", "Kubernetes"];
// Base UI Combobox — items passed to Root, rendered open (defaultOpen).
// cfg.overrides.ComboboxRoot pins cardMode: single + viewport with room below.
export const Open = () => (
<div style={{ width: 320, padding: 16, paddingBottom: 200 }}>
<ComboboxRoot items={skills} defaultOpen>
<ComboboxInput placeholder="Add a skill…" />
<ComboboxContent>
<ComboboxEmpty>No skills found.</ComboboxEmpty>
<ComboboxList>
{(item: string) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</ComboboxRoot>
</div>
);
-42
View File
@@ -1,42 +0,0 @@
import { DownloadSimpleIcon, GearIcon, PlusIcon, UserIcon } from "@phosphor-icons/react";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@reactive-resume/ui/components/command";
// Command renders inline (cmdk) — a searchable command palette. No overlay.
export const Palette = () => (
<div style={{ width: 400, padding: 16 }}>
<div style={{ border: "1px solid var(--border)", borderRadius: 10, overflow: "hidden" }}>
<Command>
<CommandInput placeholder="Type a command or search…" />
<CommandList>
<CommandGroup heading="Actions">
<CommandItem>
<PlusIcon /> New resume
<CommandShortcut>⌘N</CommandShortcut>
</CommandItem>
<CommandItem>
<DownloadSimpleIcon /> Export as PDF
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Account">
<CommandItem>
<UserIcon /> Profile
</CommandItem>
<CommandItem>
<GearIcon /> Settings
<CommandShortcut>⌘,</CommandShortcut>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</div>
</div>
);
-48
View File
@@ -1,48 +0,0 @@
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from "@reactive-resume/ui/components/context-menu";
// Right-click menu — rendered open (defaultOpen) so the card shows the menu.
// cfg.overrides.ContextMenu pins cardMode: single + viewport.
export const Open = () => (
<div style={{ display: "flex", justifyContent: "center", padding: 24, paddingBottom: 160 }}>
<ContextMenu defaultOpen>
<ContextMenuTrigger>
<div
style={{
display: "grid",
placeItems: "center",
width: 240,
height: 96,
border: "1px dashed var(--border)",
borderRadius: 8,
color: "var(--muted-foreground)",
fontSize: 13,
}}
>
Right-click a resume card
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuGroup>
<ContextMenuLabel>Software Engineer</ContextMenuLabel>
<ContextMenuItem>Open</ContextMenuItem>
<ContextMenuItem>
Rename
<ContextMenuShortcut>F2</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem>Duplicate</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive">Delete</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</div>
);
-30
View File
@@ -1,30 +0,0 @@
import { Button } from "@reactive-resume/ui/components/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
// Overlay component — rendered open (defaultOpen) so the card shows the real
// dialog surface. cfg.overrides.Dialog pins cardMode: single + a viewport for
// the portal (content is fixed-positioned at the viewport centre).
export const Open = () => (
<Dialog defaultOpen>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete resume</DialogTitle>
<DialogDescription>
This permanently deletes “Software Engineer” along with its version history. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
<DialogClose render={<Button variant="destructive" />}>Delete resume</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
-41
View File
@@ -1,41 +0,0 @@
import { CopyIcon, DownloadSimpleIcon, PencilIcon, TrashIcon } from "@phosphor-icons/react";
import { Button } from "@reactive-resume/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
// Anchored menu — rendered open (defaultOpen), positioned below its trigger.
// cfg.overrides.DropdownMenu pins cardMode: single + viewport with room below.
export const Open = () => (
<div style={{ display: "flex", justifyContent: "center", paddingTop: 16, paddingBottom: 220 }}>
<DropdownMenu defaultOpen>
<DropdownMenuTrigger render={<Button variant="outline" />}>Resume actions</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuLabel>Software Engineer</DropdownMenuLabel>
<DropdownMenuItem>
<PencilIcon /> Rename
</DropdownMenuItem>
<DropdownMenuItem>
<CopyIcon /> Duplicate
<DropdownMenuShortcut>⌘D</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<DownloadSimpleIcon /> Export PDF
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<TrashIcon /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
-27
View File
@@ -1,27 +0,0 @@
import type * as React from "react";
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
// FormItem carries its own field context (id + error state) — FormLabel /
// FormControl / FormDescription / FormMessage compose under it standalone.
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 16, padding: 16, width: 340 };
export const Default = () => (
<div style={wrap}>
<FormItem>
<FormLabel>Headline</FormLabel>
<FormControl render={<Input placeholder="Senior Software Engineer" />} />
<FormDescription>Shown under your name at the top of the resume.</FormDescription>
</FormItem>
</div>
);
export const WithError = () => (
<div style={wrap}>
<FormItem hasError>
<FormLabel>Email</FormLabel>
<FormControl render={<Input defaultValue="jane@" />} />
<FormMessage errors={["Enter a valid email address."]} />
</FormItem>
</div>
);
-19
View File
@@ -1,19 +0,0 @@
import { Button } from "@reactive-resume/ui/components/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@reactive-resume/ui/components/hover-card";
// Anchored preview-card overlay — rendered open (defaultOpen).
// cfg.overrides.HoverCard pins cardMode: single + viewport with room below the trigger.
export const Open = () => (
<div style={{ display: "flex", justifyContent: "center", paddingTop: 24, paddingBottom: 180 }}>
<HoverCard defaultOpen>
<HoverCardTrigger render={<Button variant="link" />}>@jane-doe</HoverCardTrigger>
<HoverCardContent>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontWeight: 600 }}>Jane Doe</span>
<span style={{ color: "var(--muted-foreground)" }}>Senior Software Engineer · San Francisco</span>
<span style={{ color: "var(--muted-foreground)", fontSize: 12 }}>3 published resumes · joined 2023</span>
</div>
</HoverCardContent>
</HoverCard>
</div>
);
-40
View File
@@ -1,40 +0,0 @@
import type * as React from "react";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
const field: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 6, padding: 16, width: 320 };
export const Default = () => (
<div style={field}>
<Label htmlFor="full-name">Full name</Label>
<Input id="full-name" defaultValue="Ada Lovelace" />
</div>
);
export const Placeholder = () => (
<div style={field}>
<Label htmlFor="headline">Headline</Label>
<Input id="headline" placeholder="e.g. Senior Software Engineer" />
</div>
);
export const Email = () => (
<div style={field}>
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" defaultValue="ada@analyticalengine.dev" />
</div>
);
export const Invalid = () => (
<div style={field}>
<Label htmlFor="website">Website</Label>
<Input id="website" aria-invalid defaultValue="not-a-valid-url" />
</div>
);
export const Disabled = () => (
<div style={field}>
<Label htmlFor="username">Username</Label>
<Input id="username" disabled defaultValue="ada.lovelace" />
</div>
);
-55
View File
@@ -1,55 +0,0 @@
import type * as React from "react";
import { CopyIcon, GlobeIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from "@reactive-resume/ui/components/input-group";
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 16, padding: 16, width: 380 };
export const Addons = () => (
<div style={wrap}>
<InputGroup>
<InputGroupAddon>
<MagnifyingGlassIcon />
</InputGroupAddon>
<InputGroupInput placeholder="Search resumes" defaultValue="Product Designer" />
</InputGroup>
<InputGroup>
<InputGroupAddon>
<GlobeIcon />
<InputGroupText>rxresu.me/u/</InputGroupText>
</InputGroupAddon>
<InputGroupInput defaultValue="jordan-rivera" />
</InputGroup>
<InputGroup>
<InputGroupInput readOnly defaultValue="rxr_live_9f3c8a21bd47e50a" />
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-sm" aria-label="Copy API key">
<CopyIcon />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</div>
);
export const WithTextarea = () => (
<div style={wrap}>
<InputGroup>
<InputGroupTextarea
rows={3}
defaultValue="Senior product designer focused on design systems, accessibility, and shipping polished interfaces."
/>
<InputGroupAddon align="block-end">
<InputGroupText>240 characters left</InputGroupText>
<InputGroupButton style={{ marginLeft: "auto" }}>Generate with AI</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</div>
);
-68
View File
@@ -1,68 +0,0 @@
import type * as React from "react";
import { Kbd, KbdGroup } from "@reactive-resume/ui/components/kbd";
const row: React.CSSProperties = { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 12, padding: 20 };
const listRow: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 24,
fontSize: 13,
color: "var(--foreground)",
};
const col: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 10, padding: 20, minWidth: 260 };
export const Keys = () => (
<div style={row}>
<Kbd>⌘</Kbd>
<Kbd>⇧</Kbd>
<Kbd>⌥</Kbd>
<Kbd>Esc</Kbd>
<Kbd>Enter</Kbd>
<Kbd>Tab</Kbd>
</div>
);
export const Combinations = () => (
<div style={row}>
<KbdGroup>
<Kbd>⌘</Kbd>
<Kbd>K</Kbd>
</KbdGroup>
<KbdGroup>
<Kbd>⌘</Kbd>
<Kbd>S</Kbd>
</KbdGroup>
<KbdGroup>
<Kbd>⌘</Kbd>
<Kbd>⇧</Kbd>
<Kbd>P</Kbd>
</KbdGroup>
</div>
);
export const ShortcutList = () => (
<div style={col}>
<div style={listRow}>
<span>Command palette</span>
<KbdGroup>
<Kbd>⌘</Kbd>
<Kbd>K</Kbd>
</KbdGroup>
</div>
<div style={listRow}>
<span>Save resume</span>
<KbdGroup>
<Kbd>⌘</Kbd>
<Kbd>S</Kbd>
</KbdGroup>
</div>
<div style={listRow}>
<span>Undo</span>
<KbdGroup>
<Kbd>⌘</Kbd>
<Kbd>Z</Kbd>
</KbdGroup>
</div>
</div>
);
-39
View File
@@ -1,39 +0,0 @@
import type * as React from "react";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
import { Switch } from "@reactive-resume/ui/components/switch";
const field: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 6, padding: 16, width: 320 };
const row: React.CSSProperties = { display: "flex", alignItems: "center", gap: 10, padding: 16, width: 320 };
export const WithInput = () => (
<div style={field}>
<Label htmlFor="company">Company</Label>
<Input id="company" defaultValue="Analytical Engine Co." />
</div>
);
export const Required = () => (
<div style={field}>
<Label htmlFor="job-title">
Job title <span style={{ color: "var(--destructive)" }}>*</span>
</Label>
<Input id="job-title" placeholder="e.g. Lead Engineer" />
</div>
);
export const WithSwitch = () => (
<div style={row}>
<Switch id="public-resume" defaultChecked />
<Label htmlFor="public-resume">Public resume</Label>
</div>
);
export const Disabled = () => (
<div style={field}>
<Label htmlFor="locked-field" data-disabled="true" style={{ opacity: 0.5 }}>
Locked field
</Label>
<Input id="locked-field" disabled defaultValue="Read only" />
</div>
);
-39
View File
@@ -1,39 +0,0 @@
import type * as React from "react";
import { CheckCircleIcon, SparkleIcon, WarningCircleIcon } from "@phosphor-icons/react";
import { Marker, MarkerContent, MarkerIcon } from "@reactive-resume/ui/components/marker";
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 12, padding: 16, width: 360 };
export const Statuses = () => (
<div style={wrap}>
<Marker style={{ width: "fit-content", borderRadius: 8, padding: "12px 16px", background: "var(--muted)" }}>
<MarkerIcon>
<SparkleIcon />
</MarkerIcon>
<MarkerContent>Tailoring your resume…</MarkerContent>
</Marker>
<Marker style={{ width: "fit-content" }}>
<MarkerIcon>
<CheckCircleIcon />
</MarkerIcon>
<MarkerContent>Applied 4 edits to your resume</MarkerContent>
</Marker>
<Marker style={{ width: "fit-content" }}>
<MarkerIcon>
<WarningCircleIcon />
</MarkerIcon>
<MarkerContent>Couldn't reach the AI provider</MarkerContent>
</Marker>
</div>
);
export const Dividers = () => (
<div style={wrap}>
<Marker variant="separator">
<MarkerContent>Today</MarkerContent>
</Marker>
<Marker variant="border">
<MarkerContent>Conversation history</MarkerContent>
</Marker>
</div>
);
-65
View File
@@ -1,65 +0,0 @@
import type * as React from "react";
import { SparkleIcon, UserIcon } from "@phosphor-icons/react";
import { Bubble, BubbleContent } from "@reactive-resume/ui/components/bubble";
import {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageGroup,
MessageHeader,
} from "@reactive-resume/ui/components/message";
const avatar: React.CSSProperties = { display: "flex", alignItems: "center", justifyContent: "center", padding: 8 };
export const Conversation = () => (
<div style={{ display: "flex", padding: 16, width: 460 }}>
<MessageGroup style={{ width: "100%" }}>
<Message align="end">
<MessageAvatar>
<span style={avatar}>
<UserIcon />
</span>
</MessageAvatar>
<MessageContent>
<Bubble align="end">
<BubbleContent>Tailor my resume for a product manager role.</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message align="start">
<MessageAvatar>
<span style={avatar}>
<SparkleIcon />
</span>
</MessageAvatar>
<MessageContent>
<Bubble variant="muted" align="start">
<BubbleContent>
Done — I emphasized roadmap ownership and stakeholder communication in your summary.
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageGroup>
</div>
);
export const WithMeta = () => (
<div style={{ display: "flex", padding: 16, width: 460 }}>
<Message align="start">
<MessageAvatar>
<span style={avatar}>
<SparkleIcon />
</span>
</MessageAvatar>
<MessageContent>
<MessageHeader>Reactive AI</MessageHeader>
<Bubble variant="tinted" align="start">
<BubbleContent>I found 4 weak bullets and rewrote them with stronger verbs and metrics.</BubbleContent>
</Bubble>
<MessageFooter>Just now · applied 4 edits</MessageFooter>
</MessageContent>
</Message>
</div>
);
-57
View File
@@ -1,57 +0,0 @@
import type * as React from "react";
import { SparkleIcon, UserIcon } from "@phosphor-icons/react";
import { Bubble, BubbleContent } from "@reactive-resume/ui/components/bubble";
import { Message, MessageAvatar, MessageContent } from "@reactive-resume/ui/components/message";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@reactive-resume/ui/components/message-scroller";
const avatar: React.CSSProperties = { display: "flex", alignItems: "center", justifyContent: "center", padding: 8 };
const turns = [
{ role: "user", text: "Can you review my resume for a senior engineering role?" },
{ role: "assistant", text: "Sure — I'll focus on scope, impact, and leadership signals. Reading it now." },
{ role: "user", text: "Great, keep the tone concise." },
{
role: "assistant",
text: "I rewrote your summary and tightened three experience bullets with measurable outcomes.",
},
{ role: "user", text: "Perfect, publish the draft." },
{ role: "assistant", text: "Draft saved and published to your public resume. Anything else you'd like to refine?" },
];
export const Thread = () => (
<div style={{ height: 340, width: 460, padding: 12 }}>
<MessageScrollerProvider autoScroll defaultScrollPosition="end">
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent style={{ padding: 12 }}>
{turns.map((turn, index) => (
<MessageScrollerItem key={turn.text} messageId={`turn-${index}`}>
<Message align={turn.role === "user" ? "end" : "start"}>
<MessageAvatar>
<span style={avatar}>{turn.role === "user" ? <UserIcon /> : <SparkleIcon />}</span>
</MessageAvatar>
<MessageContent>
<Bubble
variant={turn.role === "user" ? "default" : "muted"}
align={turn.role === "user" ? "end" : "start"}
>
<BubbleContent>{turn.text}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</div>
);
-33
View File
@@ -1,33 +0,0 @@
import { Button } from "@reactive-resume/ui/components/button";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@reactive-resume/ui/components/popover";
// Anchored overlay — rendered open (defaultOpen), positioned below its trigger.
// cfg.overrides.Popover pins cardMode: single + viewport with room for the popup.
export const Open = () => (
<div style={{ display: "flex", justifyContent: "center", paddingTop: 24, paddingBottom: 220 }}>
<Popover defaultOpen>
<PopoverTrigger render={<Button variant="outline" />}>Share resume</PopoverTrigger>
<PopoverContent>
<PopoverHeader>
<PopoverTitle>Public link</PopoverTitle>
<PopoverDescription>Anyone with this link can view your published resume.</PopoverDescription>
</PopoverHeader>
<div style={{ display: "flex", gap: 8 }}>
<Button size="sm" variant="secondary">
Copy link
</Button>
<Button size="sm" variant="ghost">
Open
</Button>
</div>
</PopoverContent>
</Popover>
</div>
);
-57
View File
@@ -1,57 +0,0 @@
import type * as React from "react";
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
const panelStyle: React.CSSProperties = { height: "100%", padding: 16, fontSize: 14, lineHeight: 1.6 };
const label: React.CSSProperties = {
fontSize: 11,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--muted-foreground)",
marginBottom: 8,
};
export const BuilderLayout = () => (
<div style={{ height: 240, width: 460, border: "1px solid var(--border)", borderRadius: 8, overflow: "hidden" }}>
<ResizableGroup orientation="horizontal">
<ResizablePanel defaultSize={40}>
<div style={panelStyle}>
<div style={label}>Editor</div>
<div>Basics</div>
<div>Work Experience</div>
<div>Education</div>
<div>Skills</div>
</div>
</ResizablePanel>
<ResizableSeparator withHandle />
<ResizablePanel defaultSize={60}>
<div style={{ ...panelStyle, background: "var(--muted)" }}>
<div style={label}>Live Preview</div>
<div style={{ fontWeight: 600, fontSize: 16 }}>Jordan Rivera</div>
<div style={{ color: "var(--muted-foreground)" }}>Senior Product Designer</div>
</div>
</ResizablePanel>
</ResizableGroup>
</div>
);
export const VerticalSplit = () => (
<div style={{ height: 240, width: 300, border: "1px solid var(--border)", borderRadius: 8, overflow: "hidden" }}>
<ResizableGroup orientation="vertical">
<ResizablePanel defaultSize={50}>
<div style={panelStyle}>
<div style={label}>Summary</div>
<div>Eight years building design systems and shipping delightful product experiences.</div>
</div>
</ResizablePanel>
<ResizableSeparator withHandle />
<ResizablePanel defaultSize={50}>
<div style={{ ...panelStyle, background: "var(--muted)" }}>
<div style={label}>Contact</div>
<div>jordan.rivera@email.com</div>
<div>San Francisco, CA</div>
</div>
</ResizablePanel>
</ResizableGroup>
</div>
);
-39
View File
@@ -1,39 +0,0 @@
import type * as React from "react";
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
const templates = [
{ name: "Azurill", tag: "Minimal" },
{ name: "Bronzor", tag: "Classic" },
{ name: "Chikorita", tag: "Modern" },
{ name: "Ditto", tag: "Compact" },
{ name: "Gengar", tag: "Bold" },
{ name: "Glalie", tag: "Elegant" },
{ name: "Kakuna", tag: "Timeless" },
{ name: "Leafish", tag: "Creative" },
{ name: "Nosepass", tag: "Formal" },
{ name: "Onyx", tag: "Technical" },
{ name: "Pikachu", tag: "Friendly" },
{ name: "Rhyhorn", tag: "Corporate" },
];
const rowStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 14px",
borderBottom: "1px solid var(--border)",
fontSize: 14,
};
export const TemplateList = () => (
<ScrollArea style={{ height: 240, width: 320, border: "1px solid var(--border)", borderRadius: 8 }}>
<div style={{ padding: 4 }}>
{templates.map((template) => (
<div key={template.name} style={rowStyle}>
<span style={{ fontWeight: 500 }}>{template.name}</span>
<span style={{ color: "var(--muted-foreground)", fontSize: 12 }}>{template.tag}</span>
</div>
))}
</div>
</ScrollArea>
);
-45
View File
@@ -1,45 +0,0 @@
import type * as React from "react";
import { Separator } from "@reactive-resume/ui/components/separator";
const block: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 12,
padding: 20,
maxWidth: 360,
fontSize: 13,
color: "var(--foreground)",
};
const inline: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 12,
padding: 20,
fontSize: 13,
color: "var(--muted-foreground)",
};
export const Horizontal = () => (
<div style={block}>
<div>
<strong style={{ display: "block", fontSize: 14 }}>Amruth Pillai</strong>
<span style={{ color: "var(--muted-foreground)" }}>Senior Software Engineer</span>
</div>
<Separator />
<span style={{ color: "var(--muted-foreground)" }}>
Building resume tooling at Reactive Resume. Open-source enthusiast.
</span>
</div>
);
export const Vertical = () => (
<div style={inline}>
<span>Profile</span>
<Separator orientation="vertical" style={{ height: 16 }} />
<span>Experience</span>
<Separator orientation="vertical" style={{ height: 16 }} />
<span>Education</span>
<Separator orientation="vertical" style={{ height: 16 }} />
<span>Skills</span>
</div>
);
-38
View File
@@ -1,38 +0,0 @@
import { Button } from "@reactive-resume/ui/components/button";
import { Label } from "@reactive-resume/ui/components/label";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@reactive-resume/ui/components/sheet";
// Side drawer — rendered open (defaultOpen), anchored to the right edge.
// cfg.overrides.Sheet pins cardMode: single + viewport.
export const Open = () => (
<Sheet defaultOpen>
<SheetContent side="right">
<SheetHeader>
<SheetTitle>Resume settings</SheetTitle>
<SheetDescription>Control how “Software Engineer” appears when shared publicly.</SheetDescription>
</SheetHeader>
<div style={{ display: "flex", flexDirection: "column", gap: 14, padding: "0 16px" }}>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<Label>Public slug</Label>
<span style={{ fontSize: 13, color: "var(--muted-foreground)" }}>rxresume.me/jane-doe</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<Label>Visibility</Label>
<span style={{ fontSize: 13, color: "var(--muted-foreground)" }}>Anyone with the link can view</span>
</div>
</div>
<SheetFooter>
<SheetClose render={<Button variant="outline" />}>Cancel</SheetClose>
<SheetClose render={<Button />}>Save changes</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
);
-61
View File
@@ -1,61 +0,0 @@
import { FileTextIcon, GearIcon, HouseIcon, PlusIcon } from "@phosphor-icons/react";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
} from "@reactive-resume/ui/components/sidebar";
// SidebarProvider supplies context + --sidebar-width. `collapsible="none"`
// renders the sidebar inline (the default offcanvas variant is fixed-positioned
// and would escape the card).
export const Navigation = () => (
<SidebarProvider>
<div
style={{ height: 400, display: "flex", border: "1px solid var(--border)", borderRadius: 10, overflow: "hidden" }}
>
<Sidebar collapsible="none">
<SidebarHeader>
<div style={{ padding: 8, fontWeight: 600, fontSize: 14 }}>Reactive Resume</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Workspace</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton isActive>
<HouseIcon /> Dashboard
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton>
<FileTextIcon /> Resumes
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton>
<PlusIcon /> New resume
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton>
<GearIcon /> Settings
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
</div>
</SidebarProvider>
);
-41
View File
@@ -1,41 +0,0 @@
import type * as React from "react";
import { Skeleton } from "@reactive-resume/ui/components/skeleton";
const pad: React.CSSProperties = { padding: 20 };
export const TextLines = () => (
<div style={{ ...pad, display: "flex", flexDirection: "column", gap: 10, width: 320 }}>
<Skeleton style={{ height: 12, width: "70%" }} />
<Skeleton style={{ height: 12, width: "100%" }} />
<Skeleton style={{ height: 12, width: "90%" }} />
<Skeleton style={{ height: 12, width: "40%" }} />
</div>
);
export const ProfileHeader = () => (
<div style={{ ...pad, display: "flex", alignItems: "center", gap: 14 }}>
<Skeleton style={{ height: 48, width: 48, borderRadius: "9999px" }} />
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<Skeleton style={{ height: 14, width: 160 }} />
<Skeleton style={{ height: 12, width: 100 }} />
</div>
</div>
);
export const ResumeCard = () => (
<div
style={{
...pad,
display: "flex",
flexDirection: "column",
gap: 12,
width: 220,
border: "1px solid var(--border)",
borderRadius: 12,
}}
>
<Skeleton style={{ height: 140, width: "100%" }} />
<Skeleton style={{ height: 14, width: "60%" }} />
<Skeleton style={{ height: 12, width: "40%" }} />
</div>
);
-33
View File
@@ -1,33 +0,0 @@
import type * as React from "react";
import { Label } from "@reactive-resume/ui/components/label";
import { Slider } from "@reactive-resume/ui/components/slider";
const field: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 10, padding: 16, width: 320 };
export const Single = () => (
<div style={field}>
<Label>Skill level</Label>
<Slider defaultValue={[4]} min={0} max={5} step={1} />
</div>
);
export const Range = () => (
<div style={field}>
<Label>Experience (years)</Label>
<Slider defaultValue={[2, 8]} min={0} max={15} step={1} />
</div>
);
export const FontScale = () => (
<div style={field}>
<Label>Font size</Label>
<Slider defaultValue={[62]} min={0} max={100} />
</div>
);
export const Disabled = () => (
<div style={field}>
<Label style={{ opacity: 0.5 }}>Line height (locked)</Label>
<Slider defaultValue={[50]} min={0} max={100} disabled />
</div>
);
-35
View File
@@ -1,35 +0,0 @@
import type * as React from "react";
import { Spinner } from "@reactive-resume/ui/components/spinner";
const row: React.CSSProperties = { display: "flex", alignItems: "center", gap: 20, padding: 24 };
export const Sizes = () => (
<div style={row}>
<Spinner style={{ width: 16, height: 16 }} />
<Spinner style={{ width: 24, height: 24 }} />
<Spinner style={{ width: 32, height: 32 }} />
</div>
);
export const Colors = () => (
<div style={row}>
<Spinner style={{ width: 28, height: 28, color: "var(--primary)" }} />
<Spinner style={{ width: 28, height: 28, color: "var(--muted-foreground)" }} />
</div>
);
export const LoadingRow = () => (
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: 20,
fontSize: 13,
color: "var(--muted-foreground)",
}}
>
<Spinner style={{ width: 18, height: 18 }} />
<span>Generating your PDF…</span>
</div>
);
-40
View File
@@ -1,40 +0,0 @@
import type * as React from "react";
import { Label } from "@reactive-resume/ui/components/label";
import { Switch } from "@reactive-resume/ui/components/switch";
const row: React.CSSProperties = { display: "flex", alignItems: "center", gap: 10, padding: 16, width: 300 };
const stack: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 16, padding: 16, width: 300 };
export const On = () => (
<div style={row}>
<Switch id="sw-public" defaultChecked />
<Label htmlFor="sw-public">Public resume</Label>
</div>
);
export const Off = () => (
<div style={row}>
<Switch id="sw-template" />
<Label htmlFor="sw-template">Show icons in template</Label>
</div>
);
export const Small = () => (
<div style={row}>
<Switch id="sw-page-numbers" size="sm" defaultChecked />
<Label htmlFor="sw-page-numbers">Show page numbers</Label>
</div>
);
export const Disabled = () => (
<div style={stack}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<Switch id="sw-ai" disabled defaultChecked />
<Label htmlFor="sw-ai">AI suggestions</Label>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<Switch id="sw-index" disabled />
<Label htmlFor="sw-index">Index on search engines</Label>
</div>
</div>
);
-57
View File
@@ -1,57 +0,0 @@
import type * as React from "react";
import { BriefcaseIcon, GraduationCapIcon, SparkleIcon } from "@phosphor-icons/react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
const wrap: React.CSSProperties = { width: 460, padding: 16 };
const panel: React.CSSProperties = { padding: "12px 4px", lineHeight: 1.6 };
export const ResumeSections = () => (
<div style={wrap}>
<Tabs defaultValue="experience">
<TabsList>
<TabsTrigger value="experience">
<BriefcaseIcon /> Experience
</TabsTrigger>
<TabsTrigger value="education">
<GraduationCapIcon /> Education
</TabsTrigger>
<TabsTrigger value="skills">
<SparkleIcon /> Skills
</TabsTrigger>
</TabsList>
<TabsContent value="experience" style={panel}>
<strong>Staff Engineer · Vercel</strong>
<div>Owned the edge runtime rollout serving 2B requests per day.</div>
</TabsContent>
<TabsContent value="education" style={panel}>
<strong>M.S. Computer Science · Carnegie Mellon</strong>
<div>Focus on distributed systems and human-computer interaction.</div>
</TabsContent>
<TabsContent value="skills" style={panel}>
<strong>Core stack</strong>
<div>TypeScript, React, Go, PostgreSQL, and Kubernetes.</div>
</TabsContent>
</Tabs>
</div>
);
export const LineVariant = () => (
<div style={wrap}>
<Tabs defaultValue="preview">
<TabsList variant="line">
<TabsTrigger value="preview">Preview</TabsTrigger>
<TabsTrigger value="share">Share</TabsTrigger>
<TabsTrigger value="export">Export</TabsTrigger>
</TabsList>
<TabsContent value="preview" style={panel}>
Your resume renders live as you edit each section.
</TabsContent>
<TabsContent value="share" style={panel}>
Publish a public link at reactive-resume.app/u/your-name.
</TabsContent>
<TabsContent value="export" style={panel}>
Download a print-ready PDF or DOCX in one click.
</TabsContent>
</Tabs>
</div>
);
-37
View File
@@ -1,37 +0,0 @@
import type * as React from "react";
import { Label } from "@reactive-resume/ui/components/label";
import { Textarea } from "@reactive-resume/ui/components/textarea";
const field: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 6, padding: 16, width: 360 };
export const Default = () => (
<div style={field}>
<Label htmlFor="summary">Summary</Label>
<Textarea id="summary" placeholder="Write a short professional summary…" rows={4} />
</div>
);
export const Filled = () => (
<div style={field}>
<Label htmlFor="about">About</Label>
<Textarea
id="about"
rows={4}
defaultValue="Mathematician and writer, known for early work on Charles Babbage's Analytical Engine and the first published algorithm intended for a machine."
/>
</div>
);
export const Invalid = () => (
<div style={field}>
<Label htmlFor="bio">Biography</Label>
<Textarea id="bio" aria-invalid rows={3} defaultValue="Too short." />
</div>
);
export const Disabled = () => (
<div style={field}>
<Label htmlFor="notes">Internal notes</Label>
<Textarea id="notes" disabled rows={3} defaultValue="Notes are locked while this resume is published." />
</div>
);
-19
View File
@@ -1,19 +0,0 @@
import { useEffect } from "react";
import { toast } from "sonner";
import { Toaster } from "@reactive-resume/ui/components/sonner";
// Toaster is the toast host. Fire a persistent toast on mount so the card
// shows a real notification instead of an empty portal.
export const Notification = () => {
useEffect(() => {
toast.success("Resume published", {
description: "“Software Engineer” is now live at rxresume.me/jane-doe.",
duration: Number.POSITIVE_INFINITY,
});
}, []);
return (
<div style={{ minHeight: 140 }}>
<Toaster position="top-center" />
</div>
);
};
-66
View File
@@ -1,66 +0,0 @@
import type * as React from "react";
import {
TextAlignCenterIcon,
TextAlignLeftIcon,
TextAlignRightIcon,
TextBolderIcon,
TextItalicIcon,
TextUnderlineIcon,
} from "@phosphor-icons/react";
import { Toggle } from "@reactive-resume/ui/components/toggle";
const row: React.CSSProperties = { display: "flex", alignItems: "center", gap: 8, padding: 16 };
const group: React.CSSProperties = { display: "flex", alignItems: "center", gap: 2, padding: 16 };
export const States = () => (
<div style={row}>
<Toggle aria-label="Bold" defaultPressed>
<TextBolderIcon />
</Toggle>
<Toggle aria-label="Italic">
<TextItalicIcon />
</Toggle>
<Toggle aria-label="Underline" disabled>
<TextUnderlineIcon />
</Toggle>
</div>
);
export const Outline = () => (
<div style={row}>
<Toggle variant="outline" defaultPressed>
<TextBolderIcon /> Bold
</Toggle>
<Toggle variant="outline">
<TextItalicIcon /> Italic
</Toggle>
</div>
);
export const Sizes = () => (
<div style={row}>
<Toggle size="sm" aria-label="Bold small">
<TextBolderIcon />
</Toggle>
<Toggle size="default" aria-label="Bold default" defaultPressed>
<TextBolderIcon />
</Toggle>
<Toggle size="lg" aria-label="Bold large">
<TextBolderIcon />
</Toggle>
</div>
);
export const AlignmentGroup = () => (
<div style={group}>
<Toggle variant="outline" aria-label="Align left" defaultPressed>
<TextAlignLeftIcon />
</Toggle>
<Toggle variant="outline" aria-label="Align center">
<TextAlignCenterIcon />
</Toggle>
<Toggle variant="outline" aria-label="Align right">
<TextAlignRightIcon />
</Toggle>
</div>
);
-18
View File
@@ -1,18 +0,0 @@
import { InfoIcon } from "@phosphor-icons/react";
import { Button } from "@reactive-resume/ui/components/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
// Anchored overlay — needs TooltipProvider; rendered open (defaultOpen).
// cfg.overrides.Tooltip pins cardMode: single + viewport with room above the trigger.
export const Open = () => (
<TooltipProvider>
<div style={{ display: "flex", justifyContent: "center", paddingTop: 120, paddingBottom: 24 }}>
<Tooltip defaultOpen>
<TooltipTrigger render={<Button variant="outline" size="icon" aria-label="About visibility" />}>
<InfoIcon />
</TooltipTrigger>
<TooltipContent>Only you can see private resumes</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
-4
View File
@@ -1,4 +0,0 @@
/* design-sync Tailwind entry — compiles the UI package's globals.css to static CSS
for preview rendering, and also scans authored previews for used utilities. */
@import "../packages/ui/src/styles/globals.css";
@source "./previews/*.tsx";
+82 -26
View File
@@ -1,68 +1,124 @@
name: 🐞 Bug Report
description: Create a bug report to help improve Reactive Resume
description: Report a reproducible problem with Reactive Resume
title: "[Bug] <title>"
labels: [bug, v5, needs triage]
assignees: "AmruthPillai"
labels: ["bug", "status: needs triage"]
assignees: []
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered.
label: Existing issue
description: Search open and closed issues before submitting a new report.
options:
- label: Yes, I have searched the existing issues and none of them match my problem.
- label: I searched the existing issues and could not find a matching report.
required: true
- type: dropdown
id: variant
attributes:
label: Product Variant
description: What variant of Reactive Resume are you using?
label: Product variant
description: Where does the problem occur?
options:
- Cloud (https://rxresu.me)
- Self-Hosted
- Cloud
- Self-hosted
validations:
required: true
- type: input
id: version
attributes:
label: Reactive Resume version
description: Find this in Settings or provide the container image tag or commit SHA.
placeholder: 5.2.6
validations:
required: true
- type: dropdown
id: area
attributes:
label: Area
description: Choose the part of Reactive Resume most closely related to the problem.
options:
- Resume builder & data
- Templates, preview & export
- Accounts & sharing
- AI & Agent
- Language & localization
- Self-hosting
- API & integrations
- Applications & cover letters
- Other / unsure
validations:
required: true
- type: input
id: environment
attributes:
label: Environment
description: Include your operating system and browser. For self-hosted installations, also include the deployment method.
placeholder: Firefox 143 on Ubuntu 26.04, deployed with Docker Compose
validations:
required: true
- type: textarea
id: summary
attributes:
label: Describe the bug you're experiencing
description: A detailed description of what you're experiencing. Please provide as much detail as possible as it will help me diagnose and fix the issue faster.
label: Summary
description: Briefly describe the problem and its impact.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: Provide the smallest reliable sequence that demonstrates the problem.
placeholder: |
1. Open ...
2. Select ...
3. Observe ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
validations:
required: true
- type: dropdown
id: template
attributes:
label: What template are you using?
description: Leave blank if the issue applies to all templates, or is not template-specific.
multiple: false
label: Template
description: Leave blank when the problem is not template-specific.
options:
- Azurill
- Bronzor
- Chikorita
- Ditto
- Ditgar
- Ditto
- Gengar
- Glalie
- Kakuna
- Lapras
- Leafish
- Meowth
- Onyx
- Pikachu
- Rhyhorn
- Scizor
validations:
required: false
- type: textarea
id: logs
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false
label: Logs and screenshots
description: Add relevant logs, screenshots, or a minimal reproduction. Remove secrets and personal resume data first.
+69 -10
View File
@@ -1,23 +1,82 @@
name: ✨ Feature Request
description: Suggest an feature or idea that you would like to see in Reactive Resume
description: Propose an actionable improvement to Reactive Resume
title: "[Feature] <title>"
labels: [enhancement, v5, needs triage]
assignees: "AmruthPillai"
labels: ["enhancement", "status: needs triage"]
assignees: []
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this feature?
description: Please search to see if an issue already exists for the feature you requested.
label: Existing issue
description: Search open and closed issues before submitting a new proposal.
options:
- label: Yes, I have searched the existing issues and it doesn't exist.
- label: I searched the existing issues and could not find a matching proposal.
required: true
- type: textarea
- type: dropdown
id: variant
attributes:
label: Feature Description
description: A detailed description of the feature you would like to see in Reactive Resume. Please provide as much detail as possible as it will help me implement the feature faster.
label: Product variant
description: Choose the primary environment for this proposal.
options:
- Cloud
- Self-hosted
validations:
required: true
- type: dropdown
id: area
attributes:
label: Area
description: Choose the part of Reactive Resume most closely related to the proposal.
options:
- Resume builder & data
- Templates, preview & export
- Accounts & sharing
- AI & Agent
- Language & localization
- Self-hosting
- API & integrations
- Applications & cover letters
- Other / unsure
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem
description: What user problem or limitation should Reactive Resume solve?
validations:
required: true
- type: textarea
id: outcome
attributes:
label: Desired outcome
description: Describe the behavior you want without prescribing an implementation.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Describe current workarounds or alternatives. Write "None" if there are none.
validations:
required: true
- type: textarea
id: scope
attributes:
label: Proposed scope
description: Explain what should be included and what can remain out of scope.
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: Add examples, mockups, or related issues when useful. Remove personal resume data first.
+7
View File
@@ -1 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Questions and support
url: https://github.com/amruthpillai/reactive-resume/discussions/categories/q-a
about: Get help with setup, configuration, and using Reactive Resume.
- name: Security vulnerability
url: https://github.com/amruthpillai/reactive-resume/security/advisories/new
about: Report security vulnerabilities privately.
+17
View File
@@ -0,0 +1,17 @@
<!-- caveman-begin -->
Respond terse like smart caveman. All technical substance stay. Only fluff die.
Rules:
- Drop: articles (a/an/the), filler (just/really/basically), pleasantries, hedging
- Fragments OK. Short synonyms. Technical terms exact. Code unchanged.
- Pattern: [thing] [action] [reason]. [next step].
- Not: "Sure! I'd be happy to help you with that."
- Yes: "Bug in auth middleware. Fix:"
Switch level: /caveman lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra
Stop: "stop caveman" or "normal mode"
Auto-Clarity: drop caveman for security warnings, irreversible actions, user confused. Resume after.
Boundaries: code/commits/PRs written normal.
<!-- caveman-end -->
+34
View File
@@ -0,0 +1,34 @@
name: Label New Issues
on:
issues:
types: [opened]
permissions:
contents: read
issues: write
jobs:
label:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Apply Form Labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const { getIssueLabels } = await import(`${process.env.GITHUB_WORKSPACE}/tooling/issue-labels.mjs`);
const labels = getIssueLabels(context.payload.issue.body ?? "");
if (labels.length > 0) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.issue.number,
labels,
});
}
+30
View File
@@ -0,0 +1,30 @@
name: Close Issues Awaiting Information
on:
schedule:
- cron: "23 4 * * *"
workflow_dispatch:
permissions:
issues: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- name: Close Inactive Issues Awaiting Information
uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11
with:
only-issue-labels: "status: needs info"
days-before-issue-stale: 14
days-before-issue-close: 7
days-before-pr-stale: -1
days-before-pr-close: -1
stale-issue-label: stale
stale-issue-message: >-
This issue is waiting for information requested by a maintainer. It will close in 7 days if no new information is provided.
close-issue-message: >-
Closing because the requested information was not provided. Add the missing details in a comment and a maintainer can reopen the issue.
close-issue-reason: not_planned
remove-issue-stale-when-updated: true
+4 -11
View File
@@ -49,23 +49,16 @@ temp
.agents
.claude
.cursor
.codegraph
.opencode
.superpowers
.worktrees
.migration
graphify-out
# Local Storage Data
/data
/apps/web/data
# Git Hooks
.vite-hooks/
.ds-sync/
ds-bundle/
.design-sync/.cache/
.design-sync/learnings/
.design-sync/node_modules
packages/ui/.ds-compiled.css
packages/ui/.ds-tw-raw.css
packages/ui/dist/
i18n.cache
.vite-hooks
@@ -1,23 +0,0 @@
# Task 1: Harden public PDF response contract
## Implemented
- Pinned successful public PDF responses to `Content-Type: application/pdf` at the HTTP boundary.
- Kept `Cache-Control: private, no-store` hardcoded at that boundary for successful, validation-error, and service-error responses.
- Removed the unused `cacheControl` member from `createPublicResumePdf` and all affected tests/mocks.
- Updated the route regression to return a `text/plain` file from the service mock while asserting the HTTP response remains `application/pdf`.
## Verification
- `pnpm --filter server test -- src/http/public-resume-pdf.test.ts` — 13 files / 71 tests passed.
- `dotenvx run -f .env.local -- pnpm exec vitest run packages/api/src/features/resume/public-pdf.test.ts` — 1 file / 7 tests passed.
- `pnpm exec biome check apps/server/src/http/public-resume-pdf.ts apps/server/src/http/public-resume-pdf.test.ts packages/api/src/features/resume/public-pdf.ts packages/api/src/features/resume/public-pdf.test.ts` — passed.
- `pnpm --filter server typecheck` and `pnpm --filter @reactive-resume/api typecheck` — passed.
## Note
The API package test script did not scope to the supplied test path and initially ran the package suite, which has two unrelated baseline failures: `src/features/ai/url-policy.test.ts` and `src/features/resume/export.test.ts` (missing required env). The target test was then run directly with `.env.local` and passed.
## Self-review
`git diff --check` passed. The diff is restricted to the public PDF service contract, HTTP response header, and their focused tests.
+54
View File
@@ -1,5 +1,59 @@
<!-- intent-skills:start -->
## Skill Loading
Before editing files for a substantial task:
- Run `pnpm dlx @tanstack/intent@latest list` from the workspace root to see available local skills.
- If a listed skill matches the task, run `pnpm dlx @tanstack/intent@latest load <package>#<skill>` before changing files.
- Use the loaded `SKILL.md` guidance while making the change.
- Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed.
- Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns.
<!-- intent-skills:end -->
<!-- caveman-begin -->
Respond terse like smart caveman. All technical substance stay. Only fluff die.
Rules:
- Drop: articles (a/an/the), filler (just/really/basically), pleasantries, hedging
- Fragments OK. Short synonyms. Technical terms exact. Code unchanged.
- Pattern: [thing] [action] [reason]. [next step].
- Not: "Sure! I'd be happy to help you with that."
- Yes: "Bug in auth middleware. Fix:"
Switch level: /caveman lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra
Stop: "stop caveman" or "normal mode"
Auto-Clarity: drop caveman for security warnings, irreversible actions, user confused. Resume after.
Boundaries: code/commits/PRs written normal.
<!-- caveman-end -->
<!-- graphify-begin -->
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
When the user types `/graphify`, use the installed graphify skill or instructions before doing anything else.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
<!-- graphify-end -->
# AGENTS.md
## Agent skills
### Issue tracker
Issues and specs are tracked in GitHub Issues for `amruthpillai/reactive-resume`. See `docs/agents/issue-tracker.md`.
### Domain docs
This repository uses a multi-context domain-doc layout. See `docs/agents/domain.md`.
## Cursor Cloud specific instructions
### Overview
+9 -4
View File
@@ -234,7 +234,8 @@ Reactive Resume is and always will be free and open-source. If it has helped you
Other ways to support:
- Star this repository
- Report bugs and suggest features
- Report reproducible bugs and suggest actionable features
- Help other users in [GitHub Discussions](https://github.com/amruthpillai/reactive-resume/discussions/categories/q-a)
- Improve documentation
- Help with translations
@@ -242,9 +243,9 @@ Other ways to support:
<a href="https://www.star-history.com/?repos=amruthpillai%2Freactive-resume&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&theme=dark&legend=top-left&sealed_token=BF8sVMes0z5BhdkMhtFklhxeikeGUrSyW-CcY9E_RCQI5zqUHEbMRwcB075fUewbAtlNoCnDlWhDWjrDGhTcXMojsS2I0RCqcL-Y9p3Ez3H1A2QpRMthjFilP0YOCJEE9AZqRrqzlvj1uU2y5ixarXOuUXuuSw5DkLMViSMD8Ldl0H3BEgclnjWw4fI4" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&legend=top-left&sealed_token=BF8sVMes0z5BhdkMhtFklhxeikeGUrSyW-CcY9E_RCQI5zqUHEbMRwcB075fUewbAtlNoCnDlWhDWjrDGhTcXMojsS2I0RCqcL-Y9p3Ez3H1A2QpRMthjFilP0YOCJEE9AZqRrqzlvj1uU2y5ixarXOuUXuuSw5DkLMViSMD8Ldl0H3BEgclnjWw4fI4" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&legend=top-left&sealed_token=BF8sVMes0z5BhdkMhtFklhxeikeGUrSyW-CcY9E_RCQI5zqUHEbMRwcB075fUewbAtlNoCnDlWhDWjrDGhTcXMojsS2I0RCqcL-Y9p3Ez3H1A2QpRMthjFilP0YOCJEE9AZqRrqzlvj1uU2y5ixarXOuUXuuSw5DkLMViSMD8Ldl0H3BEgclnjWw4fI4" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&theme=dark&legend=top-left&sealed_token=QmaOn4Ech499R6kpQe8ONn911UjGUaJfQBT0MXlQLU9hTo-Ie7lTxIILWbBvmtzDGHk7ziWKN_N5iM5mgP8widn_FGHd9-PHNokPtSji8XLgbFpqatgyqIDPnOys-IhO40W3J0HeH07FL-Q8Bq6ArRk3LDtJDwjh4m0ya-2L59ULb7BaqxkSDuCytkCr" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&legend=top-left&sealed_token=QmaOn4Ech499R6kpQe8ONn911UjGUaJfQBT0MXlQLU9hTo-Ie7lTxIILWbBvmtzDGHk7ziWKN_N5iM5mgP8widn_FGHd9-PHNokPtSji8XLgbFpqatgyqIDPnOys-IhO40W3J0HeH07FL-Q8Bq6ArRk3LDtJDwjh4m0ya-2L59ULb7BaqxkSDuCytkCr" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&legend=top-left&sealed_token=QmaOn4Ech499R6kpQe8ONn911UjGUaJfQBT0MXlQLU9hTo-Ie7lTxIILWbBvmtzDGHk7ziWKN_N5iM5mgP8widn_FGHd9-PHNokPtSji8XLgbFpqatgyqIDPnOys-IhO40W3J0HeH07FL-Q8Bq6ArRk3LDtJDwjh4m0ya-2L59ULb7BaqxkSDuCytkCr" />
</picture>
</a>
@@ -260,6 +261,10 @@ Contributions make open-source thrive. Whether fixing a typo or adding a feature
See the [development setup guide](https://docs.rxresu.me/contributing/development) for detailed instructions on how to set up the project locally.
Maintainers review the [`status: needs triage` queue](https://github.com/amruthpillai/reactive-resume/issues?q=is%3Aissue+is%3Aopen+label%3A%22status%3A+needs+triage%22)
weekly. Triaged bugs become `status: confirmed`; feature proposals become `status: accepted`; reports that need details become
`status: needs info`.
## License
[MIT](./LICENSE) — do whatever you want with it.
+33 -35
View File
@@ -18,27 +18,27 @@
"#react-pdf-renderer": "@react-pdf/renderer"
},
"dependencies": {
"@ai-sdk/anthropic": "^4.0.36",
"@ai-sdk/cerebras": "^3.0.28",
"@ai-sdk/cohere": "^4.0.25",
"@ai-sdk/deepseek": "^3.0.26",
"@ai-sdk/fireworks": "^3.0.30",
"@ai-sdk/google": "^4.0.39",
"@ai-sdk/groq": "^4.0.26",
"@ai-sdk/mistral": "^4.0.27",
"@ai-sdk/openai": "^4.0.36",
"@ai-sdk/openai-compatible": "^3.0.28",
"@ai-sdk/perplexity": "^4.0.27",
"@ai-sdk/togetherai": "^3.0.29",
"@ai-sdk/xai": "^4.0.33",
"@aws-sdk/client-s3": "^3.1106.0",
"@better-auth/api-key": "^1.6.26",
"@better-auth/drizzle-adapter": "^1.6.26",
"@better-auth/infra": "^0.3.7",
"@better-auth/oauth-provider": "^1.6.26",
"@better-auth/passkey": "^1.6.26",
"@ai-sdk/anthropic": "^4.0.44",
"@ai-sdk/cerebras": "^3.0.39",
"@ai-sdk/cohere": "^4.0.33",
"@ai-sdk/deepseek": "^3.0.35",
"@ai-sdk/fireworks": "^3.0.42",
"@ai-sdk/google": "^4.0.54",
"@ai-sdk/groq": "^4.0.33",
"@ai-sdk/mistral": "^4.0.35",
"@ai-sdk/openai": "^4.0.50",
"@ai-sdk/openai-compatible": "^3.0.39",
"@ai-sdk/perplexity": "^4.0.34",
"@ai-sdk/togetherai": "^3.0.40",
"@ai-sdk/xai": "^4.0.47",
"@aws-sdk/client-s3": "^3.1119.0",
"@better-auth/api-key": "^1.7.2",
"@better-auth/drizzle-adapter": "^1.7.2",
"@better-auth/infra": "^0.4.3",
"@better-auth/oauth-provider": "^1.7.2",
"@better-auth/passkey": "^1.7.2",
"@bramus/specificity": "^2.4.2",
"@hono/node-server": "^2.1.0",
"@hono/node-server": "^2.1.1",
"@modelcontextprotocol/sdk": "^1.30.0",
"@orpc/client": "^1.15.0",
"@orpc/experimental-ratelimit": "^1.15.0",
@@ -46,56 +46,54 @@
"@orpc/openapi": "^1.15.0",
"@orpc/server": "^1.15.0",
"@orpc/zod": "^1.15.0",
"@react-pdf/renderer": "^4.6.0",
"@react-pdf/renderer": "^4.8.1",
"@reactive-resume/api": "workspace:*",
"@reactive-resume/auth": "workspace:*",
"@reactive-resume/db": "workspace:*",
"@reactive-resume/env": "workspace:*",
"@reactive-resume/mcp": "workspace:*",
"@reactive-resume/pdf": "workspace:*",
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"@sindresorhus/slugify": "^3.0.0",
"@t3-oss/env-core": "^0.13.11",
"@uiw/color-convert": "^2.10.3",
"ai": "^7.0.58",
"ai": "^7.0.83",
"bcrypt": "^6.0.0",
"better-auth": "1.6.26",
"canonicalize": "^3.0.0",
"better-auth": "1.7.2",
"cjk-regex": "^3.4.0",
"css-tree": "^3.2.1",
"deepmerge-ts": "^7.1.5",
"deepmerge-ts": "^8.0.2",
"drizzle-orm": "1.0.0-rc.4",
"drizzle-zod": "1.0.0-beta.14-a36c63d",
"es-toolkit": "^1.50.0",
"es-toolkit": "^1.51.0",
"fast-json-patch": "^3.1.1",
"hono": "^4.13.1",
"hono": "^4.13.5",
"jsonrepair": "^3.15.0",
"node-html-parser": "^9.0.1",
"nodemailer": "^9.0.5",
"ollama-ai-provider-v2": "^4.0.1",
"pdfjs-dist": "^6.2.108",
"pg": "^8.23.0",
"phosphor-icons-react-pdf": "^0.1.3",
"react": "^19.2.8",
"react-email": "^6.9.2",
"react-email": "^6.9.3",
"react-pdf-html": "^2.1.5",
"resumable-stream": "^2.2.12",
"sharp": "^0.35.3",
"sharp": "^0.35.4",
"tokenx": "^2.1.0",
"ts-pattern": "^5.9.0",
"unique-names-generator": "^4.7.1",
"uuid": "^14.0.1",
"uuid": "^14.0.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/node": "^26.2.0",
"@types/pg": "^8.21.0",
"@types/node": "^26.4.0",
"@types/pg": "^8.23.1",
"@types/react": "^19.2.18",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"tsdown": "^0.22.14",
"tsx": "^4.23.12",
"typescript": "^7.0.2",
"vitest": "^4.1.10"
"vitest": "^4.1.11"
}
}
+16 -4
View File
@@ -131,10 +131,10 @@ describe("createApp", () => {
it("uses the transport address for public PDF fallback despite rotated forwarding headers", async () => {
const { createApp } = await import("./app");
const app = createApp();
const first = new Request("http://localhost:3001/api/resumes/jane/resume/pdf?reason=render-data-hash", {
const first = new Request("http://localhost:3001/api/resumes/jane/resume/pdf", {
headers: { "x-forwarded-for": "198.51.100.1" },
});
const rotated = new Request("http://localhost:3001/api/resumes/jane/resume/pdf?reason=render-data-hash", {
const rotated = new Request("http://localhost:3001/api/resumes/jane/resume/pdf", {
headers: { "x-forwarded-for": "198.51.100.2" },
});
const env = transportEnv("203.0.113.9");
@@ -159,8 +159,8 @@ describe("createApp", () => {
const unknownRpcRequest = new Request("http://localhost:3001/api/rpc", {
headers: { "cf-connecting-ip": "198.51.100.2" },
});
const trustedOpenApiRequest = new Request("http://localhost:3001/api/openapi/resumes/jane/resume/style-projection");
const unknownOpenApiRequest = new Request("http://localhost:3001/api/openapi/resumes/jane/resume/style-projection");
const trustedOpenApiRequest = new Request("http://localhost:3001/api/openapi/resumes/jane/resume");
const unknownOpenApiRequest = new Request("http://localhost:3001/api/openapi/resumes/jane/resume");
await app.fetch(trustedRpcRequest, transportEnv("203.0.113.9"));
await app.fetch(unknownRpcRequest);
@@ -192,4 +192,16 @@ describe("createApp", () => {
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
expect(mocks.handleWebApp).not.toHaveBeenCalled();
});
it.each(["GET", "HEAD"])("routes %s / to the web app handler so SEO markup is injected", async (method) => {
const { createApp } = await import("./app");
const app = createApp();
const request = new Request("http://localhost:3001/", { method });
const response = await app.fetch(request);
expect(response.status).toBe(200);
expect(mocks.handleWebApp).toHaveBeenCalledWith(request);
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
});
});
+3
View File
@@ -65,6 +65,9 @@ export function createApp() {
app.on(["GET", "HEAD"], "/sitemap.xml", (c) => handleSitemap({ head: c.req.method === "HEAD" }));
app.on(["GET", "HEAD"], "/llms.txt", (c) => handleLlms({ head: c.req.method === "HEAD" }));
// Must precede the static middleware: serveStatic resolves "/" to dist/index.html and would
// return it verbatim, skipping the OpenGraph/Twitter/canonical/JSON-LD injection in handleWebApp.
app.on(["GET", "HEAD"], "/", (c) => handleWebApp(c.req.raw));
app.use("/*", serveWebDistStatic);
app.on(["GET", "HEAD"], "/*", (c) => handleWebApp(c.req.raw));
+11 -41
View File
@@ -6,16 +6,6 @@ const mocks = vi.hoisted(() => ({
vi.mock("@reactive-resume/api/features/resume/public-pdf", () => ({
createPublicResumePdf: mocks.createPublicResumePdf,
PUBLIC_RESUME_PDF_MISMATCH_REASONS: [
"missing-projection",
"format-version",
"language-version",
"semantic-tree-version",
"registry-fingerprint",
"adapter-fingerprint",
"render-data-hash",
"invalid-projection",
],
}));
const { handlePublicResumePdf } = await import("./public-resume-pdf");
@@ -24,18 +14,15 @@ const trustedClient = "203.0.113.9";
describe("handlePublicResumePdf", () => {
beforeEach(() => vi.clearAllMocks());
it("returns the authorized fallback PDF with strict mismatch metadata and cache policy", async () => {
it("returns the authorized on-demand PDF without forwarding compatibility metadata", async () => {
const body = new File(["%PDF"], "Ada_Lovelace.pdf", { type: "text/plain" });
mocks.createPublicResumePdf.mockResolvedValueOnce({
body,
filename: "Ada_Lovelace.pdf",
});
const registry = "0".repeat(64);
const adapter = "1".repeat(64);
const request = new Request(
`https://example.com/api/resumes/jane/resume/pdf?reason=render-data-hash&registryFingerprint=${registry}&adapterFingerprint=${adapter}`,
{ headers: { "x-forwarded-for": "203.0.113.7" } },
);
const request = new Request("https://example.com/api/resumes/jane/resume/pdf?ignored=true", {
headers: { "x-forwarded-for": "203.0.113.7" },
});
const response = await handlePublicResumePdf(request, "jane", "resume", trustedClient);
@@ -50,13 +37,10 @@ describe("handlePublicResumePdf", () => {
slug: "resume",
requestHeaders: request.headers,
trustedClient,
mismatchReason: "render-data-hash",
clientRegistryFingerprint: registry,
clientAdapterFingerprint: adapter,
});
});
it("defaults a missing mismatch reason and keeps password/private responses uncacheable", async () => {
it("keeps password and private responses uncacheable", async () => {
mocks.createPublicResumePdf.mockResolvedValueOnce({
body: new File(["%PDF"], "resume.pdf", { type: "application/pdf" }),
filename: "resume.pdf",
@@ -66,13 +50,15 @@ describe("handlePublicResumePdf", () => {
const response = await handlePublicResumePdf(request, "jane", "resume", trustedClient);
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect(mocks.createPublicResumePdf).toHaveBeenCalledWith(
expect.objectContaining({ mismatchReason: "missing-projection" }),
);
expect(mocks.createPublicResumePdf).toHaveBeenCalledWith({
username: "jane",
slug: "resume",
requestHeaders: request.headers,
trustedClient,
});
});
it.each([
[{ code: "BAD_REQUEST" }, 400],
[{ code: "NEED_PASSWORD" }, 401],
[{ code: "NOT_FOUND" }, 404],
[{ code: "RATE_LIMIT_EXCEEDED" }, 429],
@@ -90,20 +76,4 @@ describe("handlePublicResumePdf", () => {
expect(response.status).toBe(status);
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
});
it.each(["?reason=private-source", "?registryFingerprint=unsafe", "?adapterFingerprint=unsafe"])(
"rejects invalid fallback metadata before the API service",
async (search) => {
const response = await handlePublicResumePdf(
new Request(`https://example.com/api/resumes/jane/resume/pdf${search}`),
"jane",
"resume",
trustedClient,
);
expect(response.status).toBe(400);
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect(mocks.createPublicResumePdf).not.toHaveBeenCalled();
},
);
});
+1 -30
View File
@@ -1,57 +1,28 @@
import type { PublicResumePdfMismatchReason } from "@reactive-resume/api/features/resume/public-pdf";
import {
createPublicResumePdf,
PUBLIC_RESUME_PDF_MISMATCH_REASONS,
} from "@reactive-resume/api/features/resume/public-pdf";
import { createPublicResumePdf } from "@reactive-resume/api/features/resume/public-pdf";
const noStoreResponse = (body: string, status: number) =>
new Response(body, { status, headers: { "Cache-Control": "private, no-store" } });
const errorStatus = (error: unknown): number => {
const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : undefined;
if (code === "BAD_REQUEST") return 400;
if (code === "NEED_PASSWORD") return 401;
if (code === "NOT_FOUND") return 404;
if (code === "RATE_LIMIT_EXCEEDED") return 429;
return 500;
};
const fingerprint = (value: string | null): string | undefined => {
if (value === null) return;
return /^[a-f0-9]{64}$/.test(value) ? value : undefined;
};
export async function handlePublicResumePdf(
request: Request,
username: string,
slug: string,
trustedClient = "unknown",
): Promise<Response> {
const searchParams = new URL(request.url).searchParams;
const rawReason = searchParams.get("reason") ?? "missing-projection";
if (!PUBLIC_RESUME_PDF_MISMATCH_REASONS.includes(rawReason as PublicResumePdfMismatchReason)) {
return noStoreResponse("Invalid fallback metadata", 400);
}
const rawRegistryFingerprint = searchParams.get("registryFingerprint");
const rawAdapterFingerprint = searchParams.get("adapterFingerprint");
const clientRegistryFingerprint = fingerprint(rawRegistryFingerprint);
const clientAdapterFingerprint = fingerprint(rawAdapterFingerprint);
if (
(rawRegistryFingerprint !== null && clientRegistryFingerprint === undefined) ||
(rawAdapterFingerprint !== null && clientAdapterFingerprint === undefined)
) {
return noStoreResponse("Invalid fallback metadata", 400);
}
try {
const result = await createPublicResumePdf({
username,
slug,
requestHeaders: request.headers,
trustedClient,
mismatchReason: rawReason as PublicResumePdfMismatchReason,
...(clientRegistryFingerprint ? { clientRegistryFingerprint } : {}),
...(clientAdapterFingerprint ? { clientAdapterFingerprint } : {}),
});
return new Response(result.body, {
-5
View File
@@ -2,7 +2,6 @@ import { pathToFileURL } from "node:url";
import { serve } from "@hono/node-server";
import { env } from "@reactive-resume/env/server";
import { createApp } from "./http/app";
import { stylesheetPreflightRunner } from "./services/stylesheet-preflight";
import { runStartupChecks } from "./startup/checks";
export { createApp } from "./http/app";
@@ -32,10 +31,6 @@ async function main() {
console.info(`🚀 Up and running on http://localhost:${info.port}`);
},
);
// Load the heavy PDF preflight runtime once, now, so the first semantic-CSS edit
// does not pay (and time out on) the cold worker start.
stylesheetPreflightRunner.warmup();
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+1 -1
View File
@@ -54,7 +54,7 @@ export function createMcpServer(request: Request) {
`Apply body edits with JSON Patch through \`${MCP_TOOL_NAME.patchResume}\`.`,
`Change name, slug, tags, or public visibility with \`${MCP_TOOL_NAME.updateResume}\` (returns canonical share URL; anonymous access only when \`isPublic\` is true; passwords are managed in the web app only).`,
`Create short-lived authenticated PDF download URLs with \`${MCP_TOOL_NAME.downloadResumePdf}\`; set target to \`cover-letter\` to export a visible cover letter separately.`,
`Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`; read saved AI analysis with \`${MCP_TOOL_NAME.getResumeAnalysis}\`.`,
`Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`.`,
].join(" "),
},
);
+11 -4
View File
@@ -18,10 +18,12 @@ type GeneratedSpecView = {
>;
};
async function generateSpec() {
process.env.APP_URL ??= "https://rxresu.me";
process.env.DATABASE_URL ??= "postgresql://localhost/reactive_resume_test";
process.env.AUTH_SECRET ??= "openapi-generator-test-process-only";
// Building the spec walks every router and resume JSON schema, which costs seconds. It is
// deterministic and every test here only reads it, so generate it once for the whole file —
// regenerating per test made the first case time out under a loaded machine.
let specPromise: ReturnType<typeof generateOnce> | undefined;
async function generateOnce() {
const { generateOpenApiSpec } = await import("./generator");
return generateOpenApiSpec({
appUrl: "https://rxresu.me",
@@ -29,6 +31,11 @@ async function generateSpec() {
});
}
function generateSpec() {
specPromise ??= generateOnce();
return specPromise;
}
function getRequestSchema(spec: GeneratedSpecView, path: string, method: string) {
return spec.paths?.[path]?.[method]?.requestBody?.content?.["application/json"]?.schema;
}
-2
View File
@@ -3,7 +3,6 @@ import { RPCHandler } from "@orpc/server/fetch";
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
import router from "@reactive-resume/api/routers";
import { mergeResponseHeaders } from "../http/headers";
import { stylesheetPreflightRunner } from "../services/stylesheet-preflight";
import { getRequestLocale } from "./locale";
const rpcHandler = new RPCHandler(router, {
@@ -24,7 +23,6 @@ export async function handleRpc(request: Request, trustedClient = "unknown") {
reqHeaders: request.headers,
resHeaders,
trustedClient,
stylesheetPreflightRunner,
},
});
@@ -1,332 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { createStylesheetPreflightRunner, STYLESHEET_PREFLIGHT_LIMITS } from "./stylesheet-preflight";
const validStylesheet = {
languageVersion: 1,
text: "@version 1;",
} as const;
const input = {
data: defaultResumeData,
template: defaultResumeData.metadata.template,
stylesheet: validStylesheet,
} as const;
// Runners now own a long-lived, reused worker; destroy them so no worker thread
// outlives its test.
const runners: Array<{ destroy(): Promise<void> }> = [];
const track = <T extends { destroy(): Promise<void> }>(runner: T): T => {
runners.push(runner);
return runner;
};
afterEach(async () => {
await Promise.all(runners.splice(0).map((runner) => runner.destroy()));
});
const memoryExhaustionWorker = new URL(
`data:text/javascript,${encodeURIComponent(`
const retained = [];
while (true) {
const batch = Array.from({ length: 100_000 }, (_, index) => ({ batch: retained.length, index }));
retained.push(batch);
}
`)}`,
);
const failedWorker = new URL(
`data:text/javascript,${encodeURIComponent('throw new Error("sensitive worker details");')}`,
);
// Counts how many preflights this single worker instance served so a reuse test
// can prove the worker is not respawned per request.
const reuseCountingWorker = new URL(
`data:text/javascript,${encodeURIComponent(`
import { parentPort } from "node:worker_threads";
let served = 0;
parentPort.postMessage({ type: "ready" });
parentPort.on("message", (message) => {
if (message?.type !== "preflight") return;
served += 1;
parentPort.postMessage({
type: "result",
requestId: message.requestId,
result: { ok: true, pageCount: 1, byteCount: served, diagnostics: [] },
});
});
`)}`,
);
const delayedSuccessfulWorker = new URL(
`data:text/javascript,${encodeURIComponent(`
import { parentPort } from "node:worker_threads";
parentPort.postMessage({ type: "ready" });
parentPort.on("message", (message) => {
if (message?.type !== "preflight") return;
setTimeout(() => {
parentPort.postMessage({
type: "result",
requestId: message.requestId,
result: {
ok: true,
pageCount: 1,
byteCount: Number(message.input.data.basics.name),
diagnostics: [],
},
});
}, 300);
});
`)}`,
);
const delayedReadyWorker = new URL(
`data:text/javascript,${encodeURIComponent(`
import { parentPort } from "node:worker_threads";
parentPort.on("message", (message) => {
if (message?.type !== "preflight") return;
setTimeout(() => {
parentPort.postMessage({
type: "result",
requestId: message.requestId,
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [] },
});
}, 10);
});
setTimeout(() => parentPort.postMessage({ type: "ready" }), 50);
`)}`,
);
const neverCompletesWorker = new URL(
`data:text/javascript,${encodeURIComponent(`
import { parentPort } from "node:worker_threads";
parentPort.postMessage({ type: "ready" });
setInterval(() => {}, 1_000);
`)}`,
);
const synchronousFailureWorker = new URL("https://example.com/stylesheet-preflight.mjs");
const numberedInput = (number: number) => ({
...input,
data: {
...input.data,
basics: {
...input.data.basics,
name: String(number),
},
},
});
const invalidInput = () => {
const data = structuredClone(defaultResumeData);
data.customSections = [
{
id: "custom-experience",
type: "experience",
title: "Experience",
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [{ id: "summary-shaped-item", hidden: false, content: "<p>Missing company</p>" }],
} as never,
];
return { ...input, data };
};
describe("stylesheet PDF preflight worker", () => {
it("keeps the production resource policy fixed and immutable", () => {
expect(STYLESHEET_PREFLIGHT_LIMITS).toEqual({
timeoutMs: 30_000,
maxPages: 20,
maxBytes: 10_000_000,
maxPageWidthPt: 2_000,
maxPageHeightPt: 20_000,
maxPageAreaPt2: 20_000_000,
maxOldGenerationMb: 256,
maxConcurrentWorkers: 1,
maxQueuedRequests: 32,
});
expect(Object.isFrozen(STYLESHEET_PREFLIGHT_LIMITS)).toBe(true);
});
it("accepts a bounded candidate render in an isolated worker", async () => {
const runner = track(createStylesheetPreflightRunner({ timeoutMs: 15_000 }));
const result = await runner.run(input);
expect(result).toEqual(
expect.objectContaining({
ok: true,
pageCount: 1,
byteCount: expect.any(Number),
}),
);
if (!result.ok) throw new Error(`Expected successful preflight, received ${result.code}.`);
expect(result.byteCount).toBeGreaterThan(0);
expect(runner.activeWorkerCount).toBe(0);
}, 45_000);
it("reuses one warm worker across sequential requests instead of cold-starting each one", async () => {
const runner = track(createStylesheetPreflightRunner({}, reuseCountingWorker));
const first = await runner.run(input);
const second = await runner.run(input);
const third = await runner.run(input);
// A single reused worker increments its per-instance counter; a per-request
// worker would report byteCount 1 every time.
expect([first, second, third].map((result) => (result.ok ? result.byteCount : -1))).toEqual([1, 2, 3]);
expect(runner.activeWorkerCount).toBe(0);
expect(runner.queuedPreflightCount).toBe(0);
});
it("preserves structured resume-data failures across the worker boundary", async () => {
const runner = track(createStylesheetPreflightRunner({ timeoutMs: 15_000 }));
const result = runner.run(invalidInput());
await expect(result).rejects.toMatchObject({
name: "ZodError",
issues: expect.arrayContaining([expect.objectContaining({ path: ["customSections", 0, "items", 0, "company"] })]),
});
expect(runner.activeWorkerCount).toBe(0);
expect(runner.queuedPreflightCount).toBe(0);
}, 20_000);
it("terminates a worker when the render exceeds its deadline", async () => {
const runner = track(createStylesheetPreflightRunner({ timeoutMs: 1 }, neverCompletesWorker));
const result = await runner.run(input);
expect(result).toEqual(expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_TIMEOUT" }));
expect(runner.activeWorkerCount).toBe(0);
expect(runner.queuedPreflightCount).toBe(0);
});
it("starts the authored render deadline after the worker runtime is ready", async () => {
const runner = track(createStylesheetPreflightRunner({ timeoutMs: 20 }, delayedReadyWorker));
await expect(runner.run(input)).resolves.toEqual(expect.objectContaining({ ok: true, pageCount: 1 }));
expect(runner.activeWorkerCount).toBe(0);
});
it("returns deterministic output byte and page limit codes", async () => {
const byteRunner = track(createStylesheetPreflightRunner({ maxBytes: 16 }));
const pageRunner = track(createStylesheetPreflightRunner({ maxPages: 0 }));
await expect(byteRunner.run(input)).resolves.toEqual(
expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_BYTE_LIMIT" }),
);
await expect(pageRunner.run(input)).resolves.toEqual(
expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_PAGE_LIMIT" }),
);
expect(byteRunner.activeWorkerCount).toBe(0);
expect(pageRunner.activeWorkerCount).toBe(0);
}, 30_000);
it("maps worker heap exhaustion to a controlled memory-limit result", async () => {
const runner = track(
createStylesheetPreflightRunner({ maxOldGenerationMb: 8, timeoutMs: 10_000 }, memoryExhaustionWorker),
);
const result = await runner.run(input);
expect(result).toEqual(expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_MEMORY_LIMIT" }));
expect(runner.activeWorkerCount).toBe(0);
}, 15_000);
it("does not expose internal errors from a failed worker bootstrap", async () => {
const runner = track(createStylesheetPreflightRunner({}, failedWorker));
const result = await runner.run(input);
expect(result).toEqual({
ok: false,
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
message: "The PDF preflight worker failed.",
diagnostics: [],
});
expect(runner.activeWorkerCount).toBe(0);
expect(runner.queuedPreflightCount).toBe(0);
});
it("surfaces sanitized render failures from inside the worker catch path", async () => {
const throwingWorker = new URL(
`data:text/javascript,${encodeURIComponent(`
import { parentPort } from "node:worker_threads";
parentPort.postMessage({ type: "ready" });
parentPort.on("message", (message) => {
if (message?.type !== "preflight") return;
parentPort.postMessage({
type: "result",
requestId: message.requestId,
result: {
ok: false,
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
message: "The PDF preflight worker failed. (Error: Canvas is already closed)",
diagnostics: [],
},
});
});
`)}`,
);
const runner = track(createStylesheetPreflightRunner({ timeoutMs: 5_000 }, throwingWorker));
const result = await runner.run(input);
expect(result).toEqual({
ok: false,
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
message: "The PDF preflight worker failed. (Error: Canvas is already closed)",
diagnostics: [],
});
});
it("bounds concurrent workers and queued requests without charging queue time to the worker deadline", async () => {
const runner = track(
createStylesheetPreflightRunner(
{
timeoutMs: 500,
maxConcurrentWorkers: 1,
maxQueuedRequests: 2,
},
delayedSuccessfulWorker,
),
);
const completionOrder: number[] = [];
const accepted = [1, 2, 3].map((number) =>
runner.run(numberedInput(number)).then((result) => {
if (result.ok) completionOrder.push(result.byteCount);
return result;
}),
);
const rejected = runner.run(numberedInput(4));
expect(runner.activeWorkerCount).toBe(1);
expect(runner.queuedPreflightCount).toBe(2);
await expect(rejected).resolves.toEqual(
expect.objectContaining({
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
message: "The PDF preflight queue is full.",
}),
);
const results = await Promise.all(accepted);
expect(results.every((result) => result.ok)).toBe(true);
expect(completionOrder).toEqual([1, 2, 3]);
expect(runner.activeWorkerCount).toBe(0);
expect(runner.queuedPreflightCount).toBe(0);
}, 5_000);
it("does not leak a slot when the worker constructor throws synchronously", async () => {
const runner = track(createStylesheetPreflightRunner({}, synchronousFailureWorker));
await expect(runner.run(input)).resolves.toEqual(
expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_WORKER_FAILED" }),
);
expect(runner.activeWorkerCount).toBe(0);
expect(runner.queuedPreflightCount).toBe(0);
});
});
@@ -1,362 +0,0 @@
import type {
PdfPreflightFailure,
PdfPreflightResult,
StylesheetPreflightInput,
StylesheetPreflightRunner,
} from "@reactive-resume/pdf/server";
import { fileURLToPath } from "node:url";
import { Worker } from "node:worker_threads";
import { STYLESHEET_PREFLIGHT_LIMITS } from "@reactive-resume/pdf/preflight-reference";
export { STYLESHEET_PREFLIGHT_LIMITS } from "@reactive-resume/pdf/preflight-reference";
type StylesheetPreflightLimits = {
timeoutMs: number;
maxPages: number;
maxBytes: number;
maxPageWidthPt: number;
maxPageHeightPt: number;
maxPageAreaPt2: number;
maxOldGenerationMb: number;
maxConcurrentWorkers: number;
maxQueuedRequests: number;
};
const SOURCE_WORKER_LOADER_HEAP_MB = 256;
// The worker is warmed once and reused, so the one-time cold load (which is
// super-linear in available CPU — measured ~9s at 0.5 vCPU, ~53s at 0.35, ~93s at
// 0.25) is paid at startup, not per request. This ceiling must exceed that cold
// load or warmup is killed mid-bootstrap and never completes on a throttled box;
// it only bounds a genuinely stuck bootstrap and is off the per-edit path.
const WORKER_READINESS_TIMEOUT_MS = 120_000;
type SerializedPreflightCause = {
name: string;
message: string;
issues: readonly unknown[];
};
type StylesheetPreflightWorkerMessage =
| { type: "ready" }
| { type: "load_error"; message: string }
| { type: "result"; requestId: number; result: PdfPreflightResult }
| { type: "preflight_error"; requestId: number; cause: SerializedPreflightCause };
export type NodeStylesheetPreflightRunner = StylesheetPreflightRunner & {
readonly activeWorkerCount: number;
readonly queuedPreflightCount: number;
warmup(): void;
destroy(): Promise<void>;
};
const failure = (code: PdfPreflightFailure["code"], message: string): PdfPreflightFailure => ({
ok: false,
code,
message,
diagnostics: [],
});
const workerFailure = (error: Error): PdfPreflightFailure => {
const code = (error as NodeJS.ErrnoException).code;
return code === "ERR_WORKER_OUT_OF_MEMORY" || /heap out of memory/i.test(error.message)
? failure("STYLESHEET_PREFLIGHT_MEMORY_LIMIT", "The PDF preflight worker exceeded its memory limit.")
: failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker failed.");
};
const sourceWorkerExecArgv = () => {
const importIndex = process.execArgv.findIndex(
(argument, index, arguments_) =>
(argument === "--import" && arguments_[index + 1]?.includes("tsx")) ||
(argument.startsWith("--import=") && argument.includes("tsx")),
);
const inherited = process.execArgv[importIndex];
if (inherited?.startsWith("--import=")) return [inherited];
if (inherited === "--import") return [inherited, process.execArgv[importIndex + 1] as string];
return ["--import", import.meta.resolve("tsx")];
};
const workerLocation = () => {
const source = import.meta.url.endsWith(".ts");
return {
source,
url: source
? new URL("../workers/stylesheet-preflight.ts", import.meta.url)
: new URL("./stylesheet-preflight-worker.mjs", import.meta.url),
// Production must not inherit parent execArgv (e.g. --import/--input-type); source workers need tsx.
execArgv: source ? sourceWorkerExecArgv() : [],
...(source
? {
env: {
...process.env,
TSX_TSCONFIG_PATH: fileURLToPath(new URL("../../tsconfig.json", import.meta.url)),
},
}
: {}),
};
};
type PendingRequest = {
resolve: (result: PdfPreflightResult) => void;
reject: (cause: unknown) => void;
renderTimer?: ReturnType<typeof setTimeout>;
};
type Readiness = {
promise: Promise<Worker>;
resolve: (worker: Worker) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
export function createStylesheetPreflightRunner(
overrides: Partial<StylesheetPreflightLimits> = {},
testWorkerUrl?: URL,
): NodeStylesheetPreflightRunner {
const limits = Object.freeze({ ...STYLESHEET_PREFLIGHT_LIMITS, ...overrides });
let worker: Worker | undefined;
let ready = false;
let readiness: Readiness | undefined;
let destroyed = false;
let requestSeq = 0;
const pending = new Map<number, PendingRequest>();
// ponytail: process-local, bounded admission; upgrade to a pooled/distributed queue only for multi-process coordination.
const queue: Array<{
input: StylesheetPreflightInput;
resolve: PendingRequest["resolve"];
reject: PendingRequest["reject"];
}> = [];
const teardownWorker = () => {
const dead = worker;
worker = undefined;
ready = false;
if (readiness) {
clearTimeout(readiness.timer);
readiness.reject(new Error("Stylesheet preflight worker did not become ready."));
readiness = undefined;
}
if (!dead) return;
dead.off("message", onMessage);
dead.off("error", onError);
dead.off("exit", onExit);
void dead.terminate().catch(() => undefined);
};
const clearPending = (requestId: number): PendingRequest | undefined => {
const entry = pending.get(requestId);
if (!entry) return undefined;
if (entry.renderTimer) clearTimeout(entry.renderTimer);
pending.delete(requestId);
return entry;
};
const drainQueue = () => {
while (!destroyed && pending.size < limits.maxConcurrentWorkers && queue.length > 0) {
const next = queue.shift();
if (!next) return;
dispatch(next.input, next.resolve, next.reject);
}
};
const resolveRequest = (requestId: number, result: PdfPreflightResult) => {
const entry = clearPending(requestId);
if (!entry) return;
entry.resolve(result);
drainQueue();
};
const rejectRequest = (requestId: number, cause: unknown) => {
const entry = clearPending(requestId);
if (!entry) return;
entry.reject(cause);
drainQueue();
};
// A crashed/exited/timed-out worker is torn down and its in-flight requests are
// failed; the next dispatch (including any queued requests) spawns a fresh one,
// so a poisoned render never lingers across requests.
const failWorker = (result: PdfPreflightFailure) => {
teardownWorker();
const stale = [...pending.keys()];
for (const requestId of stale) resolveRequest(requestId, result);
drainQueue();
};
function onMessage(message: StylesheetPreflightWorkerMessage) {
if (message.type === "ready") {
if (readiness) {
clearTimeout(readiness.timer);
ready = true;
const resolveReady = readiness.resolve;
const readyWorker = worker;
readiness = undefined;
if (readyWorker) resolveReady(readyWorker);
}
return;
}
if (message.type === "load_error") {
failWorker(failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", message.message));
return;
}
if (message.type === "result") {
resolveRequest(message.requestId, message.result);
return;
}
if (message.type === "preflight_error") {
rejectRequest(
message.requestId,
Object.assign(new Error(message.cause.message), { name: message.cause.name, issues: message.cause.issues }),
);
}
}
function onError(error: Error) {
console.warn("[stylesheet-preflight] worker error:", error.message);
failWorker(workerFailure(error));
}
function onExit(code: number) {
if (destroyed || (!worker && pending.size === 0)) return;
console.warn(`[stylesheet-preflight] worker exited unexpectedly (code ${code})`);
failWorker(failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker failed."));
}
const startWorker = (): Promise<Worker> => {
const location = testWorkerUrl ? { source: false, url: testWorkerUrl, execArgv: [] as string[] } : workerLocation();
let spawned: Worker;
try {
spawned = new Worker(location.url, {
name: "stylesheet-preflight",
resourceLimits: {
// The source-only tsx compiler heap is outside the production render budget.
maxOldGenerationSizeMb: limits.maxOldGenerationMb + (location.source ? SOURCE_WORKER_LOADER_HEAP_MB : 0),
},
execArgv: location.execArgv,
...("env" in location ? { env: location.env } : {}),
});
} catch (error) {
return Promise.reject(error instanceof Error ? error : new Error("Failed to start PDF preflight worker."));
}
worker = spawned;
ready = false;
// The idle reused worker must not keep the process (or a test run) alive; active
// requests stay alive through their ref'd readiness/render timers.
spawned.unref();
spawned.on("message", onMessage);
spawned.once("error", onError);
spawned.once("exit", onExit);
let resolve!: (value: Worker) => void;
let reject!: (error: Error) => void;
const promise = new Promise<Worker>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
const timer = setTimeout(() => {
console.warn(`[stylesheet-preflight] worker did not become ready within ${WORKER_READINESS_TIMEOUT_MS}ms`);
teardownWorker();
}, WORKER_READINESS_TIMEOUT_MS);
readiness = { promise, resolve, reject, timer };
return promise;
};
const getReadyWorker = (): Promise<Worker> => {
if (destroyed) return Promise.reject(new Error("Stylesheet preflight worker was terminated."));
if (worker && ready) return Promise.resolve(worker);
if (readiness) return readiness.promise;
return startWorker();
};
// One retry: a worker that failed to become ready is torn down; a second
// attempt spawns a fresh worker before the request gives up.
const waitUntilReady = async (): Promise<Worker> => {
try {
return await getReadyWorker();
} catch {
return await getReadyWorker();
}
};
function dispatch(
input: StylesheetPreflightInput,
resolve: PendingRequest["resolve"],
reject: PendingRequest["reject"],
) {
const requestId = ++requestSeq;
pending.set(requestId, { resolve, reject });
void waitUntilReady()
.then((readyWorker) => {
const entry = pending.get(requestId);
if (!entry) return;
entry.renderTimer = setTimeout(() => {
// A stuck render poisons the reused worker: tear it down and respawn — but
// only the worker this request actually ran on, so a stale timer can never
// kill a newer worker already serving other requests.
clearPending(requestId);
if (worker === readyWorker) teardownWorker();
resolve(failure("STYLESHEET_PREFLIGHT_TIMEOUT", "The PDF preflight exceeded its deadline."));
drainQueue();
}, limits.timeoutMs);
readyWorker.postMessage({ type: "preflight", requestId, input, limits });
})
.catch((error: unknown) => {
resolveRequest(
requestId,
workerFailure(error instanceof Error ? error : new Error("Failed to start PDF preflight worker.")),
);
});
}
return {
get activeWorkerCount() {
return pending.size;
},
get queuedPreflightCount() {
return queue.length;
},
warmup() {
void waitUntilReady().catch(() => undefined);
},
run(input: StylesheetPreflightInput): Promise<PdfPreflightResult> {
return new Promise<PdfPreflightResult>((resolve, reject) => {
if (destroyed) {
resolve(failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker was terminated."));
return;
}
if (pending.size < limits.maxConcurrentWorkers) {
dispatch(input, resolve, reject);
return;
}
if (queue.length >= limits.maxQueuedRequests) {
resolve(failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight queue is full."));
return;
}
queue.push({ input, resolve, reject });
});
},
async destroy() {
destroyed = true;
const dead = worker;
teardownWorker();
await dead?.terminate().catch(() => undefined);
for (const requestId of [...pending.keys()]) {
resolveRequest(
requestId,
failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker was terminated."),
);
}
while (queue.length > 0) {
const next = queue.shift();
next?.resolve(failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker was terminated."));
}
},
};
}
export const stylesheetPreflightRunner = createStylesheetPreflightRunner();
+11
View File
@@ -61,7 +61,18 @@ async function validateLocalStoragePath() {
}
}
async function reapStaleAgentRuns() {
try {
const { reapStaleAgentRunsAtBoot } = await import("@reactive-resume/api/features/agent/runs");
await reapStaleAgentRunsAtBoot();
} catch (error) {
// A reap failure must not block serving traffic; stuck runs also heal lazily on access.
console.error("Failed to reap stale agent runs at boot", { error });
}
}
export async function runStartupChecks() {
await runDatabaseMigrations();
await validateLocalStoragePath();
await reapStaleAgentRuns();
}
+1
View File
@@ -33,6 +33,7 @@ describe("SEO static endpoints", () => {
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("application/xml; charset=UTF-8");
expect(text).toContain("<loc>https://app.example.com/</loc>");
expect(text).toContain("<loc>https://app.example.com/ats-checker</loc>");
expect(text).not.toContain("docs.rxresu.me");
expect(text).not.toContain("/auth");
expect(text).not.toContain("/dashboard");
+3
View File
@@ -42,6 +42,9 @@ export function handleSitemap(options?: StaticSeoOptions) {
" <url>",
` <loc>${baseUrl}/</loc>`,
" </url>",
" <url>",
` <loc>${baseUrl}/ats-checker</loc>`,
" </url>",
"</urlset>",
"",
].join("\n");
+109
View File
@@ -4,6 +4,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
env: { APP_URL: "https://rxresu.me" },
serveStatic: vi.fn((_options?: unknown) => vi.fn()),
getPublicResumeSocialMeta: vi.fn(),
}));
vi.mock("@reactive-resume/api/features/resume/social-meta", () => ({
getPublicResumeSocialMeta: mocks.getPublicResumeSocialMeta,
}));
vi.mock("node:fs", () => ({
@@ -41,6 +46,7 @@ describe("web app fallback classification", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fs.readFile).mockResolvedValue("<html>app</html>");
mocks.getPublicResumeSocialMeta.mockResolvedValue(null);
});
it("serves the shell for the root app route without noindex", async () => {
@@ -83,6 +89,109 @@ describe("web app fallback classification", () => {
expect(await dashboardResponse.text()).not.toContain('rel="canonical"');
});
describe("the ATS checker page", () => {
const shell = `<html><head><title>Reactive Resume — A free and open-source resume builder</title><meta name="description" content="Marketing copy."></head><body></body></html>`;
it("serves an indexable shell rather than a 404", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
const response = await handleWebApp(new Request("https://example.com/ats-checker"));
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
expect(response.headers.get("X-Robots-Tag")).toBeNull();
});
it("replaces the shell metadata with the checker's own", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
const html = await (await handleWebApp(new Request("https://example.com/ats-checker"))).text();
expect(html).toContain("<title>ATS Checker - Reactive Resume</title>");
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/ats-checker">');
expect(html).toContain('<meta property="og:url" content="https://rxresu.me/ats-checker">');
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/opengraph/ats-checker.png">');
expect(html).toContain('id="ats-checker-structured-data"');
expect(html).not.toContain("Marketing copy.");
});
it("answers HEAD without a body", async () => {
const response = await handleWebApp(new Request("https://example.com/ats-checker", { method: "HEAD" }));
expect(response.status).toBe(200);
expect(await response.text()).toBe("");
});
it("does not treat the checker path as a public resume owner", async () => {
const response = await handleWebApp(new Request("https://example.com/ats-checker/anything"));
expect(response.status).toBe(404);
expect(mocks.getPublicResumeSocialMeta).not.toHaveBeenCalled();
});
});
describe("public resume social cards", () => {
const shell = `<html><head><title>Reactive Resume — A free and open-source resume builder</title><meta name="description" content="Marketing copy."></head><body></body></html>`;
it("injects resume-specific social metadata and replaces the shell title", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
mocks.getPublicResumeSocialMeta.mockResolvedValue({
name: "Jane Doe",
title: "Jane Doe — Staff Engineer",
description: "Builds resilient distributed systems.",
template: "azurill",
});
const html = await (await handleWebApp(new Request("https://example.com/jane/resume"))).text();
expect(mocks.getPublicResumeSocialMeta).toHaveBeenCalledWith({ username: "jane", slug: "resume" });
expect(html).toContain("<title>Jane Doe - Reactive Resume</title>");
expect(html).toContain('<meta name="description" content="Builds resilient distributed systems.">');
expect(html).not.toContain("Marketing copy.");
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/jane/resume">');
expect(html).toContain('<meta property="og:type" content="profile">');
expect(html).toContain('<meta property="og:title" content="Jane Doe — Staff Engineer">');
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/templates/jpg/azurill.jpg">');
expect(html).toContain('<meta name="twitter:card" content="summary_large_image">');
expect(html).toContain('<meta name="twitter:image" content="https://rxresu.me/templates/jpg/azurill.jpg">');
});
it("escapes user-authored values so resume content cannot break out of the attribute", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
mocks.getPublicResumeSocialMeta.mockResolvedValue({
name: 'Jane" onload="alert(1)',
title: "<script>alert(1)</script>",
description: 'Ends with " and & ampersand',
template: "azurill",
});
const html = await (await handleWebApp(new Request("https://example.com/jane/resume"))).text();
expect(html).not.toContain("<script>alert(1)</script>");
expect(html).not.toContain('onload="alert(1)');
expect(html).toContain('<meta property="og:title" content="&lt;script&gt;alert(1)&lt;/script&gt;">');
expect(html).toContain('content="Ends with &quot; and &amp; ampersand"');
});
it("serves the plain shell when the resume is not publicly shareable", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
const html = await (await handleWebApp(new Request("https://example.com/jane/private"))).text();
expect(html).toBe(shell);
});
it("serves the plain shell when the lookup fails", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
mocks.getPublicResumeSocialMeta.mockRejectedValue(new Error("database unavailable"));
const response = await handleWebApp(new Request("https://example.com/jane/resume"));
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe(shell);
});
});
it("caches versioned homepage media immutably", async () => {
const headers = new Headers();
+122 -4
View File
@@ -20,6 +20,13 @@ function resolveWebDistPath() {
const staticRoot = resolveWebDistPath();
const indexHtmlPath = `${staticRoot}/index.html`;
const noindexShellPrefixes = ["/auth", "/dashboard", "/builder", "/agent", "/templates"];
/**
* Marketing pages the SPA owns that search engines should index.
*
* Without an entry here the fallback below returns 404 for the path in production — the dev Vite
* server serves the shell for anything, so this failure only ever shows up once deployed.
*/
const indexableAppPaths = new Set(["/ats-checker"]);
const reservedPublicResumeSegments = new Set([
"api",
"mcp",
@@ -30,6 +37,7 @@ const reservedPublicResumeSegments = new Set([
"builder",
"agent",
"templates",
"ats-checker",
]);
function isAssetPath(pathname: string): boolean {
@@ -60,8 +68,9 @@ const BASE_SECURITY_HEADERS = {
};
const ROOT_TITLE = "Reactive Resume — A free and open-source resume builder";
// Keep under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines.
const ROOT_DESCRIPTION =
"Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.";
"Free, open-source resume builder. Create, update, and share a professional resume in minutes — no ads, no paywall.";
const ROOT_POSTER_PATH = "/videos/timelapse-v1.webp";
const ROOT_FAQ_ITEMS = [
{
@@ -159,6 +168,88 @@ function createRootSeoMarkup(canonicalUrl: string) {
`;
}
const ATS_CHECKER_TITLE = "ATS Checker - Reactive Resume";
// Kept under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines.
const ATS_CHECKER_DESCRIPTION =
"Check whether software can read your resume PDF. Runs in your browser, so your file is never uploaded.";
function createAtsCheckerSeoMarkup(origin: string) {
const canonicalUrl = `${origin}/ats-checker`;
const imageUrl = `${origin}/opengraph/ats-checker.png`;
const structuredData = {
"@context": "https://schema.org",
"@type": "WebApplication",
name: "ATS Checker",
url: canonicalUrl,
description: ATS_CHECKER_DESCRIPTION,
applicationCategory: "BusinessApplication",
operatingSystem: "Web",
isAccessibleForFree: true,
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
isPartOf: { "@type": "WebSite", name: "Reactive Resume", url: `${origin}/` },
};
return `
<link rel="canonical" href="${canonicalUrl}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Reactive Resume">
<meta property="og:title" content="${ATS_CHECKER_TITLE}">
<meta property="og:description" content="${ATS_CHECKER_DESCRIPTION}">
<meta property="og:url" content="${canonicalUrl}">
<meta property="og:image" content="${imageUrl}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${ATS_CHECKER_TITLE}">
<meta name="twitter:description" content="${ATS_CHECKER_DESCRIPTION}">
<meta name="twitter:image" content="${imageUrl}">
<script id="ats-checker-structured-data" type="application/ld+json">${JSON.stringify(structuredData)}</script>
`;
}
// Resume names, headlines, and summaries are user-authored, so they must never reach the served
// HTML unescaped.
const escapeAttribute = (value: string) =>
value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
async function createPublicResumeSeoMarkup(pathname: string, origin: string) {
const [username, slug] = getPathSegments(pathname);
if (!username || !slug) return null;
// A card render must never take down the page: any lookup failure falls back to the plain shell.
const meta = await import("@reactive-resume/api/features/resume/social-meta")
.then((module) => module.getPublicResumeSocialMeta({ username, slug }))
.catch(() => null);
if (!meta) return null;
const canonicalUrl = `${origin}/${username}/${slug}`;
const imageUrl = `${origin}/templates/jpg/${meta.template}.jpg`;
const pageTitle = escapeAttribute(`${meta.name} - Reactive Resume`);
const title = escapeAttribute(meta.title);
const description = escapeAttribute(meta.description);
return {
pageTitle,
description,
markup: `
<link rel="canonical" href="${canonicalUrl}">
<meta property="og:type" content="profile">
<meta property="og:site_name" content="Reactive Resume">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${description}">
<meta property="og:url" content="${canonicalUrl}">
<meta property="og:image" content="${imageUrl}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${title}">
<meta name="twitter:description" content="${description}">
<meta name="twitter:image" content="${imageUrl}">
`,
};
}
export const serveWebDistStatic = serveStatic({
root: staticRoot,
onFound: (_path, context) => {
@@ -169,7 +260,9 @@ export const serveWebDistStatic = serveStatic({
});
function getFallbackResponseHeaders(pathname: string) {
if (pathname === "/") return { "Content-Type": "text/html; charset=UTF-8", ...BASE_SECURITY_HEADERS };
if (pathname === "/" || indexableAppPaths.has(pathname)) {
return { "Content-Type": "text/html; charset=UTF-8", ...BASE_SECURITY_HEADERS };
}
if (isNoindexShellPath(pathname) || isPublicResumePath(pathname)) {
return {
"Content-Type": "text/html; charset=UTF-8",
@@ -207,7 +300,32 @@ export async function handleWebApp(request: Request) {
const html = await fs.readFile(indexHtmlPath, "utf-8");
const canonicalUrl = new URL("/", env.APP_URL).toString();
const responseHtml = pathname === "/" ? html.replace("</head>", `${createRootSeoMarkup(canonicalUrl)}</head>`) : html;
return new Response(responseHtml, { headers });
if (pathname === "/") {
return new Response(html.replace("</head>", `${createRootSeoMarkup(canonicalUrl)}</head>`), { headers });
}
if (pathname === "/ats-checker") {
const origin = new URL(env.APP_URL).origin;
const withTitle = html
.replace(/<title>[^<]*<\/title>/, `<title>${ATS_CHECKER_TITLE}</title>`)
.replace(/<meta\s+name="description"[^>]*>/, `<meta name="description" content="${ATS_CHECKER_DESCRIPTION}">`);
return new Response(withTitle.replace("</head>", `${createAtsCheckerSeoMarkup(origin)}</head>`), { headers });
}
if (isPublicResumePath(pathname)) {
const resumeSeo = await createPublicResumeSeoMarkup(pathname, new URL(env.APP_URL).origin);
if (resumeSeo) {
// The shell's generic title/description are replaced so shares and previews show the resume,
// not the marketing copy baked into index.html.
const withTitle = html
.replace(/<title>[^<]*<\/title>/, `<title>${resumeSeo.pageTitle}</title>`)
.replace(/<meta\s+name="description"[^>]*>/, `<meta name="description" content="${resumeSeo.description}">`);
return new Response(withTitle.replace("</head>", `${resumeSeo.markup}</head>`), { headers });
}
}
return new Response(html, { headers });
}
@@ -1,78 +0,0 @@
import type {
PdfPreflightPageLimits,
PdfPreflightResult,
RenderPreflightPdfResult,
} from "@reactive-resume/pdf/preflight";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
type StylesheetPreflightInspectionLimits = PdfPreflightPageLimits & {
maxPages: number;
maxBytes: number;
};
type PdfLoadingTask = {
promise: PromiseLike<{ numPages: number }>;
destroy(): Promise<void>;
};
type LoadPdf = (options: { data: Uint8Array }) => PdfLoadingTask;
export async function inspectPreflightPdf(
rendered: Extract<RenderPreflightPdfResult, { ok: true }>,
limits: StylesheetPreflightInspectionLimits,
loadPdf: LoadPdf = getDocument,
): Promise<PdfPreflightResult> {
if (rendered.bytes.byteLength > limits.maxBytes) {
return {
ok: false,
code: "STYLESHEET_PREFLIGHT_BYTE_LIMIT",
message: "The rendered PDF exceeds the preflight byte limit.",
diagnostics: rendered.diagnostics,
};
}
const byteCount = rendered.bytes.byteLength;
let loadingTask: PdfLoadingTask;
try {
loadingTask = loadPdf({ data: rendered.bytes });
} catch {
return {
ok: false,
code: "STYLESHEET_PREFLIGHT_PARSE_FAILED",
message: "PDF inspection failed.",
diagnostics: rendered.diagnostics,
};
}
try {
let document: { numPages: number };
try {
document = await loadingTask.promise;
} catch {
return {
ok: false,
code: "STYLESHEET_PREFLIGHT_PARSE_FAILED",
message: "PDF inspection failed.",
diagnostics: rendered.diagnostics,
};
}
if (document.numPages > limits.maxPages) {
return {
ok: false,
code: "STYLESHEET_PREFLIGHT_PAGE_LIMIT",
message: "The rendered PDF exceeds the preflight page limit.",
diagnostics: rendered.diagnostics,
};
}
return {
ok: true,
pageCount: document.numPages,
byteCount,
diagnostics: rendered.diagnostics,
};
} finally {
await loadingTask.destroy().catch(() => undefined);
}
}
@@ -1,58 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { inspectPreflightPdf } from "./stylesheet-preflight-inspection";
const warning = {
code: "EXTREME_VALUE",
severity: "warning",
message: "The authored value is unusually large.",
range: {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 10, offset: 9 },
},
} as const;
const rendered = {
ok: true as const,
bytes: new TextEncoder().encode("%PDF-1.7"),
diagnostics: [warning],
};
const limits = {
maxPages: 20,
maxBytes: 10_000_000,
maxPageWidthPt: 2_000,
maxPageHeightPt: 20_000,
maxPageAreaPt2: 20_000_000,
};
describe("stylesheet preflight PDF inspection", () => {
it("preserves compiler warnings and hides parser exceptions", async () => {
const destroy = vi.fn(async () => undefined);
const result = await inspectPreflightPdf(rendered, limits, () => ({
promise: Promise.reject(new Error("sensitive parser details")),
destroy,
}));
expect(result).toEqual({
ok: false,
code: "STYLESHEET_PREFLIGHT_PARSE_FAILED",
message: "PDF inspection failed.",
diagnostics: [warning],
});
expect(destroy).toHaveBeenCalledOnce();
});
it("preserves compiler warnings when PDF.js fails before returning a loading task", async () => {
const result = await inspectPreflightPdf(rendered, limits, () => {
throw new Error("sensitive parser setup details");
});
expect(result).toEqual({
ok: false,
code: "STYLESHEET_PREFLIGHT_PARSE_FAILED",
message: "PDF inspection failed.",
diagnostics: [warning],
});
});
});
@@ -1,90 +0,0 @@
import type {
PdfPreflightPageLimits,
PdfPreflightResult,
StylesheetPreflightInput,
} from "@reactive-resume/pdf/preflight";
import { parentPort } from "node:worker_threads";
import * as React from "react";
import { inspectPreflightPdf } from "./stylesheet-preflight-inspection";
(globalThis as typeof globalThis & { React: typeof React }).React = React;
type StylesheetPreflightWorkerLimits = PdfPreflightPageLimits & {
maxPages: number;
maxBytes: number;
};
// The worker is long-lived and reused across requests: the heavy PDF runtime is
// loaded once at startup and each preflight arrives as a message (input can no
// longer come from `workerData`, which is fixed at construction time).
type StylesheetPreflightWorkerRequest = {
type: "preflight";
requestId: number;
input: StylesheetPreflightInput;
limits: StylesheetPreflightWorkerLimits;
};
type SerializedPreflightCause = {
name: string;
message: string;
issues: readonly unknown[];
};
const sanitizeWorkerCause = (cause: unknown): string => {
if (!(cause instanceof Error)) return "The PDF preflight worker failed.";
const detail = cause.message.replace(/\s+/g, " ").trim().slice(0, 200);
if (!detail) return "The PDF preflight worker failed.";
return `The PDF preflight worker failed. (${cause.name}: ${detail})`;
};
const serializeZodCause = (cause: unknown): SerializedPreflightCause | undefined => {
if (!(cause instanceof Error) || cause.name !== "ZodError" || !("issues" in cause) || !Array.isArray(cause.issues)) {
return;
}
return { name: cause.name, message: cause.message, issues: cause.issues };
};
// Load the heavy PDF runtime once. A rejection here (missing/broken dependency in
// a pruned production install) is reported explicitly instead of becoming an
// unhandled rejection that silently kills the worker and surfaces as an opaque
// runner-side failure.
const initialization = import("@reactive-resume/pdf/preflight");
void initialization.then(
() => parentPort?.postMessage({ type: "ready" }),
(cause: unknown) => {
console.error("[stylesheet-preflight] worker runtime failed to load", cause);
parentPort?.postMessage({ type: "load_error", message: sanitizeWorkerCause(cause) });
},
);
const handle = async (request: StylesheetPreflightWorkerRequest): Promise<void> => {
try {
const { renderPreflightPdf } = await initialization;
const rendered = await renderPreflightPdf(request.input, request.limits);
const result: PdfPreflightResult = rendered.ok ? await inspectPreflightPdf(rendered, request.limits) : rendered;
parentPort?.postMessage({ type: "result", requestId: request.requestId, result });
} catch (cause) {
const serializedCause = serializeZodCause(cause);
if (serializedCause) {
parentPort?.postMessage({ type: "preflight_error", requestId: request.requestId, cause: serializedCause });
return;
}
console.error("[stylesheet-preflight]", cause);
parentPort?.postMessage({
type: "result",
requestId: request.requestId,
result: {
ok: false,
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
message: sanitizeWorkerCause(cause),
diagnostics: [],
},
});
}
};
parentPort?.on("message", (message: StylesheetPreflightWorkerRequest) => {
if (message.type !== "preflight") return;
void handle(message);
});
+1 -4
View File
@@ -33,10 +33,7 @@ const promptAssetsPlugin: TsdownPlugin = {
};
export default defineConfig({
entry: {
index: "src/index.ts",
"stylesheet-preflight-worker": "src/workers/stylesheet-preflight.ts",
},
entry: { index: "src/index.ts" },
format: "esm",
platform: "node",
target: "node24",
+1 -1
View File
@@ -1,6 +1,6 @@
import { fileURLToPath } from "node:url";
// @boundaries-ignore root shared Vitest config
import { createVitestProjectConfig } from "../../vitest.shared";
import { createVitestProjectConfig } from "../../vitest.shared.mts";
export default createVitestProjectConfig({
name: "server",
+2 -1
View File
@@ -9,7 +9,8 @@
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Reactive Resume" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="description" content="Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.">
<!-- Keep under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines. -->
<meta name="description" content="Free, open-source resume builder. Create, update, and share a professional resume in minutes — no ads, no paywall.">
<link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="128x128" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" sizes="256x256 any" />
+1534 -128
View File
File diff suppressed because it is too large Load Diff
+1534 -128
View File
File diff suppressed because it is too large Load Diff
+1534 -128
View File
File diff suppressed because it is too large Load Diff
+1534 -128
View File
File diff suppressed because it is too large Load Diff
+1534 -128
View File
File diff suppressed because it is too large Load Diff
+1534 -128
View File
File diff suppressed because it is too large Load Diff
+1534 -128
View File
File diff suppressed because it is too large Load Diff
+1534 -128
View File
File diff suppressed because it is too large Load Diff
+1534 -128
View File
File diff suppressed because it is too large Load Diff
+1534 -128
View File
File diff suppressed because it is too large Load Diff
+1535 -129
View File
File diff suppressed because it is too large Load Diff
+1534 -128
View File
File diff suppressed because it is too large Load Diff
+1533 -126
View File
File diff suppressed because it is too large Load Diff
+1534 -128
View File
File diff suppressed because it is too large Load Diff
+1534 -128
View File
File diff suppressed because it is too large Load Diff
+1534 -128
View File
File diff suppressed because it is too large Load Diff
+1534 -128
View File
File diff suppressed because it is too large Load Diff
+1535 -129
View File
File diff suppressed because it is too large Load Diff
+1534 -128
View File
File diff suppressed because it is too large Load Diff
+1534 -128
View File
File diff suppressed because it is too large Load Diff
+1534 -128
View File
File diff suppressed because it is too large Load Diff
+1534 -128
View File
File diff suppressed because it is too large Load Diff
+1534 -128
View File
File diff suppressed because it is too large Load Diff
+1534 -128
View File
File diff suppressed because it is too large Load Diff

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