Compare commits

..
19 Commits
Author SHA1 Message Date
Amruth Pillai 90105cb148 chore: integrate improve-integration 2026-07-08 19:08:31 +02:00
Amruth Pillai 73daf22b2f docs: publish MCP registry metadata 2026-07-07 18:50:07 +02:00
github-actions[bot]andCrowdin Bot 25021507a0 [skip ci] chore(i18n): sync translations from crowdin (#3233)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-07 18:21:35 +02:00
Amruth Pillai 8570c1c70a fix: render cover letter exports without resume chrome 2026-07-07 17:47:52 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 5270a2a9a0 docs: tighten SEO titles and descriptions across new docs (#3232)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-07 15:08:12 +00:00
github-actions[bot]andCrowdin Bot b87a9d8282 Sync Translations from Crowdin (#3231)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-07 17:06:14 +02:00
Amruth Pillai 46afc65cc6 Add application tracker REST and MCP parity
Add comprehensive Application Tracker REST and MCP coverage, document the MCP workflow, add Markdown/ActionLint checks, bump the release version, and fill all extracted translations.
2026-07-07 17:02:39 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> dfc5559625 docs: document expanded command palette entity search (#3225)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-06 00:32:00 +02:00
github-actions[bot]andCrowdin Bot d37ac57cc5 [skip ci] chore(i18n): sync translations from crowdin (#3224)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-06 00:30:34 +02:00
Amruth Pillai fb9c217af2 feat: add command palette entity search 2026-07-06 00:29:06 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 0a64312bf8 docs: lengthen short SEO descriptions on two guides (#3222)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-05 21:45:25 +00:00
Amruth Pillai b404dbd42a Add application tracker (#3220)
* feat(applications): job application tracker with AI copilot

Add an Applications module at /dashboard/applications: pipeline board
(dnd-kit), table view with bulk actions, Insights (fit tiles, funnel,
sources, shareable funnel-flow SVG), campaigns, tags, CSV import, and
Add/Edit/Detail slide-overs. Each application links a live Reactive
Resume.

AI "Application Copilot" (applications.ai.*): job-posting autofill,
resume↔job match score (fit ring), resume tailoring, and cover-letter /
follow-up drafting — via the user's configured provider.

Board cards + table rows get context menus (edit / move / archive /
delete). Charts are CSS/SVG (no new chart dep); adds a UI Checkbox.

Also includes local TanStack devtools setup and toolchain bumps.

Claude-Session: https://claude.ai/code/session_01TEeRHnEayw2MFCShFRyL5f

* feat(applications): close follow-up gaps + squash migrations

Finish the deferred/open items on the applications tracker:

- Cover-letter upload re-enabled. Fix the storage blocker by deriving the
  key extension from content type (buildFileKey/EXTENSION_BY_CONTENT_TYPE)
  instead of hardcoding .jpeg, so PDFs serve correctly and non-JPEG image
  avatars keep working under FLAG_DISABLE_IMAGE_PROCESSING. Add
  coverLetterUrl/coverLetterName columns + Documents-section upload/remove.
- Contacts editor in the detail sheet (add/edit/remove, keyed per app).
- Board caps rendered cards per column (COLUMN_PAGE_SIZE=50 + "Show more").
- Extract new Lingui messages across locales.
- Guard coverLetterUrl to http(s)/relative at the API boundary.

Squash the five branch-only application-table migrations (create -> +tags
-> +cover-letter -> drop -> re-add) into a single clean CREATE TABLE via
drizzle-kit generate.

Claude-Session: https://claude.ai/code/session_01TEeRHnEayw2MFCShFRyL5f

* chore: update dependencies

* fix(web): address React Doctor findings — compiler, purity, query, component structure

prefer-module-scope-pure-function: hoist buildSubtitle, getDecimalPlaces,
handleLocaleChange, onLocaleChange, stop, listContent/groupedListContent to
module scope so they aren't rebuilt on every render.

react-compiler-todo (??=): rewrite draft.metadata.styleRules ??= [] to the
non-assignment form to unblock auto-memoization.

set-state-in-effect: derive updatedAtLabel at render time instead of syncing
it through useState + useEffect.

query-destructure-result: destructure useQuery results at call site in
resume-analysis and resume-thumbnail to follow TanStack Query v5 convention.

only-export-components: extract non-component exports to sibling .ts files so
Fast Refresh can preserve component state:
  - getNextWeights → typography/get-next-weights.ts
  - detectJsonImportType + ImportType → dialogs/resume/import.utils.ts
  - getLocaleOptions → features/locale/locale-options.tsx
  - preview helpers + DEFAULT_PDF_PAGE_SIZE → preview.shared.utils.ts
  - resolveHighlightToolbarState + defaultHighlightColor → rich-input.utils.ts
  - computeDelta + getSparklinePoints → statistics.utils.ts

no-multi-comp: split multi-component files into focused companions:
  - ResumePane + ToolbarButton → routes/agent/-components/resume-pane.tsx
  - DesktopBuilderShell → builder/$resumeId/-components/desktop-builder-shell.tsx
  - MobileBuilderShell + helpers → builder/$resumeId/-components/mobile-builder-shell.tsx
  - setBuilderLayout/getBuilderLayout moved to -store/sidebar.ts

fix(tests): add Resume type import to section-builder mocks and cast partial
mock data as unknown as Resume to satisfy stricter type checking; fix
noExplicitAny Biome errors in the same mocks.

* feat(applications): improve performance

* chore: fix knip issues

* perf(builder): halve per-keystroke render cost

Section-form fields called `form.handleSubmit()` on every keystroke, which
re-validated the whole form and toggled submit state — firing the render
cascade twice per character (~6809 renders/keystroke, FPS dropping to 9).

Persist via a form-level `listeners.onChange` instead and drop the per-field
`handleSubmit()` (basics, custom-fields, design). Narrow header/dock resume
subscriptions to metadata slices so they no longer re-render on content edits.

Cuts renders 6809 -> 3403 per keystroke (50%), 0 frame drops. Save, preview,
and design controls verified working; 449/449 web tests pass.

* perf(home): eliminate hero CLS from unreserved video box

The hero <section> is `flex items-center` (shrink-to-fit), so the video
wrapper's width depended on the video's intrinsic size, which only resolves
after the media loads. aspect-ratio couldn't reserve height without a definite
width, so the video grew from ~190px to ~563px after first paint and shoved the
centered hero text down ~373px (CLS ~0.095).

Give the wrapper a definite width (w-full + mx-auto on the CometCard) and set an
explicit aspect ratio + width/height on the video so its box is reserved before
load. CLS 0.095 -> 0; hero stays visually centered at max-w-4xl.

* docs: add application tracker guides

* chore(db): squash application migrations

* fix(email): import React in auth template for server-side rendering compatibility

* chore(release): v5.2.1

* Refactor resume rendering and builder workflows

* fix: address application tracker review findings
2026-07-05 23:44:04 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> be43b4556b Update from code changes: refreshed download and cover letter export docs (#3219)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-05 14:53:48 +02:00
github-actions[bot]andCrowdin Bot 0d1bfd4e6b Sync Translations from Crowdin (#3218)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-05 14:42:41 +02:00
Amruth Pillai 20c803e934 feat(export): separate resume/cover-letter downloads, redesign dialog, add Markdown export (#3217)
* feat(export): separate resume/cover-letter downloads, redesign dialog, add Markdown

Let people export the resume and cover letter as distinct documents, and add a
Markdown format alongside PDF / DOCX / JSON (handy for AI agents).

- Server/API: scope PDF generation and download URLs to a resume/cover-letter target.
- Export domain: getResumeExportData + resumeHasCoverLetter in @reactive-resume/resume.
- Redesign the download dialog: one global "What to export" scope toggle (Tabs) plus
  flattened per-format rows, reusing existing UI components and design language.
- Add Markdown export (@reactive-resume/resume/markdown) with a small tiptap-HTML converter.
- Fix blank section headings in DOCX and Markdown by injecting the locale-aware
  section-title resolver (titles are stored empty and resolved at render time).
- Locale catalogs updated for the new strings.

* test(e2e): open the download dialog before exporting JSON

The JSON export moved into the redesigned download dialog, so the spec now opens
the dialog from the Export sidebar section before clicking "Download JSON".
2026-07-05 14:40:49 +02:00
Amruth Pillai 6e7fc68068 fix(design-sync): address CodeRabbit review on preview files
- Add `import type * as React from "react"` to the 26 previews that reference
  `React.CSSProperties` under the automatic JSX runtime (React isn't a global
  type namespace there, so the annotation was unresolved standalone).
- Accordion preview: use `multiple` instead of `openMultiple` — @base-ui/react
  1.6 renamed the prop, so the multi-open cell wasn't actually multi-open.

Skipped CodeRabbit's BrandIcon dark-mode note: the preview renders in the
default light card (the dark <img> is hidden there); per-theme sources would
need component support it doesn't expose.

Claude-Session: https://claude.ai/code/session_01R8Aq8F1nTuvJwfut7g3DVE
2026-07-05 06:30:01 +02:00
Amruth Pillai a28e3baa61 chore(design-sync): add Reactive Resume UI sync inputs (#3216)
* chore(design-sync): add Reactive Resume UI sync inputs

Sync inputs for the claude.ai/design "Reactive Resume" project — the
@reactive-resume/ui design system (39 primary components).

- .design-sync/config.json — converter config (synth-entry, 202->39 card
  prune, overlay/grid cardMode overrides, cssEntry, tsconfig, buildCmd)
- .design-sync/build-css.mjs + tw-entry.css — compile Tailwind v4 globals.css
  to a self-contained stylesheet (inlined IBM Plex font), emit real .d.ts,
  and create the workspace self-symlink the converter needs
- .design-sync/previews/*.tsx — 39 authored preview compositions
- .design-sync/conventions.md — design-agent usage header (readmeHeader)
- .design-sync/NOTES.md — re-sync notes, gotchas, and risks
- packages/ui/tsconfig.emit.json — declaration emit for real prop contracts

Build artifacts (dist/types, .ds-compiled.css, ds-bundle, .cache) are gitignored.

Claude-Session: https://claude.ai/code/session_01R8Aq8F1nTuvJwfut7g3DVE

* chore(knip): ignore .design-sync inputs and drop stale es-toolkit ignore

- Ignore .design-sync/** (design-sync tooling: build-css.mjs + authored
  previews are standalone, not part of the app import graph — knip --fix was
  deleting them and failing the autofix job).
- Remove es-toolkit from apps/server ignoreDependencies: it's now really used
  (apps/server/src/http/health.ts imports withTimeout), so the ignore is stale.

Claude-Session: https://claude.ai/code/session_01R8Aq8F1nTuvJwfut7g3DVE
2026-07-05 06:25:51 +02:00
Amruth Pillai 9f9268f380 fix: migrate better auth 2fa schema 2026-07-05 00:05:53 +02:00
Amruth Pillai 8416a92153 fix: improve mobile responsive layouts 2026-07-04 23:54:02 +02:00
387 changed files with 74768 additions and 9924 deletions
+55
View File
@@ -0,0 +1,55 @@
# design-sync notes — @reactive-resume/ui
Syncs to Claude Design project **Reactive Resume** (`3c0f6556-050a-41e5-9886-c3f1ea950517`).
## Repo shape / build
- `@reactive-resume/ui` is **source-consumed** (pnpm workspace, no `dist`, exports point at `src/components/*.tsx`). Runs in the converter's **synth-entry mode** (no `--entry`).
- `buildCmd` = `node .design-sync/build-css.mjs`. That one script does three things, all required before every converter run:
1. Creates the workspace **self-symlink** `packages/ui/node_modules/@reactive-resume/ui -> ../../../ui` (pnpm doesn't self-install it; the converter resolves the DS as `node_modules/<pkg>` and esbuild needs it for `@reactive-resume/ui/components/*` self-imports).
2. Emits real **`.d.ts`** to `packages/ui/dist/types` via `tsc -p packages/ui/tsconfig.emit.json`. Without this, synth-entry mode gives weak `{[key]: unknown}` prop contracts; with it the converter's `findTypesRoot` picks up `dist/types` and every component gets real props (variant/size unions, inherited Base UI props).
3. Compiles Tailwind v4 `globals.css` → self-contained `packages/ui/.ds-compiled.css` (`cfg.cssEntry`): inlines the IBM Plex Sans latin variable woff2 as a data-URI and strips all other `@font-face` (extra scripts + the Phosphor icon web font, which previews don't use — components render Phosphor as inline React SVGs). This is why previews are fully styled with tokens + brand font and there are zero dangling font URLs.
- CSS entry scans `.design-sync/tw-entry.css` which `@import`s globals.css and adds `@source "./previews/*.tsx"` so utility classes used in authored previews are compiled. **Preview layout wrappers use inline styles** anyway (so subagents needn't recompile the shared CSS); only component-level utility classes need the recompile.
## Card scope
- The package exports **202 symbols** (39 primary components + 163 compound sub-parts). User chose **~40 primary cards**: `cfg.componentSrcMap` nulls the 163 sub-parts. All 202 stay importable from `window.RRUI` (the bundle exports everything regardless of the card list), so previews compose sub-parts (`RRUI.DialogContent`, etc.) freely.
- Multi-primary files represented by one card: `combobox.tsx`→ComboboxRoot, `form.tsx`→FormItem, `resizable.tsx`→ResizableGroup, `sonner.tsx`→Toaster.
## Preview authoring conventions (calibrated on Button / Alert / Dialog)
- Import naturally: `import { Button } from "@reactive-resume/ui/components/button"` — converter rule 2 redirects any exported-component module to `window.RRUI`, and sub-parts resolve too.
- Icons: `@phosphor-icons/react` with the `*Icon` suffix (e.g. `PlusIcon`, `TrashIcon`, `WarningIcon`). Bundles into the preview.
- Base UI compose pattern: `render={<Button variant="outline" />}` on `*.Trigger` / `*.Close` etc.
- Layout wrappers: inline `style={{ display:"flex", gap, padding }}` — not Tailwind (keeps fan-out from needing CSS recompiles).
- **Overlays** (Dialog, and expect the same for AlertDialog/Sheet/Popover/HoverCard/DropdownMenu/ContextMenu/Tooltip/Command-dialog): render open via `defaultOpen`, and set `cfg.overrides.<Name> = {cardMode:"single", primaryStory:"<export>", viewport:"WxH"}`. Use viewport width ≥ 640 so `sm:` breakpoint styles (e.g. horizontal dialog footer) engage — Dialog uses `760x440`.
- Realistic resume-app content (resumes, sections, publish/export/share), never foo/bar.
## Component composition notes (from the authoring wave)
- **Real `.d.ts` contracts require the barrel** (see build step 2 + `publishConfig.types`). Base UI prop names differ from Radix/native: Switch `defaultChecked`+`size`; Toggle `defaultPressed`+`variant`+`size`; Slider `defaultValue` array (`[n]` single / `[a,b]` range). Use uncontrolled `default*` props in previews to avoid controlled-without-onChange warnings.
- **BrandIcon renders the app's own logo/icon** (`variant="logo"|"icon"`), NOT a social/brand-slug icon. It `<img src>`s `/logo/*.svg` + `/icon/*.svg`, which the preview server (serving `ds-bundle/`) 404s. The BrandIcon preview inlines the real `apps/web/public/{logo,icon}/light.svg` as base64 `src` overrides (component spreads `{...props}` after its own `src`, so the override wins).
- **Overlays** handled by the orchestrator with `cfg.overrides` (cardMode single + primaryStory Open + viewport): Dialog, AlertDialog, Sheet, Popover, Tooltip, HoverCard, DropdownMenu, ContextMenu, ComboboxRoot. Command renders **inline** (cmdk, no overlay); Sidebar uses `collapsible="none"` to render inline (default offcanvas is fixed-positioned); Toaster fires a `duration:Infinity` toast on mount.
- **Providers composed in-preview** (no cfg.provider): Tooltip→TooltipProvider, Sidebar→SidebarProvider, MessageScroller→MessageScrollerProvider (+ explicit container height — Root is `size-full min-h-0` and collapses otherwise), FormItem carries its own context.
- **Accordion** opens statically via `defaultValue={[...itemValues]}` (the `--accordion-panel-height` warn is a non-issue — panels measure fine). **Tabs** via `defaultValue`. **ScrollArea/ResizableGroup/InputGroup** need an inline container height/width. **Separator** vertical needs an explicit height.
- Chat/attachment components (Attachment, Bubble, Message, MessageScroller, Marker) are all used only in `apps/web/src/routes/agent/-components/agent-chat.tsx` — the canonical composition source.
## Build/verify gotchas (learned the hard way)
- **A full `package-build` takes ~3-4 minutes** — not a hang. `@phosphor-icons/react` is a giant barrel, so each icon-importing preview costs ~10-20s of esbuild parse, and 30+ authored previews compile serially. Always run it in a real background task (not a 120s-capped foreground shell) and wait for completion.
- **Do NOT add a barrel `index.d.ts` + `publishConfig.types`** to get rich props for inline-param-typed components: it makes ts-morph resolve all 200+ inline Base UI param types and hangs the build for many minutes. Tried and reverted. Result: components with a named `<Name>Props` source type (Button) get real props; the rest get honest `{[key]: unknown}`.
- **Base UI menu Labels must be inside a Group**: `DropdownMenuLabel`/`ContextMenuLabel` throw `MenuGroupContext is missing` unless wrapped in `DropdownMenuGroup`/`ContextMenuGroup`. Same likely for other `*Label`/`*GroupLabel` menu parts.
- **`[RENDER_THIN]` (height 0px) is benign for fixed-position overlays** (Dialog, AlertDialog, Sheet): the content is `position:fixed` so it measures 0 in normal flow, but `rootEmpty:false` and the screenshot is correct. Confirmed via review sheets — not a failure.
- **`[GRID_OVERFLOW]` wide** → `cfg.overrides.<Name> = {cardMode:"column"}` applied to: Accordion, Attachment, Bubble, FormItem, InputGroup, Marker, Message, ResizableGroup, Tabs, Textarea. Toaster (portal escape) → `{cardMode:"single", primaryStory:"Notification"}`.
## Known render warns (triaged, not failures)
- `[TOKENS_MISSING]`: `--active-tab-{top,left,height,width}` (Base UI tab indicator sets these at runtime), `--accordion-panel-height` (Base UI accordion runtime), `--tw` (Tailwind internal), plus app-level `--resume-preview-page-gap` / `--page-primary-color` (defined by apps/web, not this package). All expected absent from the shipped stylesheet — components set them at runtime. Do not chase.
- `--font-heading` is referenced (DialogTitle `font-heading`) but not defined in the UI package tokens (app-level). Falls back to `--font-body` (IBM Plex). Cosmetic only.
- Unauthored primitives render near-empty floor cards (`[RENDER_BLANK]` for empty Button/Input/etc.) — resolved once authored.
## Re-sync risks
- `packages/ui/dist/types`, `packages/ui/.ds-compiled.css`, `packages/ui/.ds-tw-raw.css`, and the self-symlink are all gitignored build artifacts regenerated by `buildCmd` — always run `node .design-sync/build-css.mjs` before the converter/driver.
- The inlined IBM Plex font path in `build-css.mjs` is pinned to `@fontsource-variable/ibm-plex-sans/files/ibm-plex-sans-latin-wght-normal.woff2`; if that dep moves, the font inline breaks (previews fall back to system sans).
- `tsconfig.emit.json` is committed; if the package adds a real build later, prefer pointing the converter at that dist and drop the emit step.
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env node
// design-sync CSS build: compile the UI package's Tailwind v4 globals.css to
// static CSS, then make it self-contained for preview rendering by inlining the
// IBM Plex Sans (latin) variable webfont as a data-URI and dropping the other
// @font-face rules (extra scripts + the Phosphor icon font, which previews
// don't use — components render Phosphor as inline React SVGs).
//
// Output: packages/ui/.ds-compiled.css (cfg.cssEntry, bounded to the package)
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const repo = resolve(here, "..");
// pnpm doesn't self-install the workspace package into its own node_modules,
// but the design-sync converter resolves the DS as node_modules/<pkg>. Create
// the self-symlink so PKG_DIR resolves and esbuild finds @reactive-resume/ui/*
// self-imports. Mirrors the sibling symlinks pnpm already writes (utils, config).
const selfLink = resolve(repo, "packages/ui/node_modules/@reactive-resume/ui");
if (!existsSync(selfLink)) symlinkSync("../../../ui", selfLink);
// Emit real .d.ts declarations (the package is source-consumed with no build).
// The converter's findTypesRoot picks up dist/types, giving components real
// prop contracts (variant/size unions, inherited Base UI props) instead of the
// weak `{[key]: unknown}` synth-entry fallback.
execFileSync(resolve(repo, "node_modules/.bin/tsc"), ["-p", "tsconfig.emit.json"], {
cwd: resolve(repo, "packages/ui"),
stdio: "inherit",
});
// NOTE: a barrel index.d.ts + publishConfig.types was tried to give the prop
// extractor an entry for components with inline param types — but resolving all
// 200+ inline Base UI param types through ts-morph's checker hangs the build
// (many minutes). Reverted. Components with a named <Name>Props source type
// (e.g. Button) still extract real props from dist/types; the rest fall back to
// the honest `{[key]: unknown}` contract, with usage carried by the preview +
// .prompt.md. See .design-sync/NOTES.md "Re-sync risks".
const cli = resolve(repo, ".ds-sync/node_modules/.bin/tailwindcss");
const entry = resolve(here, "tw-entry.css");
const tmp = resolve(repo, "packages/ui/.ds-tw-raw.css");
const out = resolve(repo, "packages/ui/.ds-compiled.css");
const font = resolve(
repo,
"packages/ui/node_modules/@fontsource-variable/ibm-plex-sans/files/ibm-plex-sans-latin-wght-normal.woff2",
);
execFileSync(cli, ["-i", entry, "-o", tmp], { stdio: "inherit" });
let css = readFileSync(tmp, "utf8");
css = css.replace(/@font-face\s*\{[^}]*\}/g, ""); // drop all shipped @font-face
const b64 = readFileSync(font).toString("base64");
const face = `@font-face{font-family:"IBM Plex Sans Variable";font-style:normal;font-weight:100 700;font-display:swap;src:url(data:font/woff2;base64,${b64}) format("woff2-variations")}\n`;
writeFileSync(out, face + css);
console.error(` build-css: wrote ${out} (${(Buffer.byteLength(face + css) / 1024).toFixed(0)} KB, font inlined)`);
+256
View File
@@ -0,0 +1,256 @@
{
"projectId": "3c0f6556-050a-41e5-9886-c3f1ea950517",
"pkg": "@reactive-resume/ui",
"globalName": "RRUI",
"shape": "package",
"buildCmd": "node .design-sync/build-css.mjs",
"tsconfig": "tsconfig.json",
"cssEntry": ".ds-compiled.css",
"componentSrcMap": {
"AccordionContent": null,
"AccordionItem": null,
"AccordionTrigger": null,
"AlertAction": null,
"AlertDescription": null,
"AlertDialogAction": null,
"AlertDialogCancel": null,
"AlertDialogContent": null,
"AlertDialogDescription": null,
"AlertDialogFooter": null,
"AlertDialogHeader": null,
"AlertDialogMedia": null,
"AlertDialogOverlay": null,
"AlertDialogPortal": null,
"AlertDialogTitle": null,
"AlertDialogTrigger": null,
"AlertTitle": null,
"AttachmentAction": null,
"AttachmentActions": null,
"AttachmentContent": null,
"AttachmentDescription": null,
"AttachmentGroup": null,
"AttachmentMedia": null,
"AttachmentTitle": null,
"AttachmentTrigger": null,
"AvatarBadge": null,
"AvatarFallback": null,
"AvatarGroup": null,
"AvatarGroupCount": null,
"AvatarImage": null,
"BubbleContent": null,
"BubbleGroup": null,
"BubbleReactions": null,
"ButtonGroupSeparator": null,
"ButtonGroupText": null,
"ComboboxChip": null,
"ComboboxChips": null,
"ComboboxChipsInput": null,
"ComboboxClear": null,
"ComboboxCollection": null,
"ComboboxContent": null,
"ComboboxEmpty": null,
"ComboboxGroup": null,
"ComboboxInput": null,
"ComboboxItem": null,
"ComboboxLabel": null,
"ComboboxList": null,
"ComboboxSeparator": null,
"ComboboxTrigger": null,
"ComboboxValue": null,
"CommandDialog": null,
"CommandEmpty": null,
"CommandGroup": null,
"CommandInput": null,
"CommandItem": null,
"CommandList": null,
"CommandSeparator": null,
"CommandShortcut": null,
"ContextMenuCheckboxItem": null,
"ContextMenuContent": null,
"ContextMenuGroup": null,
"ContextMenuItem": null,
"ContextMenuLabel": null,
"ContextMenuPortal": null,
"ContextMenuRadioGroup": null,
"ContextMenuRadioItem": null,
"ContextMenuSeparator": null,
"ContextMenuShortcut": null,
"ContextMenuSub": null,
"ContextMenuSubContent": null,
"ContextMenuSubTrigger": null,
"ContextMenuTrigger": null,
"DialogClose": null,
"DialogContent": null,
"DialogDescription": null,
"DialogFooter": null,
"DialogHeader": null,
"DialogOverlay": null,
"DialogPortal": null,
"DialogTitle": null,
"DialogTrigger": null,
"DropdownMenuCheckboxItem": null,
"DropdownMenuContent": null,
"DropdownMenuGroup": null,
"DropdownMenuItem": null,
"DropdownMenuLabel": null,
"DropdownMenuPortal": null,
"DropdownMenuRadioGroup": null,
"DropdownMenuRadioItem": null,
"DropdownMenuSeparator": null,
"DropdownMenuShortcut": null,
"DropdownMenuSub": null,
"DropdownMenuSubContent": null,
"DropdownMenuSubTrigger": null,
"DropdownMenuTrigger": null,
"FormControl": null,
"FormDescription": null,
"FormLabel": null,
"FormMessage": null,
"HoverCardContent": null,
"HoverCardTrigger": null,
"InputGroupAddon": null,
"InputGroupButton": null,
"InputGroupInput": null,
"InputGroupText": null,
"InputGroupTextarea": null,
"KbdGroup": null,
"MarkerContent": null,
"MarkerIcon": null,
"MessageAvatar": null,
"MessageContent": null,
"MessageFooter": null,
"MessageGroup": null,
"MessageHeader": null,
"MessageScrollerButton": null,
"MessageScrollerContent": null,
"MessageScrollerItem": null,
"MessageScrollerProvider": null,
"MessageScrollerViewport": null,
"PopoverContent": null,
"PopoverDescription": null,
"PopoverHeader": null,
"PopoverTitle": null,
"PopoverTrigger": null,
"ResizablePanel": null,
"ResizableSeparator": null,
"ScrollBar": null,
"SheetClose": null,
"SheetContent": null,
"SheetDescription": null,
"SheetFooter": null,
"SheetHeader": null,
"SheetTitle": null,
"SheetTrigger": null,
"SidebarContent": null,
"SidebarFooter": null,
"SidebarGroup": null,
"SidebarGroupAction": null,
"SidebarGroupContent": null,
"SidebarGroupLabel": null,
"SidebarHeader": null,
"SidebarInput": null,
"SidebarInset": null,
"SidebarMenu": null,
"SidebarMenuAction": null,
"SidebarMenuBadge": null,
"SidebarMenuButton": null,
"SidebarMenuItem": null,
"SidebarMenuSkeleton": null,
"SidebarMenuSub": null,
"SidebarMenuSubButton": null,
"SidebarMenuSubItem": null,
"SidebarProvider": null,
"SidebarRail": null,
"SidebarSeparator": null,
"SidebarTrigger": null,
"TabsContent": null,
"TabsIndicator": null,
"TabsList": null,
"TabsTrigger": null,
"TooltipContent": null,
"TooltipProvider": null,
"TooltipTrigger": null
},
"overrides": {
"Dialog": {
"cardMode": "single",
"primaryStory": "Open",
"viewport": "760x440"
},
"AlertDialog": {
"cardMode": "single",
"primaryStory": "Open",
"viewport": "640x460"
},
"Sheet": {
"cardMode": "single",
"primaryStory": "Open",
"viewport": "760x480"
},
"Popover": {
"cardMode": "single",
"primaryStory": "Open",
"viewport": "420x340"
},
"Tooltip": {
"cardMode": "single",
"primaryStory": "Open",
"viewport": "360x260"
},
"HoverCard": {
"cardMode": "single",
"primaryStory": "Open",
"viewport": "440x320"
},
"DropdownMenu": {
"cardMode": "single",
"primaryStory": "Open",
"viewport": "440x360"
},
"ContextMenu": {
"cardMode": "single",
"primaryStory": "Open",
"viewport": "440x340"
},
"ComboboxRoot": {
"cardMode": "single",
"primaryStory": "Open",
"viewport": "420x360"
},
"Accordion": {
"cardMode": "column"
},
"Attachment": {
"cardMode": "column"
},
"Bubble": {
"cardMode": "column"
},
"FormItem": {
"cardMode": "column"
},
"InputGroup": {
"cardMode": "column"
},
"Marker": {
"cardMode": "column"
},
"Message": {
"cardMode": "column"
},
"ResizableGroup": {
"cardMode": "column"
},
"Tabs": {
"cardMode": "column"
},
"Textarea": {
"cardMode": "column"
},
"Toaster": {
"cardMode": "single",
"primaryStory": "Notification"
}
},
"readmeHeader": ".design-sync/conventions.md"
}
+60
View File
@@ -0,0 +1,60 @@
# Reactive Resume UI — how to build with it
This is `@reactive-resume/ui`: a shadcn-style React component library built on **Base UI**
primitives and **Tailwind CSS v4**. Every component is real upstream code, bundled to the
`window.RRUI` global; the 39 cards are the primary components, but all their compound
sub-parts (e.g. `DialogContent`, `AccordionItem`, `SidebarMenuButton`) are also on `RRUI`.
## Setup & wrapping
- **No global provider is required.** All design tokens live on `:root` in `styles.css` (loaded
for you), so components are styled out of the box. For dark mode, add `class="dark"` to a
wrapping element — the same tokens flip to their dark values.
- **A few components need their own provider — wrap only where you use them:**
- `Tooltip*` → wrap in `RRUI.TooltipProvider`.
- `Sidebar*` → wrap in `RRUI.SidebarProvider`.
- `MessageScroller*` → wrap in `RRUI.MessageScrollerProvider` and give it a bounded height.
- Form fields → `RRUI.FormItem` provides the field context for `FormLabel`/`FormControl`/`FormMessage`.
- **Compose compound components** from their parts, e.g. `Dialog` = `DialogTrigger` + `DialogContent`
(+ `DialogHeader`/`DialogTitle`/`DialogDescription`/`DialogFooter`). Overlay parts (Dialog, Sheet,
Popover, DropdownMenu, ContextMenu, Tooltip, HoverCard) render into a portal. Menu labels must sit
inside a `*Group` (`DropdownMenuGroup`, `ContextMenuGroup`).
- Icons come from `@phosphor-icons/react` (the `*Icon` suffix, e.g. `PlusIcon`).
## Styling idiom — Tailwind utilities on semantic tokens
Components style themselves; for **your own** layout and surfaces, use Tailwind utility classes
bound to the design system's **semantic color tokens** (never raw hex — these adapt to light/dark):
| Purpose | Utilities |
|---|---|
| Surfaces | `bg-background`, `bg-card`, `bg-popover`, `bg-muted`, `bg-sidebar` |
| Brand / actions | `bg-primary` + `text-primary-foreground`, `bg-secondary` + `text-secondary-foreground` |
| Accents / hover | `bg-accent` + `text-accent-foreground`, `hover:bg-muted` |
| Danger | `bg-destructive`, `text-destructive` |
| Text | `text-foreground` (primary), `text-muted-foreground` (secondary) |
| Borders / focus | `border`, `border-input`, `ring-ring`, `outline-ring` |
| Radius | `rounded-md`, `rounded-lg` (driven by `--radius`) |
Each token is also a CSS variable (`var(--primary)`, `var(--muted-foreground)`, `var(--border)`,
`var(--radius)`, `--font-body` = IBM Plex Sans) if you need it in inline styles.
## Where the truth lives
- **Styling:** `styles.css` and its `@import` closure (`_ds_bundle.css` = component styles; the
token definitions on `:root`/`.dark`). Read these before inventing a class or color.
- **Per component:** `components/<group>/<Name>/<Name>.prompt.md` (usage) and `<Name>.d.ts` (props —
variant/size unions where a named type exists; some fall back to a permissive shape).
## Idiomatic snippet
```jsx
// A confirm action, styled with the DS's own tokens for the surrounding layout.
<div className="flex flex-col gap-3 rounded-lg border bg-card p-4">
<p className="text-sm text-muted-foreground">Publish this resume to your public profile?</p>
<div className="flex justify-end gap-2">
<RRUI.Button variant="outline">Cancel</RRUI.Button>
<RRUI.Button>Publish</RRUI.Button>
</div>
</div>
```
+52
View File
@@ -0,0 +1,52 @@
import type * as React from "react";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
const wrap: React.CSSProperties = { width: 420, padding: 16 };
// Open by default so the panel content is visible in the card (Base UI accordion
// is uncontrolled via defaultValue, matching item `value` props).
export const Sections = () => (
<div style={wrap}>
<Accordion defaultValue={["experience"]}>
<AccordionItem value="experience">
<AccordionTrigger>Work Experience</AccordionTrigger>
<AccordionContent>
<p>Senior Product Designer · Framer — 2021 to Present</p>
<p>Led the redesign of the onboarding flow, lifting activation by 24% across web and mobile.</p>
</AccordionContent>
</AccordionItem>
<AccordionItem value="education">
<AccordionTrigger>Education</AccordionTrigger>
<AccordionContent>
<p>B.Des in Interaction Design · Rhode Island School of Design</p>
</AccordionContent>
</AccordionItem>
<AccordionItem value="skills">
<AccordionTrigger>Skills</AccordionTrigger>
<AccordionContent>
<p>Figma, prototyping, design systems, user research, and front-end handoff.</p>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
export const MultipleOpen = () => (
<div style={wrap}>
<Accordion multiple defaultValue={["summary", "certifications"]}>
<AccordionItem value="summary">
<AccordionTrigger>Professional Summary</AccordionTrigger>
<AccordionContent>
<p>Full-stack engineer with eight years shipping resilient TypeScript services and design systems.</p>
</AccordionContent>
</AccordionItem>
<AccordionItem value="certifications">
<AccordionTrigger>Certifications</AccordionTrigger>
<AccordionContent>
<p>AWS Solutions Architect · Professional</p>
<p>Certified Kubernetes Administrator</p>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
+38
View File
@@ -0,0 +1,38 @@
import type * as React from "react";
import { InfoIcon, WarningIcon } from "@phosphor-icons/react";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
import { Button } from "@reactive-resume/ui/components/button";
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 16, padding: 16, maxWidth: 540 };
export const Default = () => (
<div style={wrap}>
<Alert>
<InfoIcon />
<AlertTitle>Resume saved</AlertTitle>
<AlertDescription>Your changes were saved automatically and synced to your account.</AlertDescription>
</Alert>
</div>
);
export const Destructive = () => (
<div style={wrap}>
<Alert variant="destructive">
<WarningIcon />
<AlertTitle>Export failed</AlertTitle>
<AlertDescription>We couldn't generate your PDF. Check your connection and try again.</AlertDescription>
</Alert>
</div>
);
export const WithAction = () => (
<div style={wrap}>
<Alert>
<AlertTitle>Unsaved changes</AlertTitle>
<AlertDescription>You have edits that haven't been published to your public resume yet.</AlertDescription>
<AlertAction>
<Button size="sm">Publish</Button>
</AlertAction>
</Alert>
</div>
);
+34
View File
@@ -0,0 +1,34 @@
import { WarningIcon } from "@phosphor-icons/react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@reactive-resume/ui/components/alert-dialog";
// Overlay — rendered open (defaultOpen). cfg.overrides.AlertDialog pins
// cardMode: single + viewport (content is fixed-positioned, centred).
export const Open = () => (
<AlertDialog defaultOpen>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia>
<WarningIcon />
</AlertDialogMedia>
<AlertDialogTitle>Delete this resume?</AlertDialogTitle>
<AlertDialogDescription>
“Software Engineer” and its entire version history will be permanently removed. This action can’t be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive">Delete resume</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
+84
View File
@@ -0,0 +1,84 @@
import type * as React from "react";
import { DownloadSimpleIcon, FileDocIcon, FilePdfIcon, TrashIcon, WarningIcon } from "@phosphor-icons/react";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
} from "@reactive-resume/ui/components/attachment";
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 12, padding: 16, width: 360 };
export const WithActions = () => (
<div style={wrap}>
<Attachment>
<AttachmentMedia>
<FilePdfIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>Ansel_Bradford_Resume.pdf</AttachmentTitle>
<AttachmentDescription>248 KB · PDF</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Download">
<DownloadSimpleIcon />
</AttachmentAction>
<AttachmentAction aria-label="Remove">
<TrashIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
</div>
);
export const States = () => (
<div style={wrap}>
<Attachment size="sm" state="uploading">
<AttachmentMedia>
<FileDocIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>cover-letter.docx</AttachmentTitle>
<AttachmentDescription>Uploading…</AttachmentDescription>
</AttachmentContent>
</Attachment>
<Attachment size="sm" state="error">
<AttachmentMedia>
<WarningIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>portfolio-2024.zip</AttachmentTitle>
<AttachmentDescription>Upload failed · file too large</AttachmentDescription>
</AttachmentContent>
</Attachment>
</div>
);
export const Group = () => (
<div style={{ padding: 16, width: 360 }}>
<AttachmentGroup>
<Attachment orientation="vertical" size="sm">
<AttachmentMedia>
<FilePdfIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>Resume.pdf</AttachmentTitle>
<AttachmentDescription>248 KB</AttachmentDescription>
</AttachmentContent>
</Attachment>
<Attachment orientation="vertical" size="sm">
<AttachmentMedia>
<FileDocIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>cover-letter.docx</AttachmentTitle>
<AttachmentDescription>19 KB</AttachmentDescription>
</AttachmentContent>
</Attachment>
</AttachmentGroup>
</div>
);
+71
View File
@@ -0,0 +1,71 @@
import type * as React from "react";
import { CheckIcon } from "@phosphor-icons/react";
import {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
} from "@reactive-resume/ui/components/avatar";
const row: React.CSSProperties = { display: "flex", alignItems: "center", gap: 16, padding: 20 };
export const Fallback = () => (
<div style={row}>
<Avatar>
<AvatarFallback>AP</AvatarFallback>
</Avatar>
<Avatar>
<AvatarFallback>JD</AvatarFallback>
</Avatar>
<Avatar>
<AvatarFallback>MK</AvatarFallback>
</Avatar>
</div>
);
export const WithStatus = () => (
<div style={row}>
<Avatar>
<AvatarFallback>AP</AvatarFallback>
<AvatarBadge>
<CheckIcon weight="bold" />
</AvatarBadge>
</Avatar>
<Avatar size="lg">
<AvatarFallback>SR</AvatarFallback>
<AvatarBadge />
</Avatar>
</div>
);
export const Sizes = () => (
<div style={row}>
<Avatar size="sm">
<AvatarFallback>AP</AvatarFallback>
</Avatar>
<Avatar size="default">
<AvatarFallback>AP</AvatarFallback>
</Avatar>
<Avatar size="lg">
<AvatarFallback>AP</AvatarFallback>
</Avatar>
</div>
);
export const Group = () => (
<div style={row}>
<AvatarGroup>
<Avatar>
<AvatarFallback>AP</AvatarFallback>
</Avatar>
<Avatar>
<AvatarFallback>JD</AvatarFallback>
</Avatar>
<Avatar>
<AvatarFallback>MK</AvatarFallback>
</Avatar>
<AvatarGroupCount>+5</AvatarGroupCount>
</AvatarGroup>
</div>
);
+40
View File
@@ -0,0 +1,40 @@
import type * as React from "react";
import { CheckCircleIcon, PencilSimpleIcon, SparkleIcon } from "@phosphor-icons/react";
import { Badge } from "@reactive-resume/ui/components/badge";
const row: React.CSSProperties = { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 10, padding: 20 };
export const Variants = () => (
<div style={row}>
<Badge>Default</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="destructive">Destructive</Badge>
<Badge variant="outline">Outline</Badge>
</div>
);
export const StatusLabels = () => (
<div style={row}>
<Badge variant="secondary">
<CheckCircleIcon weight="fill" data-icon="inline-start" />
Published
</Badge>
<Badge variant="outline">
<PencilSimpleIcon data-icon="inline-start" />
Draft
</Badge>
<Badge>
<SparkleIcon weight="fill" data-icon="inline-start" />
Pro
</Badge>
<Badge variant="destructive">Expired</Badge>
</div>
);
export const Counts = () => (
<div style={row}>
<Badge>12</Badge>
<Badge variant="secondary">New</Badge>
<Badge variant="outline">v5.2</Badge>
</div>
);
File diff suppressed because one or more lines are too long
+48
View File
@@ -0,0 +1,48 @@
import type * as React from "react";
import { Bubble, BubbleContent, BubbleGroup, BubbleReactions } from "@reactive-resume/ui/components/bubble";
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 8, padding: 16, width: 420 };
export const Conversation = () => (
<div style={wrap}>
<BubbleGroup>
<Bubble align="end">
<BubbleContent>Can you make my summary sound more senior without exaggerating?</BubbleContent>
</Bubble>
<Bubble variant="muted" align="start">
<BubbleContent>
I tightened it to lead with scope and outcomes. Want me to mirror that tone in your experience bullets too?
</BubbleContent>
</Bubble>
<Bubble align="end">
<BubbleContent>Yes, keep it concise.</BubbleContent>
</Bubble>
</BubbleGroup>
</div>
);
export const Variants = () => (
<div style={wrap}>
<Bubble variant="default" align="end">
<BubbleContent>Applied 3 edits to your resume.</BubbleContent>
</Bubble>
<Bubble variant="tinted" align="start">
<BubbleContent>I emphasized measurable launch outcomes in your last role.</BubbleContent>
</Bubble>
<Bubble variant="outline" align="start">
<BubbleContent>Draft saved — publish when you're ready.</BubbleContent>
</Bubble>
<Bubble variant="destructive" align="start">
<BubbleContent>Couldn't reach the AI provider. Retry?</BubbleContent>
</Bubble>
</div>
);
export const WithReactions = () => (
<div style={{ padding: 24, width: 420 }}>
<Bubble variant="secondary" align="start">
<BubbleContent>Rewrote your headline to target a Senior Product Manager role.</BubbleContent>
<BubbleReactions>👍 2</BubbleReactions>
</Bubble>
</div>
);
+51
View File
@@ -0,0 +1,51 @@
import type * as React from "react";
import { ArrowRightIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react";
import { Button } from "@reactive-resume/ui/components/button";
const row: React.CSSProperties = { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 12, padding: 16 };
export const Variants = () => (
<div style={row}>
<Button>Save changes</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Delete</Button>
<Button variant="link">Learn more</Button>
</div>
);
export const Sizes = () => (
<div style={row}>
<Button size="xs">Extra small</Button>
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
</div>
);
export const WithIcons = () => (
<div style={row}>
<Button>
<PlusIcon /> Add section
</Button>
<Button variant="outline">
Continue <ArrowRightIcon />
</Button>
<Button variant="destructive">
<TrashIcon /> Remove
</Button>
<Button size="icon" variant="outline" aria-label="Add section">
<PlusIcon />
</Button>
</div>
);
export const Disabled = () => (
<div style={row}>
<Button disabled>Saving…</Button>
<Button variant="outline" disabled>
Disabled
</Button>
</div>
);
+67
View File
@@ -0,0 +1,67 @@
import type * as React from "react";
import {
AlignCenterHorizontalIcon,
AlignLeftIcon,
AlignRightIcon,
ArrowClockwiseIcon,
ArrowCounterClockwiseIcon,
TextBIcon,
TextItalicIcon,
TextUnderlineIcon,
} from "@phosphor-icons/react";
import { Button } from "@reactive-resume/ui/components/button";
import { ButtonGroup, ButtonGroupSeparator, ButtonGroupText } from "@reactive-resume/ui/components/button-group";
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 16, padding: 16 };
export const Formatting = () => (
<div style={wrap}>
<ButtonGroup>
<Button variant="outline" size="icon" aria-label="Bold">
<TextBIcon />
</Button>
<Button variant="outline" size="icon" aria-label="Italic">
<TextItalicIcon />
</Button>
<Button variant="outline" size="icon" aria-label="Underline">
<TextUnderlineIcon />
</Button>
<ButtonGroupSeparator />
<Button variant="outline" size="icon" aria-label="Align left">
<AlignLeftIcon />
</Button>
<Button variant="outline" size="icon" aria-label="Align center">
<AlignCenterHorizontalIcon />
</Button>
<Button variant="outline" size="icon" aria-label="Align right">
<AlignRightIcon />
</Button>
</ButtonGroup>
<ButtonGroup>
<Button variant="outline">
<ArrowCounterClockwiseIcon /> Undo
</Button>
<Button variant="outline">
<ArrowClockwiseIcon /> Redo
</Button>
</ButtonGroup>
</div>
);
export const WithText = () => (
<div style={wrap}>
<ButtonGroup>
<ButtonGroupText>Zoom</ButtonGroupText>
<Button variant="outline">50%</Button>
<Button variant="outline">100%</Button>
<Button variant="outline">150%</Button>
</ButtonGroup>
<ButtonGroup orientation="vertical">
<Button variant="outline">Export PDF</Button>
<Button variant="outline">Export DOCX</Button>
<Button variant="outline">Copy link</Button>
</ButtonGroup>
</div>
);
+30
View File
@@ -0,0 +1,30 @@
import {
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxRoot,
} from "@reactive-resume/ui/components/combobox";
const skills = ["TypeScript", "React", "Node.js", "GraphQL", "PostgreSQL", "Kubernetes"];
// Base UI Combobox — items passed to Root, rendered open (defaultOpen).
// cfg.overrides.ComboboxRoot pins cardMode: single + viewport with room below.
export const Open = () => (
<div style={{ width: 320, padding: 16, paddingBottom: 200 }}>
<ComboboxRoot items={skills} defaultOpen>
<ComboboxInput placeholder="Add a skill…" />
<ComboboxContent>
<ComboboxEmpty>No skills found.</ComboboxEmpty>
<ComboboxList>
{(item: string) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</ComboboxRoot>
</div>
);
+42
View File
@@ -0,0 +1,42 @@
import { DownloadSimpleIcon, GearIcon, PlusIcon, UserIcon } from "@phosphor-icons/react";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@reactive-resume/ui/components/command";
// Command renders inline (cmdk) — a searchable command palette. No overlay.
export const Palette = () => (
<div style={{ width: 400, padding: 16 }}>
<div style={{ border: "1px solid var(--border)", borderRadius: 10, overflow: "hidden" }}>
<Command>
<CommandInput placeholder="Type a command or search…" />
<CommandList>
<CommandGroup heading="Actions">
<CommandItem>
<PlusIcon /> New resume
<CommandShortcut>⌘N</CommandShortcut>
</CommandItem>
<CommandItem>
<DownloadSimpleIcon /> Export as PDF
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Account">
<CommandItem>
<UserIcon /> Profile
</CommandItem>
<CommandItem>
<GearIcon /> Settings
<CommandShortcut>⌘,</CommandShortcut>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</div>
</div>
);
+48
View File
@@ -0,0 +1,48 @@
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from "@reactive-resume/ui/components/context-menu";
// Right-click menu — rendered open (defaultOpen) so the card shows the menu.
// cfg.overrides.ContextMenu pins cardMode: single + viewport.
export const Open = () => (
<div style={{ display: "flex", justifyContent: "center", padding: 24, paddingBottom: 160 }}>
<ContextMenu defaultOpen>
<ContextMenuTrigger>
<div
style={{
display: "grid",
placeItems: "center",
width: 240,
height: 96,
border: "1px dashed var(--border)",
borderRadius: 8,
color: "var(--muted-foreground)",
fontSize: 13,
}}
>
Right-click a resume card
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuGroup>
<ContextMenuLabel>Software Engineer</ContextMenuLabel>
<ContextMenuItem>Open</ContextMenuItem>
<ContextMenuItem>
Rename
<ContextMenuShortcut>F2</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem>Duplicate</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive">Delete</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</div>
);
+30
View File
@@ -0,0 +1,30 @@
import { Button } from "@reactive-resume/ui/components/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
// Overlay component — rendered open (defaultOpen) so the card shows the real
// dialog surface. cfg.overrides.Dialog pins cardMode: single + a viewport for
// the portal (content is fixed-positioned at the viewport centre).
export const Open = () => (
<Dialog defaultOpen>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete resume</DialogTitle>
<DialogDescription>
This permanently deletes “Software Engineer” along with its version history. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
<DialogClose render={<Button variant="destructive" />}>Delete resume</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
+41
View File
@@ -0,0 +1,41 @@
import { CopyIcon, DownloadSimpleIcon, PencilIcon, TrashIcon } from "@phosphor-icons/react";
import { Button } from "@reactive-resume/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
// Anchored menu — rendered open (defaultOpen), positioned below its trigger.
// cfg.overrides.DropdownMenu pins cardMode: single + viewport with room below.
export const Open = () => (
<div style={{ display: "flex", justifyContent: "center", paddingTop: 16, paddingBottom: 220 }}>
<DropdownMenu defaultOpen>
<DropdownMenuTrigger render={<Button variant="outline" />}>Resume actions</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuLabel>Software Engineer</DropdownMenuLabel>
<DropdownMenuItem>
<PencilIcon /> Rename
</DropdownMenuItem>
<DropdownMenuItem>
<CopyIcon /> Duplicate
<DropdownMenuShortcut>⌘D</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<DownloadSimpleIcon /> Export PDF
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<TrashIcon /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
+27
View File
@@ -0,0 +1,27 @@
import type * as React from "react";
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
// FormItem carries its own field context (id + error state) — FormLabel /
// FormControl / FormDescription / FormMessage compose under it standalone.
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 16, padding: 16, width: 340 };
export const Default = () => (
<div style={wrap}>
<FormItem>
<FormLabel>Headline</FormLabel>
<FormControl render={<Input placeholder="Senior Software Engineer" />} />
<FormDescription>Shown under your name at the top of the resume.</FormDescription>
</FormItem>
</div>
);
export const WithError = () => (
<div style={wrap}>
<FormItem hasError>
<FormLabel>Email</FormLabel>
<FormControl render={<Input defaultValue="jane@" />} />
<FormMessage errors={["Enter a valid email address."]} />
</FormItem>
</div>
);
+19
View File
@@ -0,0 +1,19 @@
import { Button } from "@reactive-resume/ui/components/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@reactive-resume/ui/components/hover-card";
// Anchored preview-card overlay — rendered open (defaultOpen).
// cfg.overrides.HoverCard pins cardMode: single + viewport with room below the trigger.
export const Open = () => (
<div style={{ display: "flex", justifyContent: "center", paddingTop: 24, paddingBottom: 180 }}>
<HoverCard defaultOpen>
<HoverCardTrigger render={<Button variant="link" />}>@jane-doe</HoverCardTrigger>
<HoverCardContent>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontWeight: 600 }}>Jane Doe</span>
<span style={{ color: "var(--muted-foreground)" }}>Senior Software Engineer · San Francisco</span>
<span style={{ color: "var(--muted-foreground)", fontSize: 12 }}>3 published resumes · joined 2023</span>
</div>
</HoverCardContent>
</HoverCard>
</div>
);
+40
View File
@@ -0,0 +1,40 @@
import type * as React from "react";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
const field: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 6, padding: 16, width: 320 };
export const Default = () => (
<div style={field}>
<Label htmlFor="full-name">Full name</Label>
<Input id="full-name" defaultValue="Ada Lovelace" />
</div>
);
export const Placeholder = () => (
<div style={field}>
<Label htmlFor="headline">Headline</Label>
<Input id="headline" placeholder="e.g. Senior Software Engineer" />
</div>
);
export const Email = () => (
<div style={field}>
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" defaultValue="ada@analyticalengine.dev" />
</div>
);
export const Invalid = () => (
<div style={field}>
<Label htmlFor="website">Website</Label>
<Input id="website" aria-invalid defaultValue="not-a-valid-url" />
</div>
);
export const Disabled = () => (
<div style={field}>
<Label htmlFor="username">Username</Label>
<Input id="username" disabled defaultValue="ada.lovelace" />
</div>
);
+55
View File
@@ -0,0 +1,55 @@
import type * as React from "react";
import { CopyIcon, GlobeIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from "@reactive-resume/ui/components/input-group";
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 16, padding: 16, width: 380 };
export const Addons = () => (
<div style={wrap}>
<InputGroup>
<InputGroupAddon>
<MagnifyingGlassIcon />
</InputGroupAddon>
<InputGroupInput placeholder="Search resumes" defaultValue="Product Designer" />
</InputGroup>
<InputGroup>
<InputGroupAddon>
<GlobeIcon />
<InputGroupText>rxresu.me/u/</InputGroupText>
</InputGroupAddon>
<InputGroupInput defaultValue="jordan-rivera" />
</InputGroup>
<InputGroup>
<InputGroupInput readOnly defaultValue="rxr_live_9f3c8a21bd47e50a" />
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-sm" aria-label="Copy API key">
<CopyIcon />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</div>
);
export const WithTextarea = () => (
<div style={wrap}>
<InputGroup>
<InputGroupTextarea
rows={3}
defaultValue="Senior product designer focused on design systems, accessibility, and shipping polished interfaces."
/>
<InputGroupAddon align="block-end">
<InputGroupText>240 characters left</InputGroupText>
<InputGroupButton style={{ marginLeft: "auto" }}>Generate with AI</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</div>
);
+68
View File
@@ -0,0 +1,68 @@
import type * as React from "react";
import { Kbd, KbdGroup } from "@reactive-resume/ui/components/kbd";
const row: React.CSSProperties = { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 12, padding: 20 };
const listRow: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 24,
fontSize: 13,
color: "var(--foreground)",
};
const col: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 10, padding: 20, minWidth: 260 };
export const Keys = () => (
<div style={row}>
<Kbd>⌘</Kbd>
<Kbd>⇧</Kbd>
<Kbd>⌥</Kbd>
<Kbd>Esc</Kbd>
<Kbd>Enter</Kbd>
<Kbd>Tab</Kbd>
</div>
);
export const Combinations = () => (
<div style={row}>
<KbdGroup>
<Kbd>⌘</Kbd>
<Kbd>K</Kbd>
</KbdGroup>
<KbdGroup>
<Kbd>⌘</Kbd>
<Kbd>S</Kbd>
</KbdGroup>
<KbdGroup>
<Kbd>⌘</Kbd>
<Kbd>⇧</Kbd>
<Kbd>P</Kbd>
</KbdGroup>
</div>
);
export const ShortcutList = () => (
<div style={col}>
<div style={listRow}>
<span>Command palette</span>
<KbdGroup>
<Kbd>⌘</Kbd>
<Kbd>K</Kbd>
</KbdGroup>
</div>
<div style={listRow}>
<span>Save resume</span>
<KbdGroup>
<Kbd>⌘</Kbd>
<Kbd>S</Kbd>
</KbdGroup>
</div>
<div style={listRow}>
<span>Undo</span>
<KbdGroup>
<Kbd>⌘</Kbd>
<Kbd>Z</Kbd>
</KbdGroup>
</div>
</div>
);
+39
View File
@@ -0,0 +1,39 @@
import type * as React from "react";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
import { Switch } from "@reactive-resume/ui/components/switch";
const field: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 6, padding: 16, width: 320 };
const row: React.CSSProperties = { display: "flex", alignItems: "center", gap: 10, padding: 16, width: 320 };
export const WithInput = () => (
<div style={field}>
<Label htmlFor="company">Company</Label>
<Input id="company" defaultValue="Analytical Engine Co." />
</div>
);
export const Required = () => (
<div style={field}>
<Label htmlFor="job-title">
Job title <span style={{ color: "var(--destructive)" }}>*</span>
</Label>
<Input id="job-title" placeholder="e.g. Lead Engineer" />
</div>
);
export const WithSwitch = () => (
<div style={row}>
<Switch id="public-resume" defaultChecked />
<Label htmlFor="public-resume">Public resume</Label>
</div>
);
export const Disabled = () => (
<div style={field}>
<Label htmlFor="locked-field" data-disabled="true" style={{ opacity: 0.5 }}>
Locked field
</Label>
<Input id="locked-field" disabled defaultValue="Read only" />
</div>
);
+39
View File
@@ -0,0 +1,39 @@
import type * as React from "react";
import { CheckCircleIcon, SparkleIcon, WarningCircleIcon } from "@phosphor-icons/react";
import { Marker, MarkerContent, MarkerIcon } from "@reactive-resume/ui/components/marker";
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 12, padding: 16, width: 360 };
export const Statuses = () => (
<div style={wrap}>
<Marker style={{ width: "fit-content", borderRadius: 8, padding: "12px 16px", background: "var(--muted)" }}>
<MarkerIcon>
<SparkleIcon />
</MarkerIcon>
<MarkerContent>Tailoring your resume…</MarkerContent>
</Marker>
<Marker style={{ width: "fit-content" }}>
<MarkerIcon>
<CheckCircleIcon />
</MarkerIcon>
<MarkerContent>Applied 4 edits to your resume</MarkerContent>
</Marker>
<Marker style={{ width: "fit-content" }}>
<MarkerIcon>
<WarningCircleIcon />
</MarkerIcon>
<MarkerContent>Couldn't reach the AI provider</MarkerContent>
</Marker>
</div>
);
export const Dividers = () => (
<div style={wrap}>
<Marker variant="separator">
<MarkerContent>Today</MarkerContent>
</Marker>
<Marker variant="border">
<MarkerContent>Conversation history</MarkerContent>
</Marker>
</div>
);
+65
View File
@@ -0,0 +1,65 @@
import type * as React from "react";
import { SparkleIcon, UserIcon } from "@phosphor-icons/react";
import { Bubble, BubbleContent } from "@reactive-resume/ui/components/bubble";
import {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageGroup,
MessageHeader,
} from "@reactive-resume/ui/components/message";
const avatar: React.CSSProperties = { display: "flex", alignItems: "center", justifyContent: "center", padding: 8 };
export const Conversation = () => (
<div style={{ display: "flex", padding: 16, width: 460 }}>
<MessageGroup style={{ width: "100%" }}>
<Message align="end">
<MessageAvatar>
<span style={avatar}>
<UserIcon />
</span>
</MessageAvatar>
<MessageContent>
<Bubble align="end">
<BubbleContent>Tailor my resume for a product manager role.</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message align="start">
<MessageAvatar>
<span style={avatar}>
<SparkleIcon />
</span>
</MessageAvatar>
<MessageContent>
<Bubble variant="muted" align="start">
<BubbleContent>
Done — I emphasized roadmap ownership and stakeholder communication in your summary.
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageGroup>
</div>
);
export const WithMeta = () => (
<div style={{ display: "flex", padding: 16, width: 460 }}>
<Message align="start">
<MessageAvatar>
<span style={avatar}>
<SparkleIcon />
</span>
</MessageAvatar>
<MessageContent>
<MessageHeader>Reactive AI</MessageHeader>
<Bubble variant="tinted" align="start">
<BubbleContent>I found 4 weak bullets and rewrote them with stronger verbs and metrics.</BubbleContent>
</Bubble>
<MessageFooter>Just now · applied 4 edits</MessageFooter>
</MessageContent>
</Message>
</div>
);
+57
View File
@@ -0,0 +1,57 @@
import type * as React from "react";
import { SparkleIcon, UserIcon } from "@phosphor-icons/react";
import { Bubble, BubbleContent } from "@reactive-resume/ui/components/bubble";
import { Message, MessageAvatar, MessageContent } from "@reactive-resume/ui/components/message";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@reactive-resume/ui/components/message-scroller";
const avatar: React.CSSProperties = { display: "flex", alignItems: "center", justifyContent: "center", padding: 8 };
const turns = [
{ role: "user", text: "Can you review my resume for a senior engineering role?" },
{ role: "assistant", text: "Sure — I'll focus on scope, impact, and leadership signals. Reading it now." },
{ role: "user", text: "Great, keep the tone concise." },
{
role: "assistant",
text: "I rewrote your summary and tightened three experience bullets with measurable outcomes.",
},
{ role: "user", text: "Perfect, publish the draft." },
{ role: "assistant", text: "Draft saved and published to your public resume. Anything else you'd like to refine?" },
];
export const Thread = () => (
<div style={{ height: 340, width: 460, padding: 12 }}>
<MessageScrollerProvider autoScroll defaultScrollPosition="end">
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent style={{ padding: 12 }}>
{turns.map((turn, index) => (
<MessageScrollerItem key={turn.text} messageId={`turn-${index}`}>
<Message align={turn.role === "user" ? "end" : "start"}>
<MessageAvatar>
<span style={avatar}>{turn.role === "user" ? <UserIcon /> : <SparkleIcon />}</span>
</MessageAvatar>
<MessageContent>
<Bubble
variant={turn.role === "user" ? "default" : "muted"}
align={turn.role === "user" ? "end" : "start"}
>
<BubbleContent>{turn.text}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</div>
);
+33
View File
@@ -0,0 +1,33 @@
import { Button } from "@reactive-resume/ui/components/button";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@reactive-resume/ui/components/popover";
// Anchored overlay — rendered open (defaultOpen), positioned below its trigger.
// cfg.overrides.Popover pins cardMode: single + viewport with room for the popup.
export const Open = () => (
<div style={{ display: "flex", justifyContent: "center", paddingTop: 24, paddingBottom: 220 }}>
<Popover defaultOpen>
<PopoverTrigger render={<Button variant="outline" />}>Share resume</PopoverTrigger>
<PopoverContent>
<PopoverHeader>
<PopoverTitle>Public link</PopoverTitle>
<PopoverDescription>Anyone with this link can view your published resume.</PopoverDescription>
</PopoverHeader>
<div style={{ display: "flex", gap: 8 }}>
<Button size="sm" variant="secondary">
Copy link
</Button>
<Button size="sm" variant="ghost">
Open
</Button>
</div>
</PopoverContent>
</Popover>
</div>
);
+57
View File
@@ -0,0 +1,57 @@
import type * as React from "react";
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
const panelStyle: React.CSSProperties = { height: "100%", padding: 16, fontSize: 14, lineHeight: 1.6 };
const label: React.CSSProperties = {
fontSize: 11,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--muted-foreground)",
marginBottom: 8,
};
export const BuilderLayout = () => (
<div style={{ height: 240, width: 460, border: "1px solid var(--border)", borderRadius: 8, overflow: "hidden" }}>
<ResizableGroup orientation="horizontal">
<ResizablePanel defaultSize={40}>
<div style={panelStyle}>
<div style={label}>Editor</div>
<div>Basics</div>
<div>Work Experience</div>
<div>Education</div>
<div>Skills</div>
</div>
</ResizablePanel>
<ResizableSeparator withHandle />
<ResizablePanel defaultSize={60}>
<div style={{ ...panelStyle, background: "var(--muted)" }}>
<div style={label}>Live Preview</div>
<div style={{ fontWeight: 600, fontSize: 16 }}>Jordan Rivera</div>
<div style={{ color: "var(--muted-foreground)" }}>Senior Product Designer</div>
</div>
</ResizablePanel>
</ResizableGroup>
</div>
);
export const VerticalSplit = () => (
<div style={{ height: 240, width: 300, border: "1px solid var(--border)", borderRadius: 8, overflow: "hidden" }}>
<ResizableGroup orientation="vertical">
<ResizablePanel defaultSize={50}>
<div style={panelStyle}>
<div style={label}>Summary</div>
<div>Eight years building design systems and shipping delightful product experiences.</div>
</div>
</ResizablePanel>
<ResizableSeparator withHandle />
<ResizablePanel defaultSize={50}>
<div style={{ ...panelStyle, background: "var(--muted)" }}>
<div style={label}>Contact</div>
<div>jordan.rivera@email.com</div>
<div>San Francisco, CA</div>
</div>
</ResizablePanel>
</ResizableGroup>
</div>
);
+39
View File
@@ -0,0 +1,39 @@
import type * as React from "react";
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
const templates = [
{ name: "Azurill", tag: "Minimal" },
{ name: "Bronzor", tag: "Classic" },
{ name: "Chikorita", tag: "Modern" },
{ name: "Ditto", tag: "Compact" },
{ name: "Gengar", tag: "Bold" },
{ name: "Glalie", tag: "Elegant" },
{ name: "Kakuna", tag: "Timeless" },
{ name: "Leafish", tag: "Creative" },
{ name: "Nosepass", tag: "Formal" },
{ name: "Onyx", tag: "Technical" },
{ name: "Pikachu", tag: "Friendly" },
{ name: "Rhyhorn", tag: "Corporate" },
];
const rowStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 14px",
borderBottom: "1px solid var(--border)",
fontSize: 14,
};
export const TemplateList = () => (
<ScrollArea style={{ height: 240, width: 320, border: "1px solid var(--border)", borderRadius: 8 }}>
<div style={{ padding: 4 }}>
{templates.map((template) => (
<div key={template.name} style={rowStyle}>
<span style={{ fontWeight: 500 }}>{template.name}</span>
<span style={{ color: "var(--muted-foreground)", fontSize: 12 }}>{template.tag}</span>
</div>
))}
</div>
</ScrollArea>
);
+45
View File
@@ -0,0 +1,45 @@
import type * as React from "react";
import { Separator } from "@reactive-resume/ui/components/separator";
const block: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 12,
padding: 20,
maxWidth: 360,
fontSize: 13,
color: "var(--foreground)",
};
const inline: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 12,
padding: 20,
fontSize: 13,
color: "var(--muted-foreground)",
};
export const Horizontal = () => (
<div style={block}>
<div>
<strong style={{ display: "block", fontSize: 14 }}>Amruth Pillai</strong>
<span style={{ color: "var(--muted-foreground)" }}>Senior Software Engineer</span>
</div>
<Separator />
<span style={{ color: "var(--muted-foreground)" }}>
Building resume tooling at Reactive Resume. Open-source enthusiast.
</span>
</div>
);
export const Vertical = () => (
<div style={inline}>
<span>Profile</span>
<Separator orientation="vertical" style={{ height: 16 }} />
<span>Experience</span>
<Separator orientation="vertical" style={{ height: 16 }} />
<span>Education</span>
<Separator orientation="vertical" style={{ height: 16 }} />
<span>Skills</span>
</div>
);
+38
View File
@@ -0,0 +1,38 @@
import { Button } from "@reactive-resume/ui/components/button";
import { Label } from "@reactive-resume/ui/components/label";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@reactive-resume/ui/components/sheet";
// Side drawer — rendered open (defaultOpen), anchored to the right edge.
// cfg.overrides.Sheet pins cardMode: single + viewport.
export const Open = () => (
<Sheet defaultOpen>
<SheetContent side="right">
<SheetHeader>
<SheetTitle>Resume settings</SheetTitle>
<SheetDescription>Control how “Software Engineer” appears when shared publicly.</SheetDescription>
</SheetHeader>
<div style={{ display: "flex", flexDirection: "column", gap: 14, padding: "0 16px" }}>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<Label>Public slug</Label>
<span style={{ fontSize: 13, color: "var(--muted-foreground)" }}>rxresume.me/jane-doe</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<Label>Visibility</Label>
<span style={{ fontSize: 13, color: "var(--muted-foreground)" }}>Anyone with the link can view</span>
</div>
</div>
<SheetFooter>
<SheetClose render={<Button variant="outline" />}>Cancel</SheetClose>
<SheetClose render={<Button />}>Save changes</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
);
+61
View File
@@ -0,0 +1,61 @@
import { FileTextIcon, GearIcon, HouseIcon, PlusIcon } from "@phosphor-icons/react";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
} from "@reactive-resume/ui/components/sidebar";
// SidebarProvider supplies context + --sidebar-width. `collapsible="none"`
// renders the sidebar inline (the default offcanvas variant is fixed-positioned
// and would escape the card).
export const Navigation = () => (
<SidebarProvider>
<div
style={{ height: 400, display: "flex", border: "1px solid var(--border)", borderRadius: 10, overflow: "hidden" }}
>
<Sidebar collapsible="none">
<SidebarHeader>
<div style={{ padding: 8, fontWeight: 600, fontSize: 14 }}>Reactive Resume</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Workspace</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton isActive>
<HouseIcon /> Dashboard
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton>
<FileTextIcon /> Resumes
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton>
<PlusIcon /> New resume
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton>
<GearIcon /> Settings
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
</div>
</SidebarProvider>
);
+41
View File
@@ -0,0 +1,41 @@
import type * as React from "react";
import { Skeleton } from "@reactive-resume/ui/components/skeleton";
const pad: React.CSSProperties = { padding: 20 };
export const TextLines = () => (
<div style={{ ...pad, display: "flex", flexDirection: "column", gap: 10, width: 320 }}>
<Skeleton style={{ height: 12, width: "70%" }} />
<Skeleton style={{ height: 12, width: "100%" }} />
<Skeleton style={{ height: 12, width: "90%" }} />
<Skeleton style={{ height: 12, width: "40%" }} />
</div>
);
export const ProfileHeader = () => (
<div style={{ ...pad, display: "flex", alignItems: "center", gap: 14 }}>
<Skeleton style={{ height: 48, width: 48, borderRadius: "9999px" }} />
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<Skeleton style={{ height: 14, width: 160 }} />
<Skeleton style={{ height: 12, width: 100 }} />
</div>
</div>
);
export const ResumeCard = () => (
<div
style={{
...pad,
display: "flex",
flexDirection: "column",
gap: 12,
width: 220,
border: "1px solid var(--border)",
borderRadius: 12,
}}
>
<Skeleton style={{ height: 140, width: "100%" }} />
<Skeleton style={{ height: 14, width: "60%" }} />
<Skeleton style={{ height: 12, width: "40%" }} />
</div>
);
+33
View File
@@ -0,0 +1,33 @@
import type * as React from "react";
import { Label } from "@reactive-resume/ui/components/label";
import { Slider } from "@reactive-resume/ui/components/slider";
const field: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 10, padding: 16, width: 320 };
export const Single = () => (
<div style={field}>
<Label>Skill level</Label>
<Slider defaultValue={[4]} min={0} max={5} step={1} />
</div>
);
export const Range = () => (
<div style={field}>
<Label>Experience (years)</Label>
<Slider defaultValue={[2, 8]} min={0} max={15} step={1} />
</div>
);
export const FontScale = () => (
<div style={field}>
<Label>Font size</Label>
<Slider defaultValue={[62]} min={0} max={100} />
</div>
);
export const Disabled = () => (
<div style={field}>
<Label style={{ opacity: 0.5 }}>Line height (locked)</Label>
<Slider defaultValue={[50]} min={0} max={100} disabled />
</div>
);
+35
View File
@@ -0,0 +1,35 @@
import type * as React from "react";
import { Spinner } from "@reactive-resume/ui/components/spinner";
const row: React.CSSProperties = { display: "flex", alignItems: "center", gap: 20, padding: 24 };
export const Sizes = () => (
<div style={row}>
<Spinner style={{ width: 16, height: 16 }} />
<Spinner style={{ width: 24, height: 24 }} />
<Spinner style={{ width: 32, height: 32 }} />
</div>
);
export const Colors = () => (
<div style={row}>
<Spinner style={{ width: 28, height: 28, color: "var(--primary)" }} />
<Spinner style={{ width: 28, height: 28, color: "var(--muted-foreground)" }} />
</div>
);
export const LoadingRow = () => (
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: 20,
fontSize: 13,
color: "var(--muted-foreground)",
}}
>
<Spinner style={{ width: 18, height: 18 }} />
<span>Generating your PDF…</span>
</div>
);
+40
View File
@@ -0,0 +1,40 @@
import type * as React from "react";
import { Label } from "@reactive-resume/ui/components/label";
import { Switch } from "@reactive-resume/ui/components/switch";
const row: React.CSSProperties = { display: "flex", alignItems: "center", gap: 10, padding: 16, width: 300 };
const stack: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 16, padding: 16, width: 300 };
export const On = () => (
<div style={row}>
<Switch id="sw-public" defaultChecked />
<Label htmlFor="sw-public">Public resume</Label>
</div>
);
export const Off = () => (
<div style={row}>
<Switch id="sw-template" />
<Label htmlFor="sw-template">Show icons in template</Label>
</div>
);
export const Small = () => (
<div style={row}>
<Switch id="sw-page-numbers" size="sm" defaultChecked />
<Label htmlFor="sw-page-numbers">Show page numbers</Label>
</div>
);
export const Disabled = () => (
<div style={stack}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<Switch id="sw-ai" disabled defaultChecked />
<Label htmlFor="sw-ai">AI suggestions</Label>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<Switch id="sw-index" disabled />
<Label htmlFor="sw-index">Index on search engines</Label>
</div>
</div>
);
+57
View File
@@ -0,0 +1,57 @@
import type * as React from "react";
import { BriefcaseIcon, GraduationCapIcon, SparkleIcon } from "@phosphor-icons/react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
const wrap: React.CSSProperties = { width: 460, padding: 16 };
const panel: React.CSSProperties = { padding: "12px 4px", lineHeight: 1.6 };
export const ResumeSections = () => (
<div style={wrap}>
<Tabs defaultValue="experience">
<TabsList>
<TabsTrigger value="experience">
<BriefcaseIcon /> Experience
</TabsTrigger>
<TabsTrigger value="education">
<GraduationCapIcon /> Education
</TabsTrigger>
<TabsTrigger value="skills">
<SparkleIcon /> Skills
</TabsTrigger>
</TabsList>
<TabsContent value="experience" style={panel}>
<strong>Staff Engineer · Vercel</strong>
<div>Owned the edge runtime rollout serving 2B requests per day.</div>
</TabsContent>
<TabsContent value="education" style={panel}>
<strong>M.S. Computer Science · Carnegie Mellon</strong>
<div>Focus on distributed systems and human-computer interaction.</div>
</TabsContent>
<TabsContent value="skills" style={panel}>
<strong>Core stack</strong>
<div>TypeScript, React, Go, PostgreSQL, and Kubernetes.</div>
</TabsContent>
</Tabs>
</div>
);
export const LineVariant = () => (
<div style={wrap}>
<Tabs defaultValue="preview">
<TabsList variant="line">
<TabsTrigger value="preview">Preview</TabsTrigger>
<TabsTrigger value="share">Share</TabsTrigger>
<TabsTrigger value="export">Export</TabsTrigger>
</TabsList>
<TabsContent value="preview" style={panel}>
Your resume renders live as you edit each section.
</TabsContent>
<TabsContent value="share" style={panel}>
Publish a public link at reactive-resume.app/u/your-name.
</TabsContent>
<TabsContent value="export" style={panel}>
Download a print-ready PDF or DOCX in one click.
</TabsContent>
</Tabs>
</div>
);
+37
View File
@@ -0,0 +1,37 @@
import type * as React from "react";
import { Label } from "@reactive-resume/ui/components/label";
import { Textarea } from "@reactive-resume/ui/components/textarea";
const field: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 6, padding: 16, width: 360 };
export const Default = () => (
<div style={field}>
<Label htmlFor="summary">Summary</Label>
<Textarea id="summary" placeholder="Write a short professional summary…" rows={4} />
</div>
);
export const Filled = () => (
<div style={field}>
<Label htmlFor="about">About</Label>
<Textarea
id="about"
rows={4}
defaultValue="Mathematician and writer, known for early work on Charles Babbage's Analytical Engine and the first published algorithm intended for a machine."
/>
</div>
);
export const Invalid = () => (
<div style={field}>
<Label htmlFor="bio">Biography</Label>
<Textarea id="bio" aria-invalid rows={3} defaultValue="Too short." />
</div>
);
export const Disabled = () => (
<div style={field}>
<Label htmlFor="notes">Internal notes</Label>
<Textarea id="notes" disabled rows={3} defaultValue="Notes are locked while this resume is published." />
</div>
);
+19
View File
@@ -0,0 +1,19 @@
import { useEffect } from "react";
import { toast } from "sonner";
import { Toaster } from "@reactive-resume/ui/components/sonner";
// Toaster is the toast host. Fire a persistent toast on mount so the card
// shows a real notification instead of an empty portal.
export const Notification = () => {
useEffect(() => {
toast.success("Resume published", {
description: "“Software Engineer” is now live at rxresume.me/jane-doe.",
duration: Number.POSITIVE_INFINITY,
});
}, []);
return (
<div style={{ minHeight: 140 }}>
<Toaster position="top-center" />
</div>
);
};
+66
View File
@@ -0,0 +1,66 @@
import type * as React from "react";
import {
TextAlignCenterIcon,
TextAlignLeftIcon,
TextAlignRightIcon,
TextBolderIcon,
TextItalicIcon,
TextUnderlineIcon,
} from "@phosphor-icons/react";
import { Toggle } from "@reactive-resume/ui/components/toggle";
const row: React.CSSProperties = { display: "flex", alignItems: "center", gap: 8, padding: 16 };
const group: React.CSSProperties = { display: "flex", alignItems: "center", gap: 2, padding: 16 };
export const States = () => (
<div style={row}>
<Toggle aria-label="Bold" defaultPressed>
<TextBolderIcon />
</Toggle>
<Toggle aria-label="Italic">
<TextItalicIcon />
</Toggle>
<Toggle aria-label="Underline" disabled>
<TextUnderlineIcon />
</Toggle>
</div>
);
export const Outline = () => (
<div style={row}>
<Toggle variant="outline" defaultPressed>
<TextBolderIcon /> Bold
</Toggle>
<Toggle variant="outline">
<TextItalicIcon /> Italic
</Toggle>
</div>
);
export const Sizes = () => (
<div style={row}>
<Toggle size="sm" aria-label="Bold small">
<TextBolderIcon />
</Toggle>
<Toggle size="default" aria-label="Bold default" defaultPressed>
<TextBolderIcon />
</Toggle>
<Toggle size="lg" aria-label="Bold large">
<TextBolderIcon />
</Toggle>
</div>
);
export const AlignmentGroup = () => (
<div style={group}>
<Toggle variant="outline" aria-label="Align left" defaultPressed>
<TextAlignLeftIcon />
</Toggle>
<Toggle variant="outline" aria-label="Align center">
<TextAlignCenterIcon />
</Toggle>
<Toggle variant="outline" aria-label="Align right">
<TextAlignRightIcon />
</Toggle>
</div>
);
+18
View File
@@ -0,0 +1,18 @@
import { InfoIcon } from "@phosphor-icons/react";
import { Button } from "@reactive-resume/ui/components/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
// Anchored overlay — needs TooltipProvider; rendered open (defaultOpen).
// cfg.overrides.Tooltip pins cardMode: single + viewport with room above the trigger.
export const Open = () => (
<TooltipProvider>
<div style={{ display: "flex", justifyContent: "center", paddingTop: 120, paddingBottom: 24 }}>
<Tooltip defaultOpen>
<TooltipTrigger render={<Button variant="outline" size="icon" aria-label="About visibility" />}>
<InfoIcon />
</TooltipTrigger>
<TooltipContent>Only you can see private resumes</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
+4
View File
@@ -0,0 +1,4 @@
/* design-sync Tailwind entry — compiles the UI package's globals.css to static CSS
for preview rendering, and also scans authored previews for used utilities. */
@import "../packages/ui/src/styles/globals.css";
@source "./previews/*.tsx";
+9
View File
@@ -59,3 +59,12 @@ temp
# Git Hooks
.vite-hooks/
.ds-sync/
ds-bundle/
.design-sync/.cache/
.design-sync/learnings/
.design-sync/node_modules
packages/ui/.ds-compiled.css
packages/ui/.ds-tw-raw.css
packages/ui/dist/
i18n.cache
+31
View File
@@ -0,0 +1,31 @@
config:
default: true
MD007: false
MD009: false
MD010: false
MD012: false
MD013: false
MD001: false
MD022: false
MD024: false
MD025: false
MD028: false
MD031: false
MD032: false
MD033: false
MD034: false
MD036: false
MD040: false
MD041: false
MD046: false
MD060: false
frontMatter: "^---[\\s\\S]*?---"
gitignore: true
globs:
- "**/*.{md,mdx}"
ignores:
- ".design-sync/**"
- "node_modules/**"
- ".turbo/**"
- "dist/**"
+1 -1
View File
@@ -60,7 +60,7 @@ If your company would like to sponsor Reactive Resume, email [hello@amruthpillai
- Professionally designed templates
- A4 and Letter size support
- Customizable colors, fonts, and spacing
- Custom CSS for advanced styling
- Structured Style Rules for section and text styling
**Privacy & Control**
+31 -22
View File
@@ -17,24 +17,33 @@
"#react-pdf-renderer": "@react-pdf/renderer"
},
"dependencies": {
"@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",
"@ai-sdk/anthropic": "^4.0.10",
"@ai-sdk/cerebras": "^3.0.6",
"@ai-sdk/cohere": "^4.0.6",
"@ai-sdk/deepseek": "^3.0.6",
"@ai-sdk/fireworks": "^3.0.7",
"@ai-sdk/google": "^4.0.10",
"@ai-sdk/groq": "^4.0.6",
"@ai-sdk/mistral": "^4.0.7",
"@ai-sdk/openai": "^4.0.9",
"@ai-sdk/openai-compatible": "^3.0.6",
"@ai-sdk/perplexity": "^4.0.7",
"@ai-sdk/togetherai": "^3.0.7",
"@ai-sdk/xai": "^4.0.9",
"@aws-sdk/client-s3": "^3.1081.0",
"@better-auth/api-key": "^1.6.23",
"@better-auth/drizzle-adapter": "^1.6.23",
"@better-auth/infra": "^0.3.4",
"@better-auth/infra": "^0.3.5",
"@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.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",
"@orpc/client": "^1.14.7",
"@orpc/experimental-ratelimit": "^1.14.7",
"@orpc/json-schema": "^1.14.7",
"@orpc/openapi": "^1.14.7",
"@orpc/server": "^1.14.7",
"@orpc/zod": "^1.14.7",
"@react-pdf/renderer": "^4.5.1",
"@reactive-resume/api": "workspace:*",
"@reactive-resume/auth": "workspace:*",
@@ -46,7 +55,7 @@
"@sindresorhus/slugify": "^3.0.0",
"@t3-oss/env-core": "^0.13.11",
"@uiw/color-convert": "^2.10.3",
"ai": "^7.0.15",
"ai": "^7.0.18",
"bcrypt": "^6.0.0",
"better-auth": "1.6.23",
"cjk-regex": "^3.4.0",
@@ -55,15 +64,15 @@
"drizzle-zod": "1.0.0-beta.14-a36c63d",
"es-toolkit": "^1.49.0",
"fast-json-patch": "^3.1.1",
"hono": "^4.12.27",
"hono": "^4.12.28",
"jsonrepair": "^3.15.0",
"node-html-parser": "^8.0.4",
"node-html-parser": "^9.0.0",
"nodemailer": "^9.0.3",
"ollama-ai-provider-v2": "^3.6.0",
"ollama-ai-provider-v2": "^4.0.1",
"pg": "^8.22.0",
"phosphor-icons-react-pdf": "^0.1.3",
"react": "^19.2.7",
"react-email": "^6.6.6",
"react-email": "^6.6.8",
"react-pdf-html": "^2.1.5",
"resumable-stream": "^2.2.12",
"sharp": "^0.35.3",
@@ -74,13 +83,13 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/node": "^26.1.0",
"@types/node": "^26.1.1",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.17",
"@typescript/native-preview": "7.0.0-dev.20260704.1",
"tsdown": "^0.22.3",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"tsdown": "^0.22.4",
"tsx": "^4.23.0",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
"typescript": "^7.0.2",
"vitest": "^4.1.10"
}
}
+26 -1
View File
@@ -40,7 +40,32 @@ describe("handleResumePdfDownload", () => {
expect(response.headers.get("Content-Disposition")).toBe('attachment; filename="Scizor.pdf"');
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect(await response.text()).toBe("%PDF");
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({ id: "resume-1", userId: "user-1" });
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({ id: "resume-1", userId: "user-1", target: "resume" });
});
it("passes the cover letter target through to PDF rendering", async () => {
const pdf = new File([new Uint8Array([37, 80, 68, 70])], "Cover Letter.pdf", { type: "application/pdf" });
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
ok: true,
resumeId: "resume-1",
userId: "user-1",
expiresAt: "2026-06-01T10:10:00.000Z",
});
mocks.createResumePdfDownload.mockResolvedValueOnce({
headers: { "content-disposition": 'attachment; filename="Cover Letter.pdf"' },
body: pdf,
});
await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf?token=signed&target=cover-letter"),
"resume-1",
);
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({
id: "resume-1",
userId: "user-1",
target: "cover-letter",
});
});
it("rejects missing, invalid, and expired tokens before rendering", async () => {
+4 -2
View File
@@ -24,14 +24,16 @@ function errorStatus(error: unknown) {
}
export async function handleResumePdfDownload(request: Request, id: string) {
const token = new URL(request.url).searchParams.get("token");
const searchParams = new URL(request.url).searchParams;
const token = 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 });
const target = searchParams.get("target") === "cover-letter" ? "cover-letter" : "resume";
const download = await createResumePdfDownload({ id, userId: verification.userId, target });
return new Response(download.body, {
headers: {
+2
View File
@@ -33,6 +33,8 @@ describe("handleUpload", () => {
expect(response.status).toBe(200);
expect(readMock).toHaveBeenCalledWith("uploads/user-1/pictures/photo.jpeg");
expect(response.headers.get("Content-Type")).toBe("image/jpeg");
expect(response.headers.get("Cross-Origin-Resource-Policy")).toBe("same-site");
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
});
it("does not serve private agent attachment keys through the public uploads route", async () => {
-2
View File
@@ -1,7 +1,6 @@
import { createHash } from "node:crypto";
import { basename, extname, normalize } from "node:path";
import { getStorageService, inferContentType } from "@reactive-resume/api/features/storage";
import { env } from "@reactive-resume/env/server";
export async function handleUpload(request: Request) {
const { userId, filePath } = parseRouteParams(request.url);
@@ -41,7 +40,6 @@ export async function handleUpload(request: Request) {
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 });
}
+9
View File
@@ -32,6 +32,15 @@ describe("web app fallback classification", () => {
expect(await response.text()).toBe("<html>app</html>");
});
it.each(["/", "/alice/resume"])("sets framing and report-only CSP security headers on %s", async (pathname) => {
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
expect(response.status).toBe(200);
expect(response.headers.get("X-Frame-Options")).toBe("DENY");
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(response.headers.get("Content-Security-Policy-Report-Only")).toContain("frame-ancestors 'none'");
});
it.each([
"/auth/login",
"/dashboard",
+10 -1
View File
@@ -52,12 +52,21 @@ function isPublicResumePath(pathname: string): boolean {
return segments.length === 2 && firstSegment !== undefined && !reservedPublicResumeSegments.has(firstSegment);
}
const BASE_SECURITY_HEADERS = {
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Content-Security-Policy-Report-Only":
"default-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
};
function getFallbackResponseHeaders(pathname: string) {
if (pathname === "/") return { "Content-Type": "text/html; charset=UTF-8" };
if (pathname === "/") return { "Content-Type": "text/html; charset=UTF-8", ...BASE_SECURITY_HEADERS };
if (isNoindexShellPath(pathname) || isPublicResumePath(pathname)) {
return {
"Content-Type": "text/html; charset=UTF-8",
"X-Robots-Tag": "noindex, follow",
...BASE_SECURITY_HEADERS,
};
}
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+763 -48
View File
File diff suppressed because it is too large Load Diff
+780 -65
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff
+764 -49
View File
File diff suppressed because it is too large Load Diff

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