Free

Margin Notes Portfolio

A portfolio like a working notebook: big serif ink, hand-lettered notes, self-drawing flowers, and an estimator that only uses your prices.

Install

Install the "Margin Notes Portfolio" skill into ~/.claude/skills/margin-notes-portfolio.
Download each file and save it under that folder:
https://skilllift.pages.dev/packs/margin-notes-portfolio/SKILL.md → SKILL.md
https://skilllift.pages.dev/packs/margin-notes-portfolio/references/style.md → references/style.md
https://skilllift.pages.dev/packs/margin-notes-portfolio/assets/engine.html → assets/engine.html
https://skilllift.pages.dev/packs/margin-notes-portfolio/assets/engine.css → assets/engine.css
https://skilllift.pages.dev/packs/margin-notes-portfolio/assets/engine.js → assets/engine.js
https://skilllift.pages.dev/packs/margin-notes-portfolio/assets/starter.json → assets/starter.json
https://skilllift.pages.dev/packs/margin-notes-portfolio/assets/fonts-license.txt → assets/fonts-license.txt
https://skilllift.pages.dev/packs/margin-notes-portfolio/assets/fonts/fraunces.woff2 → assets/fonts/fraunces.woff2
https://skilllift.pages.dev/packs/margin-notes-portfolio/assets/fonts/newsreader.woff2 → assets/fonts/newsreader.woff2
https://skilllift.pages.dev/packs/margin-notes-portfolio/assets/fonts/newsreader-italic.woff2 → assets/fonts/newsreader-italic.woff2
https://skilllift.pages.dev/packs/margin-notes-portfolio/assets/fonts/caveat.woff2 → assets/fonts/caveat.woff2
https://skilllift.pages.dev/packs/margin-notes-portfolio/assets/fonts/zh-display.json → assets/fonts/zh-display.json
https://skilllift.pages.dev/packs/margin-notes-portfolio/assets/fonts/zh-body.json → assets/fonts/zh-body.json
https://skilllift.pages.dev/packs/margin-notes-portfolio/assets/fonts/zh-hand.json → assets/fonts/zh-hand.json
https://skilllift.pages.dev/packs/margin-notes-portfolio/scripts/check.mjs → scripts/check.mjs
https://skilllift.pages.dev/packs/margin-notes-portfolio/scripts/build.mjs → scripts/build.mjs
https://skilllift.pages.dev/packs/margin-notes-portfolio/scripts/drive.mjs → scripts/drive.mjs
https://skilllift.pages.dev/packs/margin-notes-portfolio/scripts/audit.mjs → scripts/audit.mjs
https://skilllift.pages.dev/packs/margin-notes-portfolio/scripts/lib.mjs → scripts/lib.mjs
https://skilllift.pages.dev/packs/margin-notes-portfolio/references/example.en.json → references/example.en.json
https://skilllift.pages.dev/packs/margin-notes-portfolio/references/example.zh.json → references/example.zh.json
Then read SKILL.md, tell me what it does, and remind me to start a new session.

Unpack it into ~/.claude/skills

Same skill, different brief

The left is the preview. The right was made by a separate agent from a different brief, using only the installed skill and without seeing the preview.

The preview
The preview
A new brief, made by a separate agent
A new brief, made by a separate agent

One test run on a fictional brief. Letterforms can differ on systems without the same fonts.

Builds a portfolio with personality for an independent developer, product designer, other solo professional or small studio, as one offline HTML file with hash routes rendered from one JSON block. The look is a working notebook on warm paper with a large serif headline in one strong ink, hand-lettered margin notes, self-drawing arrows and flowers, tape, sticky notes and an ID-card note. Includes work filters, case-study tabs, a price estimator driven only by the user's own prices, FAQ tabs and honest demo forms. English or Chinese. Use when someone asks for a personal or studio portfolio site with character, or asks for this style by name.

What it must pass before delivery

  • check.mjs exits 0 with the visual checks run (or you said they did not run).
  • You looked at every route at phone and desktop, and at the states from drive.mjs.
  • Every client, quote, number and date came from the user, or meta.fictional is true.
  • Nothing from the example remains.
  • The pre-publish report is in your message, or check.mjs --publish exits 0.
  • You told the user the forms are demos and do not send.
