Compare commits

...
149 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
Amruth Pillai 1d761be05b chore(release): v5.1.7 2026-05-27 23:59:14 +02:00
github-actions[bot]andCrowdin Bot c875541001 [skip ci] chore(i18n): sync translations from crowdin (#3113)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-27 23:56:10 +02:00
Amruth Pillai 16f4d2c072 docs: using custom styles 2026-05-27 23:52:19 +02:00
Amruth Pillai b491582637 chore: add missing translations 2026-05-27 23:31:58 +02:00
Amruth Pillai c6a654191c feat: improvements to custom styles 2026-05-27 22:16:14 +02:00
Amruth Pillai 8461aa65d5 chore: remove react-doctor from package scripts and update task dependencies in turbo.json 2026-05-27 11:09:33 +02:00
Amruth Pillai b04eef1479 feat: implement style rules 2026-05-27 10:57:33 +02:00
Amruth Pillai 7bff6644d8 docs: add custom styles header target design 2026-05-26 15:12:48 +02:00
Amruth Pillai 8da780c868 feat: update links for improved accessibility 2026-05-26 13:09:30 +02:00
Amruth Pillai dd1e37e579 refactor: better resume two-way sync in case of MCP/API updates 2026-05-26 12:05:38 +02:00
Amruth Pillai 19b412d84d chore(release): v5.1.6 2026-05-26 10:12:56 +02:00
Amruth Pillai 7eea6675c0 chore: update dependencies 2026-05-26 10:09:58 +02:00
Amruth Pillai 273e17c0d3 fix: issue with color format handling, resolves #3104 2026-05-26 09:59:23 +02:00
Amruth Pillai 17cddbad65 fix: reduce default list item row gap 2026-05-26 00:13:38 +02:00
Amruth PillaiandCursor 7557ab13ab fix(api): delete agent threads with sequential cleanup
Remove attachments and soft-delete the thread before storage cleanup so
partial failures do not leave inconsistent DB state. Log storage errors
without failing the request after the thread is marked deleted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 16:33:03 +02:00
Amruth PillaiandCursor c66560ee12 refactor(web): dedupe isRTL via utils locale module
Re-export isRTL from @reactive-resume/utils/locale in the web locale
helper and consolidate RTL detection tests in the utils package.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 16:32:58 +02:00
Amruth PillaiandCursor 24c882fa9f feat(pdf): roll out shared RTL layout to all templates
Introduce createRtlStyleHelpers and a single rtl flag on RenderProvider,
migrate every template page to mirrored layout styles, and rename
alignRight to alignEnd. Fix plain rich text rendering via PdfText
paragraph renderers and map legacy Times New Roman to Times-Roman.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 16:29:50 +02:00
Yu Sun 86fff7237f fix(auth): reconcile migrated social login accounts (#3095) 2026-05-25 15:46:57 +02:00
Eyal Meschmanandautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> 266bc291eb Add RTL rendering for Rhyhorn template (#3099)
* Add RTL rendering for Rhyhorn template

* Add timeout to wait-healthy just command

* Revert prettier formatting

* Revert and ignore personal relevant files

* Revert prettier formatting from all modified files

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-25 15:46:51 +02:00
Amruth Pillai 6ec4da7914 chore: update dependencies 2026-05-25 15:44:40 +02:00
Umair Khurshid 75e9446134 docs(docker): use shallow clone in quick start (#3096) 2026-05-25 15:39:01 +02:00
Amruth Pillai 39e88dd365 chore: lint using react-doctor, update translations, dynamic imports 2026-05-21 09:56:26 +02:00
Amruth Pillai 3596102c63 chore: update dependencies 2026-05-20 23:12:39 +02:00
600 changed files with 76802 additions and 31358 deletions
+12 -5
View File
@@ -12,7 +12,7 @@ APP_URL="http://localhost:3000"
# --- Database (PostgreSQL) ---
# PostgreSQL connection URL. In Docker Compose, the hostname is usually `postgres`;
# when running directly on your machine, `localhost` is typical.
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/postgres"
# --- Authentication ---
# Generated using `openssl rand -hex 32`
@@ -54,8 +54,8 @@ OAUTH_SCOPES="openid profile email"
# --- Email (optional) ---
# If SMTP_HOST, SMTP_USER, SMTP_PASS, or SMTP_FROM is missing, the app logs the
# email to the console instead.
SMTP_HOST="localhost"
SMTP_PORT="1025"
SMTP_HOST=""
SMTP_PORT=""
SMTP_USER=""
SMTP_PASS=""
SMTP_FROM="Reactive Resume <noreply@rxresu.me>"
@@ -72,13 +72,13 @@ SMTP_SECURE="false"
S3_ACCESS_KEY_ID="seaweedfs"
S3_SECRET_ACCESS_KEY="seaweedfs"
S3_REGION="us-east-1"
S3_ENDPOINT="http://localhost:8333"
S3_ENDPOINT="http://seaweedfs:8333"
S3_BUCKET="reactive-resume"
S3_FORCE_PATH_STYLE="true"
# --- AI Agent Workspace (optional) ---
# Required only for the authenticated /agent workspace and saved AI providers.
REDIS_URL="redis://localhost:6379"
REDIS_URL="redis://redis:6379"
ENCRYPTION_SECRET="change-me-to-a-secure-agent-secret-in-production"
# --- Feature Flags ---
@@ -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
+6
View File
@@ -36,6 +36,8 @@ logs
# Testing
coverage
reports
playwright-report
test-results
# Cache
tmp
@@ -44,12 +46,16 @@ temp
# AI
.codex
.agents
.claude
.cursor
.codegraph
.superpowers
.migration
# Local Storage Data
/data
/apps/web/data
# Git Hooks
.vite-hooks/
+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"
}
}
+15
View File
@@ -39,6 +39,21 @@ Internal packages are source-consumed through `package.json` export maps that po
- The builder shell lives under `apps/web/src/routes/builder/$resumeId`. The nested preview route is client-only (`ssr: false`), while the public resume route `apps/web/src/routes/$username/$slug.tsx` uses `ssr: "data-only"`.
- Browser-only resume preview code lives under `apps/web/src/features/resume/preview`, and public resume PDF viewer code lives under `apps/web/src/features/resume/public`. Keep PDF.js/canvas/browser APIs out of SSR paths and out of `packages/pdf`.
- The isomorphic oRPC client is in `apps/web/src/libs/orpc/client.ts`; server calls use an in-process router client and browser calls use `/api/rpc` with credentials included.
- For React components with explicit props, prefer a named TypeScript props type over inline object annotations in the function signature, especially once the props include more than one field or generics. For example:
```ts
type IntentSelectFieldProps<TValue extends string> = {
label: string;
id: string;
value: TValue | undefined;
options: readonly ComboboxOption<TValue>[];
onChange: (value: TValue | undefined) => void;
};
function IntentSelectField<TValue extends string>(props: IntentSelectFieldProps<TValue>) {
// ...
}
```
### Package and feature boundaries
+15 -1
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**
@@ -143,7 +157,7 @@ The quickest way to run Reactive Resume locally:
```bash
# Clone the repository
git clone https://github.com/amruthpillai/reactive-resume.git
git clone --depth=1 https://github.com/amruthpillai/reactive-resume.git
cd reactive-resume
# Start all services
+41 -40
View File
@@ -13,25 +13,28 @@
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"imports": {
"#react-pdf-renderer": "@react-pdf/renderer"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.78",
"@ai-sdk/google": "^3.0.75",
"@ai-sdk/openai": "^3.0.64",
"@ai-sdk/openai-compatible": "^2.0.47",
"@aws-sdk/client-s3": "^3.1049.0",
"@better-auth/api-key": "^1.6.11",
"@better-auth/drizzle-adapter": "^1.6.11",
"@better-auth/infra": "^0.2.8",
"@better-auth/oauth-provider": "^1.6.11",
"@better-auth/passkey": "^1.6.11",
"@hono/node-server": "^2.0.3",
"@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:*",
@@ -42,44 +45,42 @@
"@reactive-resume/utils": "workspace:*",
"@sindresorhus/slugify": "^3.0.0",
"@t3-oss/env-core": "^0.13.11",
"@uiw/color-convert": "^2.10.1",
"ai": "^6.0.185",
"@uiw/color-convert": "^2.10.3",
"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.5",
"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.46.1",
"es-toolkit": "^1.49.0",
"fast-json-patch": "^3.1.1",
"hono": "^4.12.19",
"jsonrepair": "^3.14.0",
"node-html-parser": "^7.1.0",
"nodemailer": "^8.0.7",
"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.1.5",
"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.0",
"@types/node": "^26.1.0",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.14",
"@typescript/native-preview": "7.0.0-dev.20260518.1",
"tsdown": "^0.22.0",
"tsx": "^4.22.2",
"@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.6"
"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;
}
+3 -1
View File
@@ -19,6 +19,8 @@ const oauthAuthorizeSanitizedParams = [
"resource",
] as const;
const oauthCallbackPassthroughExcludedParams = new Set(["exp", "sig"]);
function sanitizeOAuthAuthorizeRequest(request: Request): Request {
if (request.method !== "GET") return request;
@@ -199,7 +201,7 @@ export async function handleOAuth(request: Request) {
const loginUrl = new URL("/auth/login", env.APP_URL);
const oauthParams = new URLSearchParams();
for (const [key, value] of url.searchParams) {
if (!["exp", "sig"].includes(key)) {
if (!oauthCallbackPassthroughExcludedParams.has(key)) {
oauthParams.set(key, value);
}
}
+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>
+600 -239
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+599 -238
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+600 -239
View File
File diff suppressed because it is too large Load Diff
+595 -234
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+600 -239
View File
File diff suppressed because it is too large Load Diff
+599 -238
View File
File diff suppressed because it is too large Load Diff
+600 -239
View File
File diff suppressed because it is too large Load Diff
+599 -238
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+599 -238
View File
File diff suppressed because it is too large Load Diff
+600 -239
View File
File diff suppressed because it is too large Load Diff
+600 -239
View File
File diff suppressed because it is too large Load Diff
+600 -239
View File
File diff suppressed because it is too large Load Diff
+599 -238
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+600 -239
View File
File diff suppressed because it is too large Load Diff
+781 -420
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+600 -239
View File
File diff suppressed because it is too large Load Diff
+600 -239
View File
File diff suppressed because it is too large Load Diff
+600 -239
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+600 -239
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+599 -238
View File
File diff suppressed because it is too large Load Diff
+600 -239
View File
File diff suppressed because it is too large Load Diff
+600 -239
View File
File diff suppressed because it is too large Load Diff
+600 -239
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+600 -239
View File
File diff suppressed because it is too large Load Diff
+599 -238
View File
File diff suppressed because it is too large Load Diff
+599 -238
View File
File diff suppressed because it is too large Load Diff
+600 -239
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+600 -239
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+601 -240
View File
File diff suppressed because it is too large Load Diff
+599 -238
View File
File diff suppressed because it is too large Load Diff
+595 -234
View File
File diff suppressed because it is too large Load Diff
+571 -210
View File
File diff suppressed because it is too large Load Diff
+54 -50
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.187",
"@base-ui/react": "^1.4.1",
"@better-auth/api-key": "^1.6.11",
"@better-auth/infra": "^0.2.8",
"@better-auth/oauth-provider": "^1.6.11",
"@better-auth/passkey": "^1.6.11",
"@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.0.1",
"@lingui/react": "^6.0.1",
"@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:*",
@@ -39,65 +39,69 @@
"@reactive-resume/fonts": "workspace:*",
"@reactive-resume/import": "workspace:*",
"@reactive-resume/pdf": "workspace:*",
"@reactive-resume/resume": "workspace:*",
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/ui": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-form": "^1.32.0",
"@tailwindcss/vite": "^4.3.2",
"@tanstack/react-form": "^1.33.0",
"@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-query": "^5.100.11",
"@tanstack/react-router": "^1.170.4",
"@tiptap/extension-color": "^3.23.4",
"@tiptap/extension-highlight": "^3.23.4",
"@tiptap/extension-table": "^3.23.4",
"@tiptap/extension-text-align": "^3.23.4",
"@tiptap/extension-text-style": "^3.23.4",
"@tiptap/pm": "^3.23.4",
"@tiptap/react": "^3.23.4",
"@tiptap/starter-kit": "^3.23.4",
"@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.1",
"@uiw/react-color-colorful": "^2.10.1",
"ai": "^6.0.185",
"better-auth": "1.6.11",
"@uiw/color-convert": "^2.10.3",
"@uiw/react-color-colorful": "^2.10.3",
"ai": "^7.0.15",
"better-auth": "1.6.23",
"cmdk": "^1.1.1",
"drizzle-orm": "1.0.0-rc.3",
"es-toolkit": "^1.46.1",
"fuse.js": "^7.3.0",
"immer": "^11.1.8",
"js-cookie": "^3.0.7",
"motion": "^12.39.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.1",
"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": {
"@lingui/babel-plugin-lingui-macro": "^6.0.1",
"@lingui/cli": "^6.0.1",
"@lingui/format-po": "^6.0.1",
"@lingui/vite-plugin": "^6.0.1",
"@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.6",
"@tanstack/router-plugin": "^1.168.19",
"@types/babel__core": "^7.20.5",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.14",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260518.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.13"
"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

@@ -1,5 +1,5 @@
import type React from "react";
import { motion, useMotionTemplate, useMotionValue, useSpring, useTransform } from "motion/react";
import { m, useMotionTemplate, useMotionValue, useSpring, useTransform } from "motion/react";
import { useRef } from "react";
import { cn } from "@reactive-resume/utils/style";
@@ -64,7 +64,7 @@ export const CometCard = ({
return (
<div className={cn("perspective-distant transform-3d", className)}>
<motion.div
<m.div
ref={ref}
initial={{ scale: 1, z: 0 }}
onMouseMove={handleMouseMove}
@@ -75,12 +75,12 @@ export const CometCard = ({
>
{children}
<motion.div
<m.div
transition={{ duration: 0.2 }}
style={{ background: glareBackground, opacity: glareOpacity }}
className="pointer-events-none absolute inset-0 z-50 h-full w-full rounded-md mix-blend-overlay will-change-[opacity]"
/>
</motion.div>
</m.div>
</div>
);
};
@@ -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", () => {
+15 -59
View File
@@ -1,120 +1,76 @@
import { useInView, useMotionValue, useSpring } from "motion/react";
import { useCallback, useEffect, useRef } from "react";
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],
);
useEffect(() => {
if (ref.current) {
ref.current.textContent = formatValue(direction === "down" ? to : from);
}
}, [from, to, direction, formatValue]);
const formatCurrentValue = useEffectEvent((latest: number) => formatValue(latest));
useEffect(() => {
if (isInView && startWhen) {
if (typeof onStart === "function") {
onStart();
}
const timeoutId = setTimeout(() => {
motionValue.set(direction === "down" ? from : to);
}, delay * 1000);
if (ref.current) ref.current.textContent = formatCurrentValue(0);
}, []);
const durationTimeoutId = setTimeout(
() => {
if (typeof onEnd === "function") {
onEnd();
}
},
delay * 1000 + duration * 1000,
);
return () => {
clearTimeout(timeoutId);
clearTimeout(durationTimeoutId);
};
}
}, [isInView, startWhen, motionValue, direction, from, to, delay, onStart, onEnd, duration]);
useEffect(() => {
if (isInView) motionValue.set(to);
}, [isInView, motionValue, to]);
useEffect(() => {
const unsubscribe = springValue.on("change", (latest: number) => {
if (ref.current) {
ref.current.textContent = formatValue(latest);
}
if (ref.current) ref.current.textContent = formatCurrentValue(latest);
});
return () => unsubscribe();
}, [springValue, formatValue]);
}, [springValue]);
return (
<span
@@ -1,4 +1,4 @@
import { motion } from "motion/react";
import { m } from "motion/react";
type SpotlightProps = {
duration?: number;
@@ -24,13 +24,13 @@ export const Spotlight = ({
xOffset = 100,
}: SpotlightProps) => {
return (
<motion.div
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1.5 }}
className="pointer-events-none absolute inset-0 h-full w-full"
>
<motion.div
<m.div
animate={{ x: [0, xOffset, 0] }}
transition={{ duration, repeat: Number.POSITIVE_INFINITY, repeatType: "reverse", ease: "easeInOut" }}
className="pointer-events-none absolute inset-s-0 top-0 z-40 h-svh w-svw will-change-transform"
@@ -64,9 +64,9 @@ export const Spotlight = ({
transform: "rotate(-45deg) translate(-180%, -70%)",
}}
/>
</motion.div>
</m.div>
<motion.div
<m.div
animate={{ x: [0, -xOffset, 0] }}
transition={{ duration, repeat: Number.POSITIVE_INFINITY, ease: "easeInOut", repeatType: "reverse" }}
className="pointer-events-none absolute inset-e-0 top-0 z-40 h-svh w-svw will-change-transform"
@@ -100,7 +100,7 @@ export const Spotlight = ({
transform: "rotate(45deg) translate(180%, -70%)",
}}
/>
</motion.div>
</motion.div>
</m.div>
</m.div>
);
};
+12 -18
View File
@@ -1,5 +1,5 @@
import { motion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { m } from "motion/react";
import { useRef, useState } from "react";
import { cn } from "@reactive-resume/utils/style";
const textClassName = cn("fill-transparent font-bold text-3xl leading-none tracking-tight");
@@ -13,20 +13,9 @@ type TextMaskEffectProps = {
export const TextMaskEffect = ({ text, duration = 6, className, "aria-hidden": ariaHidden }: TextMaskEffectProps) => {
const svgRef = useRef<SVGSVGElement>(null);
const [cursor, setCursor] = useState({ x: 0, y: 0 });
const [hovered, setHovered] = useState(false);
const [maskPosition, setMaskPosition] = useState({ cx: "50%", cy: "50%" });
useEffect(() => {
if (svgRef.current && cursor.x !== null && cursor.y !== null) {
const svgRect = svgRef.current.getBoundingClientRect();
const cxPercentage = ((cursor.x - svgRect.left) / svgRect.width) * 100;
const cyPercentage = ((cursor.y - svgRect.top) / svgRect.height) * 100;
setMaskPosition({ cx: `${cxPercentage}%`, cy: `${cyPercentage}%` });
}
}, [cursor]);
return (
<svg
ref={svgRef}
@@ -40,7 +29,12 @@ export const TextMaskEffect = ({ text, duration = 6, className, "aria-hidden": a
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onMouseMove={(e) => {
setCursor({ x: e.clientX, y: e.clientY });
if (!svgRef.current) return;
const svgRect = svgRef.current.getBoundingClientRect();
const cxPercentage = ((e.clientX - svgRect.left) / svgRect.width) * 100;
const cyPercentage = ((e.clientY - svgRect.top) / svgRect.height) * 100;
setMaskPosition({ cx: `${cxPercentage}%`, cy: `${cyPercentage}%` });
}}
>
<defs>
@@ -56,7 +50,7 @@ export const TextMaskEffect = ({ text, duration = 6, className, "aria-hidden": a
)}
</linearGradient>
<motion.radialGradient
<m.radialGradient
r="20%"
id="revealMask"
animate={maskPosition}
@@ -66,7 +60,7 @@ export const TextMaskEffect = ({ text, duration = 6, className, "aria-hidden": a
>
<stop offset="0%" stopColor="white" />
<stop offset="100%" stopColor="black" />
</motion.radialGradient>
</m.radialGradient>
<mask id="textMask">
<rect x="0" y="0" width="100%" height="100%" fill="url(#revealMask)" />
@@ -85,7 +79,7 @@ export const TextMaskEffect = ({ text, duration = 6, className, "aria-hidden": a
{text}
</text>
<motion.text
<m.text
x="50%"
y="50%"
strokeWidth="0.3"
@@ -97,7 +91,7 @@ export const TextMaskEffect = ({ text, duration = 6, className, "aria-hidden": a
className={cn(textClassName, "stroke-zinc-300 dark:stroke-zinc-700")}
>
{text}
</motion.text>
</m.text>
<text
x="50%"
+34 -15
View File
@@ -13,7 +13,7 @@ import { CSS } from "@dnd-kit/utilities";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleIcon, XIcon } from "@phosphor-icons/react";
import { AnimatePresence, motion } from "motion/react";
import { AnimatePresence, m } from "motion/react";
import * as React from "react";
import { createPortal } from "react-dom";
import { Badge } from "@reactive-resume/ui/components/badge";
@@ -24,6 +24,7 @@ import { useControlledState } from "@/hooks/use-controlled-state";
const RETURN_KEY = "Enter";
const COMMA_KEY = ",";
const EMPTY_CHIPS: string[] = [];
type ChipItemProps = {
id: string;
@@ -34,7 +35,11 @@ type ChipItemProps = {
onRemove: (index: number) => void;
};
function ChipDragPreview({ chip }: { chip: string }) {
type ChipDragPreviewProps = {
chip: string;
};
function ChipDragPreview({ chip }: ChipDragPreviewProps) {
return (
<Badge
variant="outline"
@@ -45,7 +50,11 @@ function ChipDragPreview({ chip }: { chip: string }) {
);
}
function ChipDragOverlay({ activeChip }: { activeChip: string | null }) {
type ChipDragOverlayProps = {
activeChip: string | null;
};
function ChipDragOverlay({ activeChip }: ChipDragOverlayProps) {
const overlay = (
<DragOverlay dropAnimation={null}>{activeChip ? <ChipDragPreview chip={activeChip} /> : null}</DragOverlay>
);
@@ -65,7 +74,7 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
};
return (
<motion.div
<m.div
layout
initial={{ opacity: 0, scale: 0.92, y: -4 }}
animate={{ opacity: isDragging ? 0.62 : 1, scale: 1, y: 0 }}
@@ -86,7 +95,7 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
)}
>
<span className="max-w-32 truncate sm:max-w-44">{chip}</span>
<motion.div
<m.div
initial={false}
animate={isEditing ? { opacity: 1 } : { opacity: 0.66 }}
transition={{ duration: 0.12, ease: "easeOut" }}
@@ -124,9 +133,9 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
>
<XIcon className="size-3.5" />
</button>
</motion.div>
</m.div>
</Badge>
</motion.div>
</m.div>
);
}
@@ -137,7 +146,14 @@ type Props = Omit<React.ComponentProps<"div">, "value" | "onChange"> & {
hideDescription?: boolean;
};
export function ChipInput({ value, defaultValue = [], onChange, className, hideDescription = false, ...props }: Props) {
export function ChipInput({
value,
defaultValue = EMPTY_CHIPS,
onChange,
className,
hideDescription = false,
...props
}: Props) {
const [chips, setChips] = useControlledState<string[]>({
value,
defaultValue,
@@ -154,7 +170,10 @@ export function ChipInput({ value, defaultValue = [], onChange, className, hideD
const addChips = React.useCallback(
(values: string[]) => {
const nextValues = values.map((chip) => chip.trim()).filter(Boolean);
const nextValues = values.flatMap((chip) => {
const trimmed = chip.trim();
return trimmed ? [trimmed] : [];
});
if (nextValues.length === 0) return;
const newChips = Array.from(new Set([...chips, ...nextValues]));
@@ -195,7 +214,7 @@ export function ChipInput({ value, defaultValue = [], onChange, className, hideD
setEditingIndex(null);
setInput("");
} else if (editingIndex !== null && editingIndex > index) {
setEditingIndex(editingIndex - 1);
setEditingIndex((current) => (current !== null && current > index ? current - 1 : current));
}
},
[chips, setChips, editingIndex],
@@ -328,7 +347,7 @@ export function ChipInput({ value, defaultValue = [], onChange, className, hideD
className={cn("max-h-24 overflow-y-auto px-2 py-1.5", hasChips ? "border-border/70 border-b" : "hidden")}
>
<SortableContext items={chips} strategy={rectSortingStrategy}>
<motion.div layout className="flex flex-wrap gap-1">
<m.div layout className="flex flex-wrap gap-1">
<AnimatePresence initial={false} mode="popLayout">
{chips.map((chip, idx) => (
<ChipItem
@@ -342,7 +361,7 @@ export function ChipInput({ value, defaultValue = [], onChange, className, hideD
/>
))}
</AnimatePresence>
</motion.div>
</m.div>
</SortableContext>
</div>
<div className={cn("flex items-center gap-1.5 px-2", hasChips ? "py-1.5" : "py-0")}>
@@ -355,11 +374,11 @@ export function ChipInput({ value, defaultValue = [], onChange, className, hideD
placeholder={isEditingKeyword ? t`Editing keyword...` : t`Add a keyword...`}
onKeyDown={handleKeyDown}
onChange={handleInputChange}
className="h-9 flex-1 border-none px-0 py-0 focus-visible:border-none focus-visible:ring-0 dark:bg-transparent"
className="h-9 flex-1 border-none p-0 focus-visible:border-none focus-visible:ring-0 dark:bg-transparent"
/>
<AnimatePresence>
{chips.length > 0 && (
<motion.span
<m.span
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{
@@ -376,7 +395,7 @@ export function ChipInput({ value, defaultValue = [], onChange, className, hideD
)}
>
{isEditingKeyword ? <Trans>Edit</Trans> : chips.length}
</motion.span>
</m.span>
)}
</AnimatePresence>
</div>
@@ -7,6 +7,10 @@ import { I18nProvider } from "@lingui/react";
const queryResult = vi.hoisted(() => ({ data: undefined as number | undefined }));
type CountUpProps = {
to: number;
};
vi.mock("@tanstack/react-query", () => ({
useQuery: () => queryResult,
}));
@@ -14,7 +18,7 @@ vi.mock("@/libs/orpc/client", () => ({
orpc: { statistics: { github: { getStarCount: { queryOptions: () => ({}) } } } },
}));
vi.mock("../animation/count-up", () => ({
CountUp: ({ to }: { to: number }) => <span data-testid="count-up">{to.toLocaleString()}</span>,
CountUp: ({ to }: CountUpProps) => <span data-testid="count-up">{to.toLocaleString()}</span>,
}));
const { GithubStarsButton } = await import("./github-stars-button");
@@ -35,12 +39,12 @@ const renderButton = () =>
);
describe("GithubStarsButton", () => {
it("renders an anchor pointing at the project repo with rel=noopener and target=_blank", () => {
it("renders an anchor pointing at the project repo with rel=noopener noreferrer and target=_blank", () => {
renderButton();
const link = screen.getByRole("button") as HTMLAnchorElement;
expect(link.href).toBe("https://github.com/amruthpillai/reactive-resume");
expect(link.target).toBe("_blank");
expect(link.rel).toBe("noopener");
expect(link.rel).toBe("noopener noreferrer");
});
it("uses the no-count aria-label when star count hasn't loaded yet", () => {
@@ -18,7 +18,12 @@ export function GithubStarsButton() {
variant="outline"
nativeButton={false}
render={
<a target="_blank" href="https://github.com/amruthpillai/reactive-resume" aria-label={ariaLabel} rel="noopener">
<a
target="_blank"
href="https://github.com/amruthpillai/reactive-resume"
aria-label={ariaLabel}
rel="noopener noreferrer"
>
<GithubLogoIcon aria-hidden="true" />
{starCount != null ? (
<CountUp to={starCount} duration={0.5} separator="," className="font-bold" aria-hidden="true" />
@@ -5,23 +5,20 @@ import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
type GridProps = {
rowCount: number;
columnCount: number;
cellComponent: React.ComponentType<
{ rowIndex: number; columnIndex: number; style: React.CSSProperties } & Record<string, unknown>
>;
cellProps: Record<string, unknown>;
};
// react-window's <Grid> uses ResizeObserver / IntersectionObserver heavily and
// expects measurable layouts that happy-dom can't provide. Stub it with a simple
// pass-through that renders the first row of cells via the supplied cellComponent.
vi.mock("react-window", () => ({
Grid: ({
rowCount,
columnCount,
cellComponent: CellComponent,
cellProps,
}: {
rowCount: number;
columnCount: number;
cellComponent: React.ComponentType<
{ rowIndex: number; columnIndex: number; style: React.CSSProperties } & Record<string, unknown>
>;
cellProps: Record<string, unknown>;
}) => {
Grid: ({ rowCount, columnCount, cellComponent: CellComponent, cellProps }: GridProps) => {
const cells: React.ReactNode[] = [];
for (let r = 0; r < Math.min(rowCount, 1); r++) {
for (let c = 0; c < columnCount; c++) {
@@ -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,
});
});
});
+128 -120
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 & {
@@ -196,7 +193,7 @@ export function RichInput({ value, onChange, style, className, editorClassName,
);
}
function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen: boolean }) {
function useEditorToolbarState(editor: Editor) {
const prompt = usePrompt();
const state = useEditorState({
@@ -223,10 +220,12 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
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 EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
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(),
@@ -367,6 +346,34 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
},
});
return state;
}
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;
};
function EditorToolbar({ editor, isFullscreen }: EditorToolbarProps) {
const state = useEditorToolbarState(editor);
return renderEditorToolbar(state, isFullscreen);
}
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
@@ -417,17 +424,65 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
<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}
@@ -494,7 +549,13 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
<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" />)
@@ -567,7 +628,13 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
<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" />)
@@ -643,6 +710,7 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Decrease indent`}
disabled={!state.canLiftListItem}
onClick={state.liftListItem}
>
@@ -654,6 +722,7 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Increase indent`}
disabled={!state.canSinkListItem}
onClick={state.sinkListItem}
>
@@ -668,6 +737,7 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Remove link`}
onClick={state.unsetLink}
>
<LinkBreakIcon className="size-3.5" />
@@ -678,6 +748,7 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Add link`}
onClick={state.setLink}
>
<LinkIcon className="size-3.5" />
@@ -696,73 +767,6 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
<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}
@@ -784,6 +788,10 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
>
<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>
);
}

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