Compare commits

..
126 Commits
Author SHA1 Message Date
Amruth Pillai 3f6e22addb chore(locales): update PO revision dates and add attachment translations 2026-07-04 22:52:09 +02:00
Amruth Pillai 25b70c24f1 chore(deps): update pnpm to version 11.10.0 2026-07-04 22:49:43 +02:00
Amruth Pillai da40422dfa docs(changelog): add ai agent + command palette polish to v5.2.0
Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:40:36 +02:00
Amruth Pillai e15edafbff fix(server): restore @uiw/color-convert runtime dependency
The utils color fallback restore re-added the @uiw/color-convert import
to packages/utils but not to apps/server, whose bundle keeps the package
external. Production server startup failed with ERR_MODULE_NOT_FOUND,
breaking the E2E workflow on main. Re-add the dependency.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:40:36 +02:00
autofix-ci[bot] d5b177aa89 [autofix.ci] apply automated fixes 2026-07-04 20:29:16 +00:00
Amruth Pillai d32227ff43 test(web): assert donation-toast cookie security attributes
Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:26:25 +02:00
Amruth Pillai 7a0d1e93f3 fix(review): restore cookie attrs + color fallback, drop stale knip entry
Code-review findings:
- donation-toast: restore path/secure/sameSite cookie attributes the
  inlined useCookie dropped (security/scope regression).
- utils/color: restore @uiw fallback so percentage-notation rgb() still
  converts (custom style-rule colors are arbitrary strings); add tests
  pinning the one real difference vs the black fallback.
- knip: drop stale npm-check-updates ignoreDependencies entry.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:25:06 +02:00
Amruth Pillai 560956bbe6 test(api): add normalizeAgentResumePatchOperations to ./resume mock
service.ts calls it (added in 82d961241, merged from origin/main) but the
mock omitted it, breaking the patch-apply test. Identity mock matches the
test's pass-through expectation.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:04:28 +02:00
Amruth Pillai 7f458dc58d docs(utils): correct color fallback comment to state real ceiling
Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:00:58 +02:00
Amruth Pillai 361480445f chore(config): finding 5 — drop vitest scripts and config from packages/config
packages/config has no source or test files; the 4 vitest scripts and
vitest.config.ts exist purely for pipeline symmetry. Remove them.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:57:49 +02:00
Amruth Pillai 57fb23145c chore: finding 4 — remove npm-check-updates from root devDependencies
No script invokes it; pnpm dlx npm-check-updates still works on-demand.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:57:39 +02:00
Amruth Pillai 6207cbc026 chore(env): finding 3 — remove dead CROWDIN_PROJECT_ID/CROWDIN_API_TOKEN/GOOGLE_CLOUD_API_KEY
These three vars are only read by GitHub Actions workflows and tooling/fonts
scripts that access process.env directly — never by app/server runtime code.
Removes them from the env schema (server.ts) and turbo.json globalEnv.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:57:30 +02:00
Amruth Pillai a149e614a7 refactor(ui): finding 2 — delete packages/ui use-controlled-state (zero importers)
apps/web has its own copy with 3 importers; the packages/ui copy had none.
Deletes the hook and its 78-line test.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:57:20 +02:00
Amruth Pillai eab7534ea4 refactor(ui,web): finding 1 — inline use-cookie into donation-toast, drop js-cookie from ui
The 107-line useCookie hook had exactly one consumer (donation-toast) that
only read + set-with-expiry. Inline the two Cookies.* calls directly and
delete the hook + its 128-line test. Drop js-cookie and @types/js-cookie
from packages/ui/package.json (apps/web retains its own js-cookie dep).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:57:10 +02:00
Amruth Pillai 79a69c5507 refactor(web): finding 1 — extract SectionItemDialog shell from 14 section dialogs
Each of the 14 section-item dialog files (award→volunteer) had identical
~50-line Create/Update shells (DialogContent + header + form + footer).
Added section-item-dialog.tsx with a SectionItemDialog wrapper that takes
title, icon, onSubmit, onCancel, isSubmitting, submitLabel, singleColumn?.
cover-letter and summary-item use singleColumn=true for their one-column
layout. custom.tsx is untouched (it creates section definitions, not items).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:50:09 +02:00
Amruth Pillai 70df113ee6 refactor(web): finding 9 — public-resume reuses useResumeExport hook
Loosen useResumeExport param to ExportableResume { name, slug, data } so the
public resume page (where name may be '' for non-owner viewers) can reuse it.
getExportName() falls back to data.basics.name then slug, matching the
original inline logic.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:48:04 +02:00
Amruth Pillai 44e9a8a29f refactor(web): finding 6 — delete normalizeResumePreviewProps, inline defaults
normalizeResumePreviewProps had exactly one production caller (preview.tsx).
Defaults now live in the ResumePreview destructuring; the normalizer and its
test cases are removed.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:47:53 +02:00
Amruth Pillai e47cb37ab9 refactor(web): finding 2 — replace dialog renderer indirection with plain typed arrays
defineDialogRenderer/defineDialogRendererRegistry were identity functions.
Each registry now exports a readonly AnyDialogRendererEntry[] directly;
renderer-registry.ts retains only the types. The unused 'domain' field and
its wrapping object are gone; renderers.tsx spreads the arrays directly.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:47:37 +02:00
Amruth Pillai 02538836a9 refactor(web): finding 5 — replace match(boolean) with ternary + ActionButton wrapper
Three auth-settings components (password, two-factor, social-provider) each
duplicated an identical m.div hover/tap wrapper for both branches of
match(boolean). Extracted one ActionButton wrapper and replaced match with
a plain ternary; removed ts-pattern imports.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:47:22 +02:00
Amruth Pillai 22398a502b refactor(web): finding 4 — extract runSignIn helper in SocialAuthButtons
Three near-identical toast→auth→error→invalidate handlers collapsed into
one generic runSignIn(fn) function.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:47:11 +02:00
Amruth Pillai e00348ef84 refactor(web): finding 3 — trim CountUp to {to, duration?, separator?}
No production caller passes from/direction/delay/startWhen/onStart/onEnd.
Removed those props and their setTimeout bookkeeping; updated tests.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:47:01 +02:00
Amruth Pillai 8d17ec6583 refactor(web): finding 8 — drop localStorage migration sentinel, use null-check
Number(null) === 0 caused the old code to use a separate :initialized key as
a migration sentinel. A plain null check on localStorage.getItem() is
sufficient and removes the sentinel key entirely.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:46:50 +02:00
Amruth Pillai e93a56d753 refactor(web): finding 1 - extract TypographyGroupFields component
Extract useTypographyForm helper (captures form creation + sync) and
TypographyForm type. Extract TypographyGroupFields component with
prefix "body" | "heading" to replace 2x4 duplicated form.Field blocks.
Font Weight label ("Font Weights" vs "Font Weight") is preserved per
prefix.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:29:57 +02:00
Amruth Pillai 975cea84e3 refactor(web): finding 5 - replace ts-pattern matchers with data maps
Replace getItemTitle/getItemSubtitle exhaustive ts-pattern matchers with
TITLE_FIELD/SUBTITLE_FIELD lookup maps + special-case branches for
summary and cover-letter. Adds a shared truncateHtml helper. Removes
ts-pattern import.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:29:44 +02:00
Amruth Pillai 34398a578b refactor(web): finding 8 - inline 7 one-caller alias hooks from draft.ts
useInitializeResumeStore, useMergeResumeMetadata, useSaveStatus,
useCanUndo, useCanRedo, useUndoResume, useRedoResume each had exactly
one caller. Inline useResumeStore selectors at call sites and remove
the wrappers. Skipped usePatchResume (4 callers).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:29:32 +02:00
Amruth Pillai 27efeab796 refactor(web): finding 7 - extract makeCustomSection, derive isStandard
Extract makeCustomSection factory to eliminate duplicate CustomSection
object literal. Replace hand-maintained SectionType array in
isStandardSectionId with membership check via `id in sections`.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:29:20 +02:00
Amruth Pillai f5ec471318 refactor(web): finding 6 - remove useQuery picture preview fetch
The uploads endpoint is public (Cache-Control: public, no auth headers),
so a plain img src suffices. Remove createPicturePreviewUrl, the useQuery
call, and the object-URL cleanup useEffect; simplify PicturePreviewControls
to use normalizedPictureUrl directly.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:29:10 +02:00
Amruth Pillai 376977a9f7 refactor(web): finding 9 - simplify useBuilderSidebar selector
Remove generic selector overload from useBuilderSidebar; callers
destructure the full return object instead of using a selector that
provides no meaningful benefit (state is rebuilt on every render).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:28:59 +02:00
Amruth Pillai 9b9d5c833c refactor(web): findings 2/3/4 in page/design/custom-styles
Finding 2: Replace 4 number + 3 switch form.Field blocks with
pageNumberFields/pageSwitchFields array maps.
Finding 3: Extract ColorFormField helper for primary/text/background
color fields in ColorSectionForm.
Finding 4: Remove labelPrefix (was always component-specific), replace
local slugify with @reactive-resume/utils/string import; fix ariaLabel
template literals to keep test labels accurate.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:28:47 +02:00
Amruth Pillai 15448cad6a fix(web): raise lib to ES2023 for consumed api source
agent/service.ts uses findLastIndex/Array.prototype.with (ES2023); web
type-checks api source and its ES2022 lib lacked them. lib only affects
type defs, not Vite runtime output.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:04:52 +02:00
Amruth Pillai afd734dd61 refactor(import): add v4section/v4url aliases, inline clamp wrappers, replace classes with fns
Finding 5: Introduce V4Url + V4Section<T> type aliases in reactive-resume-v4-json.tsx; collapse
310-line V4ResumeData type (13x 5-field section header, 11x {label,href}) into typed aliases.
Inferred shape is structurally identical.

Finding 6: Remove 9 single-caller clamp/pxToPt wrappers; replace compile-time constants
(rotation=0, sidebarWidth=35, shadowWidth=0, gapX=4, gapY=6) with literals; inline dynamic
calls (clamp(x, 32, 512) etc.) at the two body/heading typography call sites.

Finding 7: Convert three stateless single-method importer classes to plain functions.
Extract shared rethrowAsImportError() to error.ts, replacing triplicated ZodError catch blocks.
Update web import dialog + all in-package test files to use function API.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:02:17 +02:00
Amruth Pillai 493ef12a9a refactor(utils→import): move single-consumer date/html/level helpers, inline field into fonts
Finding 4 — consumer-count verification:
  @reactive-resume/utils/date  → 1 consumer (packages/import/src/json-resume.tsx)
  @reactive-resume/utils/html  → 1 consumer (packages/import/src/json-resume.tsx)
  @reactive-resume/utils/level → 1 consumer (packages/import/src/json-resume.tsx)
  @reactive-resume/utils/url   → 4 consumers (auth, api/ai, import, server) — SKIPPED, stays in utils
  @reactive-resume/utils/field → 1 consumer (packages/fonts/src/index.ts)

Move date/html/level source + test files into packages/import and update json-resume.tsx imports.
Inline the two-line unique() helper into fonts/src/index.ts and drop the ./field subpath.
Drop the three moved subpaths from packages/utils exports.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:55:22 +02:00
Amruth Pillai a5935dee0f refactor(utils): replace MONTH_NAMES array, drop @uiw/color-convert, use z.enum for localeSchema
Finding 1: Replace hand-rolled MONTH_NAMES[12] array with Intl.DateTimeFormat("en-US", {month:"long"}).
Finding 2: Drop @uiw/color-convert fallback — parseColorString already rejects the same inputs, return "#000000" instead. Remove dep from utils and server package.json.
Finding 3: Replace 56 z.literal calls in z.union with z.enum([...]); identical parse behavior and inferred type.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:53:51 +02:00
Amruth Pillai 5226f04e86 fix(email): use automatic JSX runtime so templates render under tsx
Templates import no React (react-email convention); tsx resolves each
file's nearest tsconfig, so jsx:preserve made esbuild emit classic
React.createElement and background email rendering threw
'React is not defined'.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:45:05 +02:00
Amruth Pillai a1fb0597a3 Merge remote-tracking branch 'origin/main' into chore/ponytail-cleanup 2026-07-04 20:42:34 +02:00
Amruth Pillai a2a2c0a768 Merge branch 'main' into chore/ponytail-cleanup 2026-07-04 20:40:21 +02:00
Amruth Pillai f2ec6a499f refactor(server): replace hand-rolled withTimeout, merge web handlers, clean checks
- health.ts: swap hand-rolled withTimeout for es-toolkit's (fn-taking API); remove
  redundant inner try/catches from checkDatabase/checkStorage since runCheck catches
  all errors (findings 13, health cleanup)
- web.ts: merge handleWebApp/handleWebAppHead into one function; method is the only
  difference — isHead determines body presence (finding 14)
- app.ts: collapse two separate GET/HEAD wildcard routes into app.on(["GET","HEAD"])
- Update web.test.ts and app.test.ts to drop handleWebAppHead references

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:39:14 +02:00
Amruth Pillai 0fb81ad772 refactor(api): replace file-based statistics cache with in-memory Map
Removes fs, path, env, getLocalDataDirectory imports from statistics service.
A module-level Map<string, {value, cachedAt}> gives the same TTL semantics
without touching disk. Adds clearStatisticsCache() for test isolation and
updates the test to call it in afterEach instead of relying on unique
LOCAL_STORAGE_PATH temp dirs (finding 5).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:36:15 +02:00
Amruth Pillai 19470c8cd2 refactor(api,server): storage — sync S3 client, dead upload types, shared inferContentType
- S3StorageService: replace async createClient/getClient/clientPromise chain with a
  synchronous constructor field; new S3Client() is synchronous (finding 7)
- uploadFile: delete speculative screenshot/pdf upload types (never called); hardcode
  picture key and simplify to 3 lines (finding 6)