Read the full SKILL.md
---
name: margin-notes-portfolio
description: Builds a portfolio with personality for an independent developer, product designer, other solo professional or small studio, as one offline HTML file with hash routes rendered from one JSON block. The look is a working notebook on warm paper with a large serif headline in one strong ink, hand-lettered margin notes, self-drawing arrows and flowers, tape, sticky notes and an ID-card note. Includes work filters, case-study tabs, a price estimator driven only by the user's own prices, FAQ tabs and honest demo forms. English or Chinese. Use when someone asks for a personal or studio portfolio site with character, or asks for this style by name.
version: 1.0.0
---

# Margin Notes Portfolio

You write one JSON file. The engine draws everything else. You never edit `assets/engine.*`, and you never need to read them: this file tells you every field, route and automatic behaviour. `scripts/check.mjs` fails if the engine is changed.

`references/style.md` holds the exact tokens if someone asks about them. `references/example.en.json` (English) and `references/example.zh.json` (Chinese) are complete finished examples you may read for shape. Their words are not to be reused; the checker catches leftovers.

## Inputs to ask for

Ask once, in one message, and only for what the brief lacks.

Required: name; what they do in a few words; whether the person is real or invented; the language (one language per file, `en` or `zh`).

Worth asking: 1 to 12 projects with what really happened on each; services with their real prices; a photo; an email address; real client quotes with where they came from.

Never ask the user to fill in a form you could fill from what they already gave.

## When things are missing

- **The brief is thin.** Restate only the user's own facts in better words. Never invent clients, quotes, awards, numbers, employers, dates or results. Leave the field out. List every line you inferred in your final message.
- **Nobody can answer.** Proceed with what you have, leave the rest out, and list your assumptions at the end.
- **A section has no content.** Leave the whole key out. The route, nav link and footer link then vanish by themselves. Do not write "coming soon". A page with only a person, a role, a headline and a contact line is valid and passes the checker.
- **No photo.** Leave `person.portrait` out. A draft draws an original doodle self-portrait with a visible label saying it is a placeholder. This passes the default check; `--publish` fails on it until there is a photo, or until the user chooses the doodle and you set `person.keepDoodle` to `true`. Tell the user. Never generate or use a realistic person.
- **No project images.** Leave `cover` out. If no shown project has a cover, cards become ruled index cards. If some do, the bare ones get a drawn panel of the same shape. Both pass the checker.
- **No real link or email.** Leave `url`, `links` or `email` out. Never use a made-up address on a real person's site. For a fictional person, an address ending `.example` is fine and makes the contact page work. With neither email nor links, visitors cannot reach the person: the report says so and `--publish` fails.
- **Thin experience or privacy.** An experience entry needs at least two of role, org and period; a thinner one is left out, and with none left the route is absent. For a privacy page with nothing supplied, leave it out, or set `privacy` to `{"auto":true}` for two plain sentences true of this one-file page. Remove that once the page gets hosting or analytics.
- **No price known.** Use `{"type":"request"}` if the service exists but has no figure. Leave `price` out if the user did not want prices shown. Never estimate a price for them.
- **A different length.** Limits below are hard. To fit, cut words. For more projects, add projects: layouts adapt from 1 to 40.
- **Invented example.** If the person is not real, set `meta.fictional` to `true`. A single fictional label then appears at the top of every route. Do not write any other disclaimer: the checker fails repeated ones. For a real person, set `false`.

## Steps

1. Write `content.json`. Start from `assets/starter.json`, whose `lang` is `en`: set it to `zh` for a Chinese site. Delete keys you have nothing for.
2. Put image files beside it and reference them with relative paths.
3. Build a draft: `node scripts/build.mjs content.json index.html` (about a second). This embeds images and only the Chinese font slices the text needs. A draft shows the placeholder note.
4. Check: `node scripts/check.mjs index.html` (about a minute: static rules, then every route and state at 1280, 768 and 390, motion on and reduced, touch emulated). Fix `content.json` and rebuild until it passes.
5. Drive and look: `node scripts/drive.mjs index.html shots/` (about two minutes). It saves a screenshot of every route and state. Open and look at every route at phone and desktop, the estimator, one form state and the lightbox. Passing the checker does not excuse you from looking. To fix a bad line break, rewrite the words.
6. Read the pre-publish report that `build.mjs` and `check.mjs` print: placeholders, missing contact, sample addresses, fictional person.
7. Deliver. If the report lists anything, deliver as a draft and pass that list to the user word for word. Only when it is empty: `node scripts/build.mjs content.json index.html --publish`, then `node scripts/check.mjs index.html --publish`, which fails on anything still on the list. A published build hides the placeholder note. Report what you left out, what you inferred, and that the forms are demos.

