Build your own portfolio site with Claude
I paid Kajabi $2,500 a year to host my personal site. Then I rebuilt the same thing with Claude Code, and now the only things I pay for are my Claude subscription and a domain name. This is the skill that did it, in full.
Why I rebuilt mine
The $2,500 was not really for the website. It was for a page builder, a template, and hosting, and I was using maybe a tenth of what I was paying for. What I wanted was a personal site that looked like me and loaded fast. That is a static site, and static sites are close to free to host.
The reason I did not just do that years ago is the part everyone gets stuck on: describing what you want it to look like. You know good design when you see it and you cannot write it down. So the skill does not ask you to. It has you point at a site you already love and works backward from that.
1. Steal a design brief, not a design
Find a personal site you love. Then:
- Right-click anywhere on the page and choose Inspect.
- In the panel that opens, right-click the top
<html>element and choose Copy, then Copy element. - Paste that into a plain text file and save it.
- Take a full screenshot of the page and save that next to it.
Those two files are your design brief. The HTML gives structure and spacing. The screenshot gives feel, color and weight. Together they say more than a page of adjectives ever will.
To be clear about what this is: you are handing over a reference for structure and feel. The skill has a hard guardrail against copying the actual thing. It builds a list of every external asset URL and every visible string of five words or more in the reference, then checks your finished site against both lists and fails if anything survived. Their layout instincts are fair game. Their words and images are not.
2. Install the skill
Copy the file at the bottom of this page and save it at .claude/skills/portfolio-site/SKILL.md inside whatever folder you want the site to live in. Or grab the ZIP, which has the same file and a README.
Then start Claude Code in that folder and type /portfolio-site. If nothing happens, the folder name does not match the name: at the top of the file, which is nearly always the problem.
Have your resume somewhere it can read, and a headshot you actually like. It will ask for both.
Get a new build-with-AI guide every week
Weekly playbooks on prompting, agents, and content systems — free.
You're in. First email lands shortly.
3. What it asks you
The skill runs in nine stages and it is deliberately slow at the front. It asks for your brand colors, takes your headshot, reads your resume, and only then starts the interview.
The interview is the part that surprised me. It asks about your accomplishments one question at a time, and it follows up. Not a form. It already knows your job titles and dates from the resume, so it skips all of that and spends its questions on what you actually did, which is the part a resume is worst at capturing.
That output is what separates a real portfolio from a styled CV. If you rush this stage you get a nice-looking page that says nothing. It takes about twenty minutes and it is the twenty minutes that matter.
After that it picks a layout pattern for your accomplishments, builds the site in Astro, and hands you a refinement loop where you say what feels wrong and it adjusts.
4. Getting it live
The last stage pushes the site to GitHub and deploys it on Vercel. Both have free tiers that host a personal site without complaint, and Vercel rebuilds automatically every time you push a change.
You do not need to understand Git for this. The skill runs the commands. It is worth knowing what it did afterward, but it is not a prerequisite for having a website.
Buy your domain wherever you like and point it at Vercel. That step is a couple of clicks in the Vercel dashboard and it walks you through it.
What it costs
Your Claude Code subscription, which you are probably already paying for, and a domain name at roughly $12 to $20 a year. GitHub and Vercel are free at this size.
Against $2,500 a year, that is the whole argument. The site is also now a folder of files you own, so nothing about it is hostage to a platform's pricing page.
The skill file
All of it, exactly as I run it. It is long because it is doing a lot: the guardrail, the interview, the layout logic, the build, the deploy, and a troubleshooting section for when a stage goes sideways. You do not need to read it. You need to save it.
---
name: portfolio-site
description: Build and ship a personal portfolio website starting from a site the user admires, with no page-builder subscription. Takes a text file of HTML the user copied from that site, turns it into a design spec, collects brand colors and a headshot, reads their resume, interviews them about their accomplishments one question at a time, builds the accomplishments centerpiece, scaffolds an Astro project, runs it locally, refines it on plain-language feedback, then pushes to GitHub and attaches a custom domain on Vercel. Trigger "/portfolio-site", "build my portfolio website", "make me a personal website", "build my own website without Squarespace", "I want a site like this one", or when the user hands over a copied-HTML text file and asks for a site in that style.
---
# Portfolio Site
Takes a non-technical person from "I like how that site looks" to "my portfolio is
live on my own domain." Total cost is the domain registration. No Squarespace, Wix,
Webflow, WordPress or Kajabi subscription.
The user is not a developer. Read every stage in that voice: name the file, name the
button, name what they will see. Never say "just" about a step they have not done before.
## Operating rules
- **One question at a time.** Ask, wait, then ask the next. Never send a numbered list
of six questions. This is the rule the whole skill lives or dies on.
- **Rebuild, never clone.** Stage 1's guardrail is hard. It applies to every later stage.
- **The user's own words are the copy.** Capture verbatim phrasing in the interview and
put those sentences on the page. Do not smooth them into brochure language.
- **Every stage is resumable.** State lives in `.portfolio-site/` next to the project.
Resume by reading `state.json` and continuing from `stage`. Never re-ask a question
already answered in `voice.md`.
- **Verify, do not assume.** Every stage below ends with a check that either passes or
stops the work. A command exiting 0 is not proof that the thing happened.
- **Nothing gets invented.** If a command, flag, DNS value or price is not confirmed,
say so and check it rather than stating it.
## Workspace
Stages 1 through 5 run before the project directory exists, so they write to a staging
path chosen in Stage 0 step 6. Stage 6 scaffolds the Astro project into a directory of
its own, then moves the working state inside it.
```
<staging>/.portfolio-site/ Stages 1 to 5 write here, before there is a project
state.json stage, decisions, approvals
design-spec.md Stage 1 output
reference.html the HTML the user copied
originality-check.sh the copy gate, written in Stage 1, run again in Stages 6 and 8
resume.json Stage 3 output
voice.md verbatim phrasing from Stage 4
changes.md accepted refinements, newest last
<staging>/<project>/ Stage 6 scaffolds the Astro site here (git repo, this ships)
<project>/.portfolio-site/ where the working state ends up after Stage 6 moves it
```
`npm create astro` warns or refuses on a directory that already holds files, so the
scaffold gets a clean directory and the state moves in afterward. Stage 6 step 1 does the
move with `mv .portfolio-site my-portfolio/`, run from the staging path. Every path in
this file after that point is relative to the project root.
`.portfolio-site/` holds the user's resume data. It gets gitignored in Stage 8 so a
public repo never exposes it.
---
## Stage 0. Prerequisites
**Inputs:** none.
1. Run every check in one block and report each result in plain words:
```bash
node --version; npm --version; git --version; gh --version 2>/dev/null | head -1
vercel --version 2>/dev/null || echo "vercel CLI not installed (optional, see below)"
```
The Vercel CLI is the only optional one. Stage 8 offers two ways to deploy, and the
recommended Dashboard path is entirely browser-based and needs no CLI. Install it only
if the user picks the CLI path, because Stage 8 then calls `vercel login`,
`vercel link`, `vercel --prod` and `vercel domains add`, and none of those exist on a
fresh Mac.
2. **Check the Node version against what Astro actually requires today**, rather than
against a number written in this file. Astro's requirement moves:
```bash
npm view astro engines.node
```
Compare the installed version against that range and say pass or fail. As of the last
verified check the answer was `>=22.12.0`, which is stricter than most people expect,
so this is a real gate and not a formality.
3. Fix whatever failed. Exact commands for macOS:
| Missing | Fix |
|---|---|
| Node, or Node too old | `brew install node`. No Homebrew yet: install it from the one-line command on brew.sh, or download the LTS installer from nodejs.org and run it. |
| git | `xcode-select --install`, then click Install in the dialog that appears. |
| gh (GitHub CLI) | `brew install gh` |
| not signed in to GitHub | `gh auth login`, choose GitHub.com, HTTPS, and Login with a web browser. |
| vercel (only if using the CLI path in Stage 8) | `npm i -g vercel`. Skip it entirely on the Dashboard path. |
4. Check the GitHub sign-in explicitly, because `gh --version` succeeds while signed out:
```bash
gh auth status
```
5. Ask about the domain. One question: "Do you already own the web address you want, and
if so what is it?" If no, they can buy one at any registrar for roughly $10 to $20 a
year. They do not need it until Stage 8, so this does not block anything. Record the
answer either way.
6. Ask where the project folder should live and confirm the name before creating anything.
That answer is the **staging path**, for example `~/Sites`. Everything from Stage 1 to
Stage 5 gets written to `<staging>/.portfolio-site/`, and Stage 6 creates the project
at `<staging>/<project>/` and moves the state inside it. Record the staging path in
`state.json` so a resumed session finds the working files.
```bash
mkdir -p <staging>/.portfolio-site && cd <staging>
```
**Verify:** `node --version` satisfies the range from `npm view astro engines.node`,
`git --version` and `gh --version` both print a version, `gh auth status` prints a
logged-in account, and `<staging>/.portfolio-site/` exists. Any failure stops here with
the exact fix above. Do not continue on a "probably fine." A missing `vercel` is not a
failure, it only means Stage 8 takes the Dashboard path.
---
## Stage 1. Inspiration intake
**Inputs:** a `.txt` or `.html` file holding the HTML the user copied from a site they
admire. A screenshot of that page is optional and strongly recommended.
### How the user gets the file
Walk them through it if they have not done it: open the site, right click anywhere on
the page, choose Inspect, find the highlighted `<body>` or the outermost `<div>` in the
Elements panel, right click that line, Copy, Copy element. Paste into TextEdit and save
as a plain text file. Then hand the file over.
### What that copy contains, and what it does not
Say this to the user plainly before parsing, because it sets expectations for the whole
build:
**In the file:** the page structure, class names, the text on the page, inline `style="…"`
attributes, any `<style>` block if they copied high enough, image and icon URLs, data
attributes.
**Not in the file:** the stylesheet itself (nearly all of the design lives in a separate
`.css` file that was linked, not copied), the actual color and font values when they come
from that stylesheet, the font files, the images themselves (only their addresses),
JavaScript behavior, hover and scroll animations, and the breakpoints that make it work
on a phone.
**How the gap gets closed:** inline styles and any `<style>` block give real values.
Utility class names give exact values without the stylesheet, because they encode it
(`bg-slate-900`, `text-4xl`, `py-24`, `gap-6`, `max-w-5xl`). Structure gives the section
archetypes and the component inventory. Everything left over is an inference, and every
inference gets labeled as one in the spec and confirmed by the user. A screenshot closes
the color and type gap in a single step, which is why it is worth asking for.
### Steps
1. Read the file. Copy it to `.portfolio-site/reference.html`. If it is over about 500KB,
say so and sample the structural parts rather than reading it whole.
2. Extract into the DESIGN SPEC. The template and the extraction method for each field
live in `references/design-spec.md`. Fields: palette, type scale, spacing rhythm,
section archetypes, component inventory, motion notes.
3. Mark every field as MEASURED (read from the file) or INFERRED (a judgment call).
4. Write the spec to `.portfolio-site/design-spec.md` and show it to the user.
5. Ask for approval, one question: "Does this describe what you liked about that site?"
### Originality guardrail (hard, enforced)
The output is a site built from the spec. It is never a copy of their site.
- **Never reuse** their text, headlines, images, icons, logos, brand marks, or product
names. Not one sentence, not one asset.
- **Never hotlink** an image URL from the reference file. Every image on the new site is
the user's own or a licensed stock file they chose.
- **Never paste their markup.** Components get written fresh against the spec.
- **Every file in `public/images/` traces to a user-supplied path or a named stock
license recorded in `state.json`.** Downloading their hero image and renaming it
`hero.jpg` erases the URL, so no automated check can catch it. Keep the provenance by
hand: `images: [{ file, source, license }]` in `state.json`, one entry per file, filled
in at the moment the file is added. List that provenance back to the user before
Stage 8, and treat a file with no entry as a leak.
- **Name at least three deliberate divergences** and say them back to the user before
building. Real divergences change how the page reads, so pick from: a different
accent color drawn from the user's own brand, a different hero composition (photo
left instead of centered, or no photo), a different accomplishments pattern, a
different type pairing, a different section order, a different density (more
whitespace, fewer sections).
**Verify:** the spec file exists, every field is labeled MEASURED or INFERRED, the
divergence list has three or more named items with a reason each, and the user has said
yes. Then write the copy check, which gets run three times across the build.
Write it to `.portfolio-site/originality-check.sh`:
```bash
#!/usr/bin/env bash
# Run from whichever directory holds .portfolio-site/, which is the staging path in
# Stage 1 and the project root from Stage 6 on. Rebuilds both pattern files every time,
# so a cleared /tmp cannot turn this into a check that silently tests nothing.
REF=.portfolio-site/reference.html
if [ ! -f "$REF" ]; then echo "STOP: $REF is missing, cannot check originality"; exit 1; fi
# Pattern file 1: every external asset URL in the reference.
grep -o -E '(src|href)="https?://[^"]+"' "$REF" \
| sed -E 's/.*"(https?:\/\/[^"]+)".*/\1/' | grep -v '^$' | sort -u > /tmp/ref-urls.txt
# Pattern file 2: every visible string of five or more words in the reference.
node -e '
const fs=require("fs");
const html=fs.readFileSync(process.argv[1],"utf8");
const text=html.replace(/<(script|style)[\s\S]*?<\/\1>/gi," ").replace(/<[^>]+>/g,"\n");
const out=new Set();
for(const raw of text.split("\n")){
const s=raw.replace(/&[a-zA-Z#0-9]+;/g," ").replace(/\s+/g," ").trim();
if(s.split(" ").filter(Boolean).length>=5) out.add(s);
}
fs.writeFileSync("/tmp/ref-phrases.txt", out.size ? [...out].join("\n")+"\n" : "");
console.log("reference phrases of 5+ words:", out.size);
' "$REF"
if [ ! -d src ] || [ ! -d public ]; then
echo "SKIP: project not scaffolded yet, re-run this at end of Stage 6"; exit 0; fi
if [ -s /tmp/ref-phrases.txt ]; then
texthits=$(grep -rFf /tmp/ref-phrases.txt src 2>/dev/null || true)
else
texthits=""
fi
if [ -s /tmp/ref-urls.txt ]; then
urlhits=$(grep -rFf /tmp/ref-urls.txt src public 2>/dev/null || true)
else
urlhits=""
fi
if [ -n "$texthits" ]; then echo "$texthits"; echo "LEAK: reference wording reached the build"; exit 1; fi
if [ -n "$urlhits" ]; then echo "$urlhits"; echo "LEAK: reference asset URL reached the build"; exit 1; fi
echo "originality ok"
```
Then run it:
```bash
bash .portfolio-site/originality-check.sh
```
**At Stage 1 the only correct result is `SKIP`,** because `src/` and `public/` do not
exist until Stage 6 scaffolds them. That is the point of running it here: it proves the
script works, and it is honest that nothing has been checked yet. Anything reported as
passing at this moment would be meaningless.
**Run it again at the end of Stage 6 and once more before Stage 8.** By then the project
is scaffolded, so `SKIP` at either of those points means the tree is wrong, not that the
check passed. `originality ok` is the only pass, and it is the only string worth grepping
for. `LEAK` means reference material reached the build: remove it and re-run.
Two things this deliberately does. It branches on **captured output, not on the exit
code**, because `grep -rFf` exits nonzero when a directory is missing even after it found
matches, and an exit-code branch reports that as a pass while printing the leak. And it
checks **wording as well as URLs**, because pasting a headline is far more likely than
hotlinking an image, and a URL check cannot see it.
The `-s` guards are load-bearing and not defensive padding. Behavior differs between the
stock BSD grep and Homebrew replacements like ugrep, so guard the empty case rather than
relying on either. With no guard and a reference that yields no URLs, one of them reports
every file in the project as a leak.
---
## Stage 2. Brand assets
**Inputs:** brand colors, a headshot, a font preference. Ask for them one at a time.
### Colors
Ask in this order and stop at the first yes.
1. "Do you have your brand colors written down as hex codes?" Best case, use them exactly.
2. "Do you have a logo file or a screenshot of something with your colors in it?" Read the
image, name the hex values seen in it, and confirm each one with the user before
using it. Read-then-confirm, never assume.
3. Neither: build the palette from the design spec, swap the accent for something the
user picks from three options, and note that they can change it in one place later.
The palette needs five roles filled: page background, surface (cards and panels), text,
muted text, accent. Plus a border line color, which is usually text at low opacity.
**The user's brand wins every conflict with the extracted spec.** If their accent is
warm orange and the reference is cool blue, the site goes orange and the spec's
structure, rhythm and type carry the resemblance.
### Headshot
State what makes a headshot usable before they send one, so they only send it once.
- **Resolution:** at least 1200px on the short side. A photo displayed at 500px still
needs 1000px for a retina screen, and upscaling looks soft.
- **Crop:** head and shoulders. Eyes about a third of the way down the frame. Leave a
little space above the head so the crop has room to move.
- **Background:** plain, or softly blurred, and clearly separated from hair and shoulders.
A busy background competes with the text next to it.
- **Lighting:** light coming from in front. No hard shadow across half the face, no bright
window behind them turning them into a silhouette.
- **Framing:** square or portrait. A landscape group photo will not crop well and asking
for a different photo is faster than fighting it.
- **Format:** JPG or PNG, under about 1MB after export. iPhone HEIC converts with
`sips -s format jpeg in.HEIC --out out.jpg`.
Check what they send:
```bash
sips -g pixelWidth -g pixelHeight -g format "<headshot>"
```
If a check fails, say which one and what to change. Do not quietly accept a 400px photo.
No headshot and no plan to get one is a fine answer. Switch the hero to a no-photo
variant and say so plainly rather than leaving a gap.
### Fonts
Offer three options, recommend one:
1. Match the reference (name the family from the spec if it was measurable).
2. A safe pairing that suits their field.
3. Their own brand font, if they have one and it is on Google Fonts.
Google Fonts only. They are free, they are licensed for web use, and they load with one
line. Cap it at two families and three weights each, because every extra weight is
another file the visitor downloads before they see anything.
### Merge and map
Write the merged palette into the `:root` block of `src/styles/global.css`, using the
token names in `references/starter-files.md`. Raw brand values first, semantic aliases
pointing at them second, so changing a color later touches one line.
**Verify:**
1. Headshot passes: `sips -g pixelWidth -g pixelHeight -g format` shows 1200px or more on
the short side and a web format.
2. No token in `:root` still holds a placeholder. Search for one and expect nothing back.
3. Contrast passes. Three readings are gated at **4.5:1**: body text on the page
background, muted text on the page background, and button text on the accent. Large
headings need 3:1 and are not gated here. Compute it, do not eyeball it:
```bash
node -e '
const H=h=>{h=h.replace("#","");if(h.length===3)h=h.split("").map(c=>c+c).join("");
if(!/^[0-9a-fA-F]{6}$/.test(h))throw new Error("bad hex: "+h);return "#"+h};
const L=h=>{const c=[1,3,5].map(i=>parseInt(H(h).slice(i,i+2),16)/255)
.map(v=>v<=0.03928?v/12.92:((v+0.055)/1.055)**2.4);
return 0.2126*c[0]+0.7152*c[1]+0.0722*c[2]};
const R=(a,b)=>{const[x,y]=[L(a),L(b)].sort((p,q)=>q-p);
return((x+0.05)/(y+0.05)).toFixed(2)};
const MIX=(a,b,p)=>{const A=H(a),B=H(b);return "#"+[1,3,5].map(i=>Math.round(
parseInt(A.slice(i,i+2),16)*p+parseInt(B.slice(i,i+2),16)*(1-p))
.toString(16).padStart(2,"0")).join("")};
const BG="#BG", INK="#TEXT", ACCENT="#ACCENT", ACCENT_INK="#ACCENT_INK";
const MUTED=MIX(INK,BG,0.62); // the same mix color-mix() makes in global.css
console.log("muted resolves to", MUTED);
let bad=0;
const gate=(name,ratio,min)=>{
if(Number.isNaN(+ratio)){console.error("FAIL: unreadable color in "+name);process.exit(1)}
const ok=+ratio>=min; if(!ok)bad++;
console.log(name.padEnd(22), ratio, "need "+min.toFixed(1), ok?"ok":"FAIL")};
gate("text on bg", R(INK,BG), 4.5);
gate("muted on bg", R(MUTED,BG), 4.5);
gate("accent-ink on accent", R(ACCENT_INK,ACCENT), 4.5);
console.log("accent on bg ", R(ACCENT,BG), "(reported, not gated)");
if(bad)process.exit(1);'
```
Substitute the real hex values for all four. Three-digit shorthand like `#fff` is
handled. A value that is not a hex color stops the script with `bad hex`, which is
deliberate: a placeholder left in the command used to produce `NaN`, and `NaN` fails
every numeric comparison silently, so the old gate could never fail. Report the numbers
to the user along with the pass or fail on each gated line.
What to do with a failure:
- **text on bg under 4.5.** Darken the text or lighten the background, then re-run.
- **muted on bg under 4.5.** `--muted` is derived, not chosen, so nobody picks it and
nobody checks it. It carries the nav links, the footer, the section labels and the
hero subhead, which is a lot of body text. Raise the mix percentage in
`global.css` above 62, or darken `--brand-ink`, then re-run. Change the `0.62` in
the script to match the CSS, or the check keeps measuring the color you replaced.
On a cream background with a slate ink, 62% measures 4.22 and 66% measures 4.80.
- **accent-ink on accent under 4.5.** This is the text on every button. A warm accent
such as orange or amber almost always fails against white and usually needs dark
button text instead. Set `--accent-ink` to the ink color rather than `#FFFFFF`.
---
## Stage 3. Resume ingestion
**Inputs:** a PDF, a DOCX, a LinkedIn data export, or pasted text. Any one is enough.
Parse locally. Nothing gets uploaded anywhere.
Try them in this order. The paste is the universal fallback and it always works, so never
burn a person's afternoon installing a toolchain to avoid it.
| Order | Format | Command | Notes |
|---|---|---|---|
| 1 | DOCX | `textutil -convert txt -stdout resume.docx` | Built into macOS. Nothing to install, nothing to go wrong. |
| 2 | PDF | `pdftotext -layout resume.pdf -` | Needs `brew install poppler`. `-layout` keeps columns readable. |
| 3 | LinkedIn export | `unzip -l export.zip` first, then read the CSVs that came back | File names change between exports. List before reading. Commonly Profile, Positions, Education and Skills CSVs. |
| 4 | **Pasted text** | write it straight to `.portfolio-site/resume.txt` | **The universal fallback.** Ask them to open the file, select all, copy, paste. Ten seconds, zero installs, works on any format including the ones nothing else can read. |
There is a python route for PDFs, and it is a last resort rather than the no-Homebrew
answer, because on a clean Mac it fails in two ways that look like nothing happening.
`/usr/bin/python3` is a stub that pops the Xcode Command Line Tools dialog rather than
running, and a managed environment rejects `--user` installs outright under PEP 668. Gate
it, and take the paste the moment the gate says no:
```bash
if python3 -m pip --version >/dev/null 2>&1; then
echo "python route available" # then: python3 -m pip install --user pypdf, read with PdfReader
else
echo "no working pip, take the paste instead"
fi
```
**Image-only PDFs.** A resume exported from a design tool is often one flat picture with
no text layer. Detection: extraction returns under about 200 characters for a page or
more of resume. Do not attempt OCR. Say one line: "This PDF is a picture of a resume, so
there is no text in it to read. Open it, select all, copy, and paste the text to me
instead." Then take the paste.
**Two-column PDFs, which are far more common and far more dangerous.** A two-column
resume is the most popular template shape there is, and it extracts thousands of
characters, so the image-only test above waves it straight through. What comes out is
interleaved: a job title fused to a skill, a date orphaned from its role. Parsed into
JSON it produces confident garbage, and Stage 4 then skips the questions it believes were
already answered.
There is no reliable automatic detection, so the gate is a human eye on the raw text
before anything is parsed:
```bash
pdftotext -layout resume.pdf - | head -15
```
Show those 15 lines to the user and ask exactly one question: **"Does this read in the
right order?"**
If they say the lines interleave two columns, run it again without `-layout` and compare:
```bash
pdftotext resume.pdf - | head -15
```
Sometimes dropping `-layout` un-stitches the columns and sometimes it just alternates
between them. If both are scrambled, stop and take a paste. Do not parse either version.
Record the outcome in the FOUND/MISSING report below. A `-layout` extraction that had to
be abandoned belongs on the MISSING side, because everything the resume held is now
unknown rather than known.
### Extract
Write `.portfolio-site/resume.json` with:
- `roles`: title, organization, start, end, location
- `outcomes`: one per bullet that carries a result, with `number` filled where the bullet
has one and left null where it does not
- `skills`, `projects`, `education`, `testimonials`, `links`
### Report
Show a two-column readout, FOUND on the left and MISSING on the right. MISSING is the
input to Stage 4, which is how the interview avoids asking anything already answered.
**Verify:** the user has confirmed the raw extracted lines read in the right order,
`resume.json` parses as JSON, every entry in `roles` has a title and an organization, and
the FOUND/MISSING table has been shown to the user before moving on. Zero roles extracted
means the parse failed regardless of exit code. Stop and ask for a paste rather than
interviewing around a silent failure. A title and an organization on every role proves
the shape of the JSON, not the truth of it, so the raw-lines question above is the check
that actually catches a scrambled two-column extraction.
---
## Stage 4. Accomplishments interview
**Inputs:** `resume.json` and the MISSING list.
### Rules
- **One question per message.** Wait for the answer. This is not negotiable.
- **Skip what the resume answered.** Say so out loud: "Skipping the question about where
you have worked, your resume already covers it."
- **Push once for a number** whenever a claim arrives without one. "Roughly how much?" or
"About how many?" If they do not know, take the claim without the number and move on.
Do not ask twice.
- **Capture verbatim.** Append every answer, in their words, to `.portfolio-site/voice.md`.
Those sentences become the site copy. A person's own phrasing is the only thing that
makes a portfolio sound like a person.
- **Stop after 12 to 15 answered questions,** or earlier when they say they are done,
**but Q17 and Q18 are always asked before the interview closes.** They are the two the
build cannot proceed without, and at the back of the list they were structurally
unreachable: the cap always fired first. They are asked in Round 1 now.
### The questions
**Round 1, positioning**
1. In one sentence, what do you do, and who for?
2. If someone works with you or hires you, what is different afterward?
3. What should a visitor do after reading your site: hire you, email you, read your work,
book a call, or something else?
**Then these two, mandatory, never dropped for the count.** Ask them here, right after
question 3, and keep their numbers so the rest of this file still points at the right
answers:
- **Q17. What do you not want on this site?** A boundary you never asked about is a
boundary you will cross.
- **Q18. How should people reach you: email, a form, a calendar link, a social profile?**
There is no contact section without this answer, and the contact section is the entire
point of the site.
**Round 2, proof**
4. What is the one accomplishment you would want to lead with?
5. What number went up or down because of you, and by how much?
6. What is the biggest thing you have shipped, run, or built?
7. How many people, dollars, or customers were involved?
8. What is the hardest problem you have solved, and what happened after you solved it?
**Round 3, work samples**
9. Which three projects best represent you?
10. For each one: what was the situation, what did you do, what was the result?
11. Do you have links, screenshots, or press coverage for any of them?
12. Is there anything here you cannot show publicly?
**Round 4, credibility**
13. Who has said something good about your work, and what exactly did they say?
14. Which companies, clients, publications or schools can you name on the page?
15. Any awards, certifications, talks, or audience numbers worth showing?
**Round 5, voice and limits**
- **Q16.** Give me three words you want a stranger to use about you after ten seconds on
the page.
- **Q19.** Anything you want on the page that we have not covered?
Questions 17 and 18 were asked back in Round 1 and are not repeated here. The numbers
skip on purpose, so a reader can see at a glance that two questions moved rather than
went missing.
**Verify:** at least five accomplishments carry a specific number, `voice.md` holds ten or
more verbatim lines, Q17 and Q18 both have answers, and no question was asked that
`resume.json` already answered.
Fewer than three numbers means run one more short round on numbers alone. If that round
also produces none, say so plainly, record `numbers: none` in `state.json`, and eliminate
Metric tiles from the Stage 5 choice. Never run the numbers round twice. A teacher, a
career changer and a poet can all have nothing countable to report, and Timeline,
Case-study cards and Logo wall each need zero numbers, so the build is not blocked. Say
which pattern that leaves and move on.
---
## Stage 5. Accomplishments visual
**Inputs:** the design spec, `resume.json`, `voice.md`.
This is the centerpiece. It sits directly under the hero, above everything else, and it
is the thing a visitor remembers. Everything else on the page supports it.
### Pick one pattern
| Pattern | Right when | Wrong when |
|---|---|---|
| **Metric tiles** | Three to six hard numbers a stranger understands with no setup. The reference leans on big type, high contrast and generous whitespace. | A number needs a sentence of context before it lands, there are only one or two, or `state.json` records `numbers: none`, which removes this row from the choice entirely. |
| **Timeline** | The story is progression: a career arc, a founding story, a multi-year build. The reference has strong vertical rhythm and clear dividers. | Roles are lateral rather than climbing, dates are patchy, or there are fewer than three entries. |
| **Case-study cards** | The work is project-shaped and each project has a before and an after. The reference is a card grid or a masonry layout. | There are no visuals or links for the projects, or fewer than three of them. |
| **Logo wall** | Recognition is the proof: named clients, employers, publications, schools. The reference has a quiet band or a marquee row. | The names would not be recognized, there are fewer than five, or the user lacks the right to display the marks. |
Pick **one** as the centerpiece. A second pattern may appear further down as support.
Three patterns on one page is clutter and it dilutes all three.
**Logo rights:** only show a logo for an organization the user actually worked with or
was published by, and use that organization's own press-kit asset. Plain text set in the
site's own typeface is a perfectly good substitute and carries zero risk. Never take a
logo from the reference site.
### Build rules
- Numbers as numerals, units short: `$2.4M`, `18 mo`, `340%`, `12k`.
- One line of plain-English context under each number, pulled from `voice.md` in the
user's own words.
- No decorative icons unless the design spec called for them.
- At most one entrance animation, wrapped in `@media (prefers-reduced-motion: no-preference)`.
Full markup and CSS for all four patterns: `references/patterns.md`.
**Verify:** the section renders with real data (no lorem, no sample numbers), every
number on screen maps to a named line in `resume.json` or `voice.md` and that mapping
gets listed for the user, and the tile text passes the same contrast check from Stage 2
against `--surface` rather than `--bg`.
For the phone check, ask for an artifact rather than an opinion: **"Open the site on your
phone and send me one screenshot of this section."** Save it to `.portfolio-site/` and
confirm there is no sideways scrolling before advancing. A screenshot on disk is a gate.
"Reads correctly at 375px" with nothing to look at is a wish.
---
## Stage 6. Scaffold and build
**Inputs:** approved spec, merged tokens, headshot, chosen accomplishments pattern.
1. **Scaffold.** Run this from the staging path, not from inside `.portfolio-site/`.
Flags below are confirmed against `create-astro`:
```bash
cd <staging>
npm create astro@latest my-portfolio -- --template minimal --no-git --install --skip-houston --no-ai
```
If the template name is ever rejected, run `npm create astro@latest my-portfolio` with
no flags and choose the empty starter from the menu. `--no-git` is deliberate: Stage 8
owns git so there is one place where repo setup happens.
Then move the working state in, now that the project exists. Stages 1 to 5 wrote to
`<staging>/.portfolio-site/`, deliberately kept out of the scaffold's way, because
`npm create astro` warns or refuses on a target directory that already holds files:
```bash
mv .portfolio-site my-portfolio/
cd my-portfolio
ls -a .portfolio-site # design-spec.md, reference.html, originality-check.sh,
# resume.json, voice.md, state.json
```
Every path from here on is relative to the project root.
2. **Add only what a portfolio needs:**
```bash
npx astro add vercel --yes
npx astro add sitemap --yes
```
3. **Add nothing else.** No React, no MDX, no RSS, no Tailwind, no database, no payment
or AI packages. Styling is plain CSS with custom properties. The finished dependency
list is exactly `astro`, `@astrojs/vercel`, `@astrojs/sitemap`. Every extra package is
another thing that can break a deploy for a person who cannot debug it.
4. **`astro.config.mjs`:**
```js
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
import vercel from '@astrojs/vercel';
export default defineConfig({
site: 'https://www.yourdomain.com',
adapter: vercel(),
build: { format: 'directory' },
integrations: [sitemap()],
});
```
`site` gets its real value in Stage 8. `build.format: 'directory'` means a page at
`src/pages/about.astro` lives at `/about/` with a trailing slash, which matters for
canonical URLs and internal links.
5. **Create the files.** Full contents in `references/starter-files.md`:
```
src/styles/global.css Google Fonts @import on line 1, then the :root tokens
src/layouts/BaseLayout.astro typed Props, imports Nav + Footer + global.css
src/components/Nav.astro
src/components/Footer.astro
src/components/Accomplishments.astro
src/pages/index.astro one file per route
public/images/headshot.jpg
public/favicon.svg
```
The font `@import` has to be the first thing in the CSS file, before any rule, or the
browser drops it and the page silently falls back to Times New Roman.
6. **Run it so the user can see it.** The dev server holds the terminal open for as long
as the site is running, so it needs a terminal of its own. Tell the user this before
starting it, in these words: **press Cmd+T to open a second terminal tab, leave the
dev server running in the first one, and run everything else in the second.** Without
that, the natural move is Ctrl+C to get the prompt back, which kills the server, and
then every check below returns a connection error that looks like a broken site.
```bash
npm run dev
```
**Read the URL Astro prints and use that exact address from here on.** It is usually
http://localhost:4321, but Astro moves to 4322 when 4321 is already busy, and a second
copy of this project or any other dev server is enough to take it. A check pointed at
the wrong port returns `000` and reads as a dead site.
```bash
SITE=http://localhost:4321 # replace with the URL Astro actually printed
```
Tell the user to open that address in their browser. Say that the page updates by
itself when a file changes, and that closing the terminal stops the site (which is
fine, it only stops the local preview).
Backgrounding it in one tab is the alternative, and the log then holds the URL:
```bash
npm run dev > /tmp/astro-dev.log 2>&1 &
sleep 3 && grep -o 'http://localhost:[0-9]*' /tmp/astro-dev.log | head -1
```
That prints the real address to use for `SITE`. Stop the server later with `kill %1`
in the same tab, and say so out loud, because a background server left running is a
port taken and a mystery on the next build.
7. **Check it builds for production too,** in the second tab:
```bash
npm run build
```
**Verify:** all of these, and any red stops the stage. `SITE` is the address Astro
printed in step 6, not an assumption.
```bash
npm run build # exits 0
# Exactly three dependencies. Listing them is not a check, counting them is.
test "$(npm ls --depth=0 --json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(Object.keys(JSON.parse(s).dependencies||{}).length))')" -eq 3 \
&& echo "deps ok" || echo "FAIL: something beyond astro, @astrojs/vercel and @astrojs/sitemap got installed"
curl -s -o /dev/null -w "home %{http_code}\n" "$SITE/"
# Only on the photo hero. See below.
curl -s -o /dev/null -w "photo %{http_code}\n" "$SITE/images/headshot.jpg"
grep -rn --exclude-dir=node_modules --exclude-dir=dist -I "Lorem\|lorem\|TODO\|PLACEHOLDER\|your-name" src public astro.config.mjs package.json
bash .portfolio-site/originality-check.sh
```
The home curl returns `200`.
**The headshot curl is conditional.** If the build uses the photo hero, it returns `200`.
If it uses the no-photo hero that Stage 2 allows, skip this check entirely and confirm
`state.json` records `headshot: none`. A legitimate no-photo build must not be treated as
a failure.
The grep returns nothing. It reaches the project root on purpose, because
`astro.config.mjs` is where step 4 deliberately left a placeholder value, and a scan
limited to `src/` and `public/` can never see it. `--exclude-dir` keeps `node_modules`
and `dist` out of the result, and `-I` skips binary files. Note that `yourdomain` is not
in this pattern list: it is legitimately still in `astro.config.mjs` at this point and
becomes a gate in Stage 8 instead.
The originality check prints `originality ok`. At this point the project is scaffolded,
so `SKIP` means the directory tree is wrong, not that the check passed.
Then, last: the user has actually looked at it in their browser and confirmed it renders.
A passing build is not the same as a page that looks right.
---
## Stage 7. Refinement loop
**Inputs:** the running site and the user's reaction to it.
Tell them how to ask: plain language, one change per message, naming the thing they can
see. "The blue is too bright." "Make my photo smaller on my phone." "Put the projects
above the timeline." They never need to name a file.
Rules for handling it:
- **One change, then show it, then wait.** Never batch six changes into one pass. A
non-technical person cannot tell which of six changes broke the page.
- **Every change goes in the source file.** Never the browser inspector.
- **Log each accepted change** to `.portfolio-site/changes.md` so a fresh session knows
what the site currently is.
The eight most common asks:
1. **"The colors are off."** Edit only the `:root` block in `global.css`. Never chase a
color into component CSS. Re-run the Stage 2 contrast check afterward, because a
lighter text color is the usual way accessibility quietly breaks.
2. **"Make the text bigger or smaller."** Change the type-scale variables, not individual
rules, so the whole page stays in proportion. If they mean one heading only, change
that one rule and say that is what you did.
3. **"Move section X above section Y."** Reorder the component calls in
`src/pages/index.astro`. Nothing else moves. This is a two-line change.
4. **"It looks wrong on my phone."** Ask for a screenshot and which phone. Fix inside the
existing media query. Check at 375px, then 768px, then 1280px. Usual causes: a width
set in fixed pixels, an image with no `max-width`, or a grid that keeps its column
count at small sizes.
5. **"My photo looks bad, crop it differently."** Re-crop the source file, do not paper
over a bad source with CSS. Centered crop:
`sips -c 1200 1200 in.jpg --out public/images/headshot.jpg` (height then width). If the
subject is not centered, ask for a new export instead.
6. **"Add a section for X."** New file in `src/components/`, one call added to
`index.astro`. Reuse the existing section wrapper and the existing spacing tokens.
Never introduce a new spacing value, that is what makes a page look assembled.
7. **"Add another page."** New file in `src/pages/`, for example `src/pages/about.astro`,
using `BaseLayout`. It lives at `/about/`. Add the link to `Nav.astro` in the same
pass so it is not orphaned.
8. **"Make it feel more like the site I liked."** Go back to the design spec and find
which single property is off. It is spacing rhythm most of the time, then heading
weight, then line length. Change that one property. Do not copy more markup from the
reference, and do not remove the three divergences.
**Verify:** after every change, `npm run build` still exits 0, the change is visible in
the browser, and the user has said yes before the next change starts. Silence is not a yes.
---
## Stage 8. Ship
**Trigger phrase:** "push to GitHub with `<domain>`". That single sentence starts this stage.
**Inputs:** a repo name, the domain, a GitHub account, a Vercel account (free, sign in
with GitHub).
1. **Protect the private files first.** Confirm `.gitignore` contains all of these, and
add whatever is missing:
```
node_modules/
dist/
.vercel/
.env
.env.*
.portfolio-site/
```
`.portfolio-site/` holds their resume. It must never reach a public repo.
2. **Commit:**
```bash
git init
git add -A
git status # read this list out loud before committing
git commit -m "Portfolio site"
git branch -M main
```
3. **Create the repo and push in one command:**
```bash
gh repo create <repo-name> --public --source=. --remote=origin --push
```
Use `--private` instead if they prefer. Vercel deploys from private repos on the free
plan, so private costs nothing. If this errors about authentication, run
`gh auth login` and try again.
4. **Connect Vercel.** Offer both, recommend the first for a first site:
- **Dashboard:** go to vercel.com/new, click Import next to the repository, confirm
the Framework Preset says Astro, change nothing else, click Deploy. It finishes with
a `something.vercel.app` address that already works.
- **CLI:** `vercel login`, then `vercel link`, then `vercel --prod`.
After either path, every push to `main` deploys by itself. There is no second step
for future changes.
5. **Attach the domain.** Dashboard: open the project, Settings, Domains, add both
`yourdomain.com` and `www.yourdomain.com`. Or by CLI, where the argument order is
domain then project:
```bash
vercel domains add yourdomain.com <project-name>
```
6. **Add the DNS records at the registrar.** Vercel prints the exact values on the
Domains screen. **Copy them from that screen, not from memory or from this file**,
because Vercel has changed its target values before and a stale IP address produces a
site that silently never appears.
The shape is always these two records:
| Type | Name | Value | TTL |
|---|---|---|---|
| A | `@` | the IPv4 address shown on Vercel's Domains screen | Auto, or 3600 |
| CNAME | `www` | the hostname shown on Vercel's Domains screen (a `.vercel-dns.com` target) | Auto, or 3600 |
Where to put them: sign in at the registrar, find DNS or Name Servers for that domain,
and add records there. Registrar-specific paths are in `references/ship.md`.
**Delete or replace any existing A record on `@` and any existing CNAME on `www`**
that point somewhere else. Two records competing for the same name is the single most
common reason a domain never connects.
Moving the nameservers to Vercel is the alternative, and it is simpler for a domain
doing nothing else. Do not do it if that domain also handles email, because moving
nameservers without recreating the mail records breaks the email.
7. **Propagation.** Vercel usually validates within minutes. The old record's TTL sets
the worst case, up to 48 hours for stale resolvers, though 5 to 30 minutes is typical.
The SSL certificate is issued automatically once the records validate, so a browser
security warning in the first few minutes is expected and resolves itself.
Check the records rather than refreshing the browser, which caches:
```bash
dig +short yourdomain.com A
dig +short www.yourdomain.com CNAME
```
Compare each against what Vercel showed. Matching means it is working and the wait is
just propagation. Not matching means the record is wrong, and no amount of waiting
fixes it.
8. **Point the config at the live domain** now that it exists. Set `site:` in
`astro.config.mjs` to the real address, then commit and push. The sitemap contains the
wrong addresses until this matches. This is the step that clears the last placeholder,
so the gate below checks it directly.
**Verify:** every one of these, and report the actual numbers.
```bash
# The placeholder domain is gone. Stage 6 left it alone on purpose, step 8 removes it.
grep -rn --exclude-dir=node_modules --exclude-dir=dist -I "yourdomain\|YOURDOMAIN" src astro.config.mjs \
&& echo "FAIL: the placeholder domain is still in the config" || echo "domain placeholder cleared"
bash .portfolio-site/originality-check.sh # must print: originality ok
curl -s -o /dev/null -w "%{http_code}\n" -L https://www.yourdomain.com/
curl -s -o /dev/null -w "%{http_code}\n" -L https://yourdomain.com/
curl -s -o /dev/null -w "%{http_code}\n" https://www.yourdomain.com/sitemap-index.xml
curl -s https://www.yourdomain.com/ | grep -c "<title>"
```
Substitute the real domain in the curl lines. The first three return `200`. The last
returns `1`, which proves the page rendered rather than returning an empty shell. The
image provenance list from Stage 1 has an entry for every file in `public/images/`.
`dig` output matches Vercel's values. Then one human check that no command can replace:
the user opens the site on their phone over cellular data, not wifi, because their laptop
and their home router both cache the old DNS answer.
---
## What this costs
| Item | This build | The subscription alternative |
|---|---|---|
| Hosting | $0, Vercel Hobby plan | Squarespace, Wix, Webflow and WordPress.com all sit roughly in the $8 to $29 per month range for a personal site plan. Kajabi is roughly $149 per month. |
| Domain | roughly $10 to $20 per year at the registrar | usually free for year one, then billed separately at a similar rate |
| Fonts | $0, Google Fonts | $0 to several hundred for a licensed family |
| SSL certificate | $0, issued automatically | included |
| Build tooling | $0, Astro is open source | included |
| **Year one** | **roughly $10 to $20** | **roughly $100 to $350 for the cheaper builders, roughly $1,800 for Kajabi** |
All list prices are approximate and vendors change them. Check the vendor's own pricing
page before repeating a number to anyone.
Two honest caveats. Vercel's Hobby plan is meant for non-commercial personal use under
their terms, so a site that sells directly may need a paid plan. And $0 hosting assumes
normal personal-site traffic inside the free tier's limits.
The real trade is not money, it is that changing this site means asking Claude Code
rather than dragging a block in a visual editor. For most people that is faster. For
someone who wants to nudge padding at midnight without opening a terminal, it is not.
Say that out loud rather than overselling.
---
## Troubleshooting
**1. `npm create astro` or `npm install` fails on the Node version.**
Symptom: "Unsupported engine", or a syntax error deep inside a dependency file.
Cause: installed Node is older than Astro requires, and the requirement moves.
Fix: compare `node --version` against `npm view astro engines.node`. Install a supported
version with `brew install node`, or use nvm to switch. Then delete `node_modules` and
`package-lock.json` and run `npm install` again.
**2. The page renders in Times New Roman.**
Symptom: fonts look like a 1998 document.
Cause: the `@import url(...)` is not the very first line of `global.css`, or the name in
`font-family` does not exactly match the Google Fonts family name, or the weight in use
was never requested in the import URL.
Fix: move the import to line 1, match the family name character for character, and list
every weight the site uses in the URL.
**3. The headshot shows a broken image icon.**
Symptom: the layout is right, the photo is not there.
Cause: the file is in `src/` instead of `public/`, or the `src` attribute includes
`/public/`.
Fix: a file at `public/images/headshot.jpg` is served at `/images/headshot.jpg`. The word
`public` never appears in a URL. Also check the case of the filename.
**4. It builds locally but fails on Vercel.**
Symptom: green in the terminal, red in Vercel's build log.
Cause, in order of likelihood: a file was never committed and Vercel's filesystem is
case-sensitive where macOS is not, so `Headshot.JPG` and `headshot.jpg` are the same file
locally and two different files on Vercel. Or a needed package sits in `devDependencies`.
Fix: read the actual Vercel build log, then `git status` to find uncommitted files.
Reproduce it exactly with a clean clone:
`git clone <repo-url> /tmp/check && cd /tmp/check && npm ci && npm run build`.
**5. The domain shows a 404, or the registrar's parking page.**
Symptom: the `.vercel.app` address works, the real domain does not.
Cause: DNS still points at the old host, the domain was added to a different Vercel
project, or an A record and a CNAME both exist on the same name.
Fix: `dig +short yourdomain.com` and compare with the value on Vercel's Domains screen.
Delete the conflicting record. Then wait out the old record's TTL, which is the one part
of this that patience actually solves.
**6. The layout breaks on a phone.**
Symptom: the page scrolls sideways, or text runs off the edge.
Cause: a width set in fixed pixels, an image with no `max-width`, a grid that keeps its
desktop column count at small sizes, or one long unbroken string like an email address.
Fix: set `img { max-width: 100%; height: auto; }` globally, convert fixed widths to
`max-width` plus a percentage, and reduce grid columns in the small-screen media query.
Test at 375px first, because a layout that works there works nearly everywhere.
**7. `dig` returns nothing at all.**
Symptom: `dig +short yourdomain.com A` prints an empty line rather than a wrong address.
Cause: the domain is too new, or the registrar's WHOIS verification email has not been
clicked, which suspends resolution entirely. A domain bought this morning hits this
constantly.
Fix: check inbox and spam for a verification message from the registrar, confirm the
nameservers listed on the domain's page are the registrar's own, and wait. Empty is
different from wrong: empty means the domain is not answering yet, mismatched means the
record is incorrect. Only the second one gets fixed by editing a record, and item 5 above
is the wrong page to be reading for the first one.
---
## Resuming
Every stage writes `.portfolio-site/state.json` before it advances, so an interrupted
session picks up where it stopped. Resume by reading `state.json` and continuing from
`stage`.
The files on disk are the real state, not the conversation: `design-spec.md` is the
approved design, `resume.json` is what the resume said, `voice.md` is how the user talks,
`changes.md` is every refinement they accepted. Read them before asking anything. A
question already answered in `voice.md` never gets asked twice.
If a stage was interrupted mid-way, redo that one stage. Never redo an earlier one,
because the approvals in `state.json` still hold.
## Reference files
| File | Holds |
|---|---|
| `references/design-spec.md` | The DESIGN SPEC template, plus how to extract each field from copied HTML |
| `references/patterns.md` | Full markup and CSS for the four accomplishments patterns |
| `references/starter-files.md` | Exact contents for global.css, BaseLayout, Nav, Footer, index |
| `references/ship.md` | Registrar-by-registrar DNS paths, Vercel connection detail, launch checklist | Portfolio Site Skill
If this is the first skill you have installed, here are six more I run in the business, same format.