- Export inferContentType from @reactive-resume/api/features/storage (finding 8)
- uploads.ts: import inferContentType from storage instead of local duplicate; inline
  buildResponseHeaders into handleUpload (findings 11, 12)

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:33:34 +02:00
Amruth Pillai 3f050e5213 refactor(api): introduce mapAgentEnvironmentError oRPC middleware (finding 1)
Replace 12 near-identical try/catch blocks in threads/actions/attachments/messages
handlers with a single AnyMiddleware that maps the AGENT_ENVIRONMENT_UNAVAILABLE
sentinel to PRECONDITION_FAILED ORPCError.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:31:25 +02:00
Amruth Pillai 91c4a2421c refactor(api): simplify agent service — findLastIndex, shared select, combined aggregate
- attachModelPartsToLatestUserMessage: findLastIndex + Array.with, drop wrapper (finding 2)
- getOrCreateForResume: extract findActiveThreadForResume, remove 16-line dup select (finding 3)
- attachments.create: combine sum+count into one query (finding 4)

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:29:19 +02:00
Amruth Pillai 82d961241e fix: polish ai agent and command palette 2026-07-04 20:24:39 +02:00
Amruth Pillai f3a60432df refactor(pdf): introduce createBaseTemplateStyles factory (~1,150 lines removed)
Move 14 identical style slots (text/heading/div/inline/link/small/bold/
richParagraph/richListItemRow/richListItemMarker/richListItemContent/
splitRow/alignEnd/picture) from all 15 template Page.tsx files into a
single createBaseTemplateStyles factory in templates/shared. Each template
now spreads …base and keeps only its real overrides. Resolved StyleSheet
values are identical to before. Update rtl-fixture and rich-text-template-
styles tests to guard the factory file rather than each template directly.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:13:42 +02:00
Amruth Pillai 0701f3b62a refactor(pdf): extract EmailContactItem/PhoneContactItem/LocationContactItem
Add three shared contact-item components to packages/pdf/src/templates/shared/
contact-item.tsx alongside the existing WebsiteContactItem and CustomFieldContactItem.
Replace ~18 lines of inline email/phone/location JSX in all 15 template headers
with the shared components (EmailContactItem accepts an optional iconName prop
for ditgar's "at" variant; rhyhorn's array-push pattern also updated).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:04:37 +02:00
Amruth Pillai cf738b9306 refactor(pdf): replace ts-pattern match chains with satisfies Record maps
Two match chains in sections.tsx are replaced with plain lookup maps typed
via `satisfies Record<CustomSectionType, ...>` which preserves compile-time
exhaustiveness without the ts-pattern dependency. Remove ts-pattern from
packages/pdf/package.json.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:52:13 +02:00
Amruth Pillai fcc10c6b31 refactor(pdf): deduplicate parseFiniteNumber/parsePxValue/parseFontSize
Export parseFiniteNumber and parsePxValue from icon-size.ts (already the
canonical home of these helpers). Remove the three private copies in
rich-text-spacing.ts and import the shared ones instead.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:49:39 +02:00
Amruth Pillai 3e96605d4c fix(web): avoid retesting new AI providers 2026-07-04 19:42:41 +02:00
Amruth Pillai 7e35e8b657 chore(schema): delete tautology test, add helpers, collapse styleRuleSlots
- Delete templates.test.ts: re-tests z.enum semantics with a hardcoded
  fixture that duplicates the template list in templates.ts.
- default.ts: add 2-line section(icon) helper; 12 repeated 7-field blocks
  collapse to one-liners. Output is byte-identical.
- data.ts: add 2-line itemSection<T> factory; 12 baseSectionSchema.extend()
  blocks collapse to one-liners. Inferred types unchanged.
- data.ts: replace 15-field hand-listed styleRuleSlotsSchema with
  z.partialRecord(styleSlotSchema, styleIntentSchema); parse behaviour and
  TypeScript type are equivalent (unknown keys still rejected).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:38:01 +02:00
Amruth Pillai 8de15822fb chore(db): delete tautological schema-mirror tests, collapse db singleton
- Delete 4 test files (auth/resume/agent schema mirrors + relations type check)
  that re-assert Drizzle table/column names copied verbatim from the schema files.
- Collapse makeDrizzleClient() + createDatabase() into a two-line module-level
  singleton; preserves globalThis.__drizzle caching behaviour.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:36:22 +02:00
Amruth Pillai e2099b9002 refactor(auth): replace z.enum with plain TS union; drop zod dependency
AuthProvider was the only zod usage in the package — a z.enum solely to
infer a type. Replace with a plain union type and remove zod from
packages/auth/package.json dependencies.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:27:24 +02:00
Amruth Pillai 5762eb6a3e refactor(ai): collapse parser prompts to template; replace makeEmptyItem with structuredClone
- Replace pdf-parser-system.md + docx-parser-system.md with a single
  parser-system.md template; prompts.ts substitutes 6 placeholders per
  source type. Produced strings are byte-identical to the former files.
- Remove makeEmptyItem recursive walker (all SECTION_ITEM_SHAPES leaves are
  already zero-valued) and call structuredClone(shape) instead.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:27:13 +02:00
Amruth Pillai dfe75390cd refactor(docx): getBaseRun helper, hoist mainConfig, derive sections, remove dead OL code
- Add getBaseRun() next to getHtmlStyle() and replace 8 inline baseRun spreads (~27 lines).
- Hoist one mainConfig object in buildDocument; sidebar call spreads only the two
  differing color keys (~17 lines).
- Remove BUILT_IN_SECTIONS Set; derive membership via `sectionId in data.sections` (~14 lines).
- Delete unreachable ordered-list numbering machinery in html-to-docx.ts: numberingRef is
  always undefined through all call paths, so isOrdered && numberingRef is always false (~15 lines).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:27:01 +02:00
Amruth Pillai 439ae114f9 refactor(mcp): derive tool metadata from single TOOL_META record
- Extract one TOOL_META record (title/description/inputSchema/annotations per
  tool) consumed by both registerTools and buildMcpServerCard, eliminating the
  ~200-line duplication in the server card.
- Collapse TOOL_ANNOTATIONS from 14 × 4-line inline objects to 5 named
  annotation-preset consts (READ_IDEMPOTENT, WRITE_NON_IDEMPOTENT, etc.),
  saving ~55 lines.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:26:44 +02:00