**Playwright:** the visual checks need it. If `check.mjs` cannot find it, it exits 3: run `npm install playwright` and `npx playwright install chromium`, or set `NODE_PATH` to a `node_modules` that has it. `--static-only` skips the visual checks; say so in your report if you use it. Run one browser at a time.

## The content file

Lengths are in characters, written English / Chinese. Over the limit fails. Every field except those marked required may be left out. Text fields accept `**bold**`, `*italic*` and `[label](url)` only where marked *inline*.

### meta (required)
| Field | Meaning | Limit | Thin-brief rule |
|---|---|---|---|
| `lang` | `en` or `zh`. Sets the interface language. Other-language words fail the check. | | Required. Follow the user's language. |
| `fictional` | `true` if person and work are invented; adds one label. | | Required. If unsure, ask; if nobody answers, `true`. |
| `title` | Browser tab and home title. | 70 / 40 | Required. Name and role. |
| `description` | Search description. | 160 / 90 | Required. One sentence from their facts. |
| `currency` | Three-letter code such as AUD or CNY, used by every price. | 3 | Required once any price has an amount. Never guess it. |
| `priceStyle` | `written` (default) or `symbol`. On Chinese pages in CNY, written shows 8000 元起; symbol shows ¥8,000 起. Other pages use the currency symbol either way. | | Leave out. |
| `ink` | `blue` (default), `green`, `oxblood` or `plum`. The one headline ink. | | Leave out. |
| `fictionalNote` | Replaces the label's wording. | | Leave out. |
| `verified` | List of exact phrases the user really supplied that contain a claim, figure or award. | | See "Proof" below. |

### person (required)
| Field | Meaning | Limit | Thin-brief rule |
|---|---|---|---|
| `name` | On the ID card. Required. | 48 / 24 | Their words. |
| `short` | Top-left brand if the name is long. | 24 / 12 | Leave out unless the name is over 24. |
| `role` | Hand-lettered above the headline and on the card. Required. | 70 / 36 | Their words. |
| `location` | On the card. | 40 / 20 | Leave out if not given. |
| `status` | The sticky note on the portrait, such as "Booking for spring". | 60 / 30 | Leave out; never invent availability. |
| `email` | Card, contact page, and the "send from your email app" button. | | Leave out if not given. |
| `links` | List of `{label, url}` on the contact page. | | Leave out if not given. |
| `portrait` | `{src, alt, focal}`. Real photo cut into the soft blob; opens whole. | alt 140 / 70 | Leave out for the doodle. |
| `keepDoodle` | `true` only if the user chooses the doodle over a photo. Lets `--publish` pass. | | Leave out. |

Image object everywhere: `src` relative path (png, jpg, webp, gif, svg), `alt` required, `focal` optional such as `"50% 25%"` to keep that point in crops, `caption` optional, 140 / 70. Images in lightboxes are always shown whole.

### home (required)
| Field | Meaning | Limit | Thin-brief rule |
|---|---|---|---|
| `headline` | The very large serif line. Required. | 60 / 26 | One plain sentence from their own description, or just their name. |
| `mark` | Exact part of the headline that gets the hand-drawn underline. | 18 / 8 | Leave out if unsure. |
| `lede` | Under the headline. *inline* | 220 / 110 | Restate their one-liner. |
| `note` | Margin note with an arrow. First person is fine. | 70 / 34 | Leave out rather than invent a personal detail. |
| `workNote` | Margin note beside "Selected work". | 60 / 26 | Leave out. |
| `featured` | Slugs of projects on the home page. | | Leave out: the first four are used. |

### work: list of projects
| Field | Meaning | Limit | Thin-brief rule |
|---|---|---|---|
| `slug` | Lowercase letters, digits, hyphens. Route `#/work/slug`. Required. | 60 | Make from the title. In Chinese use pinyin or English. |
| `title` | Required. | 60 / 26 | Their name for it. |
| `summary` | One or two sentences on the card and top of the case study. *inline* | 180 / 90 | Their facts only. |
| `client`, `year`, `role` | Shown if given. `year` drives sorting. | 40 / 20, 12, 60 / 30 | Leave out if not given. Never name an invented client on a real site. |
| `tags` | Up to five. Feed the filter chips. | 20 / 10 each | Use one or two plain kinds. |
| `tools` | List shown in the case study. | | Leave out if not given. |
| `url`, `urlLabel` | A real live link. | | Leave out if none. |
| `note` | Margin note by the case-study title. | 70 / 34 | Leave out rather than invent. |
| `cover` | Image object. Card and case-study top. | | Leave out; see above. |
| `previews` | Tabs. Each `{label, text?, items?, image?, images?}`. One item shows without tabs. | label 20 / 10, text 900 / 450, items 240 / 120 | Leave out if there is nothing to show. |
| `sections` | `{heading, body}`, where `body` is a block list. | heading 60 / 26 | Write only what they told you. Two short sections beats four padded ones. |
| `outcome` | Sticky note at the end: what really happened. *inline* | 500 / 250 | Leave out if not told. Never invent a result. |

