mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-09-30 00:34:28 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c195dc3f8 | ||
|
|
3221afda9d | ||
|
|
8ce899a04b | ||
|
|
39f36b4ac5 | ||
|
|
39590eaff6 | ||
|
|
c8081ac2fe | ||
|
|
dbbab6fd76 | ||
|
|
8acde4c1ac | ||
|
|
4d53a6d1de | ||
|
|
ab811b5f10 | ||
|
|
65618a82a0 | ||
|
|
6f0c727770 | ||
|
|
ebcaa4729f | ||
|
|
f14e120b00 | ||
|
|
d9da31e7bc | ||
|
|
128916b9a0 | ||
|
|
00be67f702 | ||
|
|
5392728f22 | ||
|
|
0b0b4ef13b | ||
|
|
24c15cd8cd | ||
|
|
6e3853fe13 | ||
|
|
b080fcddad | ||
|
|
9dc2aade46 | ||
|
|
e2554c9be8 | ||
|
|
eedf2faf02 | ||
|
|
da2f1f8244 | ||
|
|
7a14b0dfbc | ||
|
|
23ceee2148 | ||
|
|
170550ed59 | ||
|
|
ac062bbcbd | ||
|
|
bfdd29f941 | ||
|
|
e8508e6d03 | ||
|
|
60d0440763 | ||
|
|
f4bf6887b9 | ||
|
|
817d4ef971 | ||
|
|
7c7dbaf21d | ||
|
|
762b999d1e | ||
|
|
9d0dc36706 | ||
|
|
d0fa9ae8da | ||
|
|
1e23a453a0 | ||
|
|
36c35c9bd5 | ||
|
|
0c7c3ac4c4 | ||
|
|
9509b5bc2e | ||
|
|
f848e57436 | ||
|
|
a4bc2693be | ||
|
|
104e954b77 | ||
|
|
118f3679a3 | ||
|
|
6c1280dca9 | ||
|
|
8affc567e3 | ||
|
|
409d09809a | ||
|
|
6d9ebccc63 | ||
|
|
45303fb465 | ||
|
|
f64d02df7f | ||
|
|
bad431b2fc | ||
|
|
9f13638eab | ||
|
|
13e584d522 | ||
|
|
6035402832 | ||
|
|
69961210bd | ||
|
|
7eb6d3bdbf | ||
|
|
5fc9c3ee04 | ||
|
|
a8d1f5a685 | ||
|
|
dd9843172b | ||
|
|
28d698635f | ||
|
|
3635b3d578 | ||
|
|
5a75eda893 | ||
|
|
e4b28e9825 | ||
|
|
2d6ea9ce8d | ||
|
|
0e463883af | ||
|
|
3a5b12e2a4 | ||
|
|
035d94183b | ||
|
|
efd950bd93 | ||
|
|
04100aa9ef | ||
|
|
c292968314 | ||
|
|
ba1f469950 | ||
|
|
b4f245a38e | ||
|
|
e6a31aab97 | ||
|
|
88a19619da | ||
|
|
36232b631d | ||
|
|
9eec1520a1 | ||
|
|
131c1492cd | ||
|
|
ba8e1be2ab | ||
|
|
4a8f87ab8f | ||
|
|
186c400ab7 | ||
|
|
d314361ad6 | ||
|
|
b071a118a3 | ||
|
|
3589b534f5 | ||
|
|
1ee24e5a9f | ||
|
|
93bf1e882d | ||
|
|
ae8d48bcee | ||
|
|
517199471a | ||
|
|
15f8bce988 | ||
|
|
164a279306 | ||
|
|
79e4a3ddc8 | ||
|
|
d2ffbf9618 | ||
|
|
4ac19f81b3 | ||
|
|
b303b89758 | ||
|
|
c6ac3fd1a9 | ||
|
|
fe6f84e06d | ||
|
|
9d6426b2e0 | ||
|
|
d34a429dea | ||
|
|
b69583c181 | ||
|
|
50f50b2672 | ||
|
|
fb8c73be76 | ||
|
|
18468a5658 | ||
|
|
048eab3b49 | ||
|
|
ca774c77c8 | ||
|
|
a4897c20d7 | ||
|
|
bed14a72af | ||
|
|
93c06934bd | ||
|
|
1e665fbe7e | ||
|
|
30812f8a8e | ||
|
|
dd0531091b | ||
|
|
a2901bfb2e | ||
|
|
418c7887ee | ||
|
|
12407d473d | ||
|
|
36a46cfd66 | ||
|
|
0868a92e62 |
@@ -1,55 +0,0 @@
|
||||
# design-sync notes — @reactive-resume/ui
|
||||
|
||||
Syncs to Claude Design project **Reactive Resume** (`3c0f6556-050a-41e5-9886-c3f1ea950517`).
|
||||
|
||||
## Repo shape / build
|
||||
|
||||
- `@reactive-resume/ui` is **source-consumed** (pnpm workspace, no `dist`, exports point at `src/components/*.tsx`). Runs in the converter's **synth-entry mode** (no `--entry`).
|
||||
- `buildCmd` = `node .design-sync/build-css.mjs`. That one script does three things, all required before every converter run:
|
||||
1. Creates the workspace **self-symlink** `packages/ui/node_modules/@reactive-resume/ui -> ../../../ui` (pnpm doesn't self-install it; the converter resolves the DS as `node_modules/<pkg>` and esbuild needs it for `@reactive-resume/ui/components/*` self-imports).
|
||||
2. Emits real **`.d.ts`** to `packages/ui/dist/types` via `tsc -p packages/ui/tsconfig.emit.json`. Without this, synth-entry mode gives weak `{[key]: unknown}` prop contracts; with it the converter's `findTypesRoot` picks up `dist/types` and every component gets real props (variant/size unions, inherited Base UI props).
|
||||
3. Compiles Tailwind v4 `globals.css` → self-contained `packages/ui/.ds-compiled.css` (`cfg.cssEntry`): inlines the IBM Plex Sans latin variable woff2 as a data-URI and strips all other `@font-face` (extra scripts + the Phosphor icon web font, which previews don't use — components render Phosphor as inline React SVGs). This is why previews are fully styled with tokens + brand font and there are zero dangling font URLs.
|
||||
- CSS entry scans `.design-sync/tw-entry.css` which `@import`s globals.css and adds `@source "./previews/*.tsx"` so utility classes used in authored previews are compiled. **Preview layout wrappers use inline styles** anyway (so subagents needn't recompile the shared CSS); only component-level utility classes need the recompile.
|
||||
|
||||
## Card scope
|
||||
|
||||
- The package exports **202 symbols** (39 primary components + 163 compound sub-parts). User chose **~40 primary cards**: `cfg.componentSrcMap` nulls the 163 sub-parts. All 202 stay importable from `window.RRUI` (the bundle exports everything regardless of the card list), so previews compose sub-parts (`RRUI.DialogContent`, etc.) freely.
|
||||
- Multi-primary files represented by one card: `combobox.tsx`→ComboboxRoot, `form.tsx`→FormItem, `resizable.tsx`→ResizableGroup, `sonner.tsx`→Toaster.
|
||||
|
||||
## Preview authoring conventions (calibrated on Button / Alert / Dialog)
|
||||
|
||||
- Import naturally: `import { Button } from "@reactive-resume/ui/components/button"` — converter rule 2 redirects any exported-component module to `window.RRUI`, and sub-parts resolve too.
|
||||
- Icons: `@phosphor-icons/react` with the `*Icon` suffix (e.g. `PlusIcon`, `TrashIcon`, `WarningIcon`). Bundles into the preview.
|
||||
- Base UI compose pattern: `render={<Button variant="outline" />}` on `*.Trigger` / `*.Close` etc.
|
||||
- Layout wrappers: inline `style={{ display:"flex", gap, padding }}` — not Tailwind (keeps fan-out from needing CSS recompiles).
|
||||
- **Overlays** (Dialog, and expect the same for AlertDialog/Sheet/Popover/HoverCard/DropdownMenu/ContextMenu/Tooltip/Command-dialog): render open via `defaultOpen`, and set `cfg.overrides.<Name> = {cardMode:"single", primaryStory:"<export>", viewport:"WxH"}`. Use viewport width ≥ 640 so `sm:` breakpoint styles (e.g. horizontal dialog footer) engage — Dialog uses `760x440`.
|
||||
- Realistic resume-app content (resumes, sections, publish/export/share), never foo/bar.
|
||||
|
||||
## Component composition notes (from the authoring wave)
|
||||
|
||||
- **Real `.d.ts` contracts require the barrel** (see build step 2 + `publishConfig.types`). Base UI prop names differ from Radix/native: Switch `defaultChecked`+`size`; Toggle `defaultPressed`+`variant`+`size`; Slider `defaultValue` array (`[n]` single / `[a,b]` range). Use uncontrolled `default*` props in previews to avoid controlled-without-onChange warnings.
|
||||
- **BrandIcon renders the app's own logo/icon** (`variant="logo"|"icon"`), NOT a social/brand-slug icon. It `<img src>`s `/logo/*.svg` + `/icon/*.svg`, which the preview server (serving `ds-bundle/`) 404s. The BrandIcon preview inlines the real `apps/web/public/{logo,icon}/light.svg` as base64 `src` overrides (component spreads `{...props}` after its own `src`, so the override wins).
|
||||
- **Overlays** handled by the orchestrator with `cfg.overrides` (cardMode single + primaryStory Open + viewport): Dialog, AlertDialog, Sheet, Popover, Tooltip, HoverCard, DropdownMenu, ContextMenu, ComboboxRoot. Command renders **inline** (cmdk, no overlay); Sidebar uses `collapsible="none"` to render inline (default offcanvas is fixed-positioned); Toaster fires a `duration:Infinity` toast on mount.
|
||||
- **Providers composed in-preview** (no cfg.provider): Tooltip→TooltipProvider, Sidebar→SidebarProvider, MessageScroller→MessageScrollerProvider (+ explicit container height — Root is `size-full min-h-0` and collapses otherwise), FormItem carries its own context.
|
||||
- **Accordion** opens statically via `defaultValue={[...itemValues]}` (the `--accordion-panel-height` warn is a non-issue — panels measure fine). **Tabs** via `defaultValue`. **ScrollArea/ResizableGroup/InputGroup** need an inline container height/width. **Separator** vertical needs an explicit height.
|
||||
- Chat/attachment components (Attachment, Bubble, Message, MessageScroller, Marker) are all used only in `apps/web/src/routes/agent/-components/agent-chat.tsx` — the canonical composition source.
|
||||
|
||||
## Build/verify gotchas (learned the hard way)
|
||||
|
||||
- **A full `package-build` takes ~3-4 minutes** — not a hang. `@phosphor-icons/react` is a giant barrel, so each icon-importing preview costs ~10-20s of esbuild parse, and 30+ authored previews compile serially. Always run it in a real background task (not a 120s-capped foreground shell) and wait for completion.
|
||||
- **Do NOT add a barrel `index.d.ts` + `publishConfig.types`** to get rich props for inline-param-typed components: it makes ts-morph resolve all 200+ inline Base UI param types and hangs the build for many minutes. Tried and reverted. Result: components with a named `<Name>Props` source type (Button) get real props; the rest get honest `{[key]: unknown}`.
|
||||
- **Base UI menu Labels must be inside a Group**: `DropdownMenuLabel`/`ContextMenuLabel` throw `MenuGroupContext is missing` unless wrapped in `DropdownMenuGroup`/`ContextMenuGroup`. Same likely for other `*Label`/`*GroupLabel` menu parts.
|
||||
- **`[RENDER_THIN]` (height 0px) is benign for fixed-position overlays** (Dialog, AlertDialog, Sheet): the content is `position:fixed` so it measures 0 in normal flow, but `rootEmpty:false` and the screenshot is correct. Confirmed via review sheets — not a failure.
|
||||
- **`[GRID_OVERFLOW]` wide** → `cfg.overrides.<Name> = {cardMode:"column"}` applied to: Accordion, Attachment, Bubble, FormItem, InputGroup, Marker, Message, ResizableGroup, Tabs, Textarea. Toaster (portal escape) → `{cardMode:"single", primaryStory:"Notification"}`.
|
||||
|
||||
## Known render warns (triaged, not failures)
|
||||
|
||||
- `[TOKENS_MISSING]`: `--active-tab-{top,left,height,width}` (Base UI tab indicator sets these at runtime), `--accordion-panel-height` (Base UI accordion runtime), `--tw` (Tailwind internal), plus app-level `--resume-preview-page-gap` / `--page-primary-color` (defined by apps/web, not this package). All expected absent from the shipped stylesheet — components set them at runtime. Do not chase.
|
||||
- `--font-heading` is referenced (DialogTitle `font-heading`) but not defined in the UI package tokens (app-level). Falls back to `--font-body` (IBM Plex). Cosmetic only.
|
||||
- Unauthored primitives render near-empty floor cards (`[RENDER_BLANK]` for empty Button/Input/etc.) — resolved once authored.
|
||||
|
||||
## Re-sync risks
|
||||
|
||||
- `packages/ui/dist/types`, `packages/ui/.ds-compiled.css`, `packages/ui/.ds-tw-raw.css`, and the self-symlink are all gitignored build artifacts regenerated by `buildCmd` — always run `node .design-sync/build-css.mjs` before the converter/driver.
|
||||
- The inlined IBM Plex font path in `build-css.mjs` is pinned to `@fontsource-variable/ibm-plex-sans/files/ibm-plex-sans-latin-wght-normal.woff2`; if that dep moves, the font inline breaks (previews fall back to system sans).
|
||||
- `tsconfig.emit.json` is committed; if the package adds a real build later, prefer pointing the converter at that dist and drop the emit step.
|
||||
@@ -1,55 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// design-sync CSS build: compile the UI package's Tailwind v4 globals.css to
|
||||
// static CSS, then make it self-contained for preview rendering by inlining the
|
||||
// IBM Plex Sans (latin) variable webfont as a data-URI and dropping the other
|
||||
// @font-face rules (extra scripts + the Phosphor icon font, which previews
|
||||
// don't use — components render Phosphor as inline React SVGs).
|
||||
//
|
||||
// Output: packages/ui/.ds-compiled.css (cfg.cssEntry, bounded to the package)
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repo = resolve(here, "..");
|
||||
|
||||
// pnpm doesn't self-install the workspace package into its own node_modules,
|
||||
// but the design-sync converter resolves the DS as node_modules/<pkg>. Create
|
||||
// the self-symlink so PKG_DIR resolves and esbuild finds @reactive-resume/ui/*
|
||||
// self-imports. Mirrors the sibling symlinks pnpm already writes (utils, config).
|
||||
const selfLink = resolve(repo, "packages/ui/node_modules/@reactive-resume/ui");
|
||||
if (!existsSync(selfLink)) symlinkSync("../../../ui", selfLink);
|
||||
|
||||
// Emit real .d.ts declarations (the package is source-consumed with no build).
|
||||
// The converter's findTypesRoot picks up dist/types, giving components real
|
||||
// prop contracts (variant/size unions, inherited Base UI props) instead of the
|
||||
// weak `{[key]: unknown}` synth-entry fallback.
|
||||
execFileSync(resolve(repo, "node_modules/.bin/tsc"), ["-p", "tsconfig.emit.json"], {
|
||||
cwd: resolve(repo, "packages/ui"),
|
||||
stdio: "inherit",
|
||||
});
|
||||
// NOTE: a barrel index.d.ts + publishConfig.types was tried to give the prop
|
||||
// extractor an entry for components with inline param types — but resolving all
|
||||
// 200+ inline Base UI param types through ts-morph's checker hangs the build
|
||||
// (many minutes). Reverted. Components with a named <Name>Props source type
|
||||
// (e.g. Button) still extract real props from dist/types; the rest fall back to
|
||||
// the honest `{[key]: unknown}` contract, with usage carried by the preview +
|
||||
// .prompt.md. See .design-sync/NOTES.md "Re-sync risks".
|
||||
const cli = resolve(repo, ".ds-sync/node_modules/.bin/tailwindcss");
|
||||
const entry = resolve(here, "tw-entry.css");
|
||||
const tmp = resolve(repo, "packages/ui/.ds-tw-raw.css");
|
||||
const out = resolve(repo, "packages/ui/.ds-compiled.css");
|
||||
const font = resolve(
|
||||
repo,
|
||||
"packages/ui/node_modules/@fontsource-variable/ibm-plex-sans/files/ibm-plex-sans-latin-wght-normal.woff2",
|
||||
);
|
||||
|
||||
execFileSync(cli, ["-i", entry, "-o", tmp], { stdio: "inherit" });
|
||||
|
||||
let css = readFileSync(tmp, "utf8");
|
||||
css = css.replace(/@font-face\s*\{[^}]*\}/g, ""); // drop all shipped @font-face
|
||||
const b64 = readFileSync(font).toString("base64");
|
||||
const face = `@font-face{font-family:"IBM Plex Sans Variable";font-style:normal;font-weight:100 700;font-display:swap;src:url(data:font/woff2;base64,${b64}) format("woff2-variations")}\n`;
|
||||
writeFileSync(out, face + css);
|
||||
console.error(` build-css: wrote ${out} (${(Buffer.byteLength(face + css) / 1024).toFixed(0)} KB, font inlined)`);
|
||||
@@ -1,256 +0,0 @@
|
||||
{
|
||||
"projectId": "3c0f6556-050a-41e5-9886-c3f1ea950517",
|
||||
"pkg": "@reactive-resume/ui",
|
||||
"globalName": "RRUI",
|
||||
"shape": "package",
|
||||
"buildCmd": "node .design-sync/build-css.mjs",
|
||||
"tsconfig": "tsconfig.json",
|
||||
"cssEntry": ".ds-compiled.css",
|
||||
"componentSrcMap": {
|
||||
"AccordionContent": null,
|
||||
"AccordionItem": null,
|
||||
"AccordionTrigger": null,
|
||||
"AlertAction": null,
|
||||
"AlertDescription": null,
|
||||
"AlertDialogAction": null,
|
||||
"AlertDialogCancel": null,
|
||||
"AlertDialogContent": null,
|
||||
"AlertDialogDescription": null,
|
||||
"AlertDialogFooter": null,
|
||||
"AlertDialogHeader": null,
|
||||
"AlertDialogMedia": null,
|
||||
"AlertDialogOverlay": null,
|
||||
"AlertDialogPortal": null,
|
||||
"AlertDialogTitle": null,
|
||||
"AlertDialogTrigger": null,
|
||||
"AlertTitle": null,
|
||||
"AttachmentAction": null,
|
||||
"AttachmentActions": null,
|
||||
"AttachmentContent": null,
|
||||
"AttachmentDescription": null,
|
||||
"AttachmentGroup": null,
|
||||
"AttachmentMedia": null,
|
||||
"AttachmentTitle": null,
|
||||
"AttachmentTrigger": null,
|
||||
"AvatarBadge": null,
|
||||
"AvatarFallback": null,
|
||||
"AvatarGroup": null,
|
||||
"AvatarGroupCount": null,
|
||||
"AvatarImage": null,
|
||||
"BubbleContent": null,
|
||||
"BubbleGroup": null,
|
||||
"BubbleReactions": null,
|
||||
"ButtonGroupSeparator": null,
|
||||
"ButtonGroupText": null,
|
||||
"ComboboxChip": null,
|
||||
"ComboboxChips": null,
|
||||
"ComboboxChipsInput": null,
|
||||
"ComboboxClear": null,
|
||||
"ComboboxCollection": null,
|
||||
"ComboboxContent": null,
|
||||
"ComboboxEmpty": null,
|
||||
"ComboboxGroup": null,
|
||||
"ComboboxInput": null,
|
||||
"ComboboxItem": null,
|
||||
"ComboboxLabel": null,
|
||||
"ComboboxList": null,
|
||||
"ComboboxSeparator": null,
|
||||
"ComboboxTrigger": null,
|
||||
"ComboboxValue": null,
|
||||
"CommandDialog": null,
|
||||
"CommandEmpty": null,
|
||||
"CommandGroup": null,
|
||||
"CommandInput": null,
|
||||
"CommandItem": null,
|
||||
"CommandList": null,
|
||||
"CommandSeparator": null,
|
||||
"CommandShortcut": null,
|
||||
"ContextMenuCheckboxItem": null,
|
||||
"ContextMenuContent": null,
|
||||
"ContextMenuGroup": null,
|
||||
"ContextMenuItem": null,
|
||||
"ContextMenuLabel": null,
|
||||
"ContextMenuPortal": null,
|
||||
"ContextMenuRadioGroup": null,
|
||||
"ContextMenuRadioItem": null,
|
||||
"ContextMenuSeparator": null,
|
||||
"ContextMenuShortcut": null,
|
||||
"ContextMenuSub": null,
|
||||
"ContextMenuSubContent": null,
|
||||
"ContextMenuSubTrigger": null,
|
||||
"ContextMenuTrigger": null,
|
||||
"DialogClose": null,
|
||||
"DialogContent": null,
|
||||
"DialogDescription": null,
|
||||
"DialogFooter": null,
|
||||
"DialogHeader": null,
|
||||
"DialogOverlay": null,
|
||||
"DialogPortal": null,
|
||||
"DialogTitle": null,
|
||||
"DialogTrigger": null,
|
||||
"DropdownMenuCheckboxItem": null,
|
||||
"DropdownMenuContent": null,
|
||||
"DropdownMenuGroup": null,
|
||||
"DropdownMenuItem": null,
|
||||
"DropdownMenuLabel": null,
|
||||
"DropdownMenuPortal": null,
|
||||
"DropdownMenuRadioGroup": null,
|
||||
"DropdownMenuRadioItem": null,
|
||||
"DropdownMenuSeparator": null,
|
||||
"DropdownMenuShortcut": null,
|
||||
"DropdownMenuSub": null,
|
||||
"DropdownMenuSubContent": null,
|
||||
"DropdownMenuSubTrigger": null,
|
||||
"DropdownMenuTrigger": null,
|
||||
"FormControl": null,
|
||||
"FormDescription": null,
|
||||
"FormLabel": null,
|
||||
"FormMessage": null,
|
||||
"HoverCardContent": null,
|
||||
"HoverCardTrigger": null,
|
||||
"InputGroupAddon": null,
|
||||
"InputGroupButton": null,
|
||||
"InputGroupInput": null,
|
||||
"InputGroupText": null,
|
||||
"InputGroupTextarea": null,
|
||||
"KbdGroup": null,
|
||||
"MarkerContent": null,
|
||||
"MarkerIcon": null,
|
||||
"MessageAvatar": null,
|
||||
"MessageContent": null,
|
||||
"MessageFooter": null,
|
||||
"MessageGroup": null,
|
||||
"MessageHeader": null,
|
||||
"MessageScrollerButton": null,
|
||||
"MessageScrollerContent": null,
|
||||
"MessageScrollerItem": null,
|
||||
"MessageScrollerProvider": null,
|
||||
"MessageScrollerViewport": null,
|
||||
"PopoverContent": null,
|
||||
"PopoverDescription": null,
|
||||
"PopoverHeader": null,
|
||||
"PopoverTitle": null,
|
||||
"PopoverTrigger": null,
|
||||
"ResizablePanel": null,
|
||||
"ResizableSeparator": null,
|
||||
"ScrollBar": null,
|
||||
"SheetClose": null,
|
||||
"SheetContent": null,
|
||||
"SheetDescription": null,
|
||||
"SheetFooter": null,
|
||||
"SheetHeader": null,
|
||||
"SheetTitle": null,
|
||||
"SheetTrigger": null,
|
||||
"SidebarContent": null,
|
||||
"SidebarFooter": null,
|
||||
"SidebarGroup": null,
|
||||
"SidebarGroupAction": null,
|
||||
"SidebarGroupContent": null,
|
||||
"SidebarGroupLabel": null,
|
||||
"SidebarHeader": null,
|
||||
"SidebarInput": null,
|
||||
"SidebarInset": null,
|
||||
"SidebarMenu": null,
|
||||
"SidebarMenuAction": null,
|
||||
"SidebarMenuBadge": null,
|
||||
"SidebarMenuButton": null,
|
||||
"SidebarMenuItem": null,
|
||||
"SidebarMenuSkeleton": null,
|
||||
"SidebarMenuSub": null,
|
||||
"SidebarMenuSubButton": null,
|
||||
"SidebarMenuSubItem": null,
|
||||
"SidebarProvider": null,
|
||||
"SidebarRail": null,
|
||||
"SidebarSeparator": null,
|
||||
"SidebarTrigger": null,
|
||||
"TabsContent": null,
|
||||
"TabsIndicator": null,
|
||||
"TabsList": null,
|
||||
"TabsTrigger": null,
|
||||
"TooltipContent": null,
|
||||
"TooltipProvider": null,
|
||||
"TooltipTrigger": null
|
||||
},
|
||||
"overrides": {
|
||||
"Dialog": {
|
||||
"cardMode": "single",
|
||||
"primaryStory": "Open",
|
||||
"viewport": "760x440"
|
||||
},
|
||||
"AlertDialog": {
|
||||
"cardMode": "single",
|
||||
"primaryStory": "Open",
|
||||
"viewport": "640x460"
|
||||
},
|
||||
"Sheet": {
|
||||
"cardMode": "single",
|
||||
"primaryStory": "Open",
|
||||
"viewport": "760x480"
|
||||
},
|
||||
"Popover": {
|
||||
"cardMode": "single",
|
||||
"primaryStory": "Open",
|
||||
"viewport": "420x340"
|
||||
},
|
||||
"Tooltip": {
|
||||
"cardMode": "single",
|
||||
"primaryStory": "Open",
|
||||
"viewport": "360x260"
|
||||
},
|
||||
"HoverCard": {
|
||||
"cardMode": "single",
|
||||
"primaryStory": "Open",
|
||||
"viewport": "440x320"
|
||||
},
|
||||
"DropdownMenu": {
|
||||
"cardMode": "single",
|
||||
"primaryStory": "Open",
|
||||
"viewport": "440x360"
|
||||
},
|
||||
"ContextMenu": {
|
||||
"cardMode": "single",
|
||||
"primaryStory": "Open",
|
||||
"viewport": "440x340"
|
||||
},
|
||||
"ComboboxRoot": {
|
||||
"cardMode": "single",
|
||||
"primaryStory": "Open",
|
||||
"viewport": "420x360"
|
||||
},
|
||||
"Accordion": {
|
||||
"cardMode": "column"
|
||||
},
|
||||
"Attachment": {
|
||||
"cardMode": "column"
|
||||
},
|
||||
"Bubble": {
|
||||
"cardMode": "column"
|
||||
},
|
||||
"FormItem": {
|
||||
"cardMode": "column"
|
||||
},
|
||||
"InputGroup": {
|
||||
"cardMode": "column"
|
||||
},
|
||||
"Marker": {
|
||||
"cardMode": "column"
|
||||
},
|
||||
"Message": {
|
||||
"cardMode": "column"
|
||||
},
|
||||
"ResizableGroup": {
|
||||
"cardMode": "column"
|
||||
},
|
||||
"Tabs": {
|
||||
"cardMode": "column"
|
||||
},
|
||||
"Textarea": {
|
||||
"cardMode": "column"
|
||||
},
|
||||
"Toaster": {
|
||||
"cardMode": "single",
|
||||
"primaryStory": "Notification"
|
||||
}
|
||||
},
|
||||
"readmeHeader": ".design-sync/conventions.md"
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
# Reactive Resume UI — how to build with it
|
||||
|
||||
This is `@reactive-resume/ui`: a shadcn-style React component library built on **Base UI**
|
||||
primitives and **Tailwind CSS v4**. Every component is real upstream code, bundled to the
|
||||
`window.RRUI` global; the 39 cards are the primary components, but all their compound
|
||||
sub-parts (e.g. `DialogContent`, `AccordionItem`, `SidebarMenuButton`) are also on `RRUI`.
|
||||
|
||||
## Setup & wrapping
|
||||
|
||||
- **No global provider is required.** All design tokens live on `:root` in `styles.css` (loaded
|
||||
for you), so components are styled out of the box. For dark mode, add `class="dark"` to a
|
||||
wrapping element — the same tokens flip to their dark values.
|
||||
- **A few components need their own provider — wrap only where you use them:**
|
||||
- `Tooltip*` → wrap in `RRUI.TooltipProvider`.
|
||||
- `Sidebar*` → wrap in `RRUI.SidebarProvider`.
|
||||
- `MessageScroller*` → wrap in `RRUI.MessageScrollerProvider` and give it a bounded height.
|
||||
- Form fields → `RRUI.FormItem` provides the field context for `FormLabel`/`FormControl`/`FormMessage`.
|
||||
- **Compose compound components** from their parts, e.g. `Dialog` = `DialogTrigger` + `DialogContent`
|
||||
(+ `DialogHeader`/`DialogTitle`/`DialogDescription`/`DialogFooter`). Overlay parts (Dialog, Sheet,
|
||||
Popover, DropdownMenu, ContextMenu, Tooltip, HoverCard) render into a portal. Menu labels must sit
|
||||
inside a `*Group` (`DropdownMenuGroup`, `ContextMenuGroup`).
|
||||
- Icons come from `@phosphor-icons/react` (the `*Icon` suffix, e.g. `PlusIcon`).
|
||||
|
||||
## Styling idiom — Tailwind utilities on semantic tokens
|
||||
|
||||
Components style themselves; for **your own** layout and surfaces, use Tailwind utility classes
|
||||
bound to the design system's **semantic color tokens** (never raw hex — these adapt to light/dark):
|
||||
|
||||
| Purpose | Utilities |
|
||||
|---|---|
|
||||
| Surfaces | `bg-background`, `bg-card`, `bg-popover`, `bg-muted`, `bg-sidebar` |
|
||||
| Brand / actions | `bg-primary` + `text-primary-foreground`, `bg-secondary` + `text-secondary-foreground` |
|
||||
| Accents / hover | `bg-accent` + `text-accent-foreground`, `hover:bg-muted` |
|
||||
| Danger | `bg-destructive`, `text-destructive` |
|
||||
| Text | `text-foreground` (primary), `text-muted-foreground` (secondary) |
|
||||
| Borders / focus | `border`, `border-input`, `ring-ring`, `outline-ring` |
|
||||
| Radius | `rounded-md`, `rounded-lg` (driven by `--radius`) |
|
||||
|
||||
Each token is also a CSS variable (`var(--primary)`, `var(--muted-foreground)`, `var(--border)`,
|
||||
`var(--radius)`, `--font-body` = IBM Plex Sans) if you need it in inline styles.
|
||||
|
||||
## Where the truth lives
|
||||
|
||||
- **Styling:** `styles.css` and its `@import` closure (`_ds_bundle.css` = component styles; the
|
||||
token definitions on `:root`/`.dark`). Read these before inventing a class or color.
|
||||
- **Per component:** `components/<group>/<Name>/<Name>.prompt.md` (usage) and `<Name>.d.ts` (props —
|
||||
variant/size unions where a named type exists; some fall back to a permissive shape).
|
||||
|
||||
## Idiomatic snippet
|
||||
|
||||
```jsx
|
||||
// A confirm action, styled with the DS's own tokens for the surrounding layout.
|
||||
<div className="flex flex-col gap-3 rounded-lg border bg-card p-4">
|
||||
<p className="text-sm text-muted-foreground">Publish this resume to your public profile?</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<RRUI.Button variant="outline">Cancel</RRUI.Button>
|
||||
<RRUI.Button>Publish</RRUI.Button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
@@ -1,52 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
|
||||
|
||||
const wrap: React.CSSProperties = { width: 420, padding: 16 };
|
||||
|
||||
// Open by default so the panel content is visible in the card (Base UI accordion
|
||||
// is uncontrolled via defaultValue, matching item `value` props).
|
||||
export const Sections = () => (
|
||||
<div style={wrap}>
|
||||
<Accordion defaultValue={["experience"]}>
|
||||
<AccordionItem value="experience">
|
||||
<AccordionTrigger>Work Experience</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p>Senior Product Designer · Framer — 2021 to Present</p>
|
||||
<p>Led the redesign of the onboarding flow, lifting activation by 24% across web and mobile.</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="education">
|
||||
<AccordionTrigger>Education</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p>B.Des in Interaction Design · Rhode Island School of Design</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="skills">
|
||||
<AccordionTrigger>Skills</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p>Figma, prototyping, design systems, user research, and front-end handoff.</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const MultipleOpen = () => (
|
||||
<div style={wrap}>
|
||||
<Accordion multiple defaultValue={["summary", "certifications"]}>
|
||||
<AccordionItem value="summary">
|
||||
<AccordionTrigger>Professional Summary</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p>Full-stack engineer with eight years shipping resilient TypeScript services and design systems.</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="certifications">
|
||||
<AccordionTrigger>Certifications</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p>AWS Solutions Architect · Professional</p>
|
||||
<p>Certified Kubernetes Administrator</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
@@ -1,38 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { InfoIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
|
||||
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 16, padding: 16, maxWidth: 540 };
|
||||
|
||||
export const Default = () => (
|
||||
<div style={wrap}>
|
||||
<Alert>
|
||||
<InfoIcon />
|
||||
<AlertTitle>Resume saved</AlertTitle>
|
||||
<AlertDescription>Your changes were saved automatically and synced to your account.</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Destructive = () => (
|
||||
<div style={wrap}>
|
||||
<Alert variant="destructive">
|
||||
<WarningIcon />
|
||||
<AlertTitle>Export failed</AlertTitle>
|
||||
<AlertDescription>We couldn't generate your PDF. Check your connection and try again.</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithAction = () => (
|
||||
<div style={wrap}>
|
||||
<Alert>
|
||||
<AlertTitle>Unsaved changes</AlertTitle>
|
||||
<AlertDescription>You have edits that haven't been published to your public resume yet.</AlertDescription>
|
||||
<AlertAction>
|
||||
<Button size="sm">Publish</Button>
|
||||
</AlertAction>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
@@ -1,34 +0,0 @@
|
||||
import { WarningIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
} from "@reactive-resume/ui/components/alert-dialog";
|
||||
|
||||
// Overlay — rendered open (defaultOpen). cfg.overrides.AlertDialog pins
|
||||
// cardMode: single + viewport (content is fixed-positioned, centred).
|
||||
export const Open = () => (
|
||||
<AlertDialog defaultOpen>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia>
|
||||
<WarningIcon />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Delete this resume?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
“Software Engineer” and its entire version history will be permanently removed. This action can’t be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive">Delete resume</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
@@ -1,84 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { DownloadSimpleIcon, FileDocIcon, FilePdfIcon, TrashIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentAction,
|
||||
AttachmentActions,
|
||||
AttachmentContent,
|
||||
AttachmentDescription,
|
||||
AttachmentGroup,
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
} from "@reactive-resume/ui/components/attachment";
|
||||
|
||||
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 12, padding: 16, width: 360 };
|
||||
|
||||
export const WithActions = () => (
|
||||
<div style={wrap}>
|
||||
<Attachment>
|
||||
<AttachmentMedia>
|
||||
<FilePdfIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>Ansel_Bradford_Resume.pdf</AttachmentTitle>
|
||||
<AttachmentDescription>248 KB · PDF</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions>
|
||||
<AttachmentAction aria-label="Download">
|
||||
<DownloadSimpleIcon />
|
||||
</AttachmentAction>
|
||||
<AttachmentAction aria-label="Remove">
|
||||
<TrashIcon />
|
||||
</AttachmentAction>
|
||||
</AttachmentActions>
|
||||
</Attachment>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const States = () => (
|
||||
<div style={wrap}>
|
||||
<Attachment size="sm" state="uploading">
|
||||
<AttachmentMedia>
|
||||
<FileDocIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>cover-letter.docx</AttachmentTitle>
|
||||
<AttachmentDescription>Uploading…</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
</Attachment>
|
||||
<Attachment size="sm" state="error">
|
||||
<AttachmentMedia>
|
||||
<WarningIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>portfolio-2024.zip</AttachmentTitle>
|
||||
<AttachmentDescription>Upload failed · file too large</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
</Attachment>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Group = () => (
|
||||
<div style={{ padding: 16, width: 360 }}>
|
||||
<AttachmentGroup>
|
||||
<Attachment orientation="vertical" size="sm">
|
||||
<AttachmentMedia>
|
||||
<FilePdfIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>Resume.pdf</AttachmentTitle>
|
||||
<AttachmentDescription>248 KB</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
</Attachment>
|
||||
<Attachment orientation="vertical" size="sm">
|
||||
<AttachmentMedia>
|
||||
<FileDocIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>cover-letter.docx</AttachmentTitle>
|
||||
<AttachmentDescription>19 KB</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
</Attachment>
|
||||
</AttachmentGroup>
|
||||
</div>
|
||||
);
|
||||
@@ -1,71 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { CheckIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarBadge,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
} from "@reactive-resume/ui/components/avatar";
|
||||
|
||||
const row: React.CSSProperties = { display: "flex", alignItems: "center", gap: 16, padding: 20 };
|
||||
|
||||
export const Fallback = () => (
|
||||
<div style={row}>
|
||||
<Avatar>
|
||||
<AvatarFallback>AP</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar>
|
||||
<AvatarFallback>JD</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar>
|
||||
<AvatarFallback>MK</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithStatus = () => (
|
||||
<div style={row}>
|
||||
<Avatar>
|
||||
<AvatarFallback>AP</AvatarFallback>
|
||||
<AvatarBadge>
|
||||
<CheckIcon weight="bold" />
|
||||
</AvatarBadge>
|
||||
</Avatar>
|
||||
<Avatar size="lg">
|
||||
<AvatarFallback>SR</AvatarFallback>
|
||||
<AvatarBadge />
|
||||
</Avatar>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Sizes = () => (
|
||||
<div style={row}>
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback>AP</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar size="default">
|
||||
<AvatarFallback>AP</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar size="lg">
|
||||
<AvatarFallback>AP</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Group = () => (
|
||||
<div style={row}>
|
||||
<AvatarGroup>
|
||||
<Avatar>
|
||||
<AvatarFallback>AP</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar>
|
||||
<AvatarFallback>JD</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar>
|
||||
<AvatarFallback>MK</AvatarFallback>
|
||||
</Avatar>
|
||||
<AvatarGroupCount>+5</AvatarGroupCount>
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
);
|
||||
@@ -1,40 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { CheckCircleIcon, PencilSimpleIcon, SparkleIcon } from "@phosphor-icons/react";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
|
||||
const row: React.CSSProperties = { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 10, padding: 20 };
|
||||
|
||||
export const Variants = () => (
|
||||
<div style={row}>
|
||||
<Badge>Default</Badge>
|
||||
<Badge variant="secondary">Secondary</Badge>
|
||||
<Badge variant="destructive">Destructive</Badge>
|
||||
<Badge variant="outline">Outline</Badge>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const StatusLabels = () => (
|
||||
<div style={row}>
|
||||
<Badge variant="secondary">
|
||||
<CheckCircleIcon weight="fill" data-icon="inline-start" />
|
||||
Published
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
<PencilSimpleIcon data-icon="inline-start" />
|
||||
Draft
|
||||
</Badge>
|
||||
<Badge>
|
||||
<SparkleIcon weight="fill" data-icon="inline-start" />
|
||||
Pro
|
||||
</Badge>
|
||||
<Badge variant="destructive">Expired</Badge>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Counts = () => (
|
||||
<div style={row}>
|
||||
<Badge>12</Badge>
|
||||
<Badge variant="secondary">New</Badge>
|
||||
<Badge variant="outline">v5.2</Badge>
|
||||
</div>
|
||||
);
|
||||
File diff suppressed because one or more lines are too long
@@ -1,48 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { Bubble, BubbleContent, BubbleGroup, BubbleReactions } from "@reactive-resume/ui/components/bubble";
|
||||
|
||||
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 8, padding: 16, width: 420 };
|
||||
|
||||
export const Conversation = () => (
|
||||
<div style={wrap}>
|
||||
<BubbleGroup>
|
||||
<Bubble align="end">
|
||||
<BubbleContent>Can you make my summary sound more senior without exaggerating?</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble variant="muted" align="start">
|
||||
<BubbleContent>
|
||||
I tightened it to lead with scope and outcomes. Want me to mirror that tone in your experience bullets too?
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble align="end">
|
||||
<BubbleContent>Yes, keep it concise.</BubbleContent>
|
||||
</Bubble>
|
||||
</BubbleGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Variants = () => (
|
||||
<div style={wrap}>
|
||||
<Bubble variant="default" align="end">
|
||||
<BubbleContent>Applied 3 edits to your resume.</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble variant="tinted" align="start">
|
||||
<BubbleContent>I emphasized measurable launch outcomes in your last role.</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble variant="outline" align="start">
|
||||
<BubbleContent>Draft saved — publish when you're ready.</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble variant="destructive" align="start">
|
||||
<BubbleContent>Couldn't reach the AI provider. Retry?</BubbleContent>
|
||||
</Bubble>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithReactions = () => (
|
||||
<div style={{ padding: 24, width: 420 }}>
|
||||
<Bubble variant="secondary" align="start">
|
||||
<BubbleContent>Rewrote your headline to target a Senior Product Manager role.</BubbleContent>
|
||||
<BubbleReactions>👍 2</BubbleReactions>
|
||||
</Bubble>
|
||||
</div>
|
||||
);
|
||||
@@ -1,51 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { ArrowRightIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
|
||||
const row: React.CSSProperties = { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 12, padding: 16 };
|
||||
|
||||
export const Variants = () => (
|
||||
<div style={row}>
|
||||
<Button>Save changes</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="outline">Outline</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="destructive">Delete</Button>
|
||||
<Button variant="link">Learn more</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Sizes = () => (
|
||||
<div style={row}>
|
||||
<Button size="xs">Extra small</Button>
|
||||
<Button size="sm">Small</Button>
|
||||
<Button size="default">Default</Button>
|
||||
<Button size="lg">Large</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithIcons = () => (
|
||||
<div style={row}>
|
||||
<Button>
|
||||
<PlusIcon /> Add section
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
Continue <ArrowRightIcon />
|
||||
</Button>
|
||||
<Button variant="destructive">
|
||||
<TrashIcon /> Remove
|
||||
</Button>
|
||||
<Button size="icon" variant="outline" aria-label="Add section">
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Disabled = () => (
|
||||
<div style={row}>
|
||||
<Button disabled>Saving…</Button>
|
||||
<Button variant="outline" disabled>
|
||||
Disabled
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -1,67 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import {
|
||||
AlignCenterHorizontalIcon,
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
ArrowClockwiseIcon,
|
||||
ArrowCounterClockwiseIcon,
|
||||
TextBIcon,
|
||||
TextItalicIcon,
|
||||
TextUnderlineIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { ButtonGroup, ButtonGroupSeparator, ButtonGroupText } from "@reactive-resume/ui/components/button-group";
|
||||
|
||||
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 16, padding: 16 };
|
||||
|
||||
export const Formatting = () => (
|
||||
<div style={wrap}>
|
||||
<ButtonGroup>
|
||||
<Button variant="outline" size="icon" aria-label="Bold">
|
||||
<TextBIcon />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" aria-label="Italic">
|
||||
<TextItalicIcon />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" aria-label="Underline">
|
||||
<TextUnderlineIcon />
|
||||
</Button>
|
||||
<ButtonGroupSeparator />
|
||||
<Button variant="outline" size="icon" aria-label="Align left">
|
||||
<AlignLeftIcon />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" aria-label="Align center">
|
||||
<AlignCenterHorizontalIcon />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" aria-label="Align right">
|
||||
<AlignRightIcon />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
<ButtonGroup>
|
||||
<Button variant="outline">
|
||||
<ArrowCounterClockwiseIcon /> Undo
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<ArrowClockwiseIcon /> Redo
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithText = () => (
|
||||
<div style={wrap}>
|
||||
<ButtonGroup>
|
||||
<ButtonGroupText>Zoom</ButtonGroupText>
|
||||
<Button variant="outline">50%</Button>
|
||||
<Button variant="outline">100%</Button>
|
||||
<Button variant="outline">150%</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
<ButtonGroup orientation="vertical">
|
||||
<Button variant="outline">Export PDF</Button>
|
||||
<Button variant="outline">Export DOCX</Button>
|
||||
<Button variant="outline">Copy link</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
);
|
||||
@@ -1,30 +0,0 @@
|
||||
import {
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxRoot,
|
||||
} from "@reactive-resume/ui/components/combobox";
|
||||
|
||||
const skills = ["TypeScript", "React", "Node.js", "GraphQL", "PostgreSQL", "Kubernetes"];
|
||||
|
||||
// Base UI Combobox — items passed to Root, rendered open (defaultOpen).
|
||||
// cfg.overrides.ComboboxRoot pins cardMode: single + viewport with room below.
|
||||
export const Open = () => (
|
||||
<div style={{ width: 320, padding: 16, paddingBottom: 200 }}>
|
||||
<ComboboxRoot items={skills} defaultOpen>
|
||||
<ComboboxInput placeholder="Add a skill…" />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No skills found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item: string) => (
|
||||
<ComboboxItem key={item} value={item}>
|
||||
{item}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>
|
||||
</div>
|
||||
);
|
||||
@@ -1,42 +0,0 @@
|
||||
import { DownloadSimpleIcon, GearIcon, PlusIcon, UserIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@reactive-resume/ui/components/command";
|
||||
|
||||
// Command renders inline (cmdk) — a searchable command palette. No overlay.
|
||||
export const Palette = () => (
|
||||
<div style={{ width: 400, padding: 16 }}>
|
||||
<div style={{ border: "1px solid var(--border)", borderRadius: 10, overflow: "hidden" }}>
|
||||
<Command>
|
||||
<CommandInput placeholder="Type a command or search…" />
|
||||
<CommandList>
|
||||
<CommandGroup heading="Actions">
|
||||
<CommandItem>
|
||||
<PlusIcon /> New resume
|
||||
<CommandShortcut>⌘N</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem>
|
||||
<DownloadSimpleIcon /> Export as PDF
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Account">
|
||||
<CommandItem>
|
||||
<UserIcon /> Profile
|
||||
</CommandItem>
|
||||
<CommandItem>
|
||||
<GearIcon /> Settings
|
||||
<CommandShortcut>⌘,</CommandShortcut>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,48 +0,0 @@
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuGroup,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/context-menu";
|
||||
|
||||
// Right-click menu — rendered open (defaultOpen) so the card shows the menu.
|
||||
// cfg.overrides.ContextMenu pins cardMode: single + viewport.
|
||||
export const Open = () => (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: 24, paddingBottom: 160 }}>
|
||||
<ContextMenu defaultOpen>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
width: 240,
|
||||
height: 96,
|
||||
border: "1px dashed var(--border)",
|
||||
borderRadius: 8,
|
||||
color: "var(--muted-foreground)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Right-click a resume card
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuLabel>Software Engineer</ContextMenuLabel>
|
||||
<ContextMenuItem>Open</ContextMenuItem>
|
||||
<ContextMenuItem>
|
||||
Rename
|
||||
<ContextMenuShortcut>F2</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem>Duplicate</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem variant="destructive">Delete</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
);
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
|
||||
// Overlay component — rendered open (defaultOpen) so the card shows the real
|
||||
// dialog surface. cfg.overrides.Dialog pins cardMode: single + a viewport for
|
||||
// the portal (content is fixed-positioned at the viewport centre).
|
||||
export const Open = () => (
|
||||
<Dialog defaultOpen>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete resume</DialogTitle>
|
||||
<DialogDescription>
|
||||
This permanently deletes “Software Engineer” along with its version history. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
|
||||
<DialogClose render={<Button variant="destructive" />}>Delete resume</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -1,41 +0,0 @@
|
||||
import { CopyIcon, DownloadSimpleIcon, PencilIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
|
||||
// Anchored menu — rendered open (defaultOpen), positioned below its trigger.
|
||||
// cfg.overrides.DropdownMenu pins cardMode: single + viewport with room below.
|
||||
export const Open = () => (
|
||||
<div style={{ display: "flex", justifyContent: "center", paddingTop: 16, paddingBottom: 220 }}>
|
||||
<DropdownMenu defaultOpen>
|
||||
<DropdownMenuTrigger render={<Button variant="outline" />}>Resume actions</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Software Engineer</DropdownMenuLabel>
|
||||
<DropdownMenuItem>
|
||||
<PencilIcon /> Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CopyIcon /> Duplicate
|
||||
<DropdownMenuShortcut>⌘D</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DownloadSimpleIcon /> Export PDF
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<TrashIcon /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
@@ -1,27 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
|
||||
// FormItem carries its own field context (id + error state) — FormLabel /
|
||||
// FormControl / FormDescription / FormMessage compose under it standalone.
|
||||
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 16, padding: 16, width: 340 };
|
||||
|
||||
export const Default = () => (
|
||||
<div style={wrap}>
|
||||
<FormItem>
|
||||
<FormLabel>Headline</FormLabel>
|
||||
<FormControl render={<Input placeholder="Senior Software Engineer" />} />
|
||||
<FormDescription>Shown under your name at the top of the resume.</FormDescription>
|
||||
</FormItem>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithError = () => (
|
||||
<div style={wrap}>
|
||||
<FormItem hasError>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl render={<Input defaultValue="jane@" />} />
|
||||
<FormMessage errors={["Enter a valid email address."]} />
|
||||
</FormItem>
|
||||
</div>
|
||||
);
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@reactive-resume/ui/components/hover-card";
|
||||
|
||||
// Anchored preview-card overlay — rendered open (defaultOpen).
|
||||
// cfg.overrides.HoverCard pins cardMode: single + viewport with room below the trigger.
|
||||
export const Open = () => (
|
||||
<div style={{ display: "flex", justifyContent: "center", paddingTop: 24, paddingBottom: 180 }}>
|
||||
<HoverCard defaultOpen>
|
||||
<HoverCardTrigger render={<Button variant="link" />}>@jane-doe</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ fontWeight: 600 }}>Jane Doe</span>
|
||||
<span style={{ color: "var(--muted-foreground)" }}>Senior Software Engineer · San Francisco</span>
|
||||
<span style={{ color: "var(--muted-foreground)", fontSize: 12 }}>3 published resumes · joined 2023</span>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
);
|
||||
@@ -1,40 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
|
||||
const field: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 6, padding: 16, width: 320 };
|
||||
|
||||
export const Default = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="full-name">Full name</Label>
|
||||
<Input id="full-name" defaultValue="Ada Lovelace" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Placeholder = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="headline">Headline</Label>
|
||||
<Input id="headline" placeholder="e.g. Senior Software Engineer" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Email = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" type="email" defaultValue="ada@analyticalengine.dev" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Invalid = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="website">Website</Label>
|
||||
<Input id="website" aria-invalid defaultValue="not-a-valid-url" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Disabled = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input id="username" disabled defaultValue="ada.lovelace" />
|
||||
</div>
|
||||
);
|
||||
@@ -1,55 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { CopyIcon, GlobeIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
InputGroupTextarea,
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
|
||||
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 16, padding: 16, width: 380 };
|
||||
|
||||
export const Addons = () => (
|
||||
<div style={wrap}>
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<MagnifyingGlassIcon />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput placeholder="Search resumes" defaultValue="Product Designer" />
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<GlobeIcon />
|
||||
<InputGroupText>rxresu.me/u/</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput defaultValue="jordan-rivera" />
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<InputGroupInput readOnly defaultValue="rxr_live_9f3c8a21bd47e50a" />
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton size="icon-sm" aria-label="Copy API key">
|
||||
<CopyIcon />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithTextarea = () => (
|
||||
<div style={wrap}>
|
||||
<InputGroup>
|
||||
<InputGroupTextarea
|
||||
rows={3}
|
||||
defaultValue="Senior product designer focused on design systems, accessibility, and shipping polished interfaces."
|
||||
/>
|
||||
<InputGroupAddon align="block-end">
|
||||
<InputGroupText>240 characters left</InputGroupText>
|
||||
<InputGroupButton style={{ marginLeft: "auto" }}>Generate with AI</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
@@ -1,68 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { Kbd, KbdGroup } from "@reactive-resume/ui/components/kbd";
|
||||
|
||||
const row: React.CSSProperties = { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 12, padding: 20 };
|
||||
const listRow: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 24,
|
||||
fontSize: 13,
|
||||
color: "var(--foreground)",
|
||||
};
|
||||
const col: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 10, padding: 20, minWidth: 260 };
|
||||
|
||||
export const Keys = () => (
|
||||
<div style={row}>
|
||||
<Kbd>⌘</Kbd>
|
||||
<Kbd>⇧</Kbd>
|
||||
<Kbd>⌥</Kbd>
|
||||
<Kbd>Esc</Kbd>
|
||||
<Kbd>Enter</Kbd>
|
||||
<Kbd>Tab</Kbd>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Combinations = () => (
|
||||
<div style={row}>
|
||||
<KbdGroup>
|
||||
<Kbd>⌘</Kbd>
|
||||
<Kbd>K</Kbd>
|
||||
</KbdGroup>
|
||||
<KbdGroup>
|
||||
<Kbd>⌘</Kbd>
|
||||
<Kbd>S</Kbd>
|
||||
</KbdGroup>
|
||||
<KbdGroup>
|
||||
<Kbd>⌘</Kbd>
|
||||
<Kbd>⇧</Kbd>
|
||||
<Kbd>P</Kbd>
|
||||
</KbdGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ShortcutList = () => (
|
||||
<div style={col}>
|
||||
<div style={listRow}>
|
||||
<span>Command palette</span>
|
||||
<KbdGroup>
|
||||
<Kbd>⌘</Kbd>
|
||||
<Kbd>K</Kbd>
|
||||
</KbdGroup>
|
||||
</div>
|
||||
<div style={listRow}>
|
||||
<span>Save resume</span>
|
||||
<KbdGroup>
|
||||
<Kbd>⌘</Kbd>
|
||||
<Kbd>S</Kbd>
|
||||
</KbdGroup>
|
||||
</div>
|
||||
<div style={listRow}>
|
||||
<span>Undo</span>
|
||||
<KbdGroup>
|
||||
<Kbd>⌘</Kbd>
|
||||
<Kbd>Z</Kbd>
|
||||
</KbdGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,39 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
|
||||
const field: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 6, padding: 16, width: 320 };
|
||||
const row: React.CSSProperties = { display: "flex", alignItems: "center", gap: 10, padding: 16, width: 320 };
|
||||
|
||||
export const WithInput = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="company">Company</Label>
|
||||
<Input id="company" defaultValue="Analytical Engine Co." />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Required = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="job-title">
|
||||
Job title <span style={{ color: "var(--destructive)" }}>*</span>
|
||||
</Label>
|
||||
<Input id="job-title" placeholder="e.g. Lead Engineer" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithSwitch = () => (
|
||||
<div style={row}>
|
||||
<Switch id="public-resume" defaultChecked />
|
||||
<Label htmlFor="public-resume">Public resume</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Disabled = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="locked-field" data-disabled="true" style={{ opacity: 0.5 }}>
|
||||
Locked field
|
||||
</Label>
|
||||
<Input id="locked-field" disabled defaultValue="Read only" />
|
||||
</div>
|
||||
);
|
||||
@@ -1,39 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { CheckCircleIcon, SparkleIcon, WarningCircleIcon } from "@phosphor-icons/react";
|
||||
import { Marker, MarkerContent, MarkerIcon } from "@reactive-resume/ui/components/marker";
|
||||
|
||||
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 12, padding: 16, width: 360 };
|
||||
|
||||
export const Statuses = () => (
|
||||
<div style={wrap}>
|
||||
<Marker style={{ width: "fit-content", borderRadius: 8, padding: "12px 16px", background: "var(--muted)" }}>
|
||||
<MarkerIcon>
|
||||
<SparkleIcon />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Tailoring your resume…</MarkerContent>
|
||||
</Marker>
|
||||
<Marker style={{ width: "fit-content" }}>
|
||||
<MarkerIcon>
|
||||
<CheckCircleIcon />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Applied 4 edits to your resume</MarkerContent>
|
||||
</Marker>
|
||||
<Marker style={{ width: "fit-content" }}>
|
||||
<MarkerIcon>
|
||||
<WarningCircleIcon />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Couldn't reach the AI provider</MarkerContent>
|
||||
</Marker>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Dividers = () => (
|
||||
<div style={wrap}>
|
||||
<Marker variant="separator">
|
||||
<MarkerContent>Today</MarkerContent>
|
||||
</Marker>
|
||||
<Marker variant="border">
|
||||
<MarkerContent>Conversation history</MarkerContent>
|
||||
</Marker>
|
||||
</div>
|
||||
);
|
||||
@@ -1,65 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { SparkleIcon, UserIcon } from "@phosphor-icons/react";
|
||||
import { Bubble, BubbleContent } from "@reactive-resume/ui/components/bubble";
|
||||
import {
|
||||
Message,
|
||||
MessageAvatar,
|
||||
MessageContent,
|
||||
MessageFooter,
|
||||
MessageGroup,
|
||||
MessageHeader,
|
||||
} from "@reactive-resume/ui/components/message";
|
||||
|
||||
const avatar: React.CSSProperties = { display: "flex", alignItems: "center", justifyContent: "center", padding: 8 };
|
||||
|
||||
export const Conversation = () => (
|
||||
<div style={{ display: "flex", padding: 16, width: 460 }}>
|
||||
<MessageGroup style={{ width: "100%" }}>
|
||||
<Message align="end">
|
||||
<MessageAvatar>
|
||||
<span style={avatar}>
|
||||
<UserIcon />
|
||||
</span>
|
||||
</MessageAvatar>
|
||||
<MessageContent>
|
||||
<Bubble align="end">
|
||||
<BubbleContent>Tailor my resume for a product manager role.</BubbleContent>
|
||||
</Bubble>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
<Message align="start">
|
||||
<MessageAvatar>
|
||||
<span style={avatar}>
|
||||
<SparkleIcon />
|
||||
</span>
|
||||
</MessageAvatar>
|
||||
<MessageContent>
|
||||
<Bubble variant="muted" align="start">
|
||||
<BubbleContent>
|
||||
Done — I emphasized roadmap ownership and stakeholder communication in your summary.
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</MessageGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithMeta = () => (
|
||||
<div style={{ display: "flex", padding: 16, width: 460 }}>
|
||||
<Message align="start">
|
||||
<MessageAvatar>
|
||||
<span style={avatar}>
|
||||
<SparkleIcon />
|
||||
</span>
|
||||
</MessageAvatar>
|
||||
<MessageContent>
|
||||
<MessageHeader>Reactive AI</MessageHeader>
|
||||
<Bubble variant="tinted" align="start">
|
||||
<BubbleContent>I found 4 weak bullets and rewrote them with stronger verbs and metrics.</BubbleContent>
|
||||
</Bubble>
|
||||
<MessageFooter>Just now · applied 4 edits</MessageFooter>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</div>
|
||||
);
|
||||
@@ -1,57 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { SparkleIcon, UserIcon } from "@phosphor-icons/react";
|
||||
import { Bubble, BubbleContent } from "@reactive-resume/ui/components/bubble";
|
||||
import { Message, MessageAvatar, MessageContent } from "@reactive-resume/ui/components/message";
|
||||
import {
|
||||
MessageScroller,
|
||||
MessageScrollerButton,
|
||||
MessageScrollerContent,
|
||||
MessageScrollerItem,
|
||||
MessageScrollerProvider,
|
||||
MessageScrollerViewport,
|
||||
} from "@reactive-resume/ui/components/message-scroller";
|
||||
|
||||
const avatar: React.CSSProperties = { display: "flex", alignItems: "center", justifyContent: "center", padding: 8 };
|
||||
|
||||
const turns = [
|
||||
{ role: "user", text: "Can you review my resume for a senior engineering role?" },
|
||||
{ role: "assistant", text: "Sure — I'll focus on scope, impact, and leadership signals. Reading it now." },
|
||||
{ role: "user", text: "Great, keep the tone concise." },
|
||||
{
|
||||
role: "assistant",
|
||||
text: "I rewrote your summary and tightened three experience bullets with measurable outcomes.",
|
||||
},
|
||||
{ role: "user", text: "Perfect, publish the draft." },
|
||||
{ role: "assistant", text: "Draft saved and published to your public resume. Anything else you'd like to refine?" },
|
||||
];
|
||||
|
||||
export const Thread = () => (
|
||||
<div style={{ height: 340, width: 460, padding: 12 }}>
|
||||
<MessageScrollerProvider autoScroll defaultScrollPosition="end">
|
||||
<MessageScroller>
|
||||
<MessageScrollerViewport>
|
||||
<MessageScrollerContent style={{ padding: 12 }}>
|
||||
{turns.map((turn, index) => (
|
||||
<MessageScrollerItem key={turn.text} messageId={`turn-${index}`}>
|
||||
<Message align={turn.role === "user" ? "end" : "start"}>
|
||||
<MessageAvatar>
|
||||
<span style={avatar}>{turn.role === "user" ? <UserIcon /> : <SparkleIcon />}</span>
|
||||
</MessageAvatar>
|
||||
<MessageContent>
|
||||
<Bubble
|
||||
variant={turn.role === "user" ? "default" : "muted"}
|
||||
align={turn.role === "user" ? "end" : "start"}
|
||||
>
|
||||
<BubbleContent>{turn.text}</BubbleContent>
|
||||
</Bubble>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</MessageScrollerItem>
|
||||
))}
|
||||
</MessageScrollerContent>
|
||||
</MessageScrollerViewport>
|
||||
<MessageScrollerButton />
|
||||
</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
</div>
|
||||
);
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
} from "@reactive-resume/ui/components/popover";
|
||||
|
||||
// Anchored overlay — rendered open (defaultOpen), positioned below its trigger.
|
||||
// cfg.overrides.Popover pins cardMode: single + viewport with room for the popup.
|
||||
export const Open = () => (
|
||||
<div style={{ display: "flex", justifyContent: "center", paddingTop: 24, paddingBottom: 220 }}>
|
||||
<Popover defaultOpen>
|
||||
<PopoverTrigger render={<Button variant="outline" />}>Share resume</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverHeader>
|
||||
<PopoverTitle>Public link</PopoverTitle>
|
||||
<PopoverDescription>Anyone with this link can view your published resume.</PopoverDescription>
|
||||
</PopoverHeader>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button size="sm" variant="secondary">
|
||||
Copy link
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost">
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
@@ -1,57 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
|
||||
|
||||
const panelStyle: React.CSSProperties = { height: "100%", padding: 16, fontSize: 14, lineHeight: 1.6 };
|
||||
const label: React.CSSProperties = {
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--muted-foreground)",
|
||||
marginBottom: 8,
|
||||
};
|
||||
|
||||
export const BuilderLayout = () => (
|
||||
<div style={{ height: 240, width: 460, border: "1px solid var(--border)", borderRadius: 8, overflow: "hidden" }}>
|
||||
<ResizableGroup orientation="horizontal">
|
||||
<ResizablePanel defaultSize={40}>
|
||||
<div style={panelStyle}>
|
||||
<div style={label}>Editor</div>
|
||||
<div>Basics</div>
|
||||
<div>Work Experience</div>
|
||||
<div>Education</div>
|
||||
<div>Skills</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle />
|
||||
<ResizablePanel defaultSize={60}>
|
||||
<div style={{ ...panelStyle, background: "var(--muted)" }}>
|
||||
<div style={label}>Live Preview</div>
|
||||
<div style={{ fontWeight: 600, fontSize: 16 }}>Jordan Rivera</div>
|
||||
<div style={{ color: "var(--muted-foreground)" }}>Senior Product Designer</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizableGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const VerticalSplit = () => (
|
||||
<div style={{ height: 240, width: 300, border: "1px solid var(--border)", borderRadius: 8, overflow: "hidden" }}>
|
||||
<ResizableGroup orientation="vertical">
|
||||
<ResizablePanel defaultSize={50}>
|
||||
<div style={panelStyle}>
|
||||
<div style={label}>Summary</div>
|
||||
<div>Eight years building design systems and shipping delightful product experiences.</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle />
|
||||
<ResizablePanel defaultSize={50}>
|
||||
<div style={{ ...panelStyle, background: "var(--muted)" }}>
|
||||
<div style={label}>Contact</div>
|
||||
<div>jordan.rivera@email.com</div>
|
||||
<div>San Francisco, CA</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizableGroup>
|
||||
</div>
|
||||
);
|
||||
@@ -1,39 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
|
||||
|
||||
const templates = [
|
||||
{ name: "Azurill", tag: "Minimal" },
|
||||
{ name: "Bronzor", tag: "Classic" },
|
||||
{ name: "Chikorita", tag: "Modern" },
|
||||
{ name: "Ditto", tag: "Compact" },
|
||||
{ name: "Gengar", tag: "Bold" },
|
||||
{ name: "Glalie", tag: "Elegant" },
|
||||
{ name: "Kakuna", tag: "Timeless" },
|
||||
{ name: "Leafish", tag: "Creative" },
|
||||
{ name: "Nosepass", tag: "Formal" },
|
||||
{ name: "Onyx", tag: "Technical" },
|
||||
{ name: "Pikachu", tag: "Friendly" },
|
||||
{ name: "Rhyhorn", tag: "Corporate" },
|
||||
];
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 14px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
export const TemplateList = () => (
|
||||
<ScrollArea style={{ height: 240, width: 320, border: "1px solid var(--border)", borderRadius: 8 }}>
|
||||
<div style={{ padding: 4 }}>
|
||||
{templates.map((template) => (
|
||||
<div key={template.name} style={rowStyle}>
|
||||
<span style={{ fontWeight: 500 }}>{template.name}</span>
|
||||
<span style={{ color: "var(--muted-foreground)", fontSize: 12 }}>{template.tag}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
@@ -1,45 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
|
||||
const block: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
padding: 20,
|
||||
maxWidth: 360,
|
||||
fontSize: 13,
|
||||
color: "var(--foreground)",
|
||||
};
|
||||
const inline: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: 20,
|
||||
fontSize: 13,
|
||||
color: "var(--muted-foreground)",
|
||||
};
|
||||
|
||||
export const Horizontal = () => (
|
||||
<div style={block}>
|
||||
<div>
|
||||
<strong style={{ display: "block", fontSize: 14 }}>Amruth Pillai</strong>
|
||||
<span style={{ color: "var(--muted-foreground)" }}>Senior Software Engineer</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<span style={{ color: "var(--muted-foreground)" }}>
|
||||
Building resume tooling at Reactive Resume. Open-source enthusiast.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Vertical = () => (
|
||||
<div style={inline}>
|
||||
<span>Profile</span>
|
||||
<Separator orientation="vertical" style={{ height: 16 }} />
|
||||
<span>Experience</span>
|
||||
<Separator orientation="vertical" style={{ height: 16 }} />
|
||||
<span>Education</span>
|
||||
<Separator orientation="vertical" style={{ height: 16 }} />
|
||||
<span>Skills</span>
|
||||
</div>
|
||||
);
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@reactive-resume/ui/components/sheet";
|
||||
|
||||
// Side drawer — rendered open (defaultOpen), anchored to the right edge.
|
||||
// cfg.overrides.Sheet pins cardMode: single + viewport.
|
||||
export const Open = () => (
|
||||
<Sheet defaultOpen>
|
||||
<SheetContent side="right">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Resume settings</SheetTitle>
|
||||
<SheetDescription>Control how “Software Engineer” appears when shared publicly.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14, padding: "0 16px" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<Label>Public slug</Label>
|
||||
<span style={{ fontSize: 13, color: "var(--muted-foreground)" }}>rxresume.me/jane-doe</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<Label>Visibility</Label>
|
||||
<span style={{ fontSize: 13, color: "var(--muted-foreground)" }}>Anyone with the link can view</span>
|
||||
</div>
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<SheetClose render={<Button variant="outline" />}>Cancel</SheetClose>
|
||||
<SheetClose render={<Button />}>Save changes</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
@@ -1,61 +0,0 @@
|
||||
import { FileTextIcon, GearIcon, HouseIcon, PlusIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
} from "@reactive-resume/ui/components/sidebar";
|
||||
|
||||
// SidebarProvider supplies context + --sidebar-width. `collapsible="none"`
|
||||
// renders the sidebar inline (the default offcanvas variant is fixed-positioned
|
||||
// and would escape the card).
|
||||
export const Navigation = () => (
|
||||
<SidebarProvider>
|
||||
<div
|
||||
style={{ height: 400, display: "flex", border: "1px solid var(--border)", borderRadius: 10, overflow: "hidden" }}
|
||||
>
|
||||
<Sidebar collapsible="none">
|
||||
<SidebarHeader>
|
||||
<div style={{ padding: 8, fontWeight: 600, fontSize: 14 }}>Reactive Resume</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Workspace</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton isActive>
|
||||
<HouseIcon /> Dashboard
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>
|
||||
<FileTextIcon /> Resumes
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>
|
||||
<PlusIcon /> New resume
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>
|
||||
<GearIcon /> Settings
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
@@ -1,41 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { Skeleton } from "@reactive-resume/ui/components/skeleton";
|
||||
|
||||
const pad: React.CSSProperties = { padding: 20 };
|
||||
|
||||
export const TextLines = () => (
|
||||
<div style={{ ...pad, display: "flex", flexDirection: "column", gap: 10, width: 320 }}>
|
||||
<Skeleton style={{ height: 12, width: "70%" }} />
|
||||
<Skeleton style={{ height: 12, width: "100%" }} />
|
||||
<Skeleton style={{ height: 12, width: "90%" }} />
|
||||
<Skeleton style={{ height: 12, width: "40%" }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ProfileHeader = () => (
|
||||
<div style={{ ...pad, display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<Skeleton style={{ height: 48, width: 48, borderRadius: "9999px" }} />
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<Skeleton style={{ height: 14, width: 160 }} />
|
||||
<Skeleton style={{ height: 12, width: 100 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ResumeCard = () => (
|
||||
<div
|
||||
style={{
|
||||
...pad,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
width: 220,
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 12,
|
||||
}}
|
||||
>
|
||||
<Skeleton style={{ height: 140, width: "100%" }} />
|
||||
<Skeleton style={{ height: 14, width: "60%" }} />
|
||||
<Skeleton style={{ height: 12, width: "40%" }} />
|
||||
</div>
|
||||
);
|
||||
@@ -1,33 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Slider } from "@reactive-resume/ui/components/slider";
|
||||
|
||||
const field: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 10, padding: 16, width: 320 };
|
||||
|
||||
export const Single = () => (
|
||||
<div style={field}>
|
||||
<Label>Skill level</Label>
|
||||
<Slider defaultValue={[4]} min={0} max={5} step={1} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Range = () => (
|
||||
<div style={field}>
|
||||
<Label>Experience (years)</Label>
|
||||
<Slider defaultValue={[2, 8]} min={0} max={15} step={1} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const FontScale = () => (
|
||||
<div style={field}>
|
||||
<Label>Font size</Label>
|
||||
<Slider defaultValue={[62]} min={0} max={100} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Disabled = () => (
|
||||
<div style={field}>
|
||||
<Label style={{ opacity: 0.5 }}>Line height (locked)</Label>
|
||||
<Slider defaultValue={[50]} min={0} max={100} disabled />
|
||||
</div>
|
||||
);
|
||||
@@ -1,35 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
|
||||
const row: React.CSSProperties = { display: "flex", alignItems: "center", gap: 20, padding: 24 };
|
||||
|
||||
export const Sizes = () => (
|
||||
<div style={row}>
|
||||
<Spinner style={{ width: 16, height: 16 }} />
|
||||
<Spinner style={{ width: 24, height: 24 }} />
|
||||
<Spinner style={{ width: 32, height: 32 }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Colors = () => (
|
||||
<div style={row}>
|
||||
<Spinner style={{ width: 28, height: 28, color: "var(--primary)" }} />
|
||||
<Spinner style={{ width: 28, height: 28, color: "var(--muted-foreground)" }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const LoadingRow = () => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: 20,
|
||||
fontSize: 13,
|
||||
color: "var(--muted-foreground)",
|
||||
}}
|
||||
>
|
||||
<Spinner style={{ width: 18, height: 18 }} />
|
||||
<span>Generating your PDF…</span>
|
||||
</div>
|
||||
);
|
||||
@@ -1,40 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
|
||||
const row: React.CSSProperties = { display: "flex", alignItems: "center", gap: 10, padding: 16, width: 300 };
|
||||
const stack: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 16, padding: 16, width: 300 };
|
||||
|
||||
export const On = () => (
|
||||
<div style={row}>
|
||||
<Switch id="sw-public" defaultChecked />
|
||||
<Label htmlFor="sw-public">Public resume</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Off = () => (
|
||||
<div style={row}>
|
||||
<Switch id="sw-template" />
|
||||
<Label htmlFor="sw-template">Show icons in template</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Small = () => (
|
||||
<div style={row}>
|
||||
<Switch id="sw-page-numbers" size="sm" defaultChecked />
|
||||
<Label htmlFor="sw-page-numbers">Show page numbers</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Disabled = () => (
|
||||
<div style={stack}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<Switch id="sw-ai" disabled defaultChecked />
|
||||
<Label htmlFor="sw-ai">AI suggestions</Label>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<Switch id="sw-index" disabled />
|
||||
<Label htmlFor="sw-index">Index on search engines</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,57 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { BriefcaseIcon, GraduationCapIcon, SparkleIcon } from "@phosphor-icons/react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
|
||||
|
||||
const wrap: React.CSSProperties = { width: 460, padding: 16 };
|
||||
const panel: React.CSSProperties = { padding: "12px 4px", lineHeight: 1.6 };
|
||||
|
||||
export const ResumeSections = () => (
|
||||
<div style={wrap}>
|
||||
<Tabs defaultValue="experience">
|
||||
<TabsList>
|
||||
<TabsTrigger value="experience">
|
||||
<BriefcaseIcon /> Experience
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="education">
|
||||
<GraduationCapIcon /> Education
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="skills">
|
||||
<SparkleIcon /> Skills
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="experience" style={panel}>
|
||||
<strong>Staff Engineer · Vercel</strong>
|
||||
<div>Owned the edge runtime rollout serving 2B requests per day.</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="education" style={panel}>
|
||||
<strong>M.S. Computer Science · Carnegie Mellon</strong>
|
||||
<div>Focus on distributed systems and human-computer interaction.</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="skills" style={panel}>
|
||||
<strong>Core stack</strong>
|
||||
<div>TypeScript, React, Go, PostgreSQL, and Kubernetes.</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const LineVariant = () => (
|
||||
<div style={wrap}>
|
||||
<Tabs defaultValue="preview">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="preview">Preview</TabsTrigger>
|
||||
<TabsTrigger value="share">Share</TabsTrigger>
|
||||
<TabsTrigger value="export">Export</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="preview" style={panel}>
|
||||
Your resume renders live as you edit each section.
|
||||
</TabsContent>
|
||||
<TabsContent value="share" style={panel}>
|
||||
Publish a public link at reactive-resume.app/u/your-name.
|
||||
</TabsContent>
|
||||
<TabsContent value="export" style={panel}>
|
||||
Download a print-ready PDF or DOCX in one click.
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
@@ -1,37 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Textarea } from "@reactive-resume/ui/components/textarea";
|
||||
|
||||
const field: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 6, padding: 16, width: 360 };
|
||||
|
||||
export const Default = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="summary">Summary</Label>
|
||||
<Textarea id="summary" placeholder="Write a short professional summary…" rows={4} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Filled = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="about">About</Label>
|
||||
<Textarea
|
||||
id="about"
|
||||
rows={4}
|
||||
defaultValue="Mathematician and writer, known for early work on Charles Babbage's Analytical Engine and the first published algorithm intended for a machine."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Invalid = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="bio">Biography</Label>
|
||||
<Textarea id="bio" aria-invalid rows={3} defaultValue="Too short." />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Disabled = () => (
|
||||
<div style={field}>
|
||||
<Label htmlFor="notes">Internal notes</Label>
|
||||
<Textarea id="notes" disabled rows={3} defaultValue="Notes are locked while this resume is published." />
|
||||
</div>
|
||||
);
|
||||
@@ -1,19 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Toaster } from "@reactive-resume/ui/components/sonner";
|
||||
|
||||
// Toaster is the toast host. Fire a persistent toast on mount so the card
|
||||
// shows a real notification instead of an empty portal.
|
||||
export const Notification = () => {
|
||||
useEffect(() => {
|
||||
toast.success("Resume published", {
|
||||
description: "“Software Engineer” is now live at rxresume.me/jane-doe.",
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
}, []);
|
||||
return (
|
||||
<div style={{ minHeight: 140 }}>
|
||||
<Toaster position="top-center" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import {
|
||||
TextAlignCenterIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignRightIcon,
|
||||
TextBolderIcon,
|
||||
TextItalicIcon,
|
||||
TextUnderlineIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Toggle } from "@reactive-resume/ui/components/toggle";
|
||||
|
||||
const row: React.CSSProperties = { display: "flex", alignItems: "center", gap: 8, padding: 16 };
|
||||
const group: React.CSSProperties = { display: "flex", alignItems: "center", gap: 2, padding: 16 };
|
||||
|
||||
export const States = () => (
|
||||
<div style={row}>
|
||||
<Toggle aria-label="Bold" defaultPressed>
|
||||
<TextBolderIcon />
|
||||
</Toggle>
|
||||
<Toggle aria-label="Italic">
|
||||
<TextItalicIcon />
|
||||
</Toggle>
|
||||
<Toggle aria-label="Underline" disabled>
|
||||
<TextUnderlineIcon />
|
||||
</Toggle>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Outline = () => (
|
||||
<div style={row}>
|
||||
<Toggle variant="outline" defaultPressed>
|
||||
<TextBolderIcon /> Bold
|
||||
</Toggle>
|
||||
<Toggle variant="outline">
|
||||
<TextItalicIcon /> Italic
|
||||
</Toggle>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Sizes = () => (
|
||||
<div style={row}>
|
||||
<Toggle size="sm" aria-label="Bold small">
|
||||
<TextBolderIcon />
|
||||
</Toggle>
|
||||
<Toggle size="default" aria-label="Bold default" defaultPressed>
|
||||
<TextBolderIcon />
|
||||
</Toggle>
|
||||
<Toggle size="lg" aria-label="Bold large">
|
||||
<TextBolderIcon />
|
||||
</Toggle>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const AlignmentGroup = () => (
|
||||
<div style={group}>
|
||||
<Toggle variant="outline" aria-label="Align left" defaultPressed>
|
||||
<TextAlignLeftIcon />
|
||||
</Toggle>
|
||||
<Toggle variant="outline" aria-label="Align center">
|
||||
<TextAlignCenterIcon />
|
||||
</Toggle>
|
||||
<Toggle variant="outline" aria-label="Align right">
|
||||
<TextAlignRightIcon />
|
||||
</Toggle>
|
||||
</div>
|
||||
);
|
||||
@@ -1,18 +0,0 @@
|
||||
import { InfoIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
|
||||
|
||||
// Anchored overlay — needs TooltipProvider; rendered open (defaultOpen).
|
||||
// cfg.overrides.Tooltip pins cardMode: single + viewport with room above the trigger.
|
||||
export const Open = () => (
|
||||
<TooltipProvider>
|
||||
<div style={{ display: "flex", justifyContent: "center", paddingTop: 120, paddingBottom: 24 }}>
|
||||
<Tooltip defaultOpen>
|
||||
<TooltipTrigger render={<Button variant="outline" size="icon" aria-label="About visibility" />}>
|
||||
<InfoIcon />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Only you can see private resumes</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
@@ -1,4 +0,0 @@
|
||||
/* design-sync Tailwind entry — compiles the UI package's globals.css to static CSS
|
||||
for preview rendering, and also scans authored previews for used utilities. */
|
||||
@import "../packages/ui/src/styles/globals.css";
|
||||
@source "./previews/*.tsx";
|
||||
@@ -1,68 +1,124 @@
|
||||
name: 🐞 Bug Report
|
||||
|
||||
description: Create a bug report to help improve Reactive Resume
|
||||
description: Report a reproducible problem with Reactive Resume
|
||||
|
||||
title: "[Bug] <title>"
|
||||
labels: [bug, v5, needs triage]
|
||||
assignees: "AmruthPillai"
|
||||
labels: ["bug", "status: needs triage"]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is there an existing issue for this?
|
||||
description: Please search to see if an issue already exists for the bug you encountered.
|
||||
label: Existing issue
|
||||
description: Search open and closed issues before submitting a new report.
|
||||
options:
|
||||
- label: Yes, I have searched the existing issues and none of them match my problem.
|
||||
- label: I searched the existing issues and could not find a matching report.
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: variant
|
||||
attributes:
|
||||
label: Product Variant
|
||||
description: What variant of Reactive Resume are you using?
|
||||
label: Product variant
|
||||
description: Where does the problem occur?
|
||||
options:
|
||||
- Cloud (https://rxresu.me)
|
||||
- Self-Hosted
|
||||
- Cloud
|
||||
- Self-hosted
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Reactive Resume version
|
||||
description: Find this in Settings or provide the container image tag or commit SHA.
|
||||
placeholder: 5.2.6
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Area
|
||||
description: Choose the part of Reactive Resume most closely related to the problem.
|
||||
options:
|
||||
- Resume builder & data
|
||||
- Templates, preview & export
|
||||
- Accounts & sharing
|
||||
- AI & Agent
|
||||
- Language & localization
|
||||
- Self-hosting
|
||||
- API & integrations
|
||||
- Applications & cover letters
|
||||
- Other / unsure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Include your operating system and browser. For self-hosted installations, also include the deployment method.
|
||||
placeholder: Firefox 143 on Ubuntu 26.04, deployed with Docker Compose
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Describe the bug you're experiencing
|
||||
description: A detailed description of what you're experiencing. Please provide as much detail as possible as it will help me diagnose and fix the issue faster.
|
||||
label: Summary
|
||||
description: Briefly describe the problem and its impact.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Provide the smallest reliable sequence that demonstrates the problem.
|
||||
placeholder: |
|
||||
1. Open ...
|
||||
2. Select ...
|
||||
3. Observe ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: template
|
||||
attributes:
|
||||
label: What template are you using?
|
||||
description: Leave blank if the issue applies to all templates, or is not template-specific.
|
||||
multiple: false
|
||||
label: Template
|
||||
description: Leave blank when the problem is not template-specific.
|
||||
options:
|
||||
- Azurill
|
||||
- Bronzor
|
||||
- Chikorita
|
||||
- Ditto
|
||||
- Ditgar
|
||||
- Ditto
|
||||
- Gengar
|
||||
- Glalie
|
||||
- Kakuna
|
||||
- Lapras
|
||||
- Leafish
|
||||
- Meowth
|
||||
- Onyx
|
||||
- Pikachu
|
||||
- Rhyhorn
|
||||
- Scizor
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Anything else?
|
||||
description: |
|
||||
Links? References? Anything that will give us more context about the issue you are encountering!
|
||||
|
||||
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
|
||||
validations:
|
||||
required: false
|
||||
label: Logs and screenshots
|
||||
description: Add relevant logs, screenshots, or a minimal reproduction. Remove secrets and personal resume data first.
|
||||
|
||||
@@ -1,23 +1,82 @@
|
||||
name: ✨ Feature Request
|
||||
|
||||
description: Suggest an feature or idea that you would like to see in Reactive Resume
|
||||
description: Propose an actionable improvement to Reactive Resume
|
||||
|
||||
title: "[Feature] <title>"
|
||||
labels: [enhancement, v5, needs triage]
|
||||
assignees: "AmruthPillai"
|
||||
labels: ["enhancement", "status: needs triage"]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is there an existing issue for this feature?
|
||||
description: Please search to see if an issue already exists for the feature you requested.
|
||||
label: Existing issue
|
||||
description: Search open and closed issues before submitting a new proposal.
|
||||
options:
|
||||
- label: Yes, I have searched the existing issues and it doesn't exist.
|
||||
- label: I searched the existing issues and could not find a matching proposal.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
- type: dropdown
|
||||
id: variant
|
||||
attributes:
|
||||
label: Feature Description
|
||||
description: A detailed description of the feature you would like to see in Reactive Resume. Please provide as much detail as possible as it will help me implement the feature faster.
|
||||
label: Product variant
|
||||
description: Choose the primary environment for this proposal.
|
||||
options:
|
||||
- Cloud
|
||||
- Self-hosted
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Area
|
||||
description: Choose the part of Reactive Resume most closely related to the proposal.
|
||||
options:
|
||||
- Resume builder & data
|
||||
- Templates, preview & export
|
||||
- Accounts & sharing
|
||||
- AI & Agent
|
||||
- Language & localization
|
||||
- Self-hosting
|
||||
- API & integrations
|
||||
- Applications & cover letters
|
||||
- Other / unsure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
description: What user problem or limitation should Reactive Resume solve?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: outcome
|
||||
attributes:
|
||||
label: Desired outcome
|
||||
description: Describe the behavior you want without prescribing an implementation.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Describe current workarounds or alternatives. Write "None" if there are none.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: scope
|
||||
attributes:
|
||||
label: Proposed scope
|
||||
description: Explain what should be included and what can remain out of scope.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add examples, mockups, or related issues when useful. Remove personal resume data first.
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions and support
|
||||
url: https://github.com/amruthpillai/reactive-resume/discussions/categories/q-a
|
||||
about: Get help with setup, configuration, and using Reactive Resume.
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/amruthpillai/reactive-resume/security/advisories/new
|
||||
about: Report security vulnerabilities privately.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- caveman-begin -->
|
||||
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
||||
|
||||
Rules:
|
||||
- Drop: articles (a/an/the), filler (just/really/basically), pleasantries, hedging
|
||||
- Fragments OK. Short synonyms. Technical terms exact. Code unchanged.
|
||||
- Pattern: [thing] [action] [reason]. [next step].
|
||||
- Not: "Sure! I'd be happy to help you with that."
|
||||
- Yes: "Bug in auth middleware. Fix:"
|
||||
|
||||
Switch level: /caveman lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra
|
||||
Stop: "stop caveman" or "normal mode"
|
||||
|
||||
Auto-Clarity: drop caveman for security warnings, irreversible actions, user confused. Resume after.
|
||||
|
||||
Boundaries: code/commits/PRs written normal.
|
||||
<!-- caveman-end -->
|
||||
@@ -56,6 +56,9 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run Server and Tooling Tests
|
||||
run: pnpm exec turbo run test:ci --filter=server --filter=@reactive-resume/tooling
|
||||
|
||||
- name: Install Playwright Browser
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
@@ -73,8 +76,8 @@ jobs:
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Run E2E Tests
|
||||
run: pnpm test:e2e:ci
|
||||
- name: Run Baseline E2E Tests
|
||||
run: pnpm exec playwright test --grep-invert "@semantic-css"
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: always()
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Label New Issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Apply Form Labels
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
const { getIssueLabels } = await import(`${process.env.GITHUB_WORKSPACE}/tooling/issue-labels.mjs`);
|
||||
const labels = getIssueLabels(context.payload.issue.body ?? "");
|
||||
|
||||
if (labels.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Close Issues Awaiting Information
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "23 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Close Inactive Issues Awaiting Information
|
||||
uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11
|
||||
with:
|
||||
only-issue-labels: "status: needs info"
|
||||
days-before-issue-stale: 14
|
||||
days-before-issue-close: 7
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
stale-issue-label: stale
|
||||
stale-issue-message: >-
|
||||
This issue is waiting for information requested by a maintainer. It will close in 7 days if no new information is provided.
|
||||
close-issue-message: >-
|
||||
Closing because the requested information was not provided. Add the missing details in a comment and a maintainer can reopen the issue.
|
||||
close-issue-reason: not_planned
|
||||
remove-issue-stale-when-updated: true
|
||||
+3
-10
@@ -51,6 +51,7 @@ temp
|
||||
.cursor
|
||||
.codegraph
|
||||
.superpowers
|
||||
.worktrees
|
||||
.migration
|
||||
|
||||
# Local Storage Data
|
||||
@@ -58,13 +59,5 @@ temp
|
||||
/apps/web/data
|
||||
|
||||
# Git Hooks
|
||||
.vite-hooks/
|
||||
.ds-sync/
|
||||
ds-bundle/
|
||||
.design-sync/.cache/
|
||||
.design-sync/learnings/
|
||||
.design-sync/node_modules
|
||||
packages/ui/.ds-compiled.css
|
||||
packages/ui/.ds-tw-raw.css
|
||||
packages/ui/dist/
|
||||
i18n.cache
|
||||
.vite-hooks
|
||||
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
<!-- intent-skills:start -->
|
||||
## Skill Loading
|
||||
|
||||
Before editing files for a substantial task:
|
||||
- Run `pnpm dlx @tanstack/intent@latest list` from the workspace root to see available local skills.
|
||||
- If a listed skill matches the task, run `pnpm dlx @tanstack/intent@latest load <package>#<skill>` before changing files.
|
||||
- Use the loaded `SKILL.md` guidance while making the change.
|
||||
- Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed.
|
||||
- Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns.
|
||||
<!-- intent-skills:end -->
|
||||
|
||||
# AGENTS.md
|
||||
|
||||
## Agent skills
|
||||
|
||||
### Issue tracker
|
||||
|
||||
Issues and specs are tracked in GitHub Issues for `amruthpillai/reactive-resume`. See `docs/agents/issue-tracker.md`.
|
||||
|
||||
### Domain docs
|
||||
|
||||
This repository uses a multi-context domain-doc layout. See `docs/agents/domain.md`.
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
### Overview
|
||||
@@ -12,7 +33,7 @@ Internal packages are source-consumed through `package.json` export maps that po
|
||||
|
||||
- **Node.js 24** (matches Dockerfile `ARG NODE_VERSION=24`). Use `nvm install 24 && nvm use 24` if needed.
|
||||
- **Docker** is required to run PostgreSQL. Start it with `sudo dockerd &` if the daemon isn't running.
|
||||
- **pnpm 11.1.2** is managed via corepack (`corepack enable`).
|
||||
- **pnpm 11.21.0**. Install pnpm directly using the [official installation guide](https://pnpm.io/installation).
|
||||
|
||||
### Codebase map
|
||||
|
||||
@@ -107,7 +128,7 @@ The production server runs migrations during startup before serving traffic. Man
|
||||
|
||||
### Environment
|
||||
|
||||
Copy `.env.example` to `.env`. The three required variables are:
|
||||
Copy `.env.example` to `.env.local`. The three required variables are:
|
||||
|
||||
- `APP_URL` (default `http://localhost:3000`)
|
||||
- `DATABASE_URL` (default `postgresql://postgres:postgres@localhost:5432/postgres`)
|
||||
@@ -115,6 +136,8 @@ Copy `.env.example` to `.env`. The three required variables are:
|
||||
|
||||
S3/SeaweedFS is optional. If `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, and `S3_BUCKET` are all set, the app uses S3-compatible storage. The checked-in `.env.example` sets SeaweedFS defaults, so either start the `seaweedfs` compose service too or comment out those S3 vars to use local filesystem storage under `<workspace>/data`. `LOCAL_STORAGE_PATH` must be absolute when set.
|
||||
|
||||
`REDIS_URL` and `ENCRYPTION_SECRET` are optional for core resume flows, but both are required for saved AI providers and the authenticated `/agent` workspace. Start the `redis` compose service and set both vars in `.env.local` when working on those features. For host-run development, use `REDIS_URL=redis://localhost:6379`; the container-run app uses `REDIS_URL=redis://redis:6379`.
|
||||
|
||||
When running dev servers or migration commands, prefix the command with `dotenvx run -f .env.local --`. For example: `dotenvx run -f .env.local -- pnpm dev`. Tests, typechecks, linters, boundary checks, and `pnpm build` do not need this prefix by default. If one of those commands fails because a specific environment variable is required, rerun it with the `dotenvx run -f .env.local --` prefix.
|
||||
|
||||
### Common commands
|
||||
@@ -124,6 +147,7 @@ When running dev servers or migration commands, prefix the command with `dotenvx
|
||||
| Install deps | `pnpm install` |
|
||||
| Start Postgres only | `sudo docker compose -f compose.dev.yml up -d postgres` |
|
||||
| Start Postgres + SeaweedFS | `sudo docker compose -f compose.dev.yml up -d postgres seaweedfs seaweedfs_create_bucket` |
|
||||
| Start full dev infrastructure | `sudo docker compose -f compose.dev.yml up -d postgres redis seaweedfs seaweedfs_create_bucket` |
|
||||
| Generate migrations | `dotenvx run -f .env.local -- pnpm db:generate` |
|
||||
| Run migrations | `dotenvx run -f .env.local -- pnpm db:migrate` |
|
||||
| Dev server | `dotenvx run -f .env.local -- pnpm dev` (starts on port 3000) |
|
||||
@@ -155,3 +179,21 @@ Vitest test paths are package-relative when running through `pnpm --filter <pack
|
||||
- Most packages use `tsgo --noEmit` for typechecking and `vitest run --passWithNoTests` for tests.
|
||||
- There may be unrelated local edits in the worktree. Inspect `git status --short` first and avoid reverting files you did not touch.
|
||||
- **New env vars require a `turbo.json` entry.** Turborepo 2.x runs in strict env mode by default — it filters out env vars that are not listed in `globalEnv` (or task-level `env`/`passThroughEnv`). Any new environment variable added to `packages/env/src/server.ts` must also be added to the `globalEnv` array in `turbo.json`, or the variable will be `undefined` inside child processes at runtime even if it is correctly set in the OS/container environment.
|
||||
|
||||
<!-- caveman-begin -->
|
||||
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
||||
|
||||
Rules:
|
||||
- Drop: articles (a/an/the), filler (just/really/basically), pleasantries, hedging
|
||||
- Fragments OK. Short synonyms. Technical terms exact. Code unchanged.
|
||||
- Pattern: [thing] [action] [reason]. [next step].
|
||||
- Not: "Sure! I'd be happy to help you with that."
|
||||
- Yes: "Bug in auth middleware. Fix:"
|
||||
|
||||
Switch level: /caveman lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra
|
||||
Stop: "stop caveman" or "normal mode"
|
||||
|
||||
Auto-Clarity: drop caveman for security warnings, irreversible actions, user confused. Resume after.
|
||||
|
||||
Boundaries: code/commits/PRs written normal.
|
||||
<!-- caveman-end -->
|
||||
|
||||
+7
-7
@@ -1,17 +1,17 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG PNPM_VERSION=11.21.0
|
||||
ARG NODE_VERSION=24
|
||||
|
||||
FROM node:${NODE_VERSION}-slim AS base
|
||||
FROM ghcr.io/pnpm/pnpm:${PNPM_VERSION} AS base
|
||||
|
||||
ARG NODE_VERSION
|
||||
|
||||
RUN pnpm runtime set node ${NODE_VERSION} -g --config.store-dir=/pnpm/runtime-store
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
PNPM_HOME="/pnpm" \
|
||||
PATH="/pnpm:$PATH" \
|
||||
TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN corepack enable
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
FROM base AS pruner
|
||||
COPY . .
|
||||
|
||||
+7
-7
@@ -1,19 +1,19 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG PNPM_VERSION=11.21.0
|
||||
ARG NODE_VERSION=24
|
||||
|
||||
FROM node:${NODE_VERSION}-slim AS dev
|
||||
FROM ghcr.io/pnpm/pnpm:${PNPM_VERSION} AS dev
|
||||
|
||||
ARG NODE_VERSION
|
||||
|
||||
RUN pnpm runtime set node ${NODE_VERSION} -g --config.store-dir=/pnpm/runtime-store
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
PNPM_HOME="/pnpm" \
|
||||
PATH="/pnpm:$PATH" \
|
||||
NODE_ENV=development \
|
||||
ENV NODE_ENV=development \
|
||||
TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||
COPY patches ./patches
|
||||
COPY apps/server/package.json ./apps/server/package.json
|
||||
|
||||
@@ -234,7 +234,8 @@ Reactive Resume is and always will be free and open-source. If it has helped you
|
||||
Other ways to support:
|
||||
|
||||
- Star this repository
|
||||
- Report bugs and suggest features
|
||||
- Report reproducible bugs and suggest actionable features
|
||||
- Help other users in [GitHub Discussions](https://github.com/amruthpillai/reactive-resume/discussions/categories/q-a)
|
||||
- Improve documentation
|
||||
- Help with translations
|
||||
|
||||
@@ -242,9 +243,9 @@ Other ways to support:
|
||||
|
||||
<a href="https://www.star-history.com/?repos=amruthpillai%2Freactive-resume&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&theme=dark&legend=top-left&sealed_token=BF8sVMes0z5BhdkMhtFklhxeikeGUrSyW-CcY9E_RCQI5zqUHEbMRwcB075fUewbAtlNoCnDlWhDWjrDGhTcXMojsS2I0RCqcL-Y9p3Ez3H1A2QpRMthjFilP0YOCJEE9AZqRrqzlvj1uU2y5ixarXOuUXuuSw5DkLMViSMD8Ldl0H3BEgclnjWw4fI4" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&legend=top-left&sealed_token=BF8sVMes0z5BhdkMhtFklhxeikeGUrSyW-CcY9E_RCQI5zqUHEbMRwcB075fUewbAtlNoCnDlWhDWjrDGhTcXMojsS2I0RCqcL-Y9p3Ez3H1A2QpRMthjFilP0YOCJEE9AZqRrqzlvj1uU2y5ixarXOuUXuuSw5DkLMViSMD8Ldl0H3BEgclnjWw4fI4" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&legend=top-left&sealed_token=BF8sVMes0z5BhdkMhtFklhxeikeGUrSyW-CcY9E_RCQI5zqUHEbMRwcB075fUewbAtlNoCnDlWhDWjrDGhTcXMojsS2I0RCqcL-Y9p3Ez3H1A2QpRMthjFilP0YOCJEE9AZqRrqzlvj1uU2y5ixarXOuUXuuSw5DkLMViSMD8Ldl0H3BEgclnjWw4fI4" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&theme=dark&legend=top-left&sealed_token=QmaOn4Ech499R6kpQe8ONn911UjGUaJfQBT0MXlQLU9hTo-Ie7lTxIILWbBvmtzDGHk7ziWKN_N5iM5mgP8widn_FGHd9-PHNokPtSji8XLgbFpqatgyqIDPnOys-IhO40W3J0HeH07FL-Q8Bq6ArRk3LDtJDwjh4m0ya-2L59ULb7BaqxkSDuCytkCr" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&legend=top-left&sealed_token=QmaOn4Ech499R6kpQe8ONn911UjGUaJfQBT0MXlQLU9hTo-Ie7lTxIILWbBvmtzDGHk7ziWKN_N5iM5mgP8widn_FGHd9-PHNokPtSji8XLgbFpqatgyqIDPnOys-IhO40W3J0HeH07FL-Q8Bq6ArRk3LDtJDwjh4m0ya-2L59ULb7BaqxkSDuCytkCr" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&legend=top-left&sealed_token=QmaOn4Ech499R6kpQe8ONn911UjGUaJfQBT0MXlQLU9hTo-Ie7lTxIILWbBvmtzDGHk7ziWKN_N5iM5mgP8widn_FGHd9-PHNokPtSji8XLgbFpqatgyqIDPnOys-IhO40W3J0HeH07FL-Q8Bq6ArRk3LDtJDwjh4m0ya-2L59ULb7BaqxkSDuCytkCr" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
@@ -260,6 +261,10 @@ Contributions make open-source thrive. Whether fixing a typo or adding a feature
|
||||
|
||||
See the [development setup guide](https://docs.rxresu.me/contributing/development) for detailed instructions on how to set up the project locally.
|
||||
|
||||
Maintainers review the [`status: needs triage` queue](https://github.com/amruthpillai/reactive-resume/issues?q=is%3Aissue+is%3Aopen+label%3A%22status%3A+needs+triage%22)
|
||||
weekly. Triaged bugs become `status: confirmed`; feature proposals become `status: accepted`; reports that need details become
|
||||
`status: needs info`.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](./LICENSE) — do whatever you want with it.
|
||||
|
||||
+46
-42
@@ -7,6 +7,7 @@
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsdown",
|
||||
"start": "node dist/index.mjs",
|
||||
"docs:gen": "tsx src/openapi/generate-spec.ts",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
@@ -17,34 +18,35 @@
|
||||
"#react-pdf-renderer": "@react-pdf/renderer"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^4.0.21",
|
||||
"@ai-sdk/cerebras": "^3.0.14",
|
||||
"@ai-sdk/cohere": "^4.0.12",
|
||||
"@ai-sdk/deepseek": "^3.0.13",
|
||||
"@ai-sdk/fireworks": "^3.0.15",
|
||||
"@ai-sdk/google": "^4.0.24",
|
||||
"@ai-sdk/groq": "^4.0.13",
|
||||
"@ai-sdk/mistral": "^4.0.14",
|
||||
"@ai-sdk/openai": "^4.0.20",
|
||||
"@ai-sdk/openai-compatible": "^3.0.14",
|
||||
"@ai-sdk/perplexity": "^4.0.13",
|
||||
"@ai-sdk/togetherai": "^3.0.15",
|
||||
"@ai-sdk/xai": "^4.0.18",
|
||||
"@aws-sdk/client-s3": "^3.1096.0",
|
||||
"@better-auth/api-key": "^1.6.25",
|
||||
"@better-auth/drizzle-adapter": "^1.6.25",
|
||||
"@better-auth/infra": "^0.3.7",
|
||||
"@better-auth/oauth-provider": "^1.6.25",
|
||||
"@better-auth/passkey": "^1.6.25",
|
||||
"@hono/node-server": "^2.0.12",
|
||||
"@ai-sdk/anthropic": "^4.0.41",
|
||||
"@ai-sdk/cerebras": "^3.0.35",
|
||||
"@ai-sdk/cohere": "^4.0.29",
|
||||
"@ai-sdk/deepseek": "^3.0.31",
|
||||
"@ai-sdk/fireworks": "^3.0.38",
|
||||
"@ai-sdk/google": "^4.0.50",
|
||||
"@ai-sdk/groq": "^4.0.30",
|
||||
"@ai-sdk/mistral": "^4.0.32",
|
||||
"@ai-sdk/openai": "^4.0.46",
|
||||
"@ai-sdk/openai-compatible": "^3.0.35",
|
||||
"@ai-sdk/perplexity": "^4.0.31",
|
||||
"@ai-sdk/togetherai": "^3.0.36",
|
||||
"@ai-sdk/xai": "^4.0.43",
|
||||
"@aws-sdk/client-s3": "^3.1116.0",
|
||||
"@better-auth/api-key": "^1.7.1",
|
||||
"@better-auth/drizzle-adapter": "^1.7.1",
|
||||
"@better-auth/infra": "^0.4.2",
|
||||
"@better-auth/oauth-provider": "^1.7.1",
|
||||
"@better-auth/passkey": "^1.7.1",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@hono/node-server": "^2.1.1",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@orpc/client": "^1.14.12",
|
||||
"@orpc/experimental-ratelimit": "^1.14.12",
|
||||
"@orpc/json-schema": "^1.14.12",
|
||||
"@orpc/openapi": "^1.14.12",
|
||||
"@orpc/server": "^1.14.12",
|
||||
"@orpc/zod": "^1.14.12",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@orpc/client": "^1.15.0",
|
||||
"@orpc/experimental-ratelimit": "^1.15.0",
|
||||
"@orpc/json-schema": "^1.15.0",
|
||||
"@orpc/openapi": "^1.15.0",
|
||||
"@orpc/server": "^1.15.0",
|
||||
"@orpc/zod": "^1.15.0",
|
||||
"@react-pdf/renderer": "^4.8.1",
|
||||
"@reactive-resume/api": "workspace:*",
|
||||
"@reactive-resume/auth": "workspace:*",
|
||||
"@reactive-resume/db": "workspace:*",
|
||||
@@ -55,41 +57,43 @@
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@t3-oss/env-core": "^0.13.11",
|
||||
"@uiw/color-convert": "^2.10.3",
|
||||
"ai": "^7.0.37",
|
||||
"ai": "^7.0.77",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.6.25",
|
||||
"better-auth": "1.7.1",
|
||||
"cjk-regex": "^3.4.0",
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"css-tree": "^3.2.1",
|
||||
"deepmerge-ts": "^8.0.2",
|
||||
"drizzle-orm": "1.0.0-rc.4",
|
||||
"drizzle-zod": "1.0.0-beta.14-a36c63d",
|
||||
"es-toolkit": "^1.50.0",
|
||||
"es-toolkit": "^1.51.0",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"hono": "^4.12.32",
|
||||
"hono": "^4.13.4",
|
||||
"jsonrepair": "^3.15.0",
|
||||
"node-html-parser": "^9.0.0",
|
||||
"nodemailer": "^9.0.3",
|
||||
"node-html-parser": "^9.0.1",
|
||||
"nodemailer": "^9.0.5",
|
||||
"ollama-ai-provider-v2": "^4.0.1",
|
||||
"pg": "^8.22.0",
|
||||
"pg": "^8.23.0",
|
||||
"phosphor-icons-react-pdf": "^0.1.3",
|
||||
"react": "^19.2.8",
|
||||
"react-email": "^6.9.1",
|
||||
"react-email": "^6.9.2",
|
||||
"react-pdf-html": "^2.1.5",
|
||||
"resumable-stream": "^2.2.12",
|
||||
"sharp": "^0.35.3",
|
||||
"tokenx": "^2.1.0",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
"uuid": "^14.0.1",
|
||||
"uuid": "^14.0.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
||||
"tsdown": "^0.22.14",
|
||||
"tsx": "^4.23.1",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
|
||||
handleUpload: vi.fn(),
|
||||
handleMcp: vi.fn(),
|
||||
handleResumePdfDownload: vi.fn(),
|
||||
handlePublicResumePdf: vi.fn(),
|
||||
handleMcpServerCard: vi.fn(),
|
||||
handleOAuthAuthorizationServer: vi.fn(),
|
||||
handleOAuthProtectedResource: vi.fn(),
|
||||
@@ -69,6 +70,15 @@ vi.mock("./resume-pdf", () => ({
|
||||
handleResumePdfDownload: mocks.handleResumePdfDownload,
|
||||
}));
|
||||
|
||||
vi.mock("./public-resume-pdf", () => ({
|
||||
handlePublicResumePdf: mocks.handlePublicResumePdf,
|
||||
}));
|
||||
|
||||
const transportEnv = (remoteAddress: string) =>
|
||||
({
|
||||
incoming: { socket: { remoteAddress } },
|
||||
}) as never;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.handleAuth.mockResolvedValue(new Response("auth"));
|
||||
@@ -79,6 +89,7 @@ beforeEach(() => {
|
||||
mocks.handleUpload.mockResolvedValue(new Response("upload"));
|
||||
mocks.handleMcp.mockResolvedValue(new Response("mcp"));
|
||||
mocks.handleResumePdfDownload.mockResolvedValue(new Response("pdf"));
|
||||
mocks.handlePublicResumePdf.mockResolvedValue(new Response("public-pdf"));
|
||||
mocks.handleMcpServerCard.mockReturnValue(new Response("server-card"));
|
||||
mocks.handleOAuthAuthorizationServer.mockReturnValue(new Response("oauth-authorization-server"));
|
||||
mocks.handleOAuthProtectedResource.mockReturnValue(new Response("oauth-protected-resource"));
|
||||
@@ -117,6 +128,51 @@ describe("createApp", () => {
|
||||
expect(mocks.handleWebApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the transport address for public PDF fallback despite rotated forwarding headers", async () => {
|
||||
const { createApp } = await import("./app");
|
||||
const app = createApp();
|
||||
const first = new Request("http://localhost:3001/api/resumes/jane/resume/pdf", {
|
||||
headers: { "x-forwarded-for": "198.51.100.1" },
|
||||
});
|
||||
const rotated = new Request("http://localhost:3001/api/resumes/jane/resume/pdf", {
|
||||
headers: { "x-forwarded-for": "198.51.100.2" },
|
||||
});
|
||||
const env = transportEnv("203.0.113.9");
|
||||
|
||||
const response = await app.fetch(first, env);
|
||||
await app.fetch(rotated, env);
|
||||
|
||||
await expect(response.text()).resolves.toBe("public-pdf");
|
||||
expect(mocks.handlePublicResumePdf).toHaveBeenNthCalledWith(1, first, "jane", "resume", "203.0.113.9");
|
||||
expect(mocks.handlePublicResumePdf).toHaveBeenNthCalledWith(2, rotated, "jane", "resume", "203.0.113.9");
|
||||
expect(mocks.handleResumePdfDownload).not.toHaveBeenCalled();
|
||||
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
|
||||
expect(mocks.handleWebApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the transport address to RPC and OpenAPI and fails closed when it is unavailable", async () => {
|
||||
const { createApp } = await import("./app");
|
||||
const app = createApp();
|
||||
const trustedRpcRequest = new Request("http://localhost:3001/api/rpc", {
|
||||
headers: { "cf-connecting-ip": "198.51.100.1" },
|
||||
});
|
||||
const unknownRpcRequest = new Request("http://localhost:3001/api/rpc", {
|
||||
headers: { "cf-connecting-ip": "198.51.100.2" },
|
||||
});
|
||||
const trustedOpenApiRequest = new Request("http://localhost:3001/api/openapi/resumes/jane/resume");
|
||||
const unknownOpenApiRequest = new Request("http://localhost:3001/api/openapi/resumes/jane/resume");
|
||||
|
||||
await app.fetch(trustedRpcRequest, transportEnv("203.0.113.9"));
|
||||
await app.fetch(unknownRpcRequest);
|
||||
await app.fetch(trustedOpenApiRequest, transportEnv("203.0.113.9"));
|
||||
await app.fetch(unknownOpenApiRequest);
|
||||
|
||||
expect(mocks.handleRpc).toHaveBeenNthCalledWith(1, trustedRpcRequest, "203.0.113.9");
|
||||
expect(mocks.handleRpc).toHaveBeenNthCalledWith(2, unknownRpcRequest, "unknown");
|
||||
expect(mocks.handleOpenApi).toHaveBeenNthCalledWith(1, trustedOpenApiRequest, "203.0.113.9");
|
||||
expect(mocks.handleOpenApi).toHaveBeenNthCalledWith(2, unknownOpenApiRequest, "unknown");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["GET", "/robots.txt", "robots", mocks.handleRobots],
|
||||
["HEAD", "/robots.txt", "", mocks.handleRobots],
|
||||
@@ -136,4 +192,16 @@ describe("createApp", () => {
|
||||
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
|
||||
expect(mocks.handleWebApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["GET", "HEAD"])("routes %s / to the web app handler so SEO markup is injected", async (method) => {
|
||||
const { createApp } = await import("./app");
|
||||
const app = createApp();
|
||||
const request = new Request("http://localhost:3001/", { method });
|
||||
|
||||
const response = await app.fetch(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.handleWebApp).toHaveBeenCalledWith(request);
|
||||
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type { Http2Bindings, HttpBindings } from "@hono/node-server";
|
||||
import type { Context } from "hono";
|
||||
import { isIP } from "node:net";
|
||||
import { getConnInfo } from "@hono/node-server/conninfo";
|
||||
import { Hono } from "hono";
|
||||
import { handleMcp } from "../mcp/handler";
|
||||
import { handleOpenApi } from "../openapi/handler";
|
||||
@@ -15,18 +19,33 @@ import { handleUpload } from "../static/uploads";
|
||||
import { handleWebApp, serveWebDistStatic } from "../static/web";
|
||||
import { handleAuth, handleOAuth } from "./auth";
|
||||
import { handleHealth } from "./health";
|
||||
import { handlePublicResumePdf } from "./public-resume-pdf";
|
||||
import { handleResumePdfDownload } from "./resume-pdf";
|
||||
|
||||
export function createApp() {
|
||||
const app = new Hono();
|
||||
type ServerEnvironment = { Bindings: HttpBindings | Http2Bindings };
|
||||
|
||||
app.all("/api/rpc", (c) => handleRpc(c.req.raw));
|
||||
app.all("/api/rpc/*", (c) => handleRpc(c.req.raw));
|
||||
app.all("/api/openapi", (c) => handleOpenApi(c.req.raw));
|
||||
app.all("/api/openapi/*", (c) => handleOpenApi(c.req.raw));
|
||||
const getTrustedClient = (context: Context<ServerEnvironment>): string => {
|
||||
try {
|
||||
const address = getConnInfo(context).remote.address?.trim();
|
||||
return address && isIP(address) ? address : "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
};
|
||||
|
||||
export function createApp() {
|
||||
const app = new Hono<ServerEnvironment>();
|
||||
|
||||
app.all("/api/rpc", (c) => handleRpc(c.req.raw, getTrustedClient(c)));
|
||||
app.all("/api/rpc/*", (c) => handleRpc(c.req.raw, getTrustedClient(c)));
|
||||
app.all("/api/openapi", (c) => handleOpenApi(c.req.raw, getTrustedClient(c)));
|
||||
app.all("/api/openapi/*", (c) => handleOpenApi(c.req.raw, getTrustedClient(c)));
|
||||
app.get("/api/auth/oauth", (c) => handleOAuth(c.req.raw));
|
||||
app.all("/api/auth/*", (c) => handleAuth(c.req.raw));
|
||||
app.get("/api/health", () => handleHealth());
|
||||
app.get("/api/resumes/:username/:slug/pdf", (c) =>
|
||||
handlePublicResumePdf(c.req.raw, c.req.param("username"), c.req.param("slug"), getTrustedClient(c)),
|
||||
);
|
||||
app.get("/api/resumes/:id/pdf", (c) => handleResumePdfDownload(c.req.raw, c.req.param("id")));
|
||||
app.get("/api/uploads/*", (c) => handleUpload(c.req.raw));
|
||||
app.get("/uploads/*", (c) => handleUpload(c.req.raw));
|
||||
@@ -46,6 +65,9 @@ export function createApp() {
|
||||
app.on(["GET", "HEAD"], "/sitemap.xml", (c) => handleSitemap({ head: c.req.method === "HEAD" }));
|
||||
app.on(["GET", "HEAD"], "/llms.txt", (c) => handleLlms({ head: c.req.method === "HEAD" }));
|
||||
|
||||
// Must precede the static middleware: serveStatic resolves "/" to dist/index.html and would
|
||||
// return it verbatim, skipping the OpenGraph/Twitter/canonical/JSON-LD injection in handleWebApp.
|
||||
app.on(["GET", "HEAD"], "/", (c) => handleWebApp(c.req.raw));
|
||||
app.use("/*", serveWebDistStatic);
|
||||
app.on(["GET", "HEAD"], "/*", (c) => handleWebApp(c.req.raw));
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createPublicResumePdf: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/api/features/resume/public-pdf", () => ({
|
||||
createPublicResumePdf: mocks.createPublicResumePdf,
|
||||
}));
|
||||
|
||||
const { handlePublicResumePdf } = await import("./public-resume-pdf");
|
||||
const trustedClient = "203.0.113.9";
|
||||
|
||||
describe("handlePublicResumePdf", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("returns the authorized on-demand PDF without forwarding compatibility metadata", async () => {
|
||||
const body = new File(["%PDF"], "Ada_Lovelace.pdf", { type: "text/plain" });
|
||||
mocks.createPublicResumePdf.mockResolvedValueOnce({
|
||||
body,
|
||||
filename: "Ada_Lovelace.pdf",
|
||||
});
|
||||
const request = new Request("https://example.com/api/resumes/jane/resume/pdf?ignored=true", {
|
||||
headers: { "x-forwarded-for": "203.0.113.7" },
|
||||
});
|
||||
|
||||
const response = await handlePublicResumePdf(request, "jane", "resume", trustedClient);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/pdf");
|
||||
expect(response.headers.get("Content-Disposition")).toBe('inline; filename="Ada_Lovelace.pdf"');
|
||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
expect(await response.text()).toBe("%PDF");
|
||||
expect(mocks.createPublicResumePdf).toHaveBeenCalledWith({
|
||||
username: "jane",
|
||||
slug: "resume",
|
||||
requestHeaders: request.headers,
|
||||
trustedClient,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps password and private responses uncacheable", async () => {
|
||||
mocks.createPublicResumePdf.mockResolvedValueOnce({
|
||||
body: new File(["%PDF"], "resume.pdf", { type: "application/pdf" }),
|
||||
filename: "resume.pdf",
|
||||
});
|
||||
const request = new Request("https://example.com/api/resumes/jane/resume/pdf");
|
||||
|
||||
const response = await handlePublicResumePdf(request, "jane", "resume", trustedClient);
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
expect(mocks.createPublicResumePdf).toHaveBeenCalledWith({
|
||||
username: "jane",
|
||||
slug: "resume",
|
||||
requestHeaders: request.headers,
|
||||
trustedClient,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ code: "NEED_PASSWORD" }, 401],
|
||||
[{ code: "NOT_FOUND" }, 404],
|
||||
[{ code: "RATE_LIMIT_EXCEEDED" }, 429],
|
||||
[{ code: "INTERNAL_SERVER_ERROR" }, 500],
|
||||
])("maps controlled API errors without caching the response", async (error, status) => {
|
||||
mocks.createPublicResumePdf.mockRejectedValueOnce(error);
|
||||
|
||||
const response = await handlePublicResumePdf(
|
||||
new Request("https://example.com/api/resumes/jane/resume/pdf"),
|
||||
"jane",
|
||||
"resume",
|
||||
trustedClient,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(status);
|
||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createPublicResumePdf } from "@reactive-resume/api/features/resume/public-pdf";
|
||||
|
||||
const noStoreResponse = (body: string, status: number) =>
|
||||
new Response(body, { status, headers: { "Cache-Control": "private, no-store" } });
|
||||
|
||||
const errorStatus = (error: unknown): number => {
|
||||
const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : undefined;
|
||||
if (code === "NEED_PASSWORD") return 401;
|
||||
if (code === "NOT_FOUND") return 404;
|
||||
if (code === "RATE_LIMIT_EXCEEDED") return 429;
|
||||
return 500;
|
||||
};
|
||||
|
||||
export async function handlePublicResumePdf(
|
||||
request: Request,
|
||||
username: string,
|
||||
slug: string,
|
||||
trustedClient = "unknown",
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const result = await createPublicResumePdf({
|
||||
username,
|
||||
slug,
|
||||
requestHeaders: request.headers,
|
||||
trustedClient,
|
||||
});
|
||||
|
||||
return new Response(result.body, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `inline; filename="${result.filename.replaceAll('"', "")}"`,
|
||||
"Cache-Control": "private, no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const status = errorStatus(error);
|
||||
return noStoreResponse(
|
||||
status === 500 ? "Failed to generate public resume PDF" : "Public resume PDF unavailable",
|
||||
status,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ describe("handleResumePdfDownload", () => {
|
||||
ok: true,
|
||||
resumeId: "resume-1",
|
||||
userId: "user-1",
|
||||
target: "resume",
|
||||
expiresAt: "2026-06-01T10:10:00.000Z",
|
||||
});
|
||||
mocks.createResumePdfDownload.mockResolvedValueOnce({
|
||||
@@ -49,6 +50,7 @@ describe("handleResumePdfDownload", () => {
|
||||
ok: true,
|
||||
resumeId: "resume-1",
|
||||
userId: "user-1",
|
||||
target: "cover-letter",
|
||||
expiresAt: "2026-06-01T10:10:00.000Z",
|
||||
});
|
||||
mocks.createResumePdfDownload.mockResolvedValueOnce({
|
||||
@@ -68,6 +70,62 @@ describe("handleResumePdfDownload", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults a legacy token without a target to resume", async () => {
|
||||
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: new File([], "Cover Letter.pdf", { type: "application/pdf" }),
|
||||
});
|
||||
|
||||
await handleResumePdfDownload(new Request("https://example.com/api/resumes/resume-1/pdf?token=legacy"), "resume-1");
|
||||
|
||||
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({
|
||||
id: "resume-1",
|
||||
userId: "user-1",
|
||||
target: "resume",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a cover-letter target for a legacy token without one", async () => {
|
||||
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
|
||||
ok: true,
|
||||
resumeId: "resume-1",
|
||||
userId: "user-1",
|
||||
expiresAt: "2026-06-01T10:10:00.000Z",
|
||||
});
|
||||
|
||||
const response = await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf?token=legacy&target=cover-letter"),
|
||||
"resume-1",
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a target that differs from the signed token", async () => {
|
||||
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
|
||||
ok: true,
|
||||
resumeId: "resume-1",
|
||||
userId: "user-1",
|
||||
target: "resume",
|
||||
expiresAt: "2026-06-01T10:10:00.000Z",
|
||||
});
|
||||
|
||||
const response = await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf?token=signed&target=cover-letter"),
|
||||
"resume-1",
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects missing, invalid, and expired tokens before rendering", async () => {
|
||||
let response = await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf"),
|
||||
|
||||
@@ -30,9 +30,16 @@ export async function handleResumePdfDownload(request: Request, id: string) {
|
||||
|
||||
const verification = verifyResumePdfDownloadToken({ resumeId: id, token });
|
||||
if (!verification.ok) return verification.reason === "expired" ? expiredResponse() : unauthorizedResponse();
|
||||
const queryTarget = searchParams.get("target");
|
||||
if (
|
||||
verification.target
|
||||
? queryTarget !== null && queryTarget !== verification.target
|
||||
: queryTarget && queryTarget !== "resume"
|
||||
)
|
||||
return unauthorizedResponse();
|
||||
|
||||
try {
|
||||
const target = searchParams.get("target") === "cover-letter" ? "cover-letter" : "resume";
|
||||
const target = verification.target ?? "resume";
|
||||
const download = await createResumePdfDownload({ id, userId: verification.userId, target });
|
||||
|
||||
return new Response(download.body, {
|
||||
|
||||
@@ -9,6 +9,14 @@ export { createApp } from "./http/app";
|
||||
async function main() {
|
||||
await runStartupChecks();
|
||||
|
||||
// Safety net: Node 24 crashes the whole process on an unhandled rejection. One request's
|
||||
// stray promise must not take the server down for everyone, so log and keep serving.
|
||||
// Registered after startup checks so a broken startup still fails loudly. (Left uncaught
|
||||
// exceptions on Node's default crash-and-restart, since process state is unsafe after one.)
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
console.error("[unhandledRejection]", reason);
|
||||
});
|
||||
|
||||
const port =
|
||||
process.env.NODE_ENV === "production" ? Number.parseInt(process.env.PORT ?? "3000", 10) : env.SERVER_PORT;
|
||||
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import { auth, verifyOAuthToken } from "@reactive-resume/auth/config";
|
||||
|
||||
const OAUTH_WARN_THROTTLE_MS = 60_000;
|
||||
let lastOAuthWarnAt = 0;
|
||||
|
||||
function warnOAuthThrottled(message: string, detail?: unknown): void {
|
||||
const now = Date.now();
|
||||
if (now - lastOAuthWarnAt < OAUTH_WARN_THROTTLE_MS) return;
|
||||
lastOAuthWarnAt = now;
|
||||
|
||||
if (detail !== undefined) {
|
||||
console.warn(message, detail);
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(message);
|
||||
}
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor() {
|
||||
super("Unauthorized");
|
||||
@@ -13,8 +29,9 @@ export async function authenticateRequest(request: Request): Promise<void> {
|
||||
try {
|
||||
const payload = await verifyOAuthToken(authHeader.slice(7));
|
||||
if (payload?.sub) return;
|
||||
} catch {
|
||||
// Invalid or expired token; fall through to API key auth.
|
||||
warnOAuthThrottled("[MCP] OAuth token verified but missing `sub` claim");
|
||||
} catch (error) {
|
||||
warnOAuthThrottled("[MCP] OAuth token verification failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ export function createMcpServer(request: Request) {
|
||||
`Read schema at \`resume://_meta/schema\`; read resume JSON via \`resume://{id}\` or \`${MCP_TOOL_NAME.getResume}\`.`,
|
||||
`Apply body edits with JSON Patch through \`${MCP_TOOL_NAME.patchResume}\`.`,
|
||||
`Change name, slug, tags, or public visibility with \`${MCP_TOOL_NAME.updateResume}\` (returns canonical share URL; anonymous access only when \`isPublic\` is true; passwords are managed in the web app only).`,
|
||||
`Create short-lived authenticated PDF download URLs with \`${MCP_TOOL_NAME.downloadResumePdf}\`.`,
|
||||
`Create short-lived authenticated PDF download URLs with \`${MCP_TOOL_NAME.downloadResumePdf}\`; set target to \`cover-letter\` to export a visible cover letter separately.`,
|
||||
`Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`; read saved AI analysis with \`${MCP_TOOL_NAME.getResumeAnalysis}\`.`,
|
||||
].join(" "),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export async function generateOpenApiDocumentation(
|
||||
target = fileURLToPath(new URL("../../../../docs/spec.json", import.meta.url)),
|
||||
) {
|
||||
const packageJson = JSON.parse(await readFile(new URL("../../../../package.json", import.meta.url), "utf8")) as {
|
||||
version: string;
|
||||
};
|
||||
process.env.APP_URL ??= "https://rxresu.me";
|
||||
process.env.DATABASE_URL ??= "postgresql://localhost/reactive_resume_docs";
|
||||
process.env.AUTH_SECRET ??= "documentation-generation-isolated-process-only";
|
||||
const { generateOpenApiSpec } = await import("./generator");
|
||||
const spec = await generateOpenApiSpec({ appUrl: "https://rxresu.me", version: packageJson.version });
|
||||
await writeFile(target, `${JSON.stringify(spec, null, "\t")}\n`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
await generateOpenApiDocumentation(process.argv[2]);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import z from "zod";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
|
||||
|
||||
type GeneratedSpecView = {
|
||||
components?: { schemas?: Record<string, unknown> };
|
||||
paths?: Record<
|
||||
string,
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
requestBody?: {
|
||||
content?: Record<string, { schema?: unknown }>;
|
||||
};
|
||||
}
|
||||
>
|
||||
>;
|
||||
};
|
||||
|
||||
// Building the spec walks every router and resume JSON schema, which costs seconds. It is
|
||||
// deterministic and every test here only reads it, so generate it once for the whole file —
|
||||
// regenerating per test made the first case time out under a loaded machine.
|
||||
let specPromise: ReturnType<typeof generateOnce> | undefined;
|
||||
|
||||
async function generateOnce() {
|
||||
const { generateOpenApiSpec } = await import("./generator");
|
||||
return generateOpenApiSpec({
|
||||
appUrl: "https://rxresu.me",
|
||||
version: "9.8.7",
|
||||
});
|
||||
}
|
||||
|
||||
function generateSpec() {
|
||||
specPromise ??= generateOnce();
|
||||
return specPromise;
|
||||
}
|
||||
|
||||
function getRequestSchema(spec: GeneratedSpecView, path: string, method: string) {
|
||||
return spec.paths?.[path]?.[method]?.requestBody?.content?.["application/json"]?.schema;
|
||||
}
|
||||
|
||||
function containsImpossibleSchema(value: unknown): boolean {
|
||||
if (Array.isArray(value)) return value.some(containsImpossibleSchema);
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const object = value as Record<string, unknown>;
|
||||
const negated = object.not;
|
||||
if (typeof negated === "object" && negated !== null && Object.keys(negated).length === 0) {
|
||||
return true;
|
||||
}
|
||||
return Object.values(object).some(containsImpossibleSchema);
|
||||
}
|
||||
|
||||
function findImpossibleRequestSchemas(spec: GeneratedSpecView) {
|
||||
const impossibleRequests: string[] = [];
|
||||
for (const [path, operations] of Object.entries(spec.paths ?? {})) {
|
||||
for (const [method, operation] of Object.entries(operations)) {
|
||||
for (const [mediaType, content] of Object.entries(operation.requestBody?.content ?? {})) {
|
||||
if (containsImpossibleSchema(content.schema)) {
|
||||
impossibleRequests.push(`${method.toUpperCase()} ${path} (${mediaType})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return impossibleRequests;
|
||||
}
|
||||
|
||||
describe("generateOpenApiSpec", () => {
|
||||
it("uses caller-provided application URL and version", async () => {
|
||||
const spec = await generateSpec();
|
||||
|
||||
expect(spec.info).toMatchObject({
|
||||
title: "Reactive Resume",
|
||||
version: "9.8.7",
|
||||
});
|
||||
expect(spec.servers).toEqual([{ url: "https://rxresu.me/api/openapi" }]);
|
||||
expect(spec.externalDocs).toEqual({
|
||||
url: "https://docs.rxresu.me",
|
||||
description: "Reactive Resume Documentation",
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("uses the canonical input-side ResumeData schema in update requests", async () => {
|
||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||
const { $schema: _dialect, ...canonicalInputSchema } = createResumeDataJsonSchema();
|
||||
|
||||
expect(spec.components?.schemas?.ResumeData).toEqual(canonicalInputSchema);
|
||||
expect(getRequestSchema(spec, "/resumes/{id}", "put")).toMatchObject({
|
||||
properties: {
|
||||
data: { $ref: "#/components/schemas/ResumeData" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes the custom-section type and item correlation", async () => {
|
||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||
const schema = z.fromJSONSchema(spec.components?.schemas?.ResumeData as Parameters<typeof z.fromJSONSchema>[0]);
|
||||
const mismatched = {
|
||||
...defaultResumeData,
|
||||
customSections: [
|
||||
{
|
||||
id: "custom-experience",
|
||||
type: "experience",
|
||||
title: "Experience",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
keepTogether: false,
|
||||
startOnNewPage: false,
|
||||
items: [{ id: "summary-item", hidden: false, content: "<p>Not an experience item</p>" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(schema.safeParse(mismatched).success).toBe(false);
|
||||
});
|
||||
|
||||
it("does not publish impossible request schemas", async () => {
|
||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||
|
||||
expect(findImpossibleRequestSchemas(spec)).toEqual([]);
|
||||
});
|
||||
|
||||
it("checks every request body media type for impossible schemas", () => {
|
||||
const spec: GeneratedSpecView = {
|
||||
paths: {
|
||||
"/documents": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": { schema: { type: "object" } },
|
||||
"multipart/form-data": { schema: { not: {} } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(findImpossibleRequestSchemas(spec)).toEqual(["POST /documents (multipart/form-data)"]);
|
||||
});
|
||||
|
||||
it("documents imported data as an accepted ResumeData input", async () => {
|
||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||
|
||||
expect(getRequestSchema(spec, "/resumes/import", "post")).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { $ref: "#/components/schemas/ResumeData" },
|
||||
},
|
||||
required: ["data"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { OpenAPIGenerator } from "@orpc/openapi";
|
||||
import { JSON_SCHEMA_INPUT_REGISTRY, ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||
import { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume/export";
|
||||
import router from "@reactive-resume/api/routers";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
|
||||
|
||||
export const openAPIRouter = {
|
||||
...router,
|
||||
resume: {
|
||||
...router.resume,
|
||||
downloadPdf: downloadResumePdfProcedure,
|
||||
},
|
||||
};
|
||||
|
||||
const { $schema: _dialect, ...resumeDataInputSchema } = createResumeDataJsonSchema();
|
||||
type ResumeDataInputJsonSchema = Parameters<typeof JSON_SCHEMA_INPUT_REGISTRY.add<typeof resumeDataSchema>>[1];
|
||||
JSON_SCHEMA_INPUT_REGISTRY.add(resumeDataSchema, resumeDataInputSchema as unknown as ResumeDataInputJsonSchema);
|
||||
const importResumeInputSchema = openAPIRouter.resume.import["~orpc"].inputSchema;
|
||||
if (importResumeInputSchema) {
|
||||
JSON_SCHEMA_INPUT_REGISTRY.add(importResumeInputSchema, {
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { $ref: "#/components/schemas/ResumeData" },
|
||||
},
|
||||
required: ["data"],
|
||||
});
|
||||
}
|
||||
|
||||
const openAPIGenerator = new OpenAPIGenerator({
|
||||
schemaConverters: [
|
||||
new ZodToJsonSchemaConverter({
|
||||
interceptors: [
|
||||
({ options, next }) => {
|
||||
const [required, schema] = next();
|
||||
const impossible =
|
||||
Object.keys(schema).length === 1 &&
|
||||
typeof schema.not === "object" &&
|
||||
schema.not !== null &&
|
||||
Object.keys(schema.not).length === 0;
|
||||
return options.strategy === "input" && impossible ? [required, {}] : [required, schema];
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
type GenerateOpenApiSpecOptions = {
|
||||
appUrl: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export async function generateOpenApiSpec({ appUrl, version }: GenerateOpenApiSpecOptions) {
|
||||
return await openAPIGenerator.generate(openAPIRouter, {
|
||||
info: {
|
||||
title: "Reactive Resume",
|
||||
version,
|
||||
description: "Reactive Resume API",
|
||||
license: { name: "MIT", url: "https://github.com/amruthpillai/reactive-resume/blob/main/LICENSE" },
|
||||
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
|
||||
},
|
||||
servers: [{ url: `${appUrl}/api/openapi` }],
|
||||
externalDocs: { url: "https://docs.rxresu.me", description: "Reactive Resume Documentation" },
|
||||
commonSchemas: {
|
||||
ResumeData: { schema: resumeDataSchema, strategy: "input" },
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
apiKey: {
|
||||
type: "apiKey",
|
||||
name: "x-api-key",
|
||||
in: "header",
|
||||
description: "The API key to authenticate requests.",
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [{ apiKey: [] }],
|
||||
filter: ({ contract }) => !contract["~orpc"].route.tags?.includes("Internal"),
|
||||
});
|
||||
}
|
||||
@@ -1,24 +1,13 @@
|
||||
import { SmartCoercionPlugin } from "@orpc/json-schema";
|
||||
import { OpenAPIGenerator } from "@orpc/openapi";
|
||||
import { OpenAPIHandler } from "@orpc/openapi/fetch";
|
||||
import { onError } from "@orpc/server";
|
||||
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
|
||||
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||
import { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume/export";
|
||||
import router from "@reactive-resume/api/routers";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { appVersion } from "../app-version";
|
||||
import { mergeResponseHeaders } from "../http/headers";
|
||||
import { getRequestLocale } from "../rpc/locale";
|
||||
|
||||
const openAPIRouter = {
|
||||
...router,
|
||||
resume: {
|
||||
...router.resume,
|
||||
downloadPdf: downloadResumePdfProcedure,
|
||||
},
|
||||
};
|
||||
import { generateOpenApiSpec, openAPIRouter } from "./generator";
|
||||
|
||||
const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
|
||||
plugins: [
|
||||
@@ -36,46 +25,15 @@ const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
|
||||
],
|
||||
});
|
||||
|
||||
const openAPIGenerator = new OpenAPIGenerator({
|
||||
schemaConverters: [new ZodToJsonSchemaConverter()],
|
||||
});
|
||||
|
||||
export async function handleOpenApi(request: Request) {
|
||||
export async function handleOpenApi(request: Request, trustedClient = "unknown") {
|
||||
if (request.method === "GET" && (request.url.endsWith("/spec.json") || request.url.endsWith("/spec"))) {
|
||||
const spec = await openAPIGenerator.generate(openAPIRouter, {
|
||||
info: {
|
||||
title: "Reactive Resume",
|
||||
version: appVersion,
|
||||
description: "Reactive Resume API",
|
||||
license: { name: "MIT", url: "https://github.com/amruthpillai/reactive-resume/blob/main/LICENSE" },
|
||||
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
|
||||
},
|
||||
servers: [{ url: `${env.APP_URL}/api/openapi` }],
|
||||
externalDocs: { url: "https://docs.rxresu.me", description: "Reactive Resume Documentation" },
|
||||
commonSchemas: {
|
||||
ResumeData: { schema: resumeDataSchema },
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
apiKey: {
|
||||
type: "apiKey",
|
||||
name: "x-api-key",
|
||||
in: "header",
|
||||
description: "The API key to authenticate requests.",
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [{ apiKey: [] }],
|
||||
filter: ({ contract }) => !contract["~orpc"].route.tags?.includes("Internal"),
|
||||
});
|
||||
|
||||
return Response.json(spec);
|
||||
return Response.json(await generateOpenApiSpec({ appUrl: env.APP_URL, version: appVersion }));
|
||||
}
|
||||
|
||||
const resHeaders = new Headers();
|
||||
const { response } = await openAPIHandler.handle(request, {
|
||||
prefix: "/api/openapi",
|
||||
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders },
|
||||
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders, trustedClient },
|
||||
});
|
||||
|
||||
if (!response) return new Response("NOT_FOUND", { status: 404 });
|
||||
|
||||
@@ -14,11 +14,16 @@ const rpcHandler = new RPCHandler(router, {
|
||||
],
|
||||
});
|
||||
|
||||
export async function handleRpc(request: Request) {
|
||||
export async function handleRpc(request: Request, trustedClient = "unknown") {
|
||||
const resHeaders = new Headers();
|
||||
const { response } = await rpcHandler.handle(request, {
|
||||
prefix: "/api/rpc",
|
||||
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders },
|
||||
context: {
|
||||
locale: getRequestLocale(request),
|
||||
reqHeaders: request.headers,
|
||||
resHeaders,
|
||||
trustedClient,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response) return new Response("NOT_FOUND", { status: 404 });
|
||||
|
||||
@@ -61,7 +61,18 @@ async function validateLocalStoragePath() {
|
||||
}
|
||||
}
|
||||
|
||||
async function reapStaleAgentRuns() {
|
||||
try {
|
||||
const { reapStaleAgentRunsAtBoot } = await import("@reactive-resume/api/features/agent/runs");
|
||||
await reapStaleAgentRunsAtBoot();
|
||||
} catch (error) {
|
||||
// A reap failure must not block serving traffic; stuck runs also heal lazily on access.
|
||||
console.error("Failed to reap stale agent runs at boot", { error });
|
||||
}
|
||||
}
|
||||
|
||||
export async function runStartupChecks() {
|
||||
await runDatabaseMigrations();
|
||||
await validateLocalStoragePath();
|
||||
await reapStaleAgentRuns();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import z from "zod";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { handleSchemaJson } from "./schema";
|
||||
|
||||
describe("handleSchemaJson", () => {
|
||||
it("publishes the custom-section type and item correlation", async () => {
|
||||
const response = handleSchemaJson();
|
||||
const schema = z.fromJSONSchema((await response.json()) as Parameters<typeof z.fromJSONSchema>[0]);
|
||||
const mismatched = {
|
||||
...defaultResumeData,
|
||||
customSections: [
|
||||
{
|
||||
id: "custom-experience",
|
||||
type: "experience",
|
||||
title: "Experience",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
keepTogether: false,
|
||||
startOnNewPage: false,
|
||||
items: [{ id: "summary-item", hidden: false, content: "<p>Not an experience item</p>" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(schema.safeParse(mismatched).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,8 @@
|
||||
import z from "zod";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
|
||||
import { appVersion } from "../app-version";
|
||||
|
||||
export function handleSchemaJson() {
|
||||
const resumeDataJSONSchema = z.toJSONSchema(resumeDataSchema);
|
||||
|
||||
return Response.json(resumeDataJSONSchema, {
|
||||
return Response.json(createResumeDataJsonSchema(), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/schema+json; charset=utf-8",
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
env: { APP_URL: "https://rxresu.me" },
|
||||
serveStatic: vi.fn((_options?: unknown) => vi.fn()),
|
||||
getPublicResumeSocialMeta: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/api/features/resume/social-meta", () => ({
|
||||
getPublicResumeSocialMeta: mocks.getPublicResumeSocialMeta,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => true),
|
||||
}));
|
||||
@@ -12,15 +22,31 @@ vi.mock("node:fs/promises", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@hono/node-server/serve-static", () => ({
|
||||
serveStatic: vi.fn(() => vi.fn()),
|
||||
serveStatic: mocks.serveStatic,
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({
|
||||
env: mocks.env,
|
||||
}));
|
||||
|
||||
type StaticOptions = {
|
||||
onFound?: (
|
||||
path: string,
|
||||
context: {
|
||||
req: { path: string };
|
||||
header: (name: string, value: string) => void;
|
||||
},
|
||||
) => void | Promise<void>;
|
||||
};
|
||||
|
||||
const { handleWebApp } = await import("./web");
|
||||
const staticOptions = mocks.serveStatic.mock.calls[0]?.[0] as StaticOptions | undefined;
|
||||
|
||||
describe("web app fallback classification", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fs.readFile).mockResolvedValue("<html>app</html>");
|
||||
mocks.getPublicResumeSocialMeta.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("serves the shell for the root app route without noindex", async () => {
|
||||
@@ -32,6 +58,118 @@ describe("web app fallback classification", () => {
|
||||
expect(await response.text()).toBe("<html>app</html>");
|
||||
});
|
||||
|
||||
it("injects canonical metadata and structured data into tracking-parameter root requests only", async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(`
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Reactive Resume — A free and open-source resume builder</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume."
|
||||
>
|
||||
</head>
|
||||
<body><div id="app"></div></body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const response = await handleWebApp(new Request("http://server.internal/?utm_source=search"));
|
||||
const html = await response.text();
|
||||
|
||||
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/">');
|
||||
expect(html).toContain('<link rel="preload" href="/videos/timelapse-v1.webp" as="image" fetchpriority="high">');
|
||||
expect(html).toContain('<meta property="og:url" content="https://rxresu.me/">');
|
||||
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/opengraph/banner.jpg">');
|
||||
expect(html).toContain('id="reactive-resume-structured-data"');
|
||||
expect(html).toContain('"@type":["SoftwareApplication","WebApplication"]');
|
||||
expect(html).toContain('"url":"https://rxresu.me/"');
|
||||
expect(html).not.toContain("utm_source");
|
||||
|
||||
const dashboardResponse = await handleWebApp(new Request("https://example.com/dashboard"));
|
||||
expect(await dashboardResponse.text()).not.toContain('rel="canonical"');
|
||||
});
|
||||
|
||||
describe("public resume social cards", () => {
|
||||
const shell = `<html><head><title>Reactive Resume — A free and open-source resume builder</title><meta name="description" content="Marketing copy."></head><body></body></html>`;
|
||||
|
||||
it("injects resume-specific social metadata and replaces the shell title", async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(shell);
|
||||
mocks.getPublicResumeSocialMeta.mockResolvedValue({
|
||||
name: "Jane Doe",
|
||||
title: "Jane Doe — Staff Engineer",
|
||||
description: "Builds resilient distributed systems.",
|
||||
template: "azurill",
|
||||
});
|
||||
|
||||
const html = await (await handleWebApp(new Request("https://example.com/jane/resume"))).text();
|
||||
|
||||
expect(mocks.getPublicResumeSocialMeta).toHaveBeenCalledWith({ username: "jane", slug: "resume" });
|
||||
expect(html).toContain("<title>Jane Doe - Reactive Resume</title>");
|
||||
expect(html).toContain('<meta name="description" content="Builds resilient distributed systems.">');
|
||||
expect(html).not.toContain("Marketing copy.");
|
||||
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/jane/resume">');
|
||||
expect(html).toContain('<meta property="og:type" content="profile">');
|
||||
expect(html).toContain('<meta property="og:title" content="Jane Doe — Staff Engineer">');
|
||||
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/templates/jpg/azurill.jpg">');
|
||||
expect(html).toContain('<meta name="twitter:card" content="summary_large_image">');
|
||||
expect(html).toContain('<meta name="twitter:image" content="https://rxresu.me/templates/jpg/azurill.jpg">');
|
||||
});
|
||||
|
||||
it("escapes user-authored values so resume content cannot break out of the attribute", async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(shell);
|
||||
mocks.getPublicResumeSocialMeta.mockResolvedValue({
|
||||
name: 'Jane" onload="alert(1)',
|
||||
title: "<script>alert(1)</script>",
|
||||
description: 'Ends with " and & ampersand',
|
||||
template: "azurill",
|
||||
});
|
||||
|
||||
const html = await (await handleWebApp(new Request("https://example.com/jane/resume"))).text();
|
||||
|
||||
expect(html).not.toContain("<script>alert(1)</script>");
|
||||
expect(html).not.toContain('onload="alert(1)');
|
||||
expect(html).toContain('<meta property="og:title" content="<script>alert(1)</script>">');
|
||||
expect(html).toContain('content="Ends with " and & ampersand"');
|
||||
});
|
||||
|
||||
it("serves the plain shell when the resume is not publicly shareable", async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(shell);
|
||||
|
||||
const html = await (await handleWebApp(new Request("https://example.com/jane/private"))).text();
|
||||
|
||||
expect(html).toBe(shell);
|
||||
});
|
||||
|
||||
it("serves the plain shell when the lookup fails", async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(shell);
|
||||
mocks.getPublicResumeSocialMeta.mockRejectedValue(new Error("database unavailable"));
|
||||
|
||||
const response = await handleWebApp(new Request("https://example.com/jane/resume"));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.text()).resolves.toBe(shell);
|
||||
});
|
||||
});
|
||||
|
||||
it("caches versioned homepage media immutably", async () => {
|
||||
const headers = new Headers();
|
||||
|
||||
await staticOptions?.onFound?.("", {
|
||||
req: { path: "/videos/timelapse-v1.mp4" },
|
||||
header: (name, value) => headers.set(name, value),
|
||||
});
|
||||
|
||||
expect(headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
|
||||
|
||||
const unversionedHeaders = new Headers();
|
||||
await staticOptions?.onFound?.("", {
|
||||
req: { path: "/videos/timelapse.mp4" },
|
||||
header: (name, value) => unversionedHeaders.set(name, value),
|
||||
});
|
||||
|
||||
expect(unversionedHeaders.get("Cache-Control")).toBeNull();
|
||||
});
|
||||
|
||||
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}`));
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
|
||||
function resolveWebDistPath() {
|
||||
const candidates = [
|
||||
@@ -31,8 +32,6 @@ const reservedPublicResumeSegments = new Set([
|
||||
"templates",
|
||||
]);
|
||||
|
||||
export const serveWebDistStatic = serveStatic({ root: staticRoot });
|
||||
|
||||
function isAssetPath(pathname: string): boolean {
|
||||
return pathname.split("/").pop()?.includes(".") ?? false;
|
||||
}
|
||||
@@ -60,6 +59,161 @@ const BASE_SECURITY_HEADERS = {
|
||||
"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'",
|
||||
};
|
||||
|
||||
const ROOT_TITLE = "Reactive Resume — A free and open-source resume builder";
|
||||
// Keep under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines.
|
||||
const ROOT_DESCRIPTION =
|
||||
"Free, open-source resume builder. Create, update, and share a professional resume in minutes — no ads, no paywall.";
|
||||
const ROOT_POSTER_PATH = "/videos/timelapse-v1.webp";
|
||||
const ROOT_FAQ_ITEMS = [
|
||||
{
|
||||
question: "Is Reactive Resume really free?",
|
||||
answer:
|
||||
"Yes! Reactive Resume is completely free to use, with no hidden costs, premium tiers, or subscription fees. It's open-source and will always remain free.",
|
||||
},
|
||||
{
|
||||
question: "How is my data protected?",
|
||||
answer:
|
||||
"Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data.",
|
||||
},
|
||||
{
|
||||
question: "Can I export my resume to PDF?",
|
||||
answer:
|
||||
"Absolutely! You can export your resume to PDF with a single click. The exported PDF maintains all your formatting and styling perfectly.",
|
||||
},
|
||||
{
|
||||
question: "Is Reactive Resume available in multiple languages?",
|
||||
answer:
|
||||
"Yes, Reactive Resume is available in multiple languages. You can choose your preferred language in the settings page, or using the language switcher in the top right corner. If you don't see your language, or you would like to improve the existing translations, you can contribute to the translations on Crowdin.",
|
||||
},
|
||||
{
|
||||
question: "What makes Reactive Resume different from other resume builders?",
|
||||
answer:
|
||||
"Reactive Resume is open-source, privacy-focused, and completely free. Unlike other resume builders, it doesn't show ads, track your data, or limit your features behind a paywall.",
|
||||
},
|
||||
{
|
||||
question: "How do I share my resume?",
|
||||
answer:
|
||||
"You can share your resume via a unique public URL, protect it with a password, or download it as a PDF to share directly. The choice is yours!",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function createRootSeoMarkup(canonicalUrl: string) {
|
||||
const origin = new URL(canonicalUrl).origin;
|
||||
const imageUrl = `${origin}/opengraph/banner.jpg`;
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "WebSite",
|
||||
name: "Reactive Resume",
|
||||
url: canonicalUrl,
|
||||
},
|
||||
{
|
||||
"@type": ["SoftwareApplication", "WebApplication"],
|
||||
name: "Reactive Resume",
|
||||
url: canonicalUrl,
|
||||
description: ROOT_DESCRIPTION,
|
||||
applicationCategory: "BusinessApplication",
|
||||
operatingSystem: "Web",
|
||||
isAccessibleForFree: true,
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
},
|
||||
codeRepository: "https://github.com/amruthpillai/reactive-resume",
|
||||
},
|
||||
{
|
||||
"@type": "Project",
|
||||
name: "Reactive Resume",
|
||||
url: canonicalUrl,
|
||||
sameAs: ["https://github.com/amruthpillai/reactive-resume"],
|
||||
},
|
||||
{
|
||||
"@type": "FAQPage",
|
||||
mainEntity: ROOT_FAQ_ITEMS.map((item) => ({
|
||||
"@type": "Question",
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return `
|
||||
<link rel="canonical" href="${canonicalUrl}">
|
||||
<link rel="preload" href="${ROOT_POSTER_PATH}" as="image" fetchpriority="high">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Reactive Resume">
|
||||
<meta property="og:title" content="${ROOT_TITLE}">
|
||||
<meta property="og:description" content="${ROOT_DESCRIPTION}">
|
||||
<meta property="og:url" content="${canonicalUrl}">
|
||||
<meta property="og:image" content="${imageUrl}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="${ROOT_TITLE}">
|
||||
<meta name="twitter:description" content="${ROOT_DESCRIPTION}">
|
||||
<meta name="twitter:image" content="${imageUrl}">
|
||||
<script id="reactive-resume-structured-data" type="application/ld+json">${JSON.stringify(structuredData)}</script>
|
||||
`;
|
||||
}
|
||||
|
||||
// Resume names, headlines, and summaries are user-authored, so they must never reach the served
|
||||
// HTML unescaped.
|
||||
const escapeAttribute = (value: string) =>
|
||||
value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
async function createPublicResumeSeoMarkup(pathname: string, origin: string) {
|
||||
const [username, slug] = getPathSegments(pathname);
|
||||
if (!username || !slug) return null;
|
||||
|
||||
// A card render must never take down the page: any lookup failure falls back to the plain shell.
|
||||
const meta = await import("@reactive-resume/api/features/resume/social-meta")
|
||||
.then((module) => module.getPublicResumeSocialMeta({ username, slug }))
|
||||
.catch(() => null);
|
||||
if (!meta) return null;
|
||||
|
||||
const canonicalUrl = `${origin}/${username}/${slug}`;
|
||||
const imageUrl = `${origin}/templates/jpg/${meta.template}.jpg`;
|
||||
const pageTitle = escapeAttribute(`${meta.name} - Reactive Resume`);
|
||||
const title = escapeAttribute(meta.title);
|
||||
const description = escapeAttribute(meta.description);
|
||||
|
||||
return {
|
||||
pageTitle,
|
||||
description,
|
||||
markup: `
|
||||
<link rel="canonical" href="${canonicalUrl}">
|
||||
<meta property="og:type" content="profile">
|
||||
<meta property="og:site_name" content="Reactive Resume">
|
||||
<meta property="og:title" content="${title}">
|
||||
<meta property="og:description" content="${description}">
|
||||
<meta property="og:url" content="${canonicalUrl}">
|
||||
<meta property="og:image" content="${imageUrl}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="${title}">
|
||||
<meta name="twitter:description" content="${description}">
|
||||
<meta name="twitter:image" content="${imageUrl}">
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
export const serveWebDistStatic = serveStatic({
|
||||
root: staticRoot,
|
||||
onFound: (_path, context) => {
|
||||
if (/^\/videos\/.*-v\d+\.(?:mp4|webp)$/.test(context.req.path)) {
|
||||
context.header("Cache-Control", "public, max-age=31536000, immutable");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function getFallbackResponseHeaders(pathname: string) {
|
||||
if (pathname === "/") return { "Content-Type": "text/html; charset=UTF-8", ...BASE_SECURITY_HEADERS };
|
||||
if (isNoindexShellPath(pathname) || isPublicResumePath(pathname)) {
|
||||
@@ -98,5 +252,24 @@ export async function handleWebApp(request: Request) {
|
||||
if (isHead) return new Response(null, { status: 200, headers });
|
||||
|
||||
const html = await fs.readFile(indexHtmlPath, "utf-8");
|
||||
const canonicalUrl = new URL("/", env.APP_URL).toString();
|
||||
|
||||
if (pathname === "/") {
|
||||
return new Response(html.replace("</head>", `${createRootSeoMarkup(canonicalUrl)}</head>`), { headers });
|
||||
}
|
||||
|
||||
if (isPublicResumePath(pathname)) {
|
||||
const resumeSeo = await createPublicResumeSeoMarkup(pathname, new URL(env.APP_URL).origin);
|
||||
if (resumeSeo) {
|
||||
// The shell's generic title/description are replaced so shares and previews show the resume,
|
||||
// not the marketing copy baked into index.html.
|
||||
const withTitle = html
|
||||
.replace(/<title>[^<]*<\/title>/, `<title>${resumeSeo.pageTitle}</title>`)
|
||||
.replace(/<meta\s+name="description"[^>]*>/, `<meta name="description" content="${resumeSeo.description}">`);
|
||||
|
||||
return new Response(withTitle.replace("</head>", `${resumeSeo.markup}</head>`), { headers });
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(html, { headers });
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ export default defineConfig({
|
||||
shims: true,
|
||||
dts: false,
|
||||
define: { __APP_VERSION__: JSON.stringify(rootPackageJson.version ?? "0.0.0") },
|
||||
// The flagged dynamic imports are deliberate: they defer evaluation of env-dependent
|
||||
// modules so tests can run without env vars, not to split chunks.
|
||||
suppressWarnings: [/dynamic import will not move module into another chunk/],
|
||||
outExtensions: () => ({ js: ".mjs" }),
|
||||
deps: {
|
||||
alwaysBundle: [/^@reactive-resume\//],
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["app:server", "runtime:server", "role:adapter"]
|
||||
"tags": ["app:server", "runtime:server", "role:adapter"],
|
||||
"tasks": {
|
||||
"test:ci": {
|
||||
"cache": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
// @boundaries-ignore root shared Vitest config
|
||||
import { createVitestProjectConfig } from "../../vitest.shared";
|
||||
import { createVitestProjectConfig } from "../../vitest.shared.mts";
|
||||
|
||||
export default createVitestProjectConfig({
|
||||
name: "server",
|
||||
|
||||
+4
-3
@@ -9,14 +9,15 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Reactive Resume" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="description" content="Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.">
|
||||
<!-- Keep under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines. -->
|
||||
<meta name="description" content="Free, open-source resume builder. Create, update, and share a professional resume in minutes — no ads, no paywall.">
|
||||
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="128x128" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" sizes="256x256 any" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" type="image/png" sizes="180x180 any" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials" />
|
||||
|
||||
<title>Reactive Resume</title>
|
||||
<title>Reactive Resume — A free and open-source resume builder</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Keep #app empty: main.tsx only mounts React when rootElement has no children. -->
|
||||
@@ -56,6 +57,6 @@
|
||||
clip: rect(0 0 0 0);
|
||||
}
|
||||
</style>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" data-cfasync="false" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+621
-78
File diff suppressed because it is too large
Load Diff
+621
-78
File diff suppressed because it is too large
Load Diff
+621
-78
File diff suppressed because it is too large
Load Diff
+621
-78
File diff suppressed because it is too large
Load Diff
+621
-78
File diff suppressed because it is too large
Load Diff
+621
-78
File diff suppressed because it is too large
Load Diff
+621
-78
File diff suppressed because it is too large
Load Diff
+621
-78
File diff suppressed because it is too large
Load Diff
+621
-78
File diff suppressed because it is too large
Load Diff
+628
-85
File diff suppressed because it is too large
Load Diff
+622
-79
File diff suppressed because it is too large
Load Diff
+621
-78
File diff suppressed because it is too large
Load Diff
+620
-76
File diff suppressed because it is too large
Load Diff
+621
-78
File diff suppressed because it is too large
Load Diff
+621
-78
File diff suppressed because it is too large
Load Diff
+621
-78
File diff suppressed because it is too large
Load Diff
+621
-78
File diff suppressed because it is too large
Load Diff
+621
-78
File diff suppressed because it is too large
Load Diff
+621
-78
File diff suppressed because it is too large
Load Diff
+621
-78
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
Reference in New Issue
Block a user