github-actions[bot]andCrowdin Bot 9b41edb43d Sync Translations from Crowdin (#3213)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-04 19:09:57 +02:00
Amruth Pillai d87c6758ab chore: update dependencies 2026-07-04 19:06:31 +02:00
Amruth Pillai 44fa2badb4 fix(ai): pass system prompt via system option instead of system-role message
Some providers (OpenAI Responses API, others) reject system-role messages in
the messages array with AI_InvalidPromptError. Move the analyze/parse system
prompts to the generateText system option, matching the chat handler.
2026-07-04 18:31:40 +02:00
Amruth Pillai 0abb5a07e6 fix(i18n): strip merge conflict markers reintroduced by crowdin sync 2026-07-04 18:18:58 +02:00
Amruth Pillai a9a38ff5dc fix(e2e): restore template name as img alt in gallery so template-switch test passes 2026-07-04 18:17:04 +02:00
github-actions[bot]andCrowdin Bot bf70705f1f [skip ci] chore(i18n): sync translations from crowdin (#3211)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-04 18:09:01 +02:00
github-actions[bot]andCrowdin Bot 332aa210c4 [skip ci] chore(i18n): sync translations from crowdin (#3209)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-04 18:08:01 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> da6a9f2c78 fix: repair MDX parse error in community spotlight to unblock link check (#3210)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-04 16:07:50 +00:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 4541cf1cdc Update from code changes: refresh builder guides for v5.2.0 (#3208)
* docs: refresh builder guides for v5.2.0 (undo/redo, version history, embedded AI, header downloads)

* docs: tighten SEO descriptions on v5.2.0 builder guides

---------

Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-04 18:06:45 +02:00
Amruth Pillai 27df724d2a feat(builder): show live PDF previews in template gallery, remove hover card 2026-07-04 18:05:45 +02:00
Amruth Pillai bc09430fdf chore(i18n): translate missing strings for am-ET, el-GR, km-KH, th-TH
97 strings translated for Amharic (am-ET), 97 for Greek (el-GR),
97 for Khmer (km-KH), and 98 for Thai (th-TH). All placeholders
preserved verbatim ({0}, {label}, <0>, __APP_VERSION__, etc.).

Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 17:25:18 +02:00
Amruth Pillai e936f93e3a chore: translate missing strings for kn-IN, ml-IN, or-IN, ta-IN, te-IN
Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 17:07:34 +02:00
Amruth Pillai 3ba566506a chore: translate missing strings for bn-BD, hi-IN, mr-IN, ne-NP
Fill 96 empty msgstr entries per locale in the Indic/Devanagari batch:
Bengali (bn-BD), Hindi (hi-IN), Marathi (mr-IN), and Nepali (ne-NP).
2026-07-04 16:41:13 +02:00
Amruth Pillai a7c599b724 chore: translate missing strings for ar-SA, fa-IR, he-IL
Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 16:30:27 +02:00
Amruth Pillai dbb0b179c3 chore: translate missing strings for ja-JP, ko-KR, zh-CN, zh-TW
Fill in 96 empty msgstr entries per locale covering new UI strings
(AI assistant, version history, dashboard, account menu, connection
status, editor controls, and more).

Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 16:24:57 +02:00
Amruth Pillai fc634a202d chore: translate missing strings for id-ID, ms-MY, tr-TR, vi-VN 2026-07-04 16:20:42 +02:00
Amruth Pillai 7fab23870f chore: translate missing strings for az-AZ, bg-BG, ru-RU, sr-SP, uk-UA, uz-UZ
Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 16:08:17 +02:00
Amruth Pillai 20a8a3df9d chore: translate missing strings for pl-PL, pt-BR, pt-PT, ro-RO, sk-SK, sl-SI, sq-AL, sv-SE
Fill in all empty msgstr entries (96 per file, 92 for ro-RO) covering
new UI strings: AI assistant, version history, undo/redo, dashboard,
connection status, export data, and related builder strings.

Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 15:54:15 +02:00
Amruth Pillai e38e37383d chore: translate missing strings for cs-CZ, da-DK, fi-FI, hu-HU, lt-LT, lv-LV, nl-NL, no-NO 2026-07-04 15:38:38 +02:00
Amruth Pillai d45116b2ba chore: translate missing strings for af-ZA, ca-ES, de-DE, en-GB, es-ES, fr-FR, it-IT 2026-07-04 15:24:14 +02:00
Amruth Pillai 6ad4f13914 docs: update changelog to not use images 2026-07-04 14:59:47 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 2f5d321051 docs: trim changelog description to meet SEO length target (#3207)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-04 12:58:39 +00:00
Amruth Pillai 57e9c8c487 v5.2.0: undo/redo, version history, embedded AI assistant, mobile builder & more (#3205) 2026-07-04 14:57:25 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 09bc6ec521 docs: add pg pool error handler fix to weekly changelog (#3204)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-04 07:30:15 +00:00
helder-mattosandClaude Opus 4.8 50885176e0 fix(db): attach an error handler to the pg pool (#3172)
A Postgres connection can drop at any time — e.g. a serverless Postgres such as
Neon terminating the connection (error code 57P01). node-postgres surfaces this as
an 'error' event; without a listener node re-throws it as an unhandled 'error' and
crashes the process. Idle clients emit on the pool, but a client that is connecting
or checked out emits on the client itself, so we listen on both the pool and each
client. The pool then discards the dead client and opens a fresh one on the next
query, so the server survives transient/idle disconnects.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 09:28:18 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> cbeecf6596 Draft changelog: weekly update for post-v5.1.9 changes (#3203)
* docs: add weekly changelog entry for post-v5.1.9 changes

* docs: improve changelog title and description for SEO

---------

Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-04 07:22:49 +00:00
Shanu S ee970f2961 fix: wrap list item content in flex View so bullets respect margin (#3202)
* fix(pdf): wrap list item content in flex View so bullets respect margin

* fix(pdf): add minWidth 0 to bullet content so long text wraps
2026-07-04 09:20:01 +02:00
Amruth Pillai 578a983209 feat: polish micro-interactions with consistent motion system across the app
- add strong easing tokens (--ease-out-strong, --ease-in-out-strong, --ease-drawer)
- restore menu open/close animations (dropdown, context menu, combobox) using
  interruptible transitions via Base UI starting/ending styles
- dialogs: 200ms enter / 150ms exit; command palette opts out (keyboard-initiated)
- tooltips: 400ms initial delay with instant adjacent hovers via provider grouping
- buttons: scale press feedback, specific transition properties instead of transition-all
- tabs: sliding active-tab indicator via Base UI Tabs.Indicator
- sheet: iOS drawer curve with asymmetric enter/exit timing
- animate form validation messages and auth page entrance
- remove dead radix-idiom accordion classes in builder sidebars
2026-07-03 21:48:32 +02:00
autofix-ci[bot] 617135466d [autofix.ci] apply automated fixes 2026-07-03 19:22:57 +00:00
Diego Vega Centeno fa4c8adf78 fix: add conditional flex:1 for nested list content to fix layout (#3198) 2026-07-03 21:22:03 +02:00
Amruth Pillai 5b8ab33888 fix: rethrow non-ENOENT errors when loading .env 2026-07-03 20:55:41 +02:00
Amruth Pillai 0ba44865c7 test: add e2e specs for dashboard, sections, templates, sharing and settings workflows
- dashboard-lifecycle: rename, duplicate, delete via card context menu
- section-editing: add experience item, verify persistence across reloads
- template-switch: switch template in gallery, verify persisted selection
- sharing-password: password-protect public link, unlock as anonymous visitor
- lock-resume: lock blocks update/delete, unlock restores them
- settings-profile: profile name change persists
2026-07-03 20:04:36 +02:00
Amruth Pillai a4999c04af refactor: remove dead code, unused exports and redundant dependencies
- drop dotenv (Node 24 process.loadEnvFile) and dompurify (only used by dead code)
- delete unused ui components/hooks (card, progress, checkbox, use-confirm, use-prompt)
- delete dead sanitizeHtml/sanitizeCss, url-security helpers, patch-resume tool,
  schema/page, createResumePatches, patch-proposal preview builder, fonts fallback helpers
- inline single-caller wrappers (flags service, auth getSession, pdf renderer passthrough)
- deduplicate template color helpers into shared/color-helpers
- unexport 50+ internal-only symbols, remove dead export-map entries
- replace hand-rolled unique()/useIsMobile with Set spread and usehooks-ts
2026-07-03 20:04:36 +02:00
Amruth Pillai 2a80e6a1df chore: update dependencies 2026-07-03 20:04:36 +02:00
SimoandClaude Sonnet 4.6 4c8cc5c016 fix: remove overflow hidden from safeTextStyle (#3186)
overflow: hidden on Text elements in @react-pdf/renderer v4.x clips
content at its initial computed height, hiding any text after a line
break. minWidth/maxWidth/flexShrink already handle horizontal
containment so nothing else breaks.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 18:59:00 +02:00
Amruth Pillai d3735ebe27 chore: update dependencies 2026-06-29 09:09:00 +02:00
github-actions[bot]andCrowdin Bot 8eab8fdaa0 Sync Translations from Crowdin (#3183)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-29 09:01:42 +02:00
autofix-ci[bot] fbb9938af6 [autofix.ci] apply automated fixes 2026-06-29 01:26:48 +00:00
Andrea Accardo 5080fddf51 bugfix: fix list break with marker (#3177) (#3178)
* bugfix: fix list break with marker (#3177)

Signed-off-by: aaccardo <hackardo@gmail.com>

* refactor: fix code smell

Signed-off-by: aaccardo <hackardo@gmail.com>

---------

Signed-off-by: aaccardo <hackardo@gmail.com>
2026-06-29 03:25:55 +02:00
Amruth Pillaicoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Cursor Agentautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
dfd2c77bc9 Add Playwright E2E test setup (#3169)
* docs: design e2e test setup

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

* docs: plan e2e test implementation

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

* test: add playwright e2e scripts

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

* test: configure playwright

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

* test: add core e2e fixtures and specs

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

* ci: run e2e tests on pull requests

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

* [autofix.ci] apply automated fixes

* test: stabilize e2e suite

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

* test: ignore playwright artifacts

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

* Update .github/workflows/e2e.yml

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* test: address e2e review feedback

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-20 07:39:06 +02:00
Amruth PillaiandCursor Agent 56c90947e4 fix: ensure Atlas Cloud sponsor logo links to website (#3170)
Prevent the sponsor logo images from intercepting clicks so the
anchor reliably opens atlascloud.ai in a new tab instead of the SVG
asset.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-06-20 06:03:46 +02:00
github-actions[bot]andCrowdin Bot ae2a1dac12 Sync Translations from Crowdin (#3167)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-18 18:59:00 +02:00
Amruth Pillai dcf1b28c22 chore: release v5.1.9 2026-06-18 18:57:09 +02:00
Amruth Pillai f14d8ce693 feat: add Atlas Cloud sponsorship placements 2026-06-18 18:53:01 +02:00
robertoandAmruth Pillai 2317a82106 fix: register language-specific Noto fallback fonts for non-Latin scripts (#3158)
* fix: use language-specific Noto fonts for CJK PDF fallback

* feat: extend fallback to Arabic/Hebrew/Thai

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-06-17 13:37:09 +02:00
Cantale08andsantino cantale a523e13bfd Problem in word wrapping in the templates (#3136)
Co-authored-by: santino cantale <sopor@ARBA-TSM-WS020.tsm.local>
2026-06-17 13:28:19 +02:00
albanofazzitoandAlbano 1be75240dd fix: use non-empty placeholder for redacted resume name (#3138)
* fix: use non-empty placeholder for redacted resume name

* fix: update stale test title to match new placeholder behavior

---------

Co-authored-by: Albano <alumno26.fazzito.albano@ipm.edu.ar>
2026-06-17 13:27:39 +02:00
sdeonvacation 7275da7303 fix(ai): handle markdown-fenced JSON in analyzeResume response (#3142)
Some providers (notably Anthropic via proxies) wrap JSON output in
markdown code fences (```json ... ```), causing Output.object to
throw NoObjectGeneratedError / JSONParseError.

Replace Output.object with manual JSON boundary extraction that works
regardless of fencing. Also propagate the original AISDKError as cause
in throwAiProviderGatewayError for better diagnostics.
2026-06-17 13:27:06 +02:00
Lihan YANG bc498449d3 Fix MCP PDF download test mock (#3144) 2026-06-17 13:26:42 +02:00
Andrea Accardo 3937f7ed2b feat: add flag to disable api rate limit (#3149)
Signed-off-by: aaccardo <hackardo@gmail.com>
2026-06-17 13:26:27 +02:00
github-actions[bot]andCrowdin Bot d6de3f830f Sync Translations from Crowdin (#3162)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-17 13:18:31 +02:00
Amruth Pillai ef5ff30b13 chore: update linter configuration and add rimraf dependency 2026-06-17 10:51:10 +02:00
Amruth Pillai 37faf592b7 chore: update dependencies 2026-06-17 10:40:23 +02:00
github-actions[bot]andCrowdin Bot 76bd1e80f7 [skip ci] chore(i18n): sync translations from crowdin (#3148)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-06 09:33:09 +02:00
Amruth Pillai 042d076efa chore: update dependencies 2026-06-05 23:35:23 +02:00
github-actions[bot]andCrowdin Bot b9e4ab78ef Sync Translations from Crowdin (#3135)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-01 15:32:53 +02:00
Amruth Pillai 90a9bb9cf1 feat: add "Hide Link Underline" translation for multiple languages 2026-06-01 15:31:39 +02:00
Amruth Pillai 5fb4976ec9 feat: add hide link underline option to resume settings, resolves #3134 2026-06-01 15:30:49 +02:00
github-actions[bot]andCrowdin Bot d6a9bc6c4b Sync Translations from Crowdin (#3132)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-01 15:09:18 +02:00
Amruth Pillai 0dcdcd2960 chore(release): v5.1.8 2026-06-01 15:08:22 +02:00
JamesGoslingsandAmruth Pillai e96a51f31c feat(editor): add multicolor highlight with auto-contrast text (#3110)
* feat(editor): add multicolor highlight with auto-contrast text

Enable the Tiptap Highlight extension in multicolor mode, replacing the
single-color yellow toggle with a full color picker (16 presets + custom).
When the chosen highlight color is perceptually dark, text inside the mark
automatically renders white for readability.

Changes span the full pipeline:
- Editor: ColorPicker UI, extended renderHTML for contrast detection
- PDF: normalizeMarkElements preserves data-color as inline style
- DOCX: mergeStyle reads actual background-color from <mark>
- Utils: new isDarkColor() luminance helper

Backward-compatible: legacy <mark> without data-color still renders yellow.

Resolves #3109

* fix: handle multicolor highlight edge cases

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-06-01 14:58:15 +02:00
github-actions[bot]andCrowdin Bot 1507d869c7 Sync Translations from Crowdin (#3131)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-01 14:05:01 +02:00
JamesGoslingscoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Amruth Pillai
b932711f08 feat: add section heading icons to PDF templates (#3127)
* feat: add section heading icons to PDF templates

Add customizable Phosphor icons before section titles in PDF output.
Users can toggle visibility globally via a new "Hide section heading icons"
switch (independent of item-level icons) and customize individual section
icons through the builder sidebar icon picker.

- Add `icon` field to `baseSectionSchema` and `summarySchema`
- Add `hideSectionIcons` to `pageSchema` (defaults to true for backward compat)
- Implement `SectionHeadingIcon` component with heading font-size scaling
- Support "none" sentinel for per-section icon hiding
- Fallback to sensible defaults (briefcase, graduation-cap, etc.) for legacy data
- Add icon picker to builder sidebar sections and custom section dialogs

Closes #2632

* test: add unit tests for section heading icons

- Add tests for getResumeSectionIcon() covering built-in sections,
  summary, custom sections, "none" sentinel, and default fallbacks
- Add schema tests for baseSectionSchema icon field, summarySchema icon,
  and pageSchema hideSectionIcons default behavior

* refactor: minor updates to icon display

* Update apps/web/locales/es-ES.po

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-01 14:02:42 +02:00
Amruth Pillai 1522794733 fix: typecheck 2026-06-01 10:41:37 +02:00
Lihan YANG e00ff8ceca fix(pdf): avoid toReversed in icon size resolution (#3129) 2026-06-01 10:33:04 +02:00
Amruth Pillai 8e72311bc6 Merge branch 'main' of github.com:amruthpillai/reactive-resume 2026-06-01 10:31:53 +02:00
Amruth Pillai a8c70d784c fix: typecheck 2026-06-01 10:31:25 +02:00
Amruth Pillai 0df7f21130 feat: implement download_resume_pdf mcp tool 2026-06-01 10:26:28 +02:00
Amruth PillaiandCursor Agent 6852f586ea ci: purge Cloudflare cache after release Docker image deploy (#3122)
* ci: purge Cloudflare cache after release Docker image deploy

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

* ci: add timeout and retries to Cloudflare cache purge

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-05-29 01:44:30 +02:00
Amruth PillaiandCursor Agent 1414fecade fix(pdf): apply custom style fontSize to icons and level indicators (#3120) and
* fix(pdf): apply custom style fontSize to icon and level indicator sizes

Map fontSize from Icon and Level Indicator custom style slots to Phosphor
icon size and level indicator dimensions, since react-pdf icons ignore
fontSize in favor of the size prop.

* fix: separate global icon and scoped level indicator font sizes

Icon slot fontSize now drives all resume icons plus level display
decorations. Level indicator fontSize overrides only within level display.
Shared sizing logic lives in schema; design sidebar preview uses global rules.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-05-29 00:41:21 +02:00
Amruth PillaiandCursor Agent c1d11236ae fix(pdf): keep Glalie contact list border box square (#3121)
The decorative border around contact items must not inherit
picture border radius. Set contactList borderRadius to 0.

Fixes amruthpillai/reactive-resume#3119

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-05-29 00:22:51 +02:00
Lihan YANG d09ad2cdc0 Urgent fix server app version dev (#3117)
* fix(server): avoid app version global in MCP dev

* fix(server): use runtime-safe app version metadata
2026-05-29 00:13:45 +02:00
Amruth Pillai 9ce5bacd22 Show experience position with role progression (#3116) 2026-05-28 13:51:02 +02:00
509 changed files with 64366 additions and 26798 deletions
+7
View File
@@ -93,6 +93,13 @@ FLAG_DISABLE_EMAIL_AUTH="false"
# This is useful if you are using a machine with limited resources, like a Raspberry Pi.
FLAG_DISABLE_IMAGE_PROCESSING="false"
# This flag disables API rate limiting for authentication endpoints.
# Rate limiting is enabled by default in production to prevent abuse.
FLAG_DISABLE_API_RATE_LIMIT="false"
# This flag shows sponsor placements on the public landing page.
FLAG_SHOW_SPONSORS="false"
# Allows dynamic OAuth client registration to use any parseable redirect URI,
# including custom schemes, private hosts, and non-loopback http:// URLs.
# WARNING: Enabling this on a public or multi-tenant deployment can enable phishing
+7
View File
@@ -19,6 +19,13 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Check for merge conflict markers
run: |
if git grep -nEI '<{7} |>{7} |^={7}$' -- ':(exclude)*.md' ':(exclude)*.mdx'; then
echo "::error::Merge conflict markers found in tracked files"
exit 1
fi
- name: Install pnpm
uses: pnpm/action-setup@v6
+21
View File
@@ -249,3 +249,24 @@ jobs:
script: |
cd docker
./manage_stack.sh up reactive_resume
- name: Purge Cloudflare cache
if: ${{ needs.mode.outputs.release == 'true' }}
env:
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
set -euo pipefail
response=$(curl -fsS --max-time 10 --retry 3 --retry-delay 5 --retry-connrefused -X POST \
"https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}')
if [ "$(jq -r '.success' <<< "$response")" != "true" ]; then
echo "$response" | jq .
exit 1
fi
echo "Cloudflare cache purged successfully."
+88
View File
@@ -0,0 +1,88 @@
name: E2E Tests
on:
pull_request:
push:
branches: ["main"]
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
APP_URL: http://localhost:3000
PORT: "3000"
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
FLAG_DISABLE_SIGNUPS: "false"
FLAG_DISABLE_EMAIL_AUTH: "false"
FLAG_DISABLE_API_RATE_LIMIT: "true"
LOCAL_STORAGE_PATH: /tmp/reactive-resume-e2e-storage
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout Repository
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright Browser
run: pnpm exec playwright install --with-deps chromium
- name: Generate Test Secrets
run: |
echo "AUTH_SECRET=$(openssl rand -hex 32)" >> "$GITHUB_ENV"
echo "ENCRYPTION_SECRET=$(openssl rand -hex 32)" >> "$GITHUB_ENV"
- name: Prepare Storage
run: mkdir -p "$LOCAL_STORAGE_PATH"
- name: Run Database Migrations
run: pnpm db:migrate
- name: Build
run: pnpm build
- name: Run E2E Tests
run: pnpm test:e2e:ci
- name: Upload Playwright Report
if: always()
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: |
playwright-report
test-results
if-no-files-found: ignore
retention-days: 7
+3
View File
@@ -36,6 +36,8 @@ logs
# Testing
coverage
reports
playwright-report
test-results
# Cache
tmp
@@ -49,6 +51,7 @@ temp
.cursor
.codegraph
.superpowers
.migration
# Local Storage Data
/data
+4 -1
View File
@@ -27,5 +27,8 @@
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
],
"tailwindCSS.experimental.configFile": "src/styles/globals.css",
"typescript.experimental.useTsgo": true
"typescript.experimental.useTsgo": true,
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
+14
View File
@@ -31,6 +31,20 @@ Reactive Resume makes building resumes straightforward. Pick a template, fill in
Built with privacy as a core principle, Reactive Resume gives you complete ownership of your data. The codebase is fully open-source under the MIT license, with no tracking, no ads, and no hidden costs.
## Sponsors
Reactive Resume stays free, open-source, and independent because companies choose to support the work behind it. Thank you to every sponsor who helps fund hosting, maintenance, and continued development for the community.
<p>
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=reactive-resume">
<img src="apps/web/public/sponsors/atlas-cloud-logo-white.svg" alt="Atlas Cloud" width="320" />
</a>
</p>
[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=reactive-resume) supports Reactive Resume as a project sponsor. Atlas Cloud provides a unified AI platform for developers, with access to hundreds of models for chat, image generation, video generation, media processing, and GPU cloud workloads through one API key, one endpoint, and one billing account.
If your company would like to sponsor Reactive Resume, email [hello@amruthpillai.com](mailto:hello@amruthpillai.com).
## Features
**Resume Building**
+37 -39
View File
@@ -17,24 +17,24 @@
"#react-pdf-renderer": "@react-pdf/renderer"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.80",
"@ai-sdk/google": "^3.0.80",
"@ai-sdk/openai": "^3.0.65",
"@ai-sdk/openai-compatible": "^2.0.48",
"@aws-sdk/client-s3": "^3.1055.0",
"@better-auth/api-key": "^1.6.11",
"@better-auth/drizzle-adapter": "^1.6.11",
"@better-auth/infra": "^0.2.10",
"@better-auth/oauth-provider": "^1.6.11",
"@better-auth/passkey": "^1.6.11",
"@hono/node-server": "^2.0.4",
"@ai-sdk/anthropic": "^4.0.8",
"@ai-sdk/google": "^4.0.8",
"@ai-sdk/openai": "^4.0.7",
"@ai-sdk/openai-compatible": "^3.0.5",
"@aws-sdk/client-s3": "^3.1079.0",
"@better-auth/api-key": "^1.6.23",
"@better-auth/drizzle-adapter": "^1.6.23",
"@better-auth/infra": "^0.3.4",
"@better-auth/oauth-provider": "^1.6.23",
"@better-auth/passkey": "^1.6.23",
"@hono/node-server": "^2.0.8",
"@modelcontextprotocol/sdk": "^1.29.0",
"@orpc/client": "^1.14.3",
"@orpc/experimental-ratelimit": "^1.14.3",
"@orpc/json-schema": "^1.14.3",
"@orpc/openapi": "^1.14.3",
"@orpc/server": "^1.14.3",
"@orpc/zod": "^1.14.3",
"@orpc/client": "^1.14.6",
"@orpc/experimental-ratelimit": "^1.14.6",
"@orpc/json-schema": "^1.14.6",
"@orpc/openapi": "^1.14.6",
"@orpc/server": "^1.14.6",
"@orpc/zod": "^1.14.6",
"@react-pdf/renderer": "^4.5.1",
"@reactive-resume/api": "workspace:*",
"@reactive-resume/auth": "workspace:*",
@@ -46,43 +46,41 @@
"@sindresorhus/slugify": "^3.0.0",
"@t3-oss/env-core": "^0.13.11",
"@uiw/color-convert": "^2.10.3",
"ai": "^6.0.191",
"ai": "^7.0.15",
"bcrypt": "^6.0.0",
"better-auth": "1.6.11",
"better-auth": "1.6.23",
"cjk-regex": "^3.4.0",
"deepmerge-ts": "^7.1.5",
"dompurify": "^3.4.7",
"dotenv": "^17.4.2",
"drizzle-orm": "1.0.0-rc.3",
"drizzle-orm": "1.0.0-rc.4",
"drizzle-zod": "1.0.0-beta.14-a36c63d",
"es-toolkit": "^1.47.0",
"es-toolkit": "^1.49.0",
"fast-json-patch": "^3.1.1",
"hono": "^4.12.23",
"jsonrepair": "^3.14.0",
"node-html-parser": "^7.1.0",
"nodemailer": "^8.0.9",
"ollama-ai-provider-v2": "^3.5.1",
"pg": "^8.21.0",
"hono": "^4.12.27",
"jsonrepair": "^3.15.0",
"node-html-parser": "^8.0.4",
"nodemailer": "^9.0.3",
"ollama-ai-provider-v2": "^3.6.0",
"pg": "^8.22.0",
"phosphor-icons-react-pdf": "^0.1.3",
"react": "^19.2.6",
"react-email": "^6.5.0",
"react": "^19.2.7",
"react-email": "^6.6.6",
"react-pdf-html": "^2.1.5",
"resumable-stream": "^2.2.12",
"sharp": "^0.34.5",
"sharp": "^0.35.3",
"ts-pattern": "^5.9.0",
"unique-names-generator": "^4.7.1",
"uuid": "^14.0.0",
"uuid": "^14.0.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/node": "^25.9.1",
"@types/node": "^26.1.0",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.15",
"@typescript/native-preview": "7.0.0-dev.20260527.1",
"tsdown": "^0.22.0",
"tsx": "^4.22.3",
"@types/react": "^19.2.17",
"@typescript/native-preview": "7.0.0-dev.20260704.1",
"tsdown": "^0.22.3",
"tsx": "^4.23.0",
"typescript": "^6.0.3",
"vitest": "^4.1.7"
"vitest": "^4.1.9"
}
}
+1
View File
@@ -0,0 +1 @@
export const appVersion = typeof __APP_VERSION__ === "undefined" ? "0.0.0" : __APP_VERSION__;
+19 -4
View File
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
handleHealth: vi.fn(),
handleUpload: vi.fn(),
handleMcp: vi.fn(),
handleResumePdfDownload: vi.fn(),
handleMcpServerCard: vi.fn(),
handleOAuthAuthorizationServer: vi.fn(),
handleOAuthProtectedResource: vi.fn(),
@@ -18,7 +19,6 @@ const mocks = vi.hoisted(() => ({
handleLlms: vi.fn(),
serveWebDistStatic: vi.fn(),
handleWebApp: vi.fn(),
handleWebAppHead: vi.fn(),
}));
vi.mock("./auth", () => ({
@@ -59,13 +59,16 @@ vi.mock("../static/seo", () => ({
vi.mock("../static/web", () => ({
serveWebDistStatic: mocks.serveWebDistStatic,
handleWebApp: mocks.handleWebApp,
handleWebAppHead: mocks.handleWebAppHead,
}));
vi.mock("../mcp/handler", () => ({
handleMcp: mocks.handleMcp,
}));
vi.mock("./resume-pdf", () => ({
handleResumePdfDownload: mocks.handleResumePdfDownload,
}));
beforeEach(() => {
vi.clearAllMocks();
mocks.handleAuth.mockResolvedValue(new Response("auth"));
@@ -75,6 +78,7 @@ beforeEach(() => {
mocks.handleHealth.mockReturnValue(new Response("health"));
mocks.handleUpload.mockResolvedValue(new Response("upload"));
mocks.handleMcp.mockResolvedValue(new Response("mcp"));
mocks.handleResumePdfDownload.mockResolvedValue(new Response("pdf"));
mocks.handleMcpServerCard.mockReturnValue(new Response("server-card"));
mocks.handleOAuthAuthorizationServer.mockReturnValue(new Response("oauth-authorization-server"));
mocks.handleOAuthProtectedResource.mockReturnValue(new Response("oauth-protected-resource"));
@@ -85,7 +89,6 @@ beforeEach(() => {
mocks.handleLlms.mockReturnValue(new Response("llms"));
mocks.serveWebDistStatic.mockResolvedValue(undefined);
mocks.handleWebApp.mockResolvedValue(new Response("web"));
mocks.handleWebAppHead.mockReturnValue(new Response(null));
});
describe("createApp", () => {
@@ -101,6 +104,19 @@ describe("createApp", () => {
expect(mocks.handleAuth).not.toHaveBeenCalled();
});
it("routes signed resume PDF downloads before the web fallback", async () => {
const { createApp } = await import("./app");
const app = createApp();
const request = new Request("http://localhost:3001/api/resumes/resume-1/pdf?token=signed");
const response = await app.fetch(request);
await expect(response.text()).resolves.toBe("pdf");
expect(mocks.handleResumePdfDownload).toHaveBeenCalledWith(request, "resume-1");
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
expect(mocks.handleWebApp).not.toHaveBeenCalled();
});
it.each([
["GET", "/robots.txt", "robots", mocks.handleRobots],
["HEAD", "/robots.txt", "", mocks.handleRobots],
@@ -119,6 +135,5 @@ describe("createApp", () => {
expect(handler).toHaveBeenCalledWith({ head: method === "HEAD" });
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
expect(mocks.handleWebApp).not.toHaveBeenCalled();
expect(mocks.handleWebAppHead).not.toHaveBeenCalled();
});
});
+4 -3
View File
@@ -12,9 +12,10 @@ import { handleRpc } from "../rpc/handler";
import { handleSchemaJson } from "../static/schema";
import { handleLlms, handleRobots, handleSitemap } from "../static/seo";
import { handleUpload } from "../static/uploads";
import { handleWebApp, handleWebAppHead, serveWebDistStatic } from "../static/web";
import { handleWebApp, serveWebDistStatic } from "../static/web";
import { handleAuth, handleOAuth } from "./auth";
import { handleHealth } from "./health";
import { handleResumePdfDownload } from "./resume-pdf";
export function createApp() {
const app = new Hono();
@@ -26,6 +27,7 @@ export function createApp() {
app.get("/api/auth/oauth", (c) => handleOAuth(c.req.raw));
app.all("/api/auth/*", (c) => handleAuth(c.req.raw));
app.get("/api/health", () => handleHealth());
app.get("/api/resumes/:id/pdf", (c) => handleResumePdfDownload(c.req.raw, c.req.param("id")));
app.get("/api/uploads/*", (c) => handleUpload(c.req.raw));
app.get("/uploads/*", (c) => handleUpload(c.req.raw));
app.get("/schema.json", () => handleSchemaJson());
@@ -45,8 +47,7 @@ export function createApp() {
app.on(["GET", "HEAD"], "/llms.txt", (c) => handleLlms({ head: c.req.method === "HEAD" }));
app.use("/*", serveWebDistStatic);
app.on(["GET"], "/*", (c) => handleWebApp(c.req.raw));
app.on(["HEAD"], "/*", (c) => handleWebAppHead(c.req.raw));
app.on(["GET", "HEAD"], "/*", (c) => handleWebApp(c.req.raw));
return app;
}
+8 -39
View File
@@ -1,4 +1,5 @@
import { sql } from "drizzle-orm";
import { withTimeout } from "es-toolkit";
import { getStorageService } from "@reactive-resume/api/features/storage";
import { db } from "@reactive-resume/db/client";
@@ -11,30 +12,12 @@ type CheckResult = {
[key: string]: unknown;
};
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return "Unknown error";
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timeoutId: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
});
try {
return await Promise.race([promise, timeout]);
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}
// ponytail: es-toolkit withTimeout takes a fn, not a promise — call site passes check (not check())
async function runCheck(check: () => Promise<object>): Promise<CheckResult> {
const startedAt = performance.now();
try {
const data = await withTimeout(check(), HEALTHCHECK_TIMEOUT_MS);
const data = await withTimeout(check, HEALTHCHECK_TIMEOUT_MS);
const latencyMs = Math.round(performance.now() - startedAt);
const result = data as { status?: string };
if (result.status === "unhealthy") return { ...(data as object), status: "unhealthy", latencyMs };
@@ -42,34 +25,20 @@ async function runCheck(check: () => Promise<object>): Promise<CheckResult> {
} catch (error) {
return {
status: "unhealthy",
error: getErrorMessage(error),
error: error instanceof Error ? error.message : "Unknown error",
latencyMs: Math.round(performance.now() - startedAt),
};
}
}
// ponytail: inner try/catches removed; runCheck's outer catch handles all errors
async function checkDatabase() {
try {
await db.execute(sql`SELECT 1`);
return { status: "healthy" };
} catch (error) {
return {
status: "unhealthy",
error: error instanceof Error ? error.message : "Unknown error",
};
}
await db.execute(sql`SELECT 1`);
return { status: "healthy" };
}
async function checkStorage() {
try {
const storageService = getStorageService();
return await storageService.healthcheck();
} catch (error) {
return {
status: "unhealthy",
error: error instanceof Error ? error.message : "Unknown error",
};
}
return getStorageService().healthcheck();
}
export async function handleHealth() {
+70
View File
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createResumePdfDownload: vi.fn(),
verifyResumePdfDownloadToken: vi.fn(),
}));
vi.mock("@reactive-resume/api/features/resume/export", () => ({
createResumePdfDownload: mocks.createResumePdfDownload,
verifyResumePdfDownloadToken: mocks.verifyResumePdfDownloadToken,
}));
const { handleResumePdfDownload } = await import("./resume-pdf");
describe("handleResumePdfDownload", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders the PDF when the signed URL token is valid", async () => {
const pdf = new File([new Uint8Array([37, 80, 68, 70])], "Scizor.pdf", { type: "application/pdf" });
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
ok: true,
resumeId: "resume-1",
userId: "user-1",
expiresAt: "2026-06-01T10:10:00.000Z",
});
mocks.createResumePdfDownload.mockResolvedValueOnce({
headers: { "content-disposition": 'attachment; filename="Scizor.pdf"' },
body: pdf,
});
const response = await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf?token=signed"),
"resume-1",
);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("application/pdf");
expect(response.headers.get("Content-Disposition")).toBe('attachment; filename="Scizor.pdf"');
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect(await response.text()).toBe("%PDF");
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({ id: "resume-1", userId: "user-1" });
});
it("rejects missing, invalid, and expired tokens before rendering", async () => {
let response = await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf"),
"resume-1",
);
expect(response.status).toBe(401);
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({ ok: false, reason: "invalid_signature" });
response = await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf?token=bad"),
"resume-1",
);
expect(response.status).toBe(401);
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({ ok: false, reason: "expired" });
response = await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf?token=expired"),
"resume-1",
);
expect(response.status).toBe(410);
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
});
});
+53
View File
@@ -0,0 +1,53 @@
import { createResumePdfDownload, verifyResumePdfDownloadToken } from "@reactive-resume/api/features/resume/export";
function unauthorizedResponse() {
return new Response("Unauthorized", {
status: 401,
headers: {
"Cache-Control": "private, no-store",
},
});
}
function expiredResponse() {
return new Response("Download link expired", {
status: 410,
headers: {
"Cache-Control": "private, no-store",
},
});
}
function errorStatus(error: unknown) {
const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : undefined;
return code === "NOT_FOUND" ? 404 : 500;
}
export async function handleResumePdfDownload(request: Request, id: string) {
const token = new URL(request.url).searchParams.get("token");
if (!token) return unauthorizedResponse();
const verification = verifyResumePdfDownloadToken({ resumeId: id, token });
if (!verification.ok) return verification.reason === "expired" ? expiredResponse() : unauthorizedResponse();
try {
const download = await createResumePdfDownload({ id, userId: verification.userId });
return new Response(download.body, {
headers: {
"Content-Type": download.body.type || "application/pdf",
"Content-Disposition": download.headers["content-disposition"],
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
console.error("[PDF Download]", error);
return new Response("Failed to generate resume PDF", {
status: errorStatus(error),
headers: {
"Cache-Control": "private, no-store",
},
});
}
}
+3 -1
View File
@@ -4,6 +4,7 @@ import { onError } from "@orpc/client";
import { createRouterClient } from "@orpc/server";
import router from "@reactive-resume/api/routers";
import { MCP_TOOL_NAME, registerPrompts, registerResources, registerTools } from "@reactive-resume/mcp";
import { appVersion } from "../app-version";
import { getRequestLocale } from "../rpc/locale";
function createRequestClient(request: Request): RouterClient<typeof router> {
@@ -25,7 +26,7 @@ export async function createMcpServer(request: Request) {
const server = new McpServer(
{
name: "reactive-resume",
version: __APP_VERSION__,
version: appVersion,
title: "Reactive Resume",
websiteUrl: "https://rxresu.me",
description:
@@ -52,6 +53,7 @@ export async function createMcpServer(request: Request) {
`Read schema at \`resume://_meta/schema\`; read resume JSON via \`resume://{id}\` or \`${MCP_TOOL_NAME.getResume}\`.`,
`Apply body edits with JSON Patch through \`${MCP_TOOL_NAME.patchResume}\`.`,
`Change name, slug, tags, or public visibility with \`${MCP_TOOL_NAME.updateResume}\` (returns canonical share URL; anonymous access only when \`isPublic\` is true; passwords are managed in the web app only).`,
`Create short-lived authenticated PDF download URLs with \`${MCP_TOOL_NAME.downloadResumePdf}\`.`,
`Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`; read saved AI analysis with \`${MCP_TOOL_NAME.getResumeAnalysis}\`.`,
].join(" "),
},
+2 -1
View File
@@ -8,6 +8,7 @@ import { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume
import router from "@reactive-resume/api/routers";
import { env } from "@reactive-resume/env/server";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { appVersion } from "../app-version";
import { mergeResponseHeaders } from "../http/headers";
import { getRequestLocale } from "../rpc/locale";
@@ -44,7 +45,7 @@ export async function handleOpenApi(request: Request) {
const spec = await openAPIGenerator.generate(openAPIRouter, {
info: {
title: "Reactive Resume",
version: __APP_VERSION__,
version: appVersion,
description: "Reactive Resume API",
license: { name: "MIT", url: "https://github.com/amruthpillai/reactive-resume/blob/main/LICENSE" },
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
+2 -1
View File
@@ -2,6 +2,7 @@ import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } fr
import { auth } from "@reactive-resume/auth/config";
import { env } from "@reactive-resume/env/server";
import { buildMcpServerCard } from "@reactive-resume/mcp/server-card";
import { appVersion } from "../app-version";
const oauthAuthorizationServerHandler = oauthProviderAuthServerMetadata(auth);
const openIdConfigurationHandler = oauthProviderOpenIdConfigMetadata(auth);
@@ -11,7 +12,7 @@ export function handleWellKnownFallback() {
}
export function handleMcpServerCard() {
return Response.json(buildMcpServerCard(__APP_VERSION__), {
return Response.json(buildMcpServerCard(appVersion), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=60, stale-while-revalidate=120",
+2 -1
View File
@@ -1,5 +1,6 @@
import z from "zod";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { appVersion } from "../app-version";
export function handleSchemaJson() {
const resumeDataJSONSchema = z.toJSONSchema(resumeDataSchema);
@@ -12,7 +13,7 @@ export function handleSchemaJson() {
"Surrogate-Control": "max-age=86400",
"X-Content-Type-Options": "nosniff",
"X-Robots-Tag": "index, follow",
ETag: __APP_VERSION__,
ETag: appVersion,
Vary: "Accept",
},
});
+19 -65
View File
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { basename, extname, normalize } from "node:path";
import { getStorageService } from "@reactive-resume/api/features/storage";
import { getStorageService, inferContentType } from "@reactive-resume/api/features/storage";
import { env } from "@reactive-resume/env/server";
export async function handleUpload(request: Request) {
@@ -18,41 +18,32 @@ export async function handleUpload(request: Request) {
const filename = filePath.split("/").pop() ?? filePath;
const ext = extname(filename).toLowerCase();
const contentType = storedFile.contentType ?? inferContentTypeFromExtension(ext);
const contentType = storedFile.contentType ?? inferContentType(filename);
const etag = createEtag(storedFile);
if (isNotModified(request.headers, etag)) return makeNotModifiedResponse(etag);
const shouldForceDownload = [".pdf"].includes(ext);
const headers = buildResponseHeaders({
filename,
storedFile,
contentType,
etag,
shouldForceDownload,
});
const buffer = toArrayBuffer(storedFile.data);
const headers = new Headers();
headers.set("Content-Type", shouldForceDownload ? "application/octet-stream" : contentType);
headers.set("Content-Length", storedFile.size.toString());
return new Response(buffer, { headers });
}
function inferContentTypeFromExtension(ext: string): string {
switch (ext) {
case ".webp":
return "image/webp";
case ".png":
return "image/png";
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".gif":
return "image/gif";
case ".pdf":
return "application/pdf";
default:
return "application/octet-stream";
if (shouldForceDownload) {
headers.set("Content-Disposition", `attachment; filename="${encodeURIComponent(basename(filename))}"`);
}
headers.set("Cache-Control", "public, max-age=31536000, immutable");
headers.set("ETag", etag);
headers.set("X-Content-Type-Options", "nosniff");
headers.set("X-Robots-Tag", "noindex, nofollow");
headers.set("Cross-Origin-Resource-Policy", "same-site");
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
headers.set("X-Frame-Options", "DENY");
headers.set("X-Download-Options", "noopen");
headers.set("Access-Control-Allow-Origin", env.APP_URL);
return new Response(toArrayBuffer(storedFile.data), { headers });
}
function parseRouteParams(url: string): { userId: string | undefined; filePath: string | undefined } {
@@ -101,43 +92,6 @@ function makeNotModifiedResponse(etag: string): Response {
});
}
type BuildResponseHeaderArgs = {
filename: string;
storedFile: { size: number };
contentType: string;
etag: string;
shouldForceDownload: boolean;
};
function buildResponseHeaders({
filename,
storedFile,
contentType,
etag,
shouldForceDownload,
}: BuildResponseHeaderArgs): Headers {
const headers = new Headers();
headers.set("Content-Type", shouldForceDownload ? "application/octet-stream" : contentType);
headers.set("Content-Length", storedFile.size.toString());
if (shouldForceDownload) {
headers.set("Content-Disposition", `attachment; filename="${encodeURIComponent(basename(filename))}"`);
}
headers.set("Cache-Control", "public, max-age=31536000, immutable");
headers.set("ETag", etag);
headers.set("X-Content-Type-Options", "nosniff");
headers.set("X-Robots-Tag", "noindex, nofollow");
headers.set("Cross-Origin-Resource-Policy", "same-site");
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
headers.set("X-Frame-Options", "DENY");
headers.set("X-Download-Options", "noopen");
headers.set("Access-Control-Allow-Origin", env.APP_URL);
return headers;
}
function toArrayBuffer(data: Uint8Array): ArrayBuffer {
return data.byteOffset === 0 && data.byteLength === data.buffer.byteLength
? (data.buffer as ArrayBuffer)
+5 -3
View File
@@ -15,7 +15,7 @@ vi.mock("@hono/node-server/serve-static", () => ({
serveStatic: vi.fn(() => vi.fn()),
}));
const { handleWebApp, handleWebAppHead } = await import("./web");
const { handleWebApp } = await import("./web");
describe("web app fallback classification", () => {
beforeEach(() => {
@@ -90,8 +90,10 @@ describe("web app fallback classification", () => {
});
it("mirrors fallback status and headers for HEAD without a body", async () => {
const knownResponse = handleWebAppHead(new Request("https://example.com/dashboard"));
const unknownResponse = handleWebAppHead(new Request("https://example.com/unknown/extra/path"));
const knownResponse = await handleWebApp(new Request("https://example.com/dashboard", { method: "HEAD" }));
const unknownResponse = await handleWebApp(
new Request("https://example.com/unknown/extra/path", { method: "HEAD" }),
);
expect(knownResponse.status).toBe(200);
expect(knownResponse.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
+9 -12
View File
@@ -74,23 +74,20 @@ function notFoundResponse(options: { head?: boolean; noindex?: boolean } = {}) {
});
}
// ponytail: GET and HEAD share the same routing logic; method determines body presence
export async function handleWebApp(request: Request) {
const isHead = request.method === "HEAD";
const pathname = new URL(request.url).pathname;
if (!isNoindexShellPath(pathname) && isAssetPath(pathname)) return new Response("Not Found", { status: 404 });
if (!isNoindexShellPath(pathname) && isAssetPath(pathname)) {
return new Response(isHead ? null : "Not Found", { status: 404 });
}
const headers = getFallbackResponseHeaders(pathname);
if (!headers) return notFoundResponse({ noindex: true });
if (!headers) return notFoundResponse({ head: isHead, noindex: true });
if (isHead) return new Response(null, { status: 200, headers });
const html = await fs.readFile(indexHtmlPath, "utf-8");
return new Response(html, { headers });
}
export function handleWebAppHead(request: Request) {
const pathname = new URL(request.url).pathname;
if (!isNoindexShellPath(pathname) && isAssetPath(pathname)) return new Response(null, { status: 404 });
const headers = getFallbackResponseHeaders(pathname);
if (!headers) return notFoundResponse({ head: true, noindex: true });
return new Response(null, { status: 200, headers });
}
+36
View File
@@ -19,7 +19,43 @@
<title>Reactive Resume</title>
</head>
<body>
<!-- Keep #app empty: main.tsx only mounts React when rootElement has no children. -->
<div id="app"></div>
<!-- Branded first paint; hidden once React populates #app (higher-specificity rule below). -->
<div id="initial-loader">
<img src="/icon/dark.svg" width="48" height="48" alt="Reactive Resume" />
<div class="initial-loader__spinner"></div>
<span class="initial-loader__sr-only">Loading</span>
</div>
<style>
@keyframes app-spin { to { transform: rotate(360deg) } }
#initial-loader {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
background: #09090b;
}
#app:not(:empty) ~ #initial-loader { display: none; }
.initial-loader__spinner {
width: 24px;
height: 24px;
border: 2px solid rgba(250, 250, 250, 0.2);
border-top-color: #fafafa;
border-radius: 9999px;
animation: app-spin 0.7s linear infinite;
}
.initial-loader__sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
</style>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+512 -195
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+512 -195
View File
File diff suppressed because it is too large Load Diff
+511 -194
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+512 -195
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+698 -381
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+512 -195
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+512 -195
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+516 -199
View File
File diff suppressed because it is too large Load Diff
+513 -196
View File
File diff suppressed because it is too large Load Diff
+502 -185
View File
File diff suppressed because it is too large Load Diff
+50 -48
View File
@@ -4,7 +4,7 @@
"type": "module",
"private": true,
"scripts": {
"build": "rm -rf dist && vite build",
"build": "rimraf dist && vite build",
"dev": "vite dev",
"serve": "vite preview",
"start": "vite preview",
@@ -16,20 +16,20 @@
"lingui:extract": "lingui extract --clean --overwrite"
},
"dependencies": {
"@ai-sdk/react": "^3.0.193",
"@base-ui/react": "^1.5.0",
"@better-auth/api-key": "^1.6.11",
"@better-auth/infra": "^0.2.10",
"@better-auth/oauth-provider": "^1.6.11",
"@better-auth/passkey": "^1.6.11",
"@ai-sdk/react": "^4.0.16",
"@base-ui/react": "^1.6.0",
"@better-auth/api-key": "^1.6.23",
"@better-auth/infra": "^0.3.4",
"@better-auth/oauth-provider": "^1.6.23",
"@better-auth/passkey": "^1.6.23",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lingui/core": "^6.1.0",
"@lingui/react": "^6.1.0",
"@orpc/client": "^1.14.3",
"@orpc/server": "^1.14.3",
"@orpc/tanstack-query": "^1.14.3",
"@lingui/core": "^6.4.0",
"@lingui/react": "^6.4.0",
"@orpc/client": "^1.14.6",
"@orpc/server": "^1.14.6",
"@orpc/tanstack-query": "^1.14.6",
"@phosphor-icons/react": "^2.1.10",
"@react-pdf/renderer": "^4.5.1",
"@reactive-resume/ai": "workspace:*",
@@ -42,64 +42,66 @@
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/ui": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-form": "^1.32.1",
"@tailwindcss/vite": "^4.3.2",
"@tanstack/react-form": "^1.33.0",
"@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-query": "^5.100.14",
"@tanstack/react-router": "^1.170.8",
"@tiptap/extension-color": "^3.23.6",
"@tiptap/extension-highlight": "^3.23.6",
"@tiptap/extension-table": "^3.23.6",
"@tiptap/extension-text-align": "^3.23.6",
"@tiptap/extension-text-style": "^3.23.6",
"@tiptap/pm": "^3.23.6",
"@tiptap/react": "^3.23.6",
"@tiptap/starter-kit": "^3.23.6",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-router": "^1.170.17",
"@tiptap/extension-color": "^3.27.1",
"@tiptap/extension-highlight": "^3.27.1",
"@tiptap/extension-text-align": "^3.27.1",
"@tiptap/extension-text-style": "^3.27.1",
"@tiptap/pm": "^3.27.1",
"@tiptap/react": "^3.27.1",
"@tiptap/starter-kit": "^3.27.1",
"@types/js-cookie": "^3.0.6",
"@uiw/color-convert": "^2.10.3",
"@uiw/react-color-colorful": "^2.10.3",
"ai": "^6.0.191",
"better-auth": "1.6.11",
"ai": "^7.0.15",
"better-auth": "1.6.23",
"cmdk": "^1.1.1",
"drizzle-orm": "1.0.0-rc.3",
"es-toolkit": "^1.47.0",
"fuse.js": "^7.3.0",
"immer": "^11.1.8",
"js-cookie": "^3.0.7",
"motion": "^12.40.0",
"pdfjs-dist": "5.7.284",
"pg": "^8.21.0",
"drizzle-orm": "1.0.0-rc.4",
"es-toolkit": "^1.49.0",
"fuse.js": "^7.4.2",
"immer": "^11.1.11",
"js-cookie": "^3.0.8",
"motion": "^12.42.2",
"pdfjs-dist": "6.1.200",
"pg": "^8.22.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-easy-crop": "^6.1.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.11.2",
"react-resizable-panels": "^4.12.1",
"react-window": "^2.2.7",
"react-zoom-pan-pinch": "^4.0.3",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"ts-pattern": "^5.9.0",
"usehooks-ts": "^3.1.1",
"zod": "^4.4.3",
"zustand": "^5.0.13"
"zustand": "^5.0.14"
},
"devDependencies": {
"@babel/core": "^7.29.7",
"@lingui/babel-plugin-lingui-macro": "^6.1.0",
"@lingui/cli": "^6.1.0",
"@lingui/format-po": "^6.1.0",
"@lingui/vite-plugin": "^6.1.0",
"@babel/core": "^8.0.1",
"@lingui/babel-plugin-lingui-macro": "^6.4.0",
"@lingui/cli": "^6.4.0",
"@lingui/format-po": "^6.4.0",
"@lingui/vite-plugin": "^6.4.0",
"@reactive-resume/config": "workspace:*",
"@rolldown/plugin-babel": "^0.2.3",
"@tanstack/router-plugin": "^1.168.11",
"@tanstack/router-plugin": "^1.168.19",
"@types/babel__core": "^7.20.5",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.15",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260527.1",
"@vitejs/plugin-react": "^6.0.2",
"@typescript/native-preview": "7.0.0-dev.20260704.1",
"@vitejs/plugin-react": "^6.0.3",
"babel-plugin-macros": "^3.1.0",
"babel-plugin-react-compiler": "^1.0.0",
"rimraf": "^6.1.3",
"typescript": "^6.0.3",
"vite": "^8.0.14"
"vite": "^8.1.3"
}
}
+1
View File
@@ -1,4 +1,5 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>Reactive Resume</title>
<path fill-rule="evenodd" clip-rule="evenodd" d="M173.611 166.311L132.877 219.804H173.524L193.973 191.813L213.183 219.804H256L215.673 165.707L215.15 165.046L207.461 155.332L195.329 140.004L195.258 139.915L193.813 138.089L193.923 138.001L176.286 112.861H134.061L173.611 166.311ZM199.89 133.554L214.959 112.861H254.619L219.874 158.8L199.89 133.554Z" fill="#FAFAFA"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 36.1959V174.314H39.0678V137.614H60.3938L60.4323 137.671C60.8436 137.653 61.2517 137.634 61.6567 137.614C75.0665 136.968 85.1471 135.549 96.385 131.385C96.7596 131.246 97.1355 131.104 97.5128 130.959L97.4591 130.881C105.816 126.86 112.331 121.344 117.006 114.331C122.005 106.702 124.504 97.6915 124.504 87.2997C124.504 76.7764 122.005 67.7 117.006 60.0706C112.007 52.3097 104.904 46.3903 95.6964 42.3125C86.62 38.2347 75.7679 36.1959 63.1399 36.1959H0ZM102.156 137.725L64.8705 144.175L85.4361 174.314H127.266L102.156 137.725ZM39.0678 107.426H60.7721C68.9277 107.426 74.9786 105.65 78.9248 102.098C83.0026 98.5465 85.0415 93.6137 85.0415 87.2997C85.0415 80.8542 83.0026 75.8556 78.9248 72.304C74.9786 68.7523 68.9277 66.9765 60.7721 66.9765H39.0678V107.426Z" fill="#FAFAFA"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -1,4 +1,5 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>Reactive Resume</title>
<path fill-rule="evenodd" clip-rule="evenodd" d="M173.611 166.311L132.877 219.804H173.524L193.973 191.813L213.183 219.804H256L215.673 165.707L215.15 165.046L207.461 155.332L195.329 140.004L195.258 139.915L193.813 138.089L193.923 138.001L176.286 112.861H134.061L173.611 166.311ZM199.89 133.554L214.959 112.861H254.619L219.874 158.8L199.89 133.554Z" fill="#FAFAFA"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 36.1959V174.314H39.0678V137.614H60.3938L60.4323 137.671C60.8436 137.653 61.2517 137.634 61.6567 137.614C75.0665 136.968 85.1471 135.549 96.385 131.385C96.7596 131.246 97.1355 131.104 97.5128 130.959L97.4591 130.881C105.816 126.86 112.331 121.344 117.006 114.331C122.005 106.702 124.504 97.6915 124.504 87.2997C124.504 76.7764 122.005 67.7 117.006 60.0706C112.007 52.3097 104.904 46.3903 95.6964 42.3125C86.62 38.2347 75.7679 36.1959 63.1399 36.1959H0ZM102.156 137.725L64.8705 144.175L85.4361 174.314H127.266L102.156 137.725ZM39.0678 107.426H60.7721C68.9277 107.426 74.9786 105.65 78.9248 102.098C83.0026 98.5465 85.0415 93.6137 85.0415 87.2997C85.0415 80.8542 83.0026 75.8556 78.9248 72.304C74.9786 68.7523 68.9277 66.9765 60.7721 66.9765H39.0678V107.426Z" fill="#FAFAFA"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -1,4 +1,5 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>Reactive Resume</title>
<path fill-rule="evenodd" clip-rule="evenodd" d="M173.611 166.311L132.877 219.804H173.524L193.973 191.813L213.183 219.804H256L215.673 165.707L215.15 165.046L207.461 155.332L195.329 140.004L195.258 139.915L193.813 138.089L193.923 138.001L176.286 112.861H134.061L173.611 166.311ZM199.89 133.554L214.959 112.861H254.619L219.874 158.8L199.89 133.554Z" fill="#09090B"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 36.1959V174.314H39.0678V137.614H60.3938L60.4323 137.671C60.8436 137.653 61.2518 137.634 61.6569 137.614C75.0665 136.968 85.1471 135.549 96.3849 131.385C96.7596 131.246 97.1355 131.104 97.5128 130.959L97.4591 130.881C105.816 126.86 112.331 121.344 117.006 114.331C122.005 106.702 124.504 97.6915 124.504 87.2997C124.504 76.7764 122.005 67.7 117.006 60.0706C112.007 52.3097 104.904 46.3903 95.6964 42.3125C86.62 38.2347 75.7678 36.1959 63.1399 36.1959H0ZM102.156 137.725L64.8705 144.175L85.4361 174.314H127.266L102.156 137.725ZM39.0678 107.426H60.7721C68.9277 107.426 74.9786 105.65 78.9248 102.098C83.0026 98.5465 85.0415 93.6137 85.0415 87.2997C85.0415 80.8542 83.0026 75.8556 78.9248 72.304C74.9786 68.7523 68.9277 66.9765 60.7721 66.9765H39.0678V107.426Z" fill="#09090B"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

+1
View File
@@ -1,4 +1,5 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>Reactive Resume</title>
<rect width="512" height="512" fill="#09090B"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M183.603 184.722L156.74 220H183.545L197.032 201.54L209.701 220H237.938L211.008 183.874L211.014 183.866L210.81 183.608L205.927 177.44L197.895 167.292L197.882 167.31L197.669 167.007L196.926 166.068L196.98 166.025L185.367 149.472H157.52L183.603 184.722ZM200.951 163.096L214.129 179.747L237.027 149.472H210.872L200.951 163.096Z" fill="#FAFAFA"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M69.1088 98.9125V190H94.8735V165.797H108.938L108.938 165.797C108.943 165.797 108.948 165.797 108.953 165.797C118.377 165.381 125.285 164.448 133.094 161.494C133.195 161.455 133.296 161.417 133.397 161.378L133.382 161.356C138.893 158.705 143.19 155.066 146.273 150.442C149.569 145.41 151.218 139.468 151.218 132.615C151.218 125.675 149.569 119.689 146.273 114.658C142.976 109.539 138.292 105.636 132.219 102.946C126.234 100.257 119.077 98.9125 110.749 98.9125H69.1088ZM136.456 165.836L111.867 170.09L125.453 190H153.039L136.456 165.836ZM94.8735 145.888H109.187C114.566 145.888 118.556 144.716 121.159 142.374C123.848 140.032 125.193 136.779 125.193 132.615C125.193 128.364 123.848 125.068 121.159 122.725C118.556 120.383 114.566 119.212 109.187 119.212H94.8735V145.888Z" fill="#FAFAFA"/>

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

@@ -0,0 +1,55 @@
<svg role="img" aria-labelledby="atlas-cloud-logo-title" id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1224 792">
<title id="atlas-cloud-logo-title">Atlas Cloud</title>
<defs>
<style>
.cls-1 {
clip-path: url(#clippath);
}
.cls-2 {
fill: none;
}
.cls-2, .cls-3 {
stroke-width: 0px;
}
.cls-4 {
clip-path: url(#clippath-1);
}
.cls-5 {
clip-path: url(#clippath-2);
}
.cls-3 {
fill: #000;
}
</style>
<clipPath id="clippath">
<rect class="cls-2" x="20.73" y="315.14" width="1182.55" height="161.72"/>
</clipPath>
<clipPath id="clippath-1">
<rect class="cls-2" x="20.73" y="315.14" width="1182.55" height="161.72"/>
</clipPath>
<clipPath id="clippath-2">
<rect class="cls-2" x="20.73" y="315.14" width="1182.55" height="161.72"/>
</clipPath>
</defs>
<g class="cls-1">
<path class="cls-3" d="M104.38,315.14L20.73,476.86c38.73-15.32,70.03-17.6,98.12-16.26l-12.85-28.4c-5.63-.57-21.86-.57-29.57,1.45l27.95-62.14s42.06,91.31,42.13,91.32c8.09,1.27,29.8,8.55,41.52,14.03l-83.66-161.72Z"/>
</g>
<polygon class="cls-3" points="207.87 323.37 207.87 361.57 189.68 361.57 189.68 381.76 207.87 381.76 207.87 471.35 234.07 471.35 234.07 381.76 252.87 381.76 252.87 361.57 234.07 361.57 234.07 323.37 207.87 323.37"/>
<rect class="cls-3" x="268.07" y="323.37" width="26.19" height="147.98"/>
<g class="cls-4">
<path class="cls-3" d="M340.48,416.95c0,13.6,9.79,33.39,33.39,33.39,14.6,0,24.2-7.6,29.2-17.59,2.6-4.8,3.8-10,4.2-15.4.2-5.2-.8-10.6-3-15.4-4.6-10.4-14.8-19.4-30.6-19.4-21.2,0-33.19,17.2-33.19,34.19v.2ZM433.26,471.35h-26.19v-15.8c-7,12.8-21.6,19.2-36.99,19.2-35.2,0-55.8-27.4-55.8-58.4,0-34.6,25-58.19,55.8-58.19,20,0,32.19,10.6,36.99,19.4v-16h26.19v109.78Z"/>
<path class="cls-3" d="M506.87,391.16c-.2-5.8-3.2-11.8-13.4-11.8-8.8,0-12.99,5.6-12.99,11,.2,7,8.2,10.79,18.6,13.8,17.6,4.8,36.6,10,36.8,33.59.4,22.6-18.6,36.99-41.59,36.99-17,0-40-8.8-41.99-35.39h26.19c1,11.8,11.2,14.2,16.2,14.2,8.2,0,15.2-5.4,15-12.8,0-9.4-8.2-12-28.4-18.99-14.8-4.6-26.79-12.8-26.99-28.6,0-21.6,18.19-35,39.39-35,14.8,0,36.8,6.6,39,33h-25.8Z"/>
<path class="cls-3" d="M760.26,421.76c-9,30.59-37.8,52.39-72.39,52.39-45.19,0-77.59-35.8-77.59-76.99s30.59-76.58,77.39-76.58c42.59.2,66.79,31.59,72.59,53.19h-31.4c-4.6-10.19-17.8-26.79-41.39-26.99-28.8,0-47.39,24.2-47.39,50.39s19.4,50.79,47.6,50.79c26.39,0,38.39-20.8,41.19-26.19h31.4Z"/>
</g>
<rect class="cls-3" x="779.68" y="323.37" width="26.19" height="147.98"/>
<g class="cls-5">
<path class="cls-3" d="M852.09,416.56c0,19.2,14.59,33.39,32.59,33.39s32.4-14.2,32.4-33.6-14.4-33.59-32.4-33.59-32.59,14.2-32.59,33.59v.2ZM825.89,416.15c0-28,20.6-57.79,58.79-57.79s58.79,29.79,58.79,57.99-20.59,58.19-58.79,58.19-58.79-29.79-58.79-58.19v-.2Z"/>
<path class="cls-3" d="M1064.87,471.35h-24.6v-14.6c-4.6,8.6-14.4,18.2-34.19,18-36-.2-42.79-27.6-42.79-46.8v-66.39h26.19v59.79c0,16.2,5.4,28.99,23.99,28.8,18.8,0,25.2-12.4,25.2-28v-60.59h26.2v109.78Z"/>
<path class="cls-3" d="M1111.28,416.35c0,18.6,13.4,33.8,33.59,33.8,9.6,0,18.2-3.8,24.4-10,6.2-6.2,9.8-14.8,9.39-24.4,0-9.6-3.8-17.8-9.8-23.6-6-6-14.39-9.6-23.79-9.6-21.4,0-33.8,16.4-33.8,33.59v.2ZM1203.27,471.35h-25v-15.8c-4.8,8.2-15.2,19.2-37.6,19.2-33.79,0-55.59-26.4-55.59-58.4,0-35.59,25.4-58.19,55.19-58.19,21,0,32.8,11.19,36.8,17.59v-52.39h26.2v147.98Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,55 @@
<svg role="img" aria-labelledby="atlas-cloud-logo-title" id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1224 792">
<title id="atlas-cloud-logo-title">Atlas Cloud</title>
<defs>
<style>
.cls-1 {
clip-path: url(#clippath);
}
.cls-2 {
fill: none;
}
.cls-2, .cls-3 {
stroke-width: 0px;
}
.cls-4 {
clip-path: url(#clippath-1);
}
.cls-5 {
clip-path: url(#clippath-2);
}
.cls-3 {
fill: #fff;
}
</style>
<clipPath id="clippath">
<rect class="cls-2" x="20.73" y="315.14" width="1182.55" height="161.72"/>
</clipPath>
<clipPath id="clippath-1">
<rect class="cls-2" x="20.73" y="315.14" width="1182.55" height="161.72"/>
</clipPath>
<clipPath id="clippath-2">
<rect class="cls-2" x="20.73" y="315.14" width="1182.55" height="161.72"/>
</clipPath>
</defs>
<g class="cls-1">
<path class="cls-3" d="M104.38,315.14L20.73,476.86c38.73-15.32,70.03-17.6,98.12-16.26l-12.85-28.4c-5.63-.57-21.86-.57-29.57,1.45l27.95-62.14s42.06,91.31,42.13,91.32c8.09,1.27,29.8,8.55,41.52,14.03l-83.66-161.72Z"/>
</g>
<polygon class="cls-3" points="207.87 323.37 207.87 361.57 189.68 361.57 189.68 381.76 207.87 381.76 207.87 471.35 234.07 471.35 234.07 381.76 252.87 381.76 252.87 361.57 234.07 361.57 234.07 323.37 207.87 323.37"/>
<rect class="cls-3" x="268.07" y="323.37" width="26.19" height="147.98"/>
<g class="cls-4">
<path class="cls-3" d="M340.48,416.95c0,13.6,9.79,33.39,33.39,33.39,14.6,0,24.2-7.6,29.2-17.59,2.6-4.8,3.8-10,4.2-15.4.2-5.2-.8-10.6-3-15.4-4.6-10.4-14.8-19.4-30.6-19.4-21.2,0-33.19,17.2-33.19,34.19v.2ZM433.26,471.35h-26.19v-15.8c-7,12.8-21.6,19.2-36.99,19.2-35.2,0-55.8-27.4-55.8-58.4,0-34.6,25-58.19,55.8-58.19,20,0,32.19,10.6,36.99,19.4v-16h26.19v109.78Z"/>
<path class="cls-3" d="M506.87,391.16c-.2-5.8-3.2-11.8-13.4-11.8-8.8,0-12.99,5.6-12.99,11,.2,7,8.2,10.79,18.6,13.8,17.6,4.8,36.6,10,36.8,33.59.4,22.6-18.6,36.99-41.59,36.99-17,0-40-8.8-41.99-35.39h26.19c1,11.8,11.2,14.2,16.2,14.2,8.2,0,15.2-5.4,15-12.8,0-9.4-8.2-12-28.4-18.99-14.8-4.6-26.79-12.8-26.99-28.6,0-21.6,18.19-35,39.39-35,14.8,0,36.8,6.6,39,33h-25.8Z"/>
<path class="cls-3" d="M760.26,421.76c-9,30.59-37.8,52.39-72.39,52.39-45.19,0-77.59-35.8-77.59-76.99s30.59-76.58,77.39-76.58c42.59.2,66.79,31.59,72.59,53.19h-31.4c-4.6-10.19-17.8-26.79-41.39-26.99-28.8,0-47.39,24.2-47.39,50.39s19.4,50.79,47.6,50.79c26.39,0,38.39-20.8,41.19-26.19h31.4Z"/>
</g>
<rect class="cls-3" x="779.68" y="323.37" width="26.19" height="147.98"/>
<g class="cls-5">
<path class="cls-3" d="M852.09,416.56c0,19.2,14.59,33.39,32.59,33.39s32.4-14.2,32.4-33.6-14.4-33.59-32.4-33.59-32.59,14.2-32.59,33.59v.2ZM825.89,416.15c0-28,20.6-57.79,58.79-57.79s58.79,29.79,58.79,57.99-20.59,58.19-58.79,58.19-58.79-29.79-58.79-58.19v-.2Z"/>
<path class="cls-3" d="M1064.87,471.35h-24.6v-14.6c-4.6,8.6-14.4,18.2-34.19,18-36-.2-42.79-27.6-42.79-46.8v-66.39h26.19v59.79c0,16.2,5.4,28.99,23.99,28.8,18.8,0,25.2-12.4,25.2-28v-60.59h26.2v109.78Z"/>
<path class="cls-3" d="M1111.28,416.35c0,18.6,13.4,33.8,33.59,33.8,9.6,0,18.2-3.8,24.4-10,6.2-6.2,9.8-14.8,9.39-24.4,0-9.6-3.8-17.8-9.8-23.6-6-6-14.39-9.6-23.79-9.6-21.4,0-33.8,16.4-33.8,33.59v.2ZM1203.27,471.35h-25v-15.8c-4.8,8.2-15.2,19.2-37.6,19.2-33.79,0-55.59-26.4-55.59-58.4,0-35.59,25.4-58.19,55.19-58.19,21,0,32.8,11.19,36.8,17.59v-52.39h26.2v147.98Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -12,28 +12,22 @@ describe("CountUp", () => {
expect(span.getAttribute("aria-atomic")).toBe("true");
});
it("seeds textContent to the 'from' value when counting up", () => {
const { container } = render(<CountUp from={0} to={100} />);
it("seeds textContent to 0 on initial render", () => {
const { container } = render(<CountUp to={100} />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.textContent).toBe("0");
});
it("seeds textContent to the 'to' value when direction is down", () => {
const { container } = render(<CountUp from={0} to={100} direction="down" />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.textContent).toBe("100");
});
it("formats with the separator when one is supplied", () => {
const { container } = render(<CountUp from={1234} to={2345} separator="," />);
const { container } = render(<CountUp to={1234} separator="," />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.textContent).toBe("1,234");
expect(span.textContent).toBe("0");
});
it("preserves decimal places when from / to are fractional", () => {
const { container } = render(<CountUp from={1.25} to={3.75} />);
it("preserves decimal places when to is fractional", () => {
const { container } = render(<CountUp to={3.75} />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.textContent).toBe("1.25");
expect(span.textContent).toBe("0.00");
});
it("strips aria-live and aria-atomic when aria-hidden is set", () => {
+11 -55
View File
@@ -3,116 +3,72 @@ import { useCallback, useEffect, useEffectEvent, useRef } from "react";
type CountUpProps = {
to: number;
from?: number;
direction?: "up" | "down";
delay?: number;
duration?: number;
className?: string;
startWhen?: boolean;
separator?: string;
onStart?: () => void;
onEnd?: () => void;
"aria-hidden"?: boolean | "true" | "false";
"aria-live"?: "off" | "polite" | "assertive";
"aria-atomic"?: boolean | "true" | "false";
};
// ponytail: from/direction/delay/startWhen/onStart/onEnd removed — no production caller passes them
export function CountUp({
to,
from = 0,
direction = "up",
delay = 0,
duration = 2,
className = "",
startWhen = true,
separator = "",
onStart,
onEnd,
"aria-hidden": ariaHidden,
"aria-live": ariaLive = "polite",
"aria-atomic": ariaAtomic = "true",
}: CountUpProps) {
const ref = useRef<HTMLSpanElement>(null);
const motionValue = useMotionValue(direction === "down" ? to : from);
const motionValue = useMotionValue(0);
const damping = 20 + 40 * (1 / duration);
const stiffness = 100 * (1 / duration);
const springValue = useSpring(motionValue, {
damping,
stiffness,
});
const springValue = useSpring(motionValue, { damping, stiffness });
const isInView = useInView(ref, { once: true, margin: "0px" });
const getDecimalPlaces = (num: number): number => {
const str = num.toString();
if (str.includes(".")) {
const decimals = str.split(".")[1];
if (Number.parseInt(decimals, 10) !== 0) return decimals.length;
}
return 0;
};
const maxDecimals = Math.max(getDecimalPlaces(from), getDecimalPlaces(to));
const maxDecimals = getDecimalPlaces(to);
const formatValue = useCallback(
(latest: number) => {
const hasDecimals = maxDecimals > 0;
const options: Intl.NumberFormatOptions = {
useGrouping: !!separator,
minimumFractionDigits: hasDecimals ? maxDecimals : 0,
maximumFractionDigits: hasDecimals ? maxDecimals : 0,
minimumFractionDigits: maxDecimals,
maximumFractionDigits: maxDecimals,
};
const formattedNumber = Intl.NumberFormat("en-US", options).format(latest);
return separator ? formattedNumber.replace(/,/g, separator) : formattedNumber;
},
[maxDecimals, separator],
);
const formatCurrentValue = useEffectEvent((latest: number) => formatValue(latest));
const notifyStart = useEffectEvent(() => onStart?.());
const notifyEnd = useEffectEvent(() => onEnd?.());
useEffect(() => {
if (ref.current) {
ref.current.textContent = formatCurrentValue(direction === "down" ? to : from);
}
}, [from, to, direction]);
if (ref.current) ref.current.textContent = formatCurrentValue(0);
}, []);
useEffect(() => {
if (isInView && startWhen) {
notifyStart();
const timeoutId = setTimeout(() => {
motionValue.set(direction === "down" ? from : to);
}, delay * 1000);
const durationTimeoutId = setTimeout(
() => {
notifyEnd();
},
delay * 1000 + duration * 1000,
);
return () => {
clearTimeout(timeoutId);
clearTimeout(durationTimeoutId);
};
}
}, [isInView, startWhen, motionValue, direction, from, to, delay, duration]);
if (isInView) motionValue.set(to);
}, [isInView, motionValue, to]);
useEffect(() => {
const unsubscribe = springValue.on("change", (latest: number) => {
if (ref.current) {
ref.current.textContent = formatCurrentValue(latest);
}
if (ref.current) ref.current.textContent = formatCurrentValue(latest);
});
return () => unsubscribe();
}, [springValue]);
@@ -60,7 +60,9 @@ function IconCellComponent({ columnIndex, rowIndex, style, icons, onChange }: Ic
title={icon}
style={style}
tabIndex={-1}
onClick={() => onChange(icon)}
onClick={() => {
if (icon) onChange(icon);
}}
className="flex size-full items-center justify-center hover:bg-accent"
>
{icon ? <i className={cn("ph text-base", `ph-${icon}`)} /> : <ProhibitIcon />}
@@ -100,7 +102,7 @@ export function IconPicker({ value, onChange, popoverProps, ...props }: IconPick
<Popover {...popoverProps}>
<PopoverTrigger
render={
<Button size="icon" variant="outline" {...props}>
<Button size="icon" variant="outline" aria-label={t`Pick an icon`} {...props}>
<i className={cn("ph size-4 text-base", `ph-${value}`)} />
</Button>
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { resolveHighlightToolbarState } from "./rich-input";
describe("resolveHighlightToolbarState", () => {
it("shows legacy colorless highlights as default yellow and clearable", () => {
expect(resolveHighlightToolbarState(true, null)).toEqual({
visibleHighlightColor: "rgba(255, 255, 0, 1)",
canClearHighlight: true,
});
});
it("does not show or clear a highlight when no highlight mark is active", () => {
expect(resolveHighlightToolbarState(false, null)).toEqual({
visibleHighlightColor: undefined,
canClearHighlight: false,
});
});
});
+110 -119
View File
@@ -5,10 +5,7 @@ import { Trans } from "@lingui/react/macro";
import {
ArrowsInSimpleIcon,
ArrowsOutSimpleIcon,
CodeBlockIcon,
CodeSimpleIcon,
ColumnsPlusLeftIcon,
ColumnsPlusRightIcon,
HighlighterCircleIcon,
KeyReturnIcon,
LinkBreakIcon,
@@ -17,10 +14,6 @@ import {
ListNumbersIcon,
MinusIcon,
ParagraphIcon,
PlusIcon,
RowsPlusBottomIcon,
RowsPlusTopIcon,
TableIcon,
TextAlignCenterIcon,
TextAlignJustifyIcon,
TextAlignLeftIcon,
@@ -37,11 +30,9 @@ import {
TextOutdentIcon,
TextStrikethroughIcon,
TextUnderlineIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import Color from "@tiptap/extension-color";
import Highlight from "@tiptap/extension-highlight";
import { TableKit } from "@tiptap/extension-table";
import TextAlign from "@tiptap/extension-text-align";
import { TextStyle } from "@tiptap/extension-text-style";
import { EditorContent, EditorContext, useEditor, useEditorState } from "@tiptap/react";
@@ -56,27 +47,26 @@ import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { PopoverHeader, PopoverTitle, PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { Toggle } from "@reactive-resume/ui/components/toggle";
import { isDarkColor } from "@reactive-resume/utils/color";
import { cn } from "@reactive-resume/utils/style";
import { usePrompt } from "@/hooks/use-prompt";
import { isRTL } from "@/libs/locale";
import { ColorPicker } from "./color-picker";
const defaultTextColor = "rgba(0, 0, 0, 1)";
const defaultHighlightColor = "rgba(255, 255, 0, 1)";
const extensions = [
StarterKit.configure({
heading: {
levels: [1, 2, 3, 4, 5, 6],
},
codeBlock: {
enableTabIndentation: true,
},
codeBlock: false,
link: {
openOnClick: false,
enableClickSelection: true,
@@ -86,9 +76,16 @@ const extensions = [
}),
TextStyle,
Color,
Highlight.configure(),
Highlight.configure({ multicolor: true }).extend({
renderHTML({ HTMLAttributes }) {
const color = HTMLAttributes["data-color"] as string | undefined;
if (color && isDarkColor(color)) {
HTMLAttributes.style = `${HTMLAttributes.style ?? ""}; color: #ffffff`;
}
return ["mark", HTMLAttributes, 0];
},
}),
TextAlign.configure({ types: ["heading", "paragraph", "listItem"] }),
TableKit.configure(),
];
type Props = UseEditorOptions & {
@@ -223,10 +220,12 @@ function useEditorToolbarState(editor: Editor) {
canStrike: ctx.editor.can().chain().toggleStrike().run() ?? false,
toggleStrike: () => ctx.editor.chain().focus().toggleStrike().run(),
// Highlight
// Highlight Color
isHighlight: ctx.editor.isActive("highlight") ?? false,
canHighlight: ctx.editor.can().chain().toggleHighlight().run() ?? false,
toggleHighlight: () => ctx.editor.chain().focus().toggleHighlight().run(),
highlightColor: (ctx.editor.getAttributes("highlight").color as string | undefined) ?? null,
canHighlightColor: ctx.editor.can().chain().toggleHighlight().run() ?? false,
setHighlightColor: (color: string) => ctx.editor.chain().focus().toggleHighlight({ color }).run(),
unsetHighlightColor: () => ctx.editor.chain().focus().unsetHighlight().run(),
// Text Color
textColor: (ctx.editor.getAttributes("textStyle").color as string | undefined) ?? null,
@@ -335,28 +334,8 @@ function useEditorToolbarState(editor: Editor) {
canInlineCode: ctx.editor.can().chain().toggleCode().run() ?? false,
toggleInlineCode: () => ctx.editor.chain().focus().toggleCode().run(),
// Code Block
isCodeBlock: ctx.editor.isActive("codeBlock") ?? false,
canCodeBlock: ctx.editor.can().chain().toggleCodeBlock().run() ?? false,
toggleCodeBlock: () => ctx.editor.chain().focus().toggleCodeBlock().run(),
// Table
insertTable: () => ctx.editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
canInsertTable: ctx.editor.can().chain().insertTable().run() ?? false,
addColumnBefore: () => ctx.editor.chain().focus().addColumnBefore().run(),
canAddColumnBefore: ctx.editor.can().chain().addColumnBefore().run() ?? false,
addColumnAfter: () => ctx.editor.chain().focus().addColumnAfter().run(),
canAddColumnAfter: ctx.editor.can().chain().addColumnAfter().run() ?? false,
addRowBefore: () => ctx.editor.chain().focus().addRowBefore().run(),
canAddRowBefore: ctx.editor.can().chain().addRowBefore().run() ?? false,
addRowAfter: () => ctx.editor.chain().focus().addRowAfter().run(),
canAddRowAfter: ctx.editor.can().chain().addRowAfter().run() ?? false,
deleteColumn: () => ctx.editor.chain().focus().deleteColumn().run(),
canDeleteColumn: ctx.editor.can().chain().deleteColumn().run() ?? false,
deleteRow: () => ctx.editor.chain().focus().deleteRow().run(),
canDeleteRow: ctx.editor.can().chain().deleteRow().run() ?? false,
deleteTable: () => ctx.editor.chain().focus().deleteTable().run(),
canDeleteTable: ctx.editor.can().chain().deleteTable().run() ?? false,
// Character Count
characterCount: ctx.editor.getText().length,
// Hard Break
setHardBreak: () => ctx.editor.chain().focus().setHardBreak().run(),
@@ -372,6 +351,12 @@ function useEditorToolbarState(editor: Editor) {
type EditorToolbarState = ReturnType<typeof useEditorToolbarState>;
export function resolveHighlightToolbarState(isHighlight: boolean, highlightColor: string | null) {
const visibleHighlightColor = highlightColor ?? (isHighlight ? defaultHighlightColor : undefined);
return { visibleHighlightColor, canClearHighlight: isHighlight };
}
type EditorToolbarProps = {
editor: Editor;
isFullscreen: boolean;
@@ -384,6 +369,11 @@ function EditorToolbar({ editor, isFullscreen }: EditorToolbarProps) {
}
function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
const { visibleHighlightColor, canClearHighlight } = resolveHighlightToolbarState(
state.isHighlight,
state.highlightColor,
);
return (
<div className="flex flex-wrap items-center gap-y-0.5 rounded-md rounded-b-none border border-b-0">
<Toggle
@@ -434,17 +424,65 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
<TextStrikethroughIcon className="size-3.5" />
</Toggle>
<Toggle
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
className="rounded-none"
title={t`Highlight`}
pressed={state.isHighlight}
disabled={!state.canHighlight}
onPressedChange={state.toggleHighlight}
<ColorPicker
defaultValue={defaultHighlightColor}
value={visibleHighlightColor}
onChange={state.setHighlightColor}
trigger={
<PopoverTrigger
render={
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
className={cn("rounded-none px-2", state.isHighlight && "bg-muted text-foreground")}
title={t`Highlight`}
disabled={!state.canHighlightColor}
>
<span className="flex flex-col items-center leading-none">
<HighlighterCircleIcon className="size-3.5" />
<span
className="mt-0.5 h-0.5 w-3 rounded-full"
style={{ backgroundColor: visibleHighlightColor ?? "currentColor" }}
/>
</span>
</Button>
}
/>
}
>
<HighlighterCircleIcon className="size-3.5" />
</Toggle>
<PopoverHeader className="flex-row items-start justify-between gap-2">
<div className="flex items-center gap-2.5">
<span
className="grid size-9 place-items-center rounded-lg border border-border bg-muted/60 text-sm shadow-xs"
style={{ backgroundColor: visibleHighlightColor ?? defaultHighlightColor }}
>
<HighlighterCircleIcon className="size-4" />
</span>
<div className="flex flex-col gap-0.5">
<PopoverTitle>
<Trans>Highlight Color</Trans>
</PopoverTitle>
<span className="text-muted-foreground text-xs">
<Trans comment="Preset or custom shade refer to the color picker">
Choose a preset or custom shade.
</Trans>
</span>
</div>
</div>
<Button
size="xs"
variant="ghost"
className="shrink-0"
onClick={state.unsetHighlightColor}
disabled={!canClearHighlight}
>
<Trans comment="Clear the highlight color">Clear</Trans>
</Button>
</PopoverHeader>
</ColorPicker>
<ColorPicker
defaultValue={defaultTextColor}
@@ -511,7 +549,13 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button size={isFullscreen ? "lg" : "sm"} tabIndex={-1} variant="ghost" className="rounded-none">
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
aria-label={t`Paragraph and heading style`}
className="rounded-none"
>
{match(state)
.with({ isParagraph: true }, () => <ParagraphIcon className="size-3.5" />)
.with({ isHeading1: true }, () => <TextHOneIcon className="size-3.5" />)
@@ -584,7 +628,13 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button size={isFullscreen ? "lg" : "sm"} tabIndex={-1} variant="ghost" className="rounded-none">
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
aria-label={t`Text alignment`}
className="rounded-none"
>
{match(state)
.with({ isLeftAlign: true }, () => <TextAlignLeftIcon className="size-3.5" />)
.with({ isCenterAlign: true }, () => <TextAlignCenterIcon className="size-3.5" />)
@@ -660,6 +710,7 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Decrease indent`}
disabled={!state.canLiftListItem}
onClick={state.liftListItem}
>
@@ -671,6 +722,7 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Increase indent`}
disabled={!state.canSinkListItem}
onClick={state.sinkListItem}
>
@@ -685,6 +737,7 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Remove link`}
onClick={state.unsetLink}
>
<LinkBreakIcon className="size-3.5" />
@@ -695,6 +748,7 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Add link`}
onClick={state.setLink}
>
<LinkIcon className="size-3.5" />
@@ -713,73 +767,6 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
<CodeSimpleIcon className="size-3.5" />
</Toggle>
<Toggle
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
className="rounded-none"
title={t`Code Block`}
pressed={state.isCodeBlock}
disabled={!state.canCodeBlock}
onPressedChange={state.toggleCodeBlock}
>
<CodeBlockIcon className="size-3.5" />
</Toggle>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Table`}
>
<TableIcon className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent>
<DropdownMenuItem disabled={!state.canInsertTable} onClick={state.insertTable}>
<PlusIcon />
<Trans>Insert Table</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!state.canAddColumnBefore} onClick={state.addColumnBefore}>
<ColumnsPlusLeftIcon />
<Trans>Add Column Before</Trans>
</DropdownMenuItem>
<DropdownMenuItem disabled={!state.canAddColumnAfter} onClick={state.addColumnAfter}>
<ColumnsPlusRightIcon />
<Trans>Add Column After</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!state.canAddRowBefore} onClick={state.addRowBefore}>
<RowsPlusTopIcon />
<Trans>Add Row Before</Trans>
</DropdownMenuItem>
<DropdownMenuItem disabled={!state.canAddRowAfter} onClick={state.addRowAfter}>
<RowsPlusBottomIcon />
<Trans>Add Row After</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!state.canDeleteColumn} onClick={state.deleteColumn}>
<TrashSimpleIcon />
<Trans>Delete Column</Trans>
</DropdownMenuItem>
<DropdownMenuItem disabled={!state.canDeleteRow} onClick={state.deleteRow}>
<TrashSimpleIcon />
<Trans>Delete Row</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" disabled={!state.canDeleteTable} onClick={state.deleteTable}>
<TrashSimpleIcon />
<Trans>Delete Table</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
@@ -801,6 +788,10 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
>
<MinusIcon className="size-3.5" />
</Button>
<span className="ml-auto px-2 text-muted-foreground text-xs tabular-nums" aria-live="polite">
<Trans comment="Character count readout for the rich-text editor">{state.characterCount} characters</Trans>
</span>
</div>
);
}
+2 -2
View File
@@ -2,7 +2,7 @@ import type { Website } from "@reactive-resume/schema/resume/data";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { TagIcon } from "@phosphor-icons/react";
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import { Input } from "@reactive-resume/ui/components/input";
import {
InputGroup,
@@ -50,7 +50,7 @@ export function URLInput<TValue extends Website>({ value, onChange, hideLabelBut
[onChange, value],
);
const urlValue = useMemo(() => stripPrefix(value.url), [value.url]);
const urlValue = stripPrefix(value.url);
return (
<InputGroup>
@@ -5,7 +5,18 @@ import { fireEvent, render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { ErrorScreen } from "./error-screen";
// `Link` from tanstack/react-router requires a Router context. Stub it out with
// a plain anchor so we can render the screen in isolation.
vi.mock("@tanstack/react-router", () => ({
Link: ({ children, to, ...rest }: React.PropsWithChildren<{ to: string }>) => (
<a href={typeof to === "string" ? to : "#"} {...rest}>
{children}
</a>
),
}));
const { ErrorScreen } = await import("./error-screen");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
@@ -25,21 +36,27 @@ const renderError = (overrides: { error?: Error; reset?: () => void } = {}) => {
};
describe("ErrorScreen", () => {
it("shows the error message provided", () => {
renderError({ error: new Error("Network is down") });
expect(screen.getByText("Network is down")).toBeInTheDocument();
});
it("shows the user-facing error heading", () => {
it("shows a distinct, non-blaming error heading", () => {
renderError();
expect(screen.getByText("An error occurred while loading the page.")).toBeInTheDocument();
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});
it("calls reset when the Refresh button is clicked", () => {
it("does not surface the raw error message to the user", () => {
renderError({ error: new Error("Network is down") });
expect(screen.queryByText("Network is down")).not.toBeInTheDocument();
});
it("calls reset when the Try again button is clicked", () => {
const reset = vi.fn();
renderError({ reset });
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
fireEvent.click(screen.getByRole("button", { name: /try again/i }));
expect(reset).toHaveBeenCalledTimes(1);
});
it("offers an absolute dashboard escape", () => {
renderError();
const link = screen.getByRole("link", { name: /go to dashboard/i });
expect(link.getAttribute("href")).toBe("/dashboard");
});
});
@@ -1,11 +1,12 @@
import type { ErrorComponentProps } from "@tanstack/react-router";
import { Trans } from "@lingui/react/macro";
import { ArrowClockwiseIcon, WarningIcon } from "@phosphor-icons/react";
import { ArrowClockwiseIcon, HouseIcon, WarningIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
import { Button } from "@reactive-resume/ui/components/button";
import { Button, buttonVariants } from "@reactive-resume/ui/components/button";
export function ErrorScreen({ error, reset }: ErrorComponentProps) {
export function ErrorScreen({ reset }: ErrorComponentProps) {
return (
<div className="mx-auto flex h-svh max-w-md flex-col items-center justify-center gap-y-4">
<BrandIcon variant="logo" className="size-12" />
@@ -13,15 +14,24 @@ export function ErrorScreen({ error, reset }: ErrorComponentProps) {
<Alert>
<WarningIcon />
<AlertTitle>
<Trans>An error occurred while loading the page.</Trans>
<Trans>Something went wrong</Trans>
</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
<AlertDescription>
<Trans>An unexpected error stopped this page from loading. You can try again or head back.</Trans>
</AlertDescription>
</Alert>
<Button onClick={reset}>
<ArrowClockwiseIcon />
<Trans>Refresh</Trans>
</Button>
<div className="flex items-center gap-x-2">
<Button onClick={reset}>
<ArrowClockwiseIcon />
<Trans>Try again</Trans>
</Button>
<Link to="/dashboard" className={buttonVariants({ variant: "secondary" })}>
<HouseIcon />
<Trans>Go to dashboard</Trans>
</Link>
</div>
</div>
);
}
@@ -1,32 +1,29 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { describe, expect, it } from "vitest";
import { LoadingScreen } from "./loading-screen";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
describe("LoadingScreen", () => {
it("renders a spinner and the loading text", () => {
render(
<I18nProvider i18n={i18n}>
<LoadingScreen />
</I18nProvider>,
);
it("renders the Reactive Resume icon and spinner", () => {
render(<LoadingScreen />);
expect(screen.getByText("Loading…")).toBeInTheDocument();
const icons = screen.getAllByAltText("Reactive Resume");
expect(icons).toHaveLength(2);
expect(icons.map((icon) => icon.getAttribute("src"))).toEqual(["/icon/dark.svg", "/icon/light.svg"]);
expect(screen.getByLabelText("Loading")).toBeInTheDocument();
});
it("uses the same icon asset as the initial HTML loader", () => {
const html = readFileSync("index.html", "utf8");
expect(html).toContain('src="/icon/dark.svg"');
expect(html).not.toContain('src="/logo/dark.svg"');
});
it("fills the viewport (fixed inset-0)", () => {
const { container } = render(
<I18nProvider i18n={i18n}>
<LoadingScreen />
</I18nProvider>,
);
const { container } = render(<LoadingScreen />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.className).toContain("fixed");
@@ -1,13 +1,11 @@
import { Trans } from "@lingui/react/macro";
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
import { Spinner } from "@reactive-resume/ui/components/spinner";
export function LoadingScreen() {
return (
<div className="fixed inset-0 z-50 flex h-svh w-svw items-center justify-center gap-x-3 bg-background">
<div className="fixed inset-0 z-50 flex h-svh w-svw flex-col items-center justify-center gap-y-6 bg-background">
<BrandIcon variant="icon" className="size-12" />
<Spinner className="size-6" />
<p className="text-muted-foreground">
<Trans>Loading…</Trans>
</p>
</div>
);
}
@@ -21,27 +21,33 @@ beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
const renderScreen = (routeId = "/missing") =>
const renderScreen = () =>
render(
<I18nProvider i18n={i18n}>
<NotFoundScreen isNotFound routeId={routeId as never} />
<NotFoundScreen />
</I18nProvider>,
);
describe("NotFoundScreen", () => {
it("renders the documented error heading", () => {
it("renders a distinct not-found heading", () => {
renderScreen();
expect(screen.getByText("An error occurred while loading the page.")).toBeInTheDocument();
expect(screen.getByText("We couldn't find that page")).toBeInTheDocument();
});
it("displays the routeId that triggered the not-found", () => {
renderScreen("/dashboard/missing-page");
expect(screen.getByText("/dashboard/missing-page")).toBeInTheDocument();
it("does not surface the raw routeId", () => {
renderScreen();
expect(screen.queryByText(/\/dashboard\/missing-page/)).not.toBeInTheDocument();
});
it("renders a Go Back link", () => {
it("offers an absolute dashboard escape", () => {
renderScreen();
const link = screen.getByRole("link", { name: /go back/i });
expect(link.getAttribute("href")).toBe("..");
const link = screen.getByRole("link", { name: /go to dashboard/i });
expect(link.getAttribute("href")).toBe("/dashboard");
});
it("offers an absolute home escape", () => {
renderScreen();
const link = screen.getByRole("link", { name: /go home/i });
expect(link.getAttribute("href")).toBe("/");
});
});
@@ -1,12 +1,11 @@
import type { NotFoundRouteProps } from "@tanstack/react-router";
import { Trans } from "@lingui/react/macro";
import { ArrowLeftIcon, WarningIcon } from "@phosphor-icons/react";
import { HouseIcon, MagnifyingGlassIcon, WarningIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
import { buttonVariants } from "@reactive-resume/ui/components/button";
export function NotFoundScreen({ routeId }: NotFoundRouteProps) {
export function NotFoundScreen() {
return (
<div className="mx-auto flex h-svh max-w-md flex-col items-center justify-center gap-y-4">
<BrandIcon variant="logo" className="size-12" />
@@ -14,15 +13,24 @@ export function NotFoundScreen({ routeId }: NotFoundRouteProps) {
<Alert>
<WarningIcon />
<AlertTitle>
<Trans>An error occurred while loading the page.</Trans>
<Trans>We couldn't find that page</Trans>
</AlertTitle>
<AlertDescription>{routeId}</AlertDescription>
<AlertDescription>
<Trans>The page you're looking for may have been moved or no longer exists.</Trans>
</AlertDescription>
</Alert>
<Link to=".." className={buttonVariants()}>
<ArrowLeftIcon />
<Trans>Go Back</Trans>
</Link>
<div className="flex items-center gap-x-2">
<Link to="/dashboard" className={buttonVariants()}>
<MagnifyingGlassIcon />
<Trans>Go to dashboard</Trans>
</Link>
<Link to="/" className={buttonVariants({ variant: "secondary" })}>
<HouseIcon />
<Trans>Go home</Trans>
</Link>
</div>
</div>
);
}
@@ -71,6 +71,21 @@ describe("LevelDisplay", () => {
expect(wrapper.getAttribute("aria-label")).toContain("4");
});
it("applies custom decoration and icon sizes in pixels", () => {
const { container } = render(
<LevelDisplay type="circle" icon="star" level={2} decorationSizePx={18} iconSizePx={20} />,
);
const shapes = container.querySelectorAll("div[data-active]");
expect(shapes[0]).toHaveStyle({ width: "18px", height: "18px" });
const { container: iconContainer } = render(
<LevelDisplay type="icon" icon="star" level={2} decorationSizePx={16} />,
);
const icon = iconContainer.querySelector("i");
expect(icon).toHaveStyle({ fontSize: "16px", width: "16px", height: "16px" });
});
it("merges extra className into the wrapper", () => {
const { container } = render(<LevelDisplay type="circle" icon="star" level={1} className="extra" />);
const wrapper = container.firstChild as HTMLElement;
+36 -5
View File
@@ -3,14 +3,35 @@ import type z from "zod";
import { t } from "@lingui/core/macro";
import { cn } from "@reactive-resume/utils/style";
type Props = z.infer<typeof levelDesignSchema> & React.ComponentProps<"div"> & { level: number };
type Props = z.infer<typeof levelDesignSchema> &
React.ComponentProps<"div"> & {
level: number;
decorationSizePx?: number | undefined;
iconSizePx?: number | undefined;
};
const LEVEL_ITEM_KEYS = ["level-1", "level-2", "level-3", "level-4", "level-5"] as const;
export function LevelDisplay({ icon, type, level, className, ...props }: Props) {
const defaultDecorationClassName = "size-2.5";
export function LevelDisplay({ icon, type, level, className, decorationSizePx, iconSizePx, ...props }: Props) {
if (level === 0) return null;
if (type === "hidden" || icon === "") return null;
const decorationStyle =
decorationSizePx === undefined
? undefined
: ({ width: decorationSizePx, height: decorationSizePx } satisfies React.CSSProperties);
const resolvedIconSizePx = iconSizePx ?? decorationSizePx;
const iconStyle =
resolvedIconSizePx === undefined
? undefined
: ({
fontSize: resolvedIconSizePx,
width: resolvedIconSizePx,
height: resolvedIconSizePx,
} satisfies React.CSSProperties);
return (
<div
role="img"
@@ -29,8 +50,10 @@ export function LevelDisplay({ icon, type, level, className, ...props }: Props)
<div
key={itemKey}
data-active={isActive}
style={decorationStyle}
className={cn(
"h-2.5 flex-1 border border-(--page-primary-color) border-x-0 first:border-l last:border-r",
"flex-1 border border-(--page-primary-color) border-x-0 first:border-l last:border-r",
decorationSizePx === undefined && "h-2.5",
isActive && "bg-(--page-primary-color)",
)}
/>
@@ -41,7 +64,13 @@ export function LevelDisplay({ icon, type, level, className, ...props }: Props)
return (
<i
key={itemKey}
className={cn("ph size-2.5 text-(--page-primary-color)", `ph-${icon}`, !isActive && "opacity-40")}
style={iconStyle}
className={cn(
"ph text-(--page-primary-color)",
resolvedIconSizePx === undefined && defaultDecorationClassName,
`ph-${icon}`,
!isActive && "opacity-40",
)}
/>
);
}
@@ -50,8 +79,10 @@ export function LevelDisplay({ icon, type, level, className, ...props }: Props)
<div
key={itemKey}
data-active={isActive}
style={decorationStyle}
className={cn(
"size-2.5 border border-(--page-primary-color)",
"border border-(--page-primary-color)",
decorationSizePx === undefined && defaultDecorationClassName,
isActive && "bg-(--page-primary-color)",
type === "circle" && "rounded-full",
type === "rectangle" && "w-7",

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