**Blocks** (`body` lists): a plain string, or `{type, text, note, items, image}`. Types: `p` (*inline*, 900 / 450), `h`, `quote`, `list` and `steps` (`items`, 240 / 120 each), `code` (1500), `image`, `flower` (a small drawn divider). Any block may carry `note`: a hand-lettered margin note, 70 / 34, in the margin on wide screens, under the block on phones. Use about one note per section. Leave notes out if you have no real aside to make.

### services: list
`slug`, `title` required (48 / 20). `summary` *inline* (180 / 90). `price` (below). `priceNote` (120 / 60). `body` blocks. `includes`, `excludes`, `steps` lists (200 / 100 each). `note` (70 / 34). Routes `#/services`, `#/services/slug`. Thin brief: use only what they listed; leave `excludes` out if they did not say.

### Prices
Used by services and the estimator, and always shown through one formatter:
| `price` | English | Chinese |
|---|---|---|
| `{"type":"fixed","amount":2400}` | $2,400 | ¥2,400 |
| `{"type":"from","amount":14000}` | From $14,000 | ¥14,000 起 |
| `{"type":"per","amount":180,"unit":"screen","min":1,"max":12}` | $180 per screen | ¥180 / 张 |
| `{"type":"request"}` | Price on request | 价格面议 |

Chinese figures above are for `meta.priceStyle: "symbol"`. By default a CNY page shows 2400 元, 14000 元起 and 180 元 / 张. To show the user's exact wording for one price, add `text` (24 / 14), such as `{"type":"from","amount":8000,"text":"8,000 元起"}`: it replaces the formatted price wherever that price is listed, and must contain the same figure. Totals still use the format above.

Amounts are numbers the user gave you. Prices written in prose must match one of these amounts, or the check fails.

### estimator
`groups`: list of `{id, label (48 / 20), select: "one"|"many", items}`. Each item: `{id, label (48 / 20), note (90 / 45), price}`, price required. Also `title`, `lede` (220 / 110), `note` (160 / 80, replaces the "not a quote" line), `handNote` (70 / 34). Route `#/estimate`.

Behaviour you do not write: the total appears only after something is ticked. Fixed and per-unit add up exactly. Any `from` turns the total into "From X". Price-on-request items are never added: they are listed by name beside the priced total, and if only those are ticked no figure shows. Never add a `total` field; the check fails. Thin brief: no prices given means no estimator; leave the key out.

### Other sections
| Key | Fields and limits | Route | Thin-brief rule |
|---|---|---|---|
| `about` | `headline` 60 / 26, `lede` 220 / 110, `note` 70 / 34, `body` blocks, `principles` (up to 6 sticky notes, 90 / 45), `principlesTitle` 48 / 20, `facts` list of `{label 20 / 10, value 90 / 45}` | `#/about` | Only their facts. Leave out principles rather than invent beliefs. |
| `experience` | list of `{period 24 / 20, role 60 / 30, org 48 / 24, note 240 / 120}`; each needs two of role, org and period | `#/experience` | Only real roles; leave out if none. |
| `journal` | list of `{slug, title 70 / 30, date "2026-03-14", summary 180 / 90, body}` | `#/journal`, `#/journal/slug` | Leave out. Never pad with invented articles unless asked to draft them. |
| `faq` | list of `{tab 20 / 10, items: [{q 110 / 50, a 500 / 250, note 70 / 34}]}`. One group shows without tabs. | `#/faq` | Answer only from what they said. |
| `contact` | `title` 60 / 26, `lede` 220 / 110, `note` 70 / 34, `form` (`false` removes the form) | `#/contact` | Include if there is any way to reach them, or for a fictional page. |
| `newsletter` | `title` 60 / 26, `intro` 220 / 110, `cadence` 70 / 34, `note` 70 / 34, `url` (a real sign-up page) | `#/newsletter` | Leave out unless they have or want one. |
| `privacy` | `lede` 220 / 110, `body` blocks, or `auto: true` | `#/privacy` | Leave out, or use `auto`. Only say what is true of how this page is really hosted. |
| `testimonials` | list of `{quote 320 / 160, name 48 / 24, role 60 / 30, source}`. `source` says where the user got it and is not displayed. | home | Only real quotes the user supplied. No source, no quote. |
| `workIntro`, `servicesIntro`, `journalIntro` | `title`, `lede`, `note`, same limits as `contact` | index pages | Leave out. |
| `labels` | Rename `work`, `services`, `journal`, `selected`, `about`, `contact`, `estimate`, `experience`, `faq`, `newsletter`, `privacy`, `latest`, `quotes` in nav and headings. | | Use for other professions: `{"work":"Weddings"}`. |
| `footerLine` | The large footer line. 60 / 26 | | Leave out for the neutral default. |

