Personal site setup
Walk a new user through shipping their own portfolio at https://<their-username>.github.io. The architecture is fixed; the look, copy, and chatbot persona are all customised to the specific user.
What they get
- Single-page static site, no bundler. GitHub Pages serves
mainat the repo root. - Hero — their portrait + name + one-line location + one-line tagline.
- Projects section with a faded vertical timeline in the section head (wrench icons for work, book icons for education, sparkle icons for vibe / side-project stops; each stop shows company, role, date, location) and a grid of project cards in the body — both generated from the user's resume.
- Timeline ↔ cards filter — hover or keyboard-focus a timeline stop to hide unrelated project cards; click to pin the filter (click again to clear). Only stops that have ≥1 tied card become interactive; others stay inert.
- Folded cards — each project card shows only its category + title + tool tags by default; the summary paragraph and metric row appear on hover / focus, keeping the grid scannable.
- Chatbot (optional) — a floating bottom-right launcher + pop-out panel (not an inline page section) backed by DeepSeek's OpenAI-compatible API behind a Cloudflare Worker that holds the API key. User picks the bot's name; agent proposes a matching emoji avatar. Bot replies render markdown. Two-layer rate limit.
- Connect section — email + the user's social URLs.
Prerequisites
| Needed for | Tool | Install |
|---|---|---|
| Everything | git | usually already installed |
| Everything | GitHub account + gh CLI | brew install gh then gh auth login |
| Everything | python3 (local preview) | usually already installed |
| Chatbot | Cloudflare account (free tier) | https://dash.cloudflare.com |
| Chatbot | wrangler CLI | npm install -g wrangler then wrangler login |
| Chatbot | DeepSeek API key | https://platform.deepseek.com/ → API keys |
| Portrait shift | Node.js | brew install node |
Step-by-step
1. Gather inputs from the user
Do this before touching any files. Collect everything at once so later steps are automated edits. Mix AskUserQuestion for multiple-choice and direct prompts for free-text.
Pull each of these from the user:
| Input | How to ask | Example |
|---|---|---|
| GitHub username | direct | jane (the repo will be jane.github.io) |
| Display name | direct | Jane Doe or Jane "JD" Doe |
| Location phrase | direct, short | NYC metropolitan, Remote, Tokyo |
| Tagline | direct, one sentence | I build ML systems and love shipping. |
| Resume | paste text or path to a file | full resume — used to populate timeline, project cards, chatbot knowledge |
| Photo | path to a square image on disk | /Users/jane/Desktop/headshot.jpg. User can drop it at repo root as raw.jpg after cloning. |
| Social URLs | direct, each optional | LinkedIn / GitHub profile / Medium / personal email |
| Chatbot? | AskUserQuestion | yes / no |
| Chatbot name (if yes) | direct | Pot, Nova, Echo, etc. |
| Visual style | AskUserQuestion | "Paste a URL I like" OR "Describe it in words" |
After collecting the chatbot name, propose 2–3 emoji avatars that thematically match and let the user pick. Examples:
| Name | Proposed avatars |
|---|---|
| Pot | 🫖 (teapot) · 🍲 · 🎨 |
| Nova | 🌟 · 💫 · 🌠 |
| Echo | 📣 · 🔊 · 🌀 |
| Ember | 🔥 · 🪵 · ✨ |
| Atlas | 🗺️ · 🧭 · 🌍 |
| Bit | 💾 · 🔢 · 🤖 |
| Sage | 🌿 · 📖 · 🦉 |
If nothing obvious fits, offer neutrals: 🤖, 💬, 🪄. Store the chosen emoji + name — you'll write them into the code later.
2. Create the GitHub repo
gh repo create <username>.github.io --public --description "Personal website"
GitHub Pages user sites MUST be at <username>.github.io.
3. Clone the template
git clone https://github.com/donotdonuts/donotdonuts.github.io my-site
cd my-site
rm -rf .git
git init -b main
git remote add origin https://github.com/<username>/<username>.github.io.git
4. Pick a visual style
The template defaults to a cream + IBM Plex Mono aesthetic. Overwrite it based on the user's answer from Step 1.
4a. If the user gave a URL
Pull the page's CSS and extract tokens:
curl -sL "<url>" -o /tmp/ref.html
grep -oE 'href="[^"]+\.css[^"]*"' /tmp/ref.html | sort -u
curl -sL "<origin>/<css-path>" -o /tmp/ref-1.css
# font actually applied to body
grep -oE 'body[^{]{0,30}\{[^}]+\}' /tmp/ref-1.css | head -5
# all CSS custom-property definitions
grep -oE -- '--[a-zA-Z-]+:[^;}]+' /tmp/ref-1.css | sort -u | head -60
Look for: background hex, foreground/ink hex, border hex, accent hex, font-family applied to body, @font-face URLs.
4b. If the user gave a description
Translate the description into concrete tokens. Heuristics:
| Words | Implication |
|---|---|
| "minimal", "editorial", "paper" | Cream / off-white bg, dark ink, thin dividers |
| "dark", "terminal", "IDE" | Near-black bg (#0f1115), light ink, subtle grey borders |
| "warm", "analog" | Slightly yellow cream (#faf5e8), brown-black ink |
| "brutalist" | Pure white or pure black, 2px+ solid borders |
| "techy", "data" | Monospace (IBM Plex Mono, JetBrains Mono), cool greys |
| "magazine", "editorial" | Serif (EB Garamond, Fraunces), generous leading |
| "playful" | One saturated accent, otherwise muted |
4c. Propose + apply
Compile 13 tokens + a font stack. Show the proposed palette as a code block and get approval before editing files.
:root {
--bg: <hex>; /* page background */
--bg-soft: <hex>; /* sub-panels — usually --bg mixed ~3% toward --ink */
--bg-hover: <hex>; /* tag/chip hover */
--card-bg: <hex>; /* opaque card fill (white if light theme) */
--ink: <hex>; /* primary text */
--ink-soft: <hex>; /* ~80% of --ink */
--ink-mute: <hex>; /* ~60% */
--ink-faint: <hex>; /* ~40% */
--border: <hex>; /* subtle dividers */
--border-strong: <hex>; /* load-bearing dividers */
--up: <hex>; /* positive metrics (green-ish by default) */
--down: <hex>; /* negative metrics (red-ish by default) */
--accent-ink: <hex>; /* links */
--font-mono: "<Family>", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
Then:
- Open
css/styles.css, find the:rootblock, replace values. - Open
index.html, update the Google Fonts<link>in<head>with the new font family + weights.
5. Generate content from resume + inputs
Use the resume + other inputs from Step 1 to populate index.html:
-
<head>—<title>=<Display Name> — <first-role-from-resume>;<meta name="description">from tagline. -
Hero:
<h1 class="hero-title">← display name<p class="hero-location">← location phrase<p class="hero-tagline">← tagline
-
Timeline — parse the resume into stops. One
<li class="tl-stop">per role, per degree, AND per ongoing personal side-project category the user wants to surface. Order present → earliest (top to bottom in DOM). Pick a shortdata-experienceslug for each (e.g.mars,vibe,coach,gatech) — it's what the filter uses to tie stops to cards. Addtabindex="0"only on stops that have ≥1 tied project card (others stay inert).<li class="tl-stop tl-stop-work" data-experience="<slug>" tabindex="0"> <!-- tl-stop-work | tl-stop-edu | tl-stop-vibe (pick one) --> <svg class="tl-icon tl-icon-work" aria-hidden="true"> <use href="#icon-wrench"/> <!-- #icon-wrench | #icon-book | #icon-sparkle --> </svg> <div class="tl-info"> <strong>Company / School</strong> <span>Role / Degree</span> <em>Start — End</em> <i class="tl-loc">City, State</i> </div> </li>Drop
tabindex="0"on education / old-role stops that don't have project cards tied to them —js/main.jsauto-detects those at load and leaves them non-interactive. -
Projects — ~six
<article class="project-card">blocks, generated from notable projects/achievements in the resume. Each card MUST carry adata-experienceslug matching the stop it came from, or it won't participate in the filter:<article class="project-card" data-experience="<slug>"> <header class="project-head"> <span class="project-tag">Category</span> <h3>Project Title</h3> </header> <p>1–2 sentence summary drawn from the resume bullet.</p> <dl class="project-metrics"> <div><dt>Metric</dt><dd class="up">+value</dd></div> <div><dt>Metric</dt><dd>value</dd></div> </dl> <ul class="tag-list small"><li>Tool</li><li>Tool</li></ul> </article>Use
<dd class="up">/<dd class="down">to colour metrics. Cards render folded by default — only the tag, title, and tool tags are visible until hover / focus-within reveals the summary + metrics. Keep summaries tight; they only exist to reward a hover. -
Chat greeting — the bot's opening message is inline HTML inside
#chat-panelinindex.html(inside the panel's<div class="chat-log">), so it renders instantly when the user first opens the pop-out. Rewrite it using the chosen bot name + avatar emoji. Format:<div class="chat-msg chat-msg-bot"> <span class="chat-avatar" aria-label="<Bot Name>">EMOJI</span> <div class="chat-bubble"> <p>Hi, I'm <strong><Bot Name></strong> — <Display Name>'s AI assistant, <verb'd> from <his/her> resume.</p> <p>Ask me about:</p> <ul> <li>Companies / roles</li> <li>Methods / approaches</li> <li>Projects / skills / education</li> </ul> <p>What would you like to know?</p> </div> </div> -
Connect — email + each supplied social URL as a
<li>in.connect-list. Delete rows for any platform not provided.
6. Add the portrait
Ask where their photo is. Copy it to the repo root as raw.jpg, then resize:
cp <user-photo-path> raw.jpg
mkdir -p assets
sips -Z 720 raw.jpg --out assets/portrait.jpg
If the photo's background doesn't match the new --bg, shift it with the bundled script:
cd /tmp && rm -rf img-fix && mkdir img-fix && cd img-fix
npm init -y > /dev/null
npm install [email protected] --silent
node <this-skill-dir>/scripts/shift-portrait.js \
/path/to/my-site/raw.jpg \
/path/to/my-site/assets/portrait.jpg \
"#<your-bg-hex>"
Defaults to #fbf7eb if the third arg is omitted. raw.jpg is gitignored.
7. Configure the chatbot (skip if user said no)
7a. Worker
cd worker
wrangler login
wrangler secret put DEEPSEEK_API_KEY
worker/wrangler.toml:
ALLOWED_ORIGIN = "https://<username>.github.io,http://localhost:8000,http://127.0.0.1:8000"
MODEL = "deepseek-chat"
worker/worker.js → SYSTEM_PROMPT — rewrite from the user's resume. Keep the section structure (About / Current role / Past roles / Education / Tool box). Also rewrite the opening persona sentence using the bot's name + avatar:
const SYSTEM_PROMPT = `You are "<Bot Name>" <EMOJI> — a friendly, concise AI assistant embedded on <Display Name>'s personal website. …`;
Deploy:
wrangler deploy
7b. Client
js/config.js:
window.SITE_CONFIG = {
chatWorkerUrl: "https://<worker>.workers.dev",
};
js/chatbot.js — update the constants at the top:
const AVATAR = "<EMOJI>";
const AVATAR_LABEL = "<Bot Name>";
8. Preview + push
python3 -m http.server 8000 # http://localhost:8000
Click through every section. Test the chat. Resize to mobile width. Then:
git add .
git commit -m "initial: personal site"
git push -u origin main
Live at https://<username>.github.io/ within ~60 seconds.
Template architecture (don't change)
Section layout
Every section uses .section-grid: 280px 1fr. The 280px column fits the longest timeline label. Tighten to 240px if all labels are short, widen to 320px if long.
Timeline
- Lives inside the Projects
section-head, just below the title, atopacity: 0.45. Hover, focus, or an active filter bumps it to 0.95. - DOM order = visual top-to-bottom order. Present → earliest. NOT proportional to time — evenly distributed via flex
gap. - Three icon kinds out of the box:
#icon-wrench(work,.tl-stop-work),#icon-book(education,.tl-stop-edu),#icon-sparkle(vibe / side-project,.tl-stop-vibe). All three<symbol>definitions live in the SVG sprite right after<header>. To add a new icon category:- Add a
<symbol id="icon-foo">with your SVG path - Add
.tl-icon-foo { color: var(--ink-mute); } - Reference with
<svg class="tl-icon tl-icon-foo"><use href="#icon-foo"/></svg>
- Add a
- Every stop carries
data-experience="<slug>"; stops whose slug matches at least one project card also gettabindex="0"sojs/main.jscan make them filter-interactive.
Project filter (click + hover)
Driven entirely by js/main.js, zero config — it scans the DOM on load and wires handlers only where they apply.
- On load, every stop whose
data-experienceslug is present on at least one.project-cardgets.tl-stop-has-projects+role="button"+aria-pressed="false". Stops without tied cards stay inert. - Hover / focus an interactive stop → temp-filter: non-matching cards become
.is-dim(the grid adds.is-filtering, which CSS translates intodisplay: noneon dimmed cards — they're removed from layout, not just faded). - Click (or
Enter/Space) → lock: stop gets.is-locked, full opacity, ink-coloured icon, underlined company name. Click the same stop again to clear; click another to switch. - While locked, hovering a different stop previews its cards;
mouseleaveon the rail (orfocusoutout of it) snaps back viarestoreLockedView(). - Wiring a new experience is purely markup: add
data-experience="<slug>"to both the stop and every tied card, plustabindex="0"on the stop. No JS changes.
Folded project cards
Each card renders collapsed: project-tag + h3 + .tag-list.small only. The summary <p> and .project-metrics row appear on :hover / :focus-within. Pure CSS — .project-card > p { display: none } flips to display: block; .project-metrics flips to display: flex. .project-grid uses align-items: start so an expanded card doesn't force its row neighbours taller. The fold state composes cleanly with the filter — .is-dim cards are display: none, so they never expand.
Chatbot UI: floating launcher + panel
The chatbot is not an inline page section — it's a floating widget that overlays every page. Three siblings live after <footer> in index.html:
#chat-launcher— fixed bottom-right circular button (56 px, ink background) with the avatar emoji + a soft pulse ring. Always visible.#chat-panel— fixed pop-out panel above the launcher, starts with thehiddenattribute. Contains the panel header (avatar + bot name + subtitle +×close) and the existing#chat-widget(log, form, hint, suggestion chips). The initial bot greeting is inline HTML inside this panel so it ships instantly on page load.#chat-nudge— a pill floating just left of the launcher with a triangular tail pointing at it (pointer-events: noneso it never blocks the click). Dismisses the first time the panel opens; CSS hides it entirely below 480 px.
Open / close is wired in js/chatbot.js: click #chat-launcher toggles; click #chat-close or press Escape closes. openPanel() manages hidden, aria-expanded, focus, and scroll-to-bottom; closePanel() restores focus to the launcher. The site <nav> only links Home / Projects / Connect — no "Ask" entry, since the launcher is always reachable.
Chatbot flow
Client → Cloudflare Worker → DeepSeek's chat/completions. Worker holds the API key as a secret, enforces CORS (comma-separated ALLOWED_ORIGIN), sanitises + caps messages (≤20 msgs, ≤2000 chars), applies a per-IP sliding-window rate limit. Client has a 3-second cooldown. SYSTEM_PROMPT is the knowledge base.
Gotchas
- Bot bubble whitespace — do NOT put
white-space: pre-wrapon.chat-bubble. Bot replies render structured markdown HTML; pre-wrap preserves source indentation and creates huge blank gaps. Scope it to user bubbles only. - Axis-through-icon bug — the timeline axis
<div>must come BEFORE<ol class="tl-stops">in DOM order, AND markers needz-index: 1. Otherwise the line paints on top and bisects every icon. data-experienceslug drift — the slug on a.tl-stopmust match the slug on every card it should filter to, character-for-character. A typo on either side silently makes the stop inert (no cards → no.tl-stop-has-projects→ no handlers) or orphans cards so they never reappear under any filter. Pick slugs in Step 5 and reuse them verbatim.tabindex="0"only where it matters — only addtabindex="0"to stops that have ≥1 tied card. Adding it to purely-informational stops (old roles, degrees with no project) makes them tab-focusable and advertises interactivity the JS never wires up.- Folded-card summaries are load-bearing short — since the summary only appears on hover / focus, treat it as a reward, not the main copy. Put the important info in the title + metrics, which are always visible.
- Worker
package.jsontype — must be"module", not"commonjs". - CORS list format —
ALLOWED_ORIGINinwrangler.tomlis comma-separated. Include local dev origins so local preview can reach the live worker. - Repo name — GitHub Pages user pages must be
<username>.github.ioexactly. - Scrollbar theming — the
::-webkit-scrollbar/ Firefoxscrollbar-colorrules use--border/--bgtokens, so they auto-update when you apply a new palette. - Four bot-name places — if you rename the bot, update all four:
js/chatbot.js(AVATAR+AVATAR_LABEL),index.htmlstatic greeting +#chat-launcher/#chat-panelheader (avatar emoji, aria-labels, title, subtitle, nudge copy), andworker/worker.js(SYSTEM_PROMPT). Miss any and the bot will contradict itself.
File map
index.html Single-page site — all copy, plus the floating
#chat-launcher / #chat-panel / #chat-nudge siblings
after <footer>, and the SVG sprite with
#icon-wrench / #icon-book / #icon-sparkle after <header>
css/styles.css All styles; design tokens in :root; .is-filtering /
.is-dim / .is-locked rules drive the timeline filter;
folded-card display rules on .project-card > p and
.project-metrics
js/
config.js Runtime config (chatWorkerUrl)
main.js Footer year + timeline↔cards filter (hover / click
/ Enter / Space). Auto-wires any stop whose
data-experience matches ≥1 card
chatbot.js Launcher open/close, chat widget, markdown parser,
cooldown; AVATAR / AVATAR_LABEL at top
assets/portrait.jpg Committed web portrait (raw.jpg gitignored)
worker/
worker.js Cloudflare Worker — DeepSeek proxy, CORS, rate limit,
SYSTEM_PROMPT
wrangler.toml Worker config (ALLOWED_ORIGIN, MODEL)
CLAUDE.md Architecture notes for future sessions
Pre-ship checklist
- All inputs gathered (username, name, tagline, location, resume, photo, socials, bot name + avatar, style preference)
- Visual palette approved and applied to
:root+ Google Fonts<link> -
<title>+ meta description updated - Hero name / location / tagline replaced
- Portrait placed at
assets/portrait.jpg(colour-shifted to new--bgif needed) - Every timeline stop generated from the user's resume — no template placeholders — with correct icon type (wrench / book / sparkle) and
data-experienceslug -
tabindex="0"present on every stop that has ≥1 tied project card, absent elsewhere - Project cards generated from the user's resume, each with
data-experiencematching its stop - Hover + click +
Enter/Spaceon an interactive stop filters the project grid correctly in local preview - Folded-card summaries are tight (they only appear on hover)
- Connect section has the user's social URLs (unused rows deleted)
- Chat greeting uses the user's bot name + avatar; floating launcher + panel + nudge all show the right emoji and copy
-
js/chatbot.jsAVATAR+AVATAR_LABELmatch -
worker/worker.jsSYSTEM_PROMPT= user's resume + chosen bot persona -
worker/wrangler.tomlALLOWED_ORIGINincludes the user's GitHub Pages URL -
js/config.jschatWorkerUrlset (or intentionally"") - Local preview looks right, including mobile viewport
-
git push→ site live athttps://<username>.github.io/