A single-page, interactive resume / portfolio site you can host for free on GitHub Pages — see it live at aaronified.github.io as an example. No build step, no framework, no dependencies to install — just static HTML, a CDN copy of Tailwind CSS, Lucide icons, and a few plain JavaScript data files you edit to make the site your own.
It renders five sections — Overview, Career Trajectory, Projects, FAQ, and Recommendations — plus light/dark theming that remembers the visitor's choice and an Ask-me chat bubble that answers visitors' questions by quoting your own content back at them (no API key, no server, no AI).
The repo currently ships with one person's content as a worked example. To build your own, replace the content in the
data/*.jsfiles (and a few spots inindex.html) as described below. You never need to touch the rendering logic.
-
Get the files. Fork this repo, or click Use this template / download it.
-
Preview locally. No tooling required — either open
index.htmldirectly in your browser, or serve the folder (so relative image paths resolve exactly like production):# from the project root, pick one: python -m http.server 8000 # then visit http://localhost:8000 npx serve .
-
Edit your content in the
data/*.jsfiles (see Customizing your content). -
Set your title & SEO in
data/personal.js(see Page title, SEO & favicon). -
Deploy to GitHub Pages (see Deploying to GitHub Pages).
.
├── index.html # Markup + all rendering logic (edit metadata only)
├── data/ # ← Your content lives here. Edit these files.
│ ├── personal.js # Name, title, photo, contact links
│ ├── overview.js # Bio, quick facts, global footprint, competencies
│ ├── trajectory.js # Career/education timeline
│ ├── projects.js # Projects gallery + section config
│ ├── faqs.js # Frequently asked questions
│ ├── recommendations.js # Testimonials + references
│ └── chat.js # Ask-me chat: copy, suggestions, matching thresholds
├── assets/ # Your photo, company/school logos, avatars
├── robots.txt # Crawler rules + sitemap pointer (update the domain)
├── sitemap.xml # Single-URL sitemap (update the domain)
└── README.md # This guide
Each file in data/ defines one or more global consts (e.g. PERSONAL_DATA). index.html
loads them with <script> tags and renders them into the page on load. Editing content =
editing a data file. Nothing else is required.
All content is plain JavaScript objects/arrays. Keep the quotes and commas intact — a stray
comma or missing quote will stop the page from rendering. Fields marked (HTML allowed) accept
inline HTML such as <br> or <strong> for light formatting.
Inside most text fields you can wrap a phrase in a marker to highlight it. There are three types, each styled to match its surroundings — inside a timeline card a highlight picks up that card's brand colour; elsewhere it uses the site accent (indigo in light mode, amber in dark):
- Significant numbers / achievements → wrap in asterisks — renders as coloured, bold text.
- Skills & techniques → wrap in carets
^…^— renders with a subtle highlighter background. - Software / tools → wrap in backticks — renders as a small pill badge.
Examples — the raw text you'd type into a data field:
saving *₹50 lakh/month* → coloured figure
using ^geofencing^ → highlighted skill
Introduced `TIBCO Spotfire` → software pill
Markers work in the overview bio, competency descriptions, timeline descriptions & highlights, FAQ
answers, and recommendation quotes. They don't nest, and any HTML you already use (e.g. <br>) is
left untouched. The marker characters (*, ^, backtick) are reserved for this, so avoid them as
literal punctuation in those fields. Keep it light — a few highlights per entry reads best; over-
highlighting defeats the purpose.
Guidance for dense skill lists (e.g. competencies): pill only standalone software (languages,
platforms, apps — Python, Power BI, AWS); put libraries/packages and a couple of signature
skills in the highlight (^pandas^, ^NLP^) rather than pilling everything; and group each tool
with its own packages/features — e.g. `Python` (^pandas^, ^NumPy^, ^scikit-learn^) rather than
listing all tools first and all packages after.
const PERSONAL_DATA = {
name: "Your Name",
title: "Your Headline / Current Role",
profileImage: "assets/your-photo.jpg",
contacts: [
{ type: "phone", label: "+1 555 123 4567", href: "tel:+15551234567", icon: "phone" },
{ type: "email", label: "you@example.com", href: "mailto:you@example.com", icon: "mail" },
{ type: "linkedin", label: "linkedin.com/in/you", href: "https://linkedin.com/in/you", icon: "contact-round" }
]
};iconis any Lucide icon name.type: "linkedin"(or any external link) opens in a new tab; other types open in the same tab.- Add or remove contact entries freely.
const OVERVIEW_DATA = {
bio: "A short paragraph about you.", // (HTML allowed)
// Free-form facts. "Current Position" and "Experience" are NOT listed here — they're computed
// automatically (see below). Set emphasis:true to render a fact as a larger, highlighted row.
quickFacts: [
{ label: "Core Expertise", value: "Analytics and Insights" },
{ label: "Function Setup", value: "Ground Up Analytics" },
{ label: "Role Base", value: "Hyderabad, India" }
],
competencies: [
{
title: "Skill Area",
icon: "brain-circuit", // optional; any Lucide icon name
description: "What you do in this area."
}
]
};competenciesrender as a responsive card grid; add as many as you like.iconis any Lucide icon name (omit it for no icon).
Auto-computed fields (derived from TRAJECTORY_DATA — you don't edit these):
- Current Position — the role whose
periodends in "Present" (else the most recent entry), shown as"<role>, <company>". Add your newest role and it updates itself. - Experience — the summed duration of all
type: "work"roles ("Present" counts up to today). Shown in months up to 24 months, then in whole years. (This is independent of any "X years" you write in thebio, which stays exactly as you type it.) - Geographic footprint — countries (with flags) and a city count parsed from each entry's
location("City, Country"). The heading reads "Global Footprint" for more than one country and "Geographic Footprint" for a single country. If there's only one city, the whole footprint widget is hidden.
These three come first in the Quick Facts panel; the free-form quickFacts above follow them.
TRAJECTORY_DATA is an array in reverse-chronological order — the first entry appears at the
top of the timeline (most recent) and the last at the bottom.
Standard entry (single role):
{
id: "unique-slug", // must be unique across all entries
role: "Your Title",
company: "Company / School",
location: "City, Country",
flag: "🇮🇳", // emoji flag for the location
period: "Jan 2024 - Present",
type: "work", // "work" | "education" | "internship" (sets the badge icon)
colors: { light: "#C62828", dark: "#EF5350" }, // brand accent per theme
logo: "assets/company-logo.jpg", // optional; omit for no logo
link: "https://www.linkedin.com/company/...", // optional; makes the logo + name clickable
description: "One or two sentences of context.",
highlights: [ // optional; bullet list. Use [] for none.
"An achievement with a number.",
"Another achievement."
],
score: "8.18/10" // optional; typically for education entries
}Merged entry (multiple roles at the same organization): set isMerged: true and provide a
roles array instead of a top-level description/highlights:
{
id: "acme",
role: "Multiple",
company: "Acme Corp",
location: "City, Country",
flag: "🇮🇳",
period: "Sep 2020 - Dec 2023",
type: "work",
colors: { light: "#C20068", dark: "#FF6B9D" },
logo: "assets/acme-logo.jpg",
isMerged: true,
roles: [
{
role: "Senior Role",
period: "Jul 2022 - Dec 2023",
description: "Context for this role.",
highlights: ["…", "…"]
},
{
role: "Earlier Role",
period: "Sep 2020 - Jul 2022",
description: "Context for this role.",
highlights: ["…"]
}
]
}typecontrols the badge icon:work→ briefcase,education→ graduation cap,internship→ award.colorssets the card's accent in light and dark themes — use the org's brand color, or any hex you like.link(optional) turns the logo and company name into a link that opens in a new tab. Convention: use the org's LinkedIn company page for employers and the official website for schools. Omit it and the logo/name simply render as plain, non-clickable text.
Two exports. PROJECTS_DATA is the list of cards; PROJECTS_CONFIG holds the section-wide
switches, so behaviour is a data edit rather than a code edit.
const PROJECTS_DATA = [
{
id: "my-tool", // unique slug — the PDF export keys its saved choices off this
name: "My Tool",
tagline: "One short line under the title",
featured: true, // pins the card to the "hero" group up top
status: "Active", // small badge: "Active" / "Live" / "Alpha" / …
period: "2026 – Present",
colors: { light: "#0d9488", dark: "#2dd4bf" }, // brand accent, like trajectory.js
logo: "…/mark.svg", // mark beside the title
logoDark: "…/mark-dark.svg", // optional dark-theme variant
icon: "wrench", // Lucide icon used as the mark when there's no logo file
repo: "you/my-tool", // "owner/name" — source for auto-fetched README badges
summary: "Paragraph. Markers work here.",
highlights: ["Bullet. Markers work here too."],
tech: ["Go", "SQLite"], // stack pills
links: [ // first entry is the primary link (and the one used in the PDF)
{ label: "GitHub", href: "https://github.com/you/my-tool", icon: "folder-git-2" }
],
gallery: "wide", // tile preset name from PROJECTS_CONFIG.gallery.tiles
screenshots: [{ src: "…", alt: "Describe the screenshot" }]
},
{
id: "next-thing", name: "Next Thing", wip: true, // work-in-progress treatment: no dead links
status: "Work in Progress", period: "In development",
note: "Repository & demo coming soon", // muted chip shown instead of links
summary: "…", highlights: [], tech: [], links: [], screenshots: []
}
];Pulling images straight from GitHub. Any src is just a string, so besides a local
assets/… path it can point at a file in one of your repos:
https://raw.githubusercontent.com/<owner>/<repo>/HEAD/<path-in-repo>
GitHub serves those with a real image content-type, Access-Control-Allow-Origin: * and a
5-minute cache, so updating the repo updates this page — nothing to copy or re-sync. HEAD
follows the repo's default branch; pin a tag or branch instead if you'd rather the résumé not
move when the repo does. A tile whose image fails to load drops itself rather than showing a
broken frame, and a logo that fails falls back to the project's icon.
README badges. Give a project a repo and its badges (build status, released version,
licence, …) are read from that repo's README at runtime and shown under the title — so they
never go stale. Only images from an allow-listed host count, which keeps inline screenshots out
and means a README can't inject arbitrary content. Results are cached in localStorage, so
repeat visits paint the badges immediately with no layout shift; a repo whose README has no
badges simply shows no badge row. Set badges: [{ alt, src, href }] on a project to declare them
by hand instead (used as the offline fallback either way).
const PROJECTS_CONFIG = {
featured: { enabled: true, label: "Hero project", icon: "star" },
groups: {
featured: { heading: "Hero projects", sub: "The two I put the most into" },
rest: { heading: "Other projects", sub: "" } // "" or null hides a heading
},
badges: {
enabled: true,
source: "readme", // "readme" = parse the repo README | "data" = only use per-project `badges`
branch: "HEAD",
headerOnly: true, // scan only the README header, so inline screenshots stay out
max: 6, cacheHours: 12, height: 20,
allowHosts: ["img.shields.io", "github.com", …], // safety allow-list
exclude: [] // drop badges whose alt text contains any of these
},
gallery: {
default: "wide",
tiles: { // values become CSS custom properties — any valid CSS works
wide: { min: "210px", max: "1fr", aspect: "16 / 10", fit: "cover", position: "top" },
poster: { min: "120px", max: "180px", aspect: "2 / 3", fit: "cover", position: "center" },
full: { min: "210px", max: "1fr", aspect: "16 / 10", fit: "contain", position: "center" }
}
}
};Featured projects render first, in data order, under the groups.featured heading; everything
else follows under groups.rest. If every project is featured — or none is — the split says
nothing, so one unheaded list is rendered instead.
const FAQS_DATA = [
{ question: "A question about your work or philosophy?",
answer: "Your answer. <br><br> Use <br> for paragraph breaks." } // (HTML allowed)
];Answers support the inline markers as well, so you can pick out a figure, a method or a named tool without writing any HTML.
Three exports:
// 1. Testimonials with full quote text
const RECOMMENDATIONS_DATA = [
{
author: "Person Name",
title: "Their Title",
relationship: "How they know you (e.g. Reported to me at …)",
avatarImage: "assets/their-avatar.jpg",
text: "The full recommendation quote.",
linkedin: "https://linkedin.com/in/them"
}
];
// 2. Heading + blurb shown above the References grid
const REFERENCES_INTRO = {
heading: "Professional References",
description: "Short intro line.<br/>Can include HTML." // (HTML allowed)
};
// 3. Reference contacts (name + link, no quote)
const REFERENCES_DATA = [
{
name: "Person Name",
title: "Their Title",
relationship: "e.g. Direct Manager at …",
avatarImage: "assets/their-avatar.jpg",
linkedin: "https://linkedin.com/in/them"
}
];A floating Ask about my work bubble sits in the bottom-right corner on every tab. A visitor types a question; it answers by quoting the matching entry from your own data files and links to the section it came from.
There is no AI here, and that is the point. No API key, no server, no network call — a key
cannot live in a public static page, and a chatbot that invents your career is worse than none.
It builds a small search index over data/*.js in the browser, ranks entries with BM25, and if
nothing scores well enough it says "I don't have anything on that" and offers suggestions. It
can only ever say things you have written on the page.
const CHAT_CONFIG = {
enabled: true, // false removes the widget entirely
launcher: { label: "Ask about my work", icon: "message-circle" },
title: "Ask about my work",
greeting: "Ask me anything about my work…", // first message in the transcript
disclaimer: "Not an AI — I search this résumé and quote it back.",
suggestions: ["What do you do now?", "Tell me about Tippani"], // starter chips
noMatch: "I don't have anything on that…",
followups: { // the three "go deeper" chips under every answer
enabled: true, count: 3, label: "Go deeper",
padWithSuggestions: true, // top up from `suggestions` when the résumé runs out of links
templates: { more: "What else did you deliver at {company}?", … }
},
scoring: {
minScore: 1.2, // ↑ = answers less often, says "I don't know" more
coverage: 0.45, // cover this share of the question → answered straight…
strongMatch: 0.65, // …below that, this much matched information → answered under a hedge
relCutoff: 0.45, // how close a runner-up must score to be quoted too
maxResults: 3, // entries quoted per answer
maxBullets: 3, // highlight bullets quoted per entry
k1: 1.2, b: 0.6 // BM25 internals — leave alone unless you know them
},
aliases: [["machine learning", "ml"]], // [what visitors type, what your résumé calls it]
stopwords: ["the", "and", "know", "best", …] // words ignored when matching
};What it answers without searching. A few questions are handled by explicit rules so they can
never drift from your data: greetings, "who are you", years of experience (computed from
TRAJECTORY_DATA, never hard-coded), current role, base location, contact links, and "list your
projects". Off-résumé personal questions (salary, age, hobbies) get a straight decline.
Follow-up suggestions. Every answer ends with three chips that go deeper into what was just quoted — the rest of that entry's bullets, your other role at that employer, someone who worked with you there, the job either side of it, a skill, a sibling project, a related FAQ. They are built from your own data, so a visitor can walk the whole résumé without typing.
The rule that keeps them honest: every chip is checked against the index before it is offered, and dropped unless asking it really returns the entry it promises. A project with no highlights, a skill that appears nowhere else, a starter question pointing at content you deleted — all silently drop out. A chip can never lead to "I don't have anything on that".
The three chips come from three different generators where possible, so they point in three
directions (deeper / sideways / across) rather than three shades of the same thing. "What else did
you deliver at X?" is special: re-asking would return the same bullets, so that chip renders the
ones you haven't seen yet, and retires when the entry is exhausted. Hedged answers and declines
fall back to the starter suggestions instead — there is nothing to go deeper into.
Reword any of it in followups.templates; {company}, {role}, {skill}, {project} and
{person} are filled from your data, and a template set to "" turns that generator off. Set
followups.enabled: false to go back to plain starter chips everywhere.
Slurs and abuse. A public box that echoes what people type needs a guard. Anything matching
the abuse patterns is refused before matching runs, and the message is replaced by
moderation.hiddenLabel in the transcript rather than echoed — a slur typed into it never gets
rendered on the page. Matching happens after case, accents, leetspeak (n1gg3r), censor characters
(f*ck) and stretched letters are folded together, and word boundaries are kept so ordinary words
are safe (the Scunthorpe problem). Add patterns for your own context with
moderation.extraPatterns: ["..."]; set moderation.enabled: false to switch the guard off.
Three answers, not two. Every question lands in one of three places:
| The quoted entries… | Reply |
|---|---|
cover coverage of the question |
answered straight — "From my time at Allcargo Gati:" |
cover less, but carry strongMatch worth of matched content |
answered under partialLeadIn — "I don't have a direct answer for that. The closest thing on my résumé:" |
| match nothing worth showing | noMatch + suggestion chips |
The middle tier exists because no threshold can separate "tell me about a challenge you faced"
from "is Tippani tasty?" — to any word-counting measure they are identical (one rare word
matched, one unknown word left over). Declining loses the first; answering plainly overclaims the
second; showing the entry under a hedge is honest for both. strongMatch is measured in "words
your résumé never uses", so it keeps its meaning whatever you write.
Tuning it.
- Hedges questions it should answer straight → lower
coverage. - Declines things it should at least show → lower
strongMatch. - Visitors use a word you don't (they type "machine learning", you wrote "ML") → add an
aliasespair rather than rewording your content. - A word that is noise in questions but happens to appear in your content ("current", "explain",
"favourite", "know", "best") belongs in
stopwords— that is the single most effective knob.
Nothing is stored: no transcript in localStorage, no analytics, no requests. Closing the tab
ends the conversation.
Put your photo, company/school logos, and recommender avatars in assets/ and reference them by
relative path (e.g. "assets/my-photo.jpg"). Square images work best for the profile photo,
logos, and avatars. Filenames are case-sensitive on GitHub Pages — match them exactly.
You don't need to touch index.html for any of this — it's all data-driven. applySeo()
reads the data files on load and writes every search-related tag into the document.
In data/personal.js:
seo: {
title: "Your Name — What You Do | Where", // browser tab + Google's blue link
description: "…", // the grey snippet under the link (~155 chars)
keywords: "Your Name, Your Name job, …", // lead with the name variants people type
siteUrl: "https://yourname.github.io", // canonical origin — no trailing slash
ogImage: "assets/your-photo.jpg" // link-preview thumbnail
},
schema: {
givenName: "Your", familyName: "Name", // optional; falls back to splitting `name`
addressLocality: "Your City", addressCountry: "IN"
}Then update the two site-level files at the repo root, which can't be generated at runtime:
robots.txt— change theSitemap:line to your domain.sitemap.xml— change<loc>to your domain and refresh<lastmod>when you edit the resume.
Everything below is derived — you never write it twice:
| Tag | Source |
|---|---|
<title>, description, keywords, author |
PERSONAL_DATA.seo / .name |
<link rel="canonical"> |
seo.siteUrl (falls back to the serving origin) |
| Open Graph + Twitter card | seo + profileImage — controls LinkedIn/Slack/WhatsApp link previews |
schema.org Person JSON-LD |
job title & employer from the newest type: "work" entry in trajectory.js; alumniOf from the type: "education" entries; knowsAbout from the competency skills lists; sameAs from every https:// contact link |
rel="me" on profile links |
any contact whose href is an external URL |
The Person record is the part that matters most for a search on your name: it declares that
this page is you, and sameAs + rel="me" fold your LinkedIn and GitHub profiles into the same
identity so they reinforce each other instead of competing for the same query.
- Favicon — auto-generated: a monogram of your initials (derived from
PERSONAL_DATA.name) on a gradient tile. Nothing to configure. - Footer copyright name — filled automatically from
PERSONAL_DATA.name.
The whole page — headings, timeline, and the SEO tags above — is rendered by JavaScript, because
index.html is deliberately kept free of profile content. Crawlers that execute JavaScript
(Googlebot, Bingbot) index it correctly; crawlers that don't will see an empty shell. In practice
that covers the search engines people actually use for name lookups, but it does mean link
previews on some chat apps and scrapes by simpler bots may come up blank. Making those work would
require either hard-coding your details into index.html or adding a build step that pre-renders
it — a deliberate trade against keeping the template profile-agnostic.
Two manual steps that meaningfully speed up a name search ranking:
- Add the site to Google Search Console and
Bing Webmaster Tools, verify ownership, and submit
sitemap.xml. Use URL Inspection → Request Indexing for the first crawl. - Link to the site from your LinkedIn profile and GitHub profile. Those are high-authority pages
that already rank for your name, and the inbound links are what tie the
sameAsclaims together from both directions.
The site is fully static (no build step), so GitHub serves the files as-is and hosting is free.
GitHub Pages has two kinds of sites, and the repository name decides the URL:
| Repo name | Site type | Published URL |
|---|---|---|
your-username.github.io (exactly your username) |
User/organization site | https://your-username.github.io/ — root, no folder |
anything else, e.g. resume |
Project site | https://your-username.github.io/resume/ — served from a /resume/ subfolder |
So to get a bare https://your-username.github.io/ link with no subfolder, the repository must
be named exactly your-username.github.io (all lowercase, matching your GitHub username). That is
why this repo is named aaronified.github.io → it serves at https://aaronified.github.io/.
Because the app derives its own live URL from window.location and uses relative asset paths
(assets/…, data/…), it works at either kind of URL without edits — but the root URL is cleanest
for a résumé.
-
Create the repo. On GitHub, create a new public repository named exactly
your-username.github.io(replaceyour-usernamewith your real username, lowercase). -
Push the files. Point this project at that repo and push to
main(or upload the files through Add file → Upload files in the GitHub web UI):git remote add origin git@github.com:your-username/your-username.github.io.git git branch -M main git push -u origin main
-
Enable Pages. In the repo: Settings → Pages → Build and deployment. Set Source to Deploy from a branch, choose branch
mainand folder/ (root), then Save. -
Wait for the first deploy (~1 minute; a green check appears on the commit). Visit
https://your-username.github.io/. HTTPS is on automatically. -
Update anytime by pushing to
main(or editing files in the web UI) — Pages redeploys within a minute. A hard refresh (Ctrl/Cmd-Shift-R) clears any cached copy.
A few notes:
- Only one user/organization site per account (the single
username.github.iorepo). Everything else is a project site under a/subfolder/. - Custom domain (optional): Settings → Pages → Custom domain, add e.g.
resume.example.com, create the matching DNS record at your registrar, and tick Enforce HTTPS. GitHub writes aCNAMEfile to the repo. - No Jekyll processing is needed here; if you ever see build quirks, add an empty
.nojekyllfile at the repo root to disable Jekyll entirely.
The Save PDF button (under the profile photo, and in the sticky bar) opens the export screen: a section rail, the settings, and a live preview of the document itself. Three ways out:
| Button | What it does |
|---|---|
| Save PDF | Builds the PDF in your browser and downloads it, correctly named. Text stays selectable, links stay clickable, fonts are embedded. About 132 KB for the three pages this résumé makes at the default selection, no dialog. |
| DOCX | A real Word file, written without a library — about 85 KB, set in Calibri rather than the PDF's embedded Noto Sans, because a .docx that named a font the reader does not have would fall back unpredictably. Deliberately single-column — an ATS reads multi-column layouts in the wrong order, so this is the one to upload to a form. |
| Print… | Your browser's print dialog, if you want paper or its own PDF writer. |
All three render the same curated layout built from the same data, not the on-screen page:
- Hero — photo left; name, tagline, contacts (incl. the live web URL from
PERSONAL_DATA.websiteor the auto-detected host), and computed Experience · Base on the right. - Summary, then Core Competencies flattened to paragraphs.
- Career Trajectory — an invisible-border table where each role is its own block (
Company, City | Role | Period+ a detail row with the description and achievement bullets), separated by rules. Making each role a block lets a multi-role employer flow across a page break instead of leaving a big gap. - Projects — name, summary, achievement bullets, tech line and the primary link, in the order you set on the selection screen.
- Education — degrees, with any internships nested under the college attended at that time (matched automatically by date).
- Links are real links. Contact lines, employer names and project URLs become clickable annotations in the PDF and hyperlink relationships in the DOCX. (If you use Print… instead, every browser's own Save as PDF keeps them, but Windows' Microsoft Print to PDF is a printer driver and flattens everything — pick the browser's option, not that one.)
- The PDF is the preview, transcribed. The generator does not lay the document out a second time: the browser has already done that in the preview, so the exporter walks those sheets and writes each line where the browser put it. Two layout engines would drift; one cannot.
- Fonts are embedded from
assets/fonts/— the complete Noto Sans, regular and bold, the same files the preview renders with. One global font, not a subset, so no character can change shape between the screen and the file; that is why ₹ and accented characters survive. Anything even Noto Sans cannot draw (CJK, emoji) is reported after the save rather than dropped in silence — seeassets/fonts/README.md. - Colours: body text is dark grey, headings are black, and all inline highlights render as bold blue (no pills/backgrounds).
- FAQ and Recommendations are omitted.
Three columns. The rail on the left lists every section with its count and jumps you to it. The
middle column holds the settings. The live preview on the right renders the actual document —
not an impression of it: the print stylesheet lives in a shared .pr-doc scope used by both the
preview and the printed page, and a test asserts the two compute identical styles and identical
geometry — every selector the stylesheet defines, across both paper sizes and all three densities —
so drift in the styling or the page box gets caught rather than shipped. Dashed guides mark where each page would end, and the header reads
e.g. "3 pages (last ~40%) · A4 · normal" — the fill of the last page being what tells you whether
one more trim saves a page. Page breaks are the browser's to make, so treat the guides as close but
indicative. Below 1024px the preview hides and below 768px the rail does; the settings remain.
Everything below tailors the PDF only — the on-screen page never changes — and your choices are
saved to localStorage (pdfExportConfig), so they persist across sessions rather than resetting
per export. If you later edit data/*.js, saved choices are reconciled against the new data
(added items appear, removed ones drop, out-of-range picks reset) and a short notice lists what changed.
Each section is laid out as two columns: a left column with the section name, a count, and its universal ("All items") toggles — which stays pinned as you scroll that section, then gives way to the next section — and a right column with the individual items. Everything is fully per-item customisable — the universal toggles are simply bulk switches that flip all of a section's child toggles at once (e.g. hide every logo, or every reference photo). Per-item toggles are packed into compact rows to keep scrolling short.
- Page — paper size (A4 or US Letter) and density (Compact / Normal / Roomy). Density moves text size, leading, block spacing and page margins together, so it genuinely changes how much fits: on this résumé, compact is ~2.0 pages where normal is ~2.4 and roomy ~3.0.
- Contact Details — universal: profile photo. Per line (reorderable via ▲/▼ arrows or drag): include phone, GitHub, email, LinkedIn, and the derived live web URL.
- Summary — include toggle plus an editor to rewrite the opening paragraph for this export.
- Core Competencies — reorderable pills (arrows or drag); include toggle; the pencil expands an
editor to rewrite the text with the highlight markers (
`software`→ pill,^skill^→ highlight,*number*→ key figure). A one-line help strip above the field shows each marker's rendered result, and a live preview sits below it. - Work Experience and Education & Internships — universal: Logos, Descriptions (+ Scores for education). Per entry: include, compact Description / Logo / (Score) toggles, and an achievement count (− / +) with a Choose which to hide pick-list (default: drop from the last backward). Education descriptions are off by default (they duplicate the degree title) but can be switched on.
- Projects — reorderable (▲/▼ arrows or drag) so they print in the order you want. Universal:
Descriptions. Per project: include, Description toggle, and the same achievement count (− / +)
with a Choose which to hide pick-list. Projects flagged
wip: trueare off by default. - References — off by default. Universal: Photos, Titles, Relationships, Contacts. Merges your
recommenders and professional references (
RECOMMENDATIONS_DATA+REFERENCES_DATA) into one combined list — the recommendation quote text is never included. Per person (reorderable): include, show/edit title, show/edit relationship, show photo, show contact info (LinkedIn + editable email + editable mobile).
Use Reset to defaults to clear all choices — it highlights whenever your current selection differs from the defaults, so you can always tell (and undo) at a glance. Quality depends on the browser's print engine, so preview before sharing.
Browser furniture: the running header/footer on each page (URL, date, page number) is the browser's own, toggled by the Headers and footers checkbox in the print dialog. We deliberately don't add a custom CSS footer (it would clash with the browser's). Automatic page numbers can't be generated by CSS in browser print-to-PDF — leave Headers and footers on if you want them, or use a print library such as paged.js.
- No build / no install. Tailwind and Lucide load from a CDN, so there's no
npm install. The trade-off is a small runtime cost and a dependency on those CDNs being reachable. - Theming. Light/dark is toggled by the button in the nav and saved to
localStorage; it defaults to the visitor's system preference. Per-entry accent colors come from each timeline entry'scolorsfield. - Validate your JSON-ish data. After editing a
data/*.jsfile, a quicknode --check data/that-file.jscatches syntax mistakes (missing comma/quote) before you deploy. - Encoding. Files are UTF-8. Keep emoji flags and symbols as real UTF-8 characters; avoid editors/tools that re-save as a different code page.
- Accessibility. The template includes a skip-to-content link, focus rings, ARIA attributes on
the accordions, and honors
prefers-reduced-motion. Try to preserve these if you customize the markup.