**Proof:** figures and claims like percentages, multiples, "award", "trusted by" or "10 years" fail the check as possible invented proof. If the user really gave you one, copy its exact phrase into `meta.verified`. Never add a phrase there that the user did not give you.

## Routes and automatic behaviour

Routes: `#/`, `#/work`, `#/work/slug`, `#/services`, `#/services/slug`, `#/estimate`, `#/about`, `#/experience`, `#/journal`, `#/journal/slug`, `#/contact`, `#/faq`, `#/newsletter`, `#/privacy`. Anything else, including a section with no content, shows the designed 404.

The page does these itself, so do not try to:
- Work layout follows the count: 1 is a wide card, 2 and 4 are pairs, others fill rows with no gaps.
- Filter chips appear at 3 or more projects with 2 or more tags. Search and sort appear at 4 or more. With 1 or 2 projects there are no controls, which is intended, not a fault. Filter state lives in the URL.
- Tabs with one item show without a tab strip. The testimonial carousel has controls only with 2 or more quotes, and does not auto-advance.
- Very long words shrink the headline until it fits. Chinese lines break only between words in all text, and last words stay paired. A hand note holds about 9 to 12 Chinese characters per line in the margin; if a note wraps badly, shorten it.
- Tall images in body text are limited to 72% of the screen height and open whole in the lightbox.
- Doodles draw once per route on first sight, and appear at once under reduced motion. Text is never animated or hidden.
- Forms validate, then show a confirmation that says plainly nothing was sent. If `person.email` exists, a button opens the visitor's own email app. To collect messages for real, the file needs hosting and a form service: that is outside this pack, so tell the user.
- The estimator's button carries the selection into the contact form.
- Images open whole in a lightbox: arrows, Escape and swipe all work. Crops respect `focal`.
- Fonts embed from the pack: Fraunces, Newsreader, Caveat, plus Noto Serif SC, LXGW WenKai and Long Cang for Chinese. All are open-licensed, with the licence in a comment in the file. Characters outside GB2312 fall back to a system font and the build names them.

## What check.mjs fails on

Engine changes; external requests; placeholders; leftover example text; possible invented proof; repeated or missing fictional label; a demo form that claims a message was sent; a price in prose missing from the price rules; over-limit fields; the other language; page overflow; text off screen or clipped; low contrast; text hidden by effects; stranded words or characters; split Chinese words; large empty regions; doodles crossing text; notes overlapping; a covered placeholder label; tap targets under 44 px on phone; broken tabs, filters, estimator, forms or lightbox.

If a line-break failure persists, rewrite the words. Do not ask for an exception. Empty-region failures are never fixed by adding words: short content is always acceptable. If one appears on a page with little content, remove the empty section or report it as a pack fault; never pad.

## Deliverables

One `index.html`, offline, labelled draft or published, and a short note of what you left out, inferred or assumed, including the pre-publish list if any.

## Before delivering

- [ ] `check.mjs` exits 0 with the visual checks run (or you said they did not run).
- [ ] You looked at every route at phone and desktop, and at the states from `drive.mjs`.
- [ ] Every client, quote, number and date came from the user, or `meta.fictional` is `true`.
- [ ] Nothing from the example remains.
- [ ] The pre-publish report is in your message, or `check.mjs --publish` exits 0.
- [ ] You told the user the forms are demos and do not send.

## When not to use it

For a sober corporate site, a large catalogue needing a real CMS, a shop or anything that must really receive messages without further hosting. For a photographer whose images must dominate every screen, a gallery-first layout will serve better. It has one language per file; make two files for two languages.

## Requirements

Node 18 or later. Playwright for the visual checks (`npm install playwright`; `NODE_PATH` if needed). No network at build or view time.

Used in these workflows

Similar skills