How I
built this

Welcome to the meta case study of how I built this website. For this project I can actually just share the raw data. Browse through my Figma file and GitHub repo, I tried to include explanations and information,

Design

Figma file →

My one and only Figma. Since starting my rookie journey as a about-to-be designer, Figma has always been my place to play around, no matter which use case. Affinity also started playing a role a bit, especially when it comes to print design. But Figma is my home turf and I enjoy working in it. Here the file where all the website came to life. I tried to keep a bit more artifacts than usually, take a look:

Code

GitHub repository →

This isn’t the first try at coding a website, my journey started with a blog in Jimdo, evolved to Webflow where we build the website for 2zero and ultimately went back to pure HTML/CSS. I still have to rely on coding agents to do the heavy lifting but I at least can understand the generated code and structure which helps to question the output of LLMs and also to do changes on my own. Take a look:

Max Pinkert — portfolio

Not the first try of creating a portfolio but hopefully a more long-term approach.

Me, myself and I, a source of my work, my profile, my skills, my random thoughts and anything in between and outside.

The repo (still not used to this lingo…) is public on purpose. My hope is to show you the behind the scenes of this design project in itself.

Status: trust the process

Start here

Depending on why you’re here:

👋 You want to know how I code behind-the-scenes/ which is fairly empty, as I’m still developing my coding skills and had to rely on Claude Code. Nonetheless I already included some skills I use on a regular basis and specifically for this project -> skills.

🎨 You’re a designer First of all, why are you here? You’re probably looking for my Figma file: https://www.figma.com/design/8SQOIPl0teOTvoFH1EffaB/Portfolio?node-id=29-39&t=TJXfA7cuqJFqoJID-1

I did also put down my thoughts on probably a heavily discussed topic on how to move from Design to Code. Honestly, I keep finding new inspiration on how to tackle this and very tough to keep up with all the updates regarding the infrastructure and tools of this process. It ended up as a skill rather than a document, because that is what actually gets read by the thing doing the work: → behind-the-scenes/skills/figma-to-astro.md, and DESIGN.md for what the tokens are for.

⚙️ You’re an engineer Don’t judge me and my beautiful repo. If you like my approach of trying to bridge Design and Code, feel free to get in touch and let’s work on our next projects together!

🤖 You’re an agent → CLAUDE.md. Read it before touching anything!!!

How work flows through this repo

In my previous life I was a Quality Manager, so I can’t write a document without including the word process in it. So here you go:

Figma (design)  ── Figma MCP // Claude CODE ──▶  repo (implementation)  ── Actions ──▶  GitHub Pages
                              ▲
                   Decap CMS ─┘  (.md)

All visual design is done by me in Figma and handed to Claude Code with Figma MCP as a Figma URL. Where there’s no design yet, the rule is: build restrained and token-driven, so the real design drops in cleanly later.

Stack

Concern Choice Why
Framework Astro Seems like a solid choice. As long as I don’t have any specific requirements, I’ll stick to it
Styling Plain CSS + custom properties Never really happy with how “Variables & Styles” work in Figma and how to translate this into CSS code. Know of any better solution?
CMS Decap CMS Writes markdown back into the repo. Looks like a nice minimalistic solution
Hosting GitHub Pages via GitHub Actions Honestly can’t be bothered to use more and more tools so was hoping to find a solution within the existing tool stack and GitHub pages is also free for my use case

npm commands

npm install
npm run dev        # http://localhost:4321
Command What it does
npm run dev Dev server with hot reload
npm run pdf Re-prints /resume and /handshake to their PDFs in public/
npm run tokens Regenerates tokens.css + tokens.json from DESIGN.md
npm run verify The one to run before pushing — check, build, HTML lint, format
npm run check astro check — 0 errors, 0 warnings
npm run build Static build into dist/
npm run preview Serve the built output
npm run format Prettier, write mode
npm run lint:html Accessibility + HTML validation against dist/
npx decap-server Local CMS backend so /admin works without OAuth

CI runs the same chain on every pull request, plus npm audit.

The design system in one screen

Tokens live in src/styles/tokens.css as one flat tier, named after the thing: --color-lemon-500, --space-md, --font-size-lg. Components use those names directly.

DESIGN.md is the source, written in the DESIGN.md format: its front matter holds the tokens, and both src/styles/tokens.css and design/tokens.json are generated from it by npm run tokens.

The kind of conflicts I have with Claude Code: “There is no role layer (--text-secondary, --surface-raised). There was one; it was removed on purpose. I design in terms of the palette, not in terms of roles, so a second set of names for the same values just meant translating in both directions — Figma to code, and back again in my head.”

The kind of AI filler text no one needs: “The palette is nature-inspired: Lemon (citrus yellow, the signature accent), Pickled (a sharp pink-red counterweight), Tomato (used sparingly as a signal), Herbs (a grounded green), plus warm-tinted neutrals so nothing sits coldly against them. The neutral ends are named rather than numbered — --color-neutral-white is #fdfcf8 and --color-neutral-black is #040302, because neither is a pure white or black.”

I guess this should rather be in CLAUDE.md, right Claude?: “The one rule that matters: never hardcode a value that has a token. The trade this makes is explicit — with no role layer, changing a colour is a real decision at every place it appears rather than a one-line remap, and nothing checks contrast for you. /styleguide renders every colour against every background it actually sits on, which is the check. A dark mode would be a genuine refactor rather than a remap, should it ever be wanted.”

Note to myself: How to add content?

Either through the CMS (/admin) or by hand: create src/content/projects/<slug>.md with the frontmatter documented in docs/cms.md — one file for both the fields and the editor.

A field lives in three places and all three must agree, or shit will hit the fan:

  1. src/content.config.ts — the zod schema
  2. public/admin/config.yml — the CMS form
  3. docs/cms.md — the explanation

Licence

Split, because the code and the work aren’t the same thing:

  • MIT — the site code, the config, the build tooling, and the agent skills in behind-the-scenes/skills/. Take them and use them and build beautiful things with them!
  • All rights reserved — the case studies, images, written copy, and the specific visual identity. Read it, link to it, quote it with attribution; don’t republish it as your own, just looking no touching!
  • Third-party fonts — neither of the above covers the typefaces. See below. I have a new kind of respect for typography after watching the Netflix Abstract Episode with Jonathan Hoefler, will for now and probably ever rely on professionals for typefaces.
Typefaces

Satoshi (Deni Anggara) and Erode (Nikhil Ranganathan, Jeremie Hornus) are © 2017–2021 Indian Type Foundry, both trademarks of the Indian Type Foundry, obtained through Fontshare and self-hosted from public/fonts/ under the ITF Free Font Licence.

JetBrains Mono is © 2020 the JetBrains Mono Project Authors, under the SIL Open Font License 1.1, installed from npm.

Terms and provenance for all three: public/fonts/LICENSE.


Max Pinkert · max.pinkert@code.berlin

/README.md

Design system rule book

All changes to this file need to be approved by Max.

Overview

Portfolio of Max Pinkert — UX and product designer working towards children and education technology.

With one flat token tier there is no --text-secondary to tell you a colour is for quiet text — that layer was deliberately removed, and this document is the replacement for it. Prose, not tokens. Nothing here introduces a name. If you find yourself wanting to add --color-status-error because this file mentions error states, stop: that is the role layer coming back in through the side door. See L4 in behind-the-scenes/skills/figma-to-astro.md.

The Figma file is the design decision, not a draft. Every value in it is a decision Max made, including the ones that look like oversights: a ramp that stops one step short, a colour that misses a contrast threshold, an asymmetry between two scales.

An agent’s job when it finds one of those is to solely report it. Say it in the audit, in the PR description, in the contrast table on /styleguide — all of those are the right channel. What is never the right move is resolving it: adding a token, darkening a value, extending a ramp, or swapping in a different step because the design appeared to need one.

The generation chain

This file is written in the DESIGN.md format (spec alpha, vendored verbatim). The front matter above is the one human-edited token source. src/styles/tokens.css and design/tokens.json are generated from it by scripts/build-tokens.mjs: change the front matter, run npm run tokens, never edit either output by hand. npm run tokens:check runs inside npm run verify and in CI, so a hand-edit or a stale output fails the build.

DESIGN.md and the Figma variable collections are the pair that has to agree, and Figma is the decision — a front-matter value that disagrees with Figma is reported, then corrected to follow Figma, never the other way round.

Group names in the front matter are the CSS custom property name minus its prefix, which is how the one-to-one Figma ↔ CSS mapping survives generation:

Front-matter group CSS custom property tokens.json path $type In export
colors --color-* color/<ramp>/<step> color yes
typography none text/* typography yes, as CSS names
fontFamily --font-* font/family/* fontFamily yes
fontSize --font-size-* font/size/* dimension yes
fontWeight --font-weight-* font/weight/* fontWeight yes
lineHeight --line-height-* font/lineHeight/* number yes
letterSpacing --letter-spacing-* font/letterSpacing/* dimension yes
spacing --space-* space/* dimension yes
rounded --radius-* radius/* dimension yes
shadows --shadow-* shadow/* shadow yes
motion.duration --duration-* motion/duration/* duration yes
motion.easing --easing-* motion/easing/* cubicBezier yes
sizes --size-* size/* dimension yes
layout --<key> — — no — Figma holds no variable
focus --focus-* — — no — accessibility, not design

Colors

Lemon

Primarily used for areas of user input, for example buttons or the custom cursor. And apart from that used as accent color.

Pickled

Primarily used for text of user input, for example ghost buttons. And as alternative accent color to Lemon.

Herbs

“Secondary” color used for the website areas inspiration and curiosity.

Tomato

“Primary” color used for the talking head, my work and other primary content.

Neutrals
Token Job today
neutral-white “Primary background”, meaning for example the page ground. body background (global.css, the reset).
neutral-100 “Secondary Background”, for example the first hierarchy level for the raised ground, things sitting above the page
neutral-200 up to -500 and -700 Additional shades for secondary text or backgrounds.
neutral-600 Quiet text, for example notes, footers, metadata.
neutral-800 Body text.
neutral-black Heading ink

Every page’s ground is neutral-white, and that is the default rather than a per-page choice. It is set once on body in global.css’s reset; a page that wants a different ground has to be drawn that way, and the frame’s own fill is what says so. neutral-100 is the step above it — the raised ground for something sitting on the page, not an alternative page colour.

Typography

Sans (--font-sans) Body text, subtitles and captions

Serif (--font-serif) Headings

Erode ships no drawn italic — Fontshare packages italics as separate *-VariableItalic files. The italicised word in the homepage slogan is a browser-synthesised slant until one is added to public/fonts/.

Mono (--font-mono) Always paired with --letter-spacing-extra-wide for readability The nav button at Figma node 109:1378 is the file’s exception: its raw settings use --letter-spacing-wide and line-height 1.6. Used for the chat interaction and subtitles.

Text styles

The five Figma heading styles live centrally in src/styles/global.css as element rules on h1–h5. Body, Mono and Captions styles are deliberately transcribed per component under a /* Figma text style: … */ comment so each component’s Figma diff remains readable. /styleguide’s Text styles table is the live index of all twelve named styles. The nav button at 109:1378 is the exception: its raw mono settings are not the Typography/Mono style.

Five of the twelve — body, body-large, body-bold, mono and captions — are inferred from component usage rather than transcribed from a Figma text style, and still need confirming against the file.

Layout

Kind Governed by Expressed as
Measure — how wide text may run characters, per face and size ch tokens, like the existing --measure: 68ch
Layout — columns, grids, gutters the page frame one grid definition, not per-page widths
Component — a fixed element the component last resort, named for the component

--size-* is the last resort, not the first.

The grid. Every 1280-wide desktop frame carries the same layout grid: COLUMNS ×10, gutter 24, margin 96 — so 1088 of content and an 87.2 column. The 375 Mobile frames draw ×5, gutter 16, margin 24. That is the page grid, and it is drawn on the frames rather than held in a variable, because a layout grid is not a Figma variable. src/styles/tokens.css transcribes it as --grid-frame (80rem), --grid-margin (6rem), --grid-columns (10), --grid-gutter (1.5rem) and --grid-column, which computes one column from the other four. Those five and --measure are the front matter’s layout group: a CSS-only group, generated into src/styles/tokens.css and excluded from design/tokens.json because Figma holds no variable for them. focus is the other such group.

The chat widths, reclassified. Both are layout, so both are column spans rather than tokens: the message column is 8 columns (54.1rem, against the 866 drawn on home - chat) and a message bubble is 5 columns (33.25rem, against 531). Computing them costs 0.4px and 1px respectively, and buys /projects the same maths instead of two more per-page widths. --size-chat-choice (400px) is the only --size-* left: four columns is 420.8px, so 400 is the component’s own cap — and the design’s own chip instances exceed it.

The page container. --content-max: 72rem is retired. No frame drew 1152; the drawn frame is 1280 with 96px margins, so .container is now max-width: var(--grid-frame) with var(--grid-margin) of padding above 48rem and --space-md (24px, the Mobile frame’s margin) below it. NavBar follows the same frame — 210:1625 Mode=Default is 1280 wide with its own 64px padding.

size/chat-column (866) and size/chat-bubble (531) still exist in the Figma Size collection with nothing in the CSS reading them. Their deletion is recorded in design/components.json under outstanding.variableWrites.

Elevation & Depth

The Figma effect styles Elevation / sm|md|lg map to --shadow-sm|md|lg by name. Read the style name, not the drop-shadow the MCP emits; the two agree today and both must change together. The Figma effect style md-lemon maps to --shadow-md-lemon: two identical lemon-500 layers, each at x 0, y 8, blur 32, spread 0 and full opacity. Layering increases color density without changing the geometry or the color token. Icon glows merge two independent copies of the same blurred source alpha; chained drop-shadows would instead cast a second shadow from the first and enlarge the effect.

Shapes

The rounded scale ships as --radius-sm|md|lg|full and comes from the Figma Radius collection. full ships 999rem while the Figma variable holds 9999px — both are a pill, and neither is derived from the other, which is why the generator carries that one value as an explicit exchange override.

Components

Atoms

https://www.figma.com/design/8SQOIPl0teOTvoFH1EffaB/Portfolio?node-id=114-14

Organisms

https://www.figma.com/design/8SQOIPl0teOTvoFH1EffaB/Portfolio?node-id=114-15

A component set published in those library pages may become a file in src/components/ before its first page use; anything not in the library stays inline on the page until its second use (CLAUDE.md R8).

States to draw

What a component needs before it can be built without invention: default, hover, focus, selected or active, disabled, and — for anything rendering a collection — empty. And the other half of the rule: where a state should not exist, the design has to say so, because silence reads as “not drawn yet” and invites invention.

Motion

  • --duration-fast + --easing-standard for hover and focus transitions — the chip’s background-color and box-shadow (index.astro:684-686).
  • --duration-base + --easing-entrance for a bubble or a choice group arriving (index.astro:563,577).
  • Arrival is staggered by animation-delay, and with the script running a row arrives when its own first character does, not on a fixed index stagger (index.astro:567-571).
  • The typing pace — 12ms per character, a 160ms beat between paragraphs in one bubble — is a page-level constant Max signed off, deliberately not a token (src/lib/chat-typing.ts:1-2).
  • Four prototype connections exist on /home — the Chat instances (115:806, 118:1469, 128:93, 128:117) navigate to home - skip (117:898) — but each carries transition: null, so no duration or easing is named in Figma and both still arrive as prose.
  • The caveat: global.css:149-162 zeroes durations under prefers-reduced-motion: reduce but not animation-delay.

Do’s and Don’ts

Every style declaration has to trace back to a decision in the Figma file — or to global.css, or to an accessibility requirement (focus rings, reduced-motion, contrast). Nothing else.

  • Do leave a state undesigned when the design leaves it undesigned. If the design does not say what a link does on hover, the answer is that links do not do anything on hover yet.
  • Don’t “pick something sensible”, and don’t pick a palette colour so that at least it is token-driven. An invented style is harder to find later than a missing one, because it looks deliberate. This covers hover and focus colours, shadows, transitions, radii, and any state the design has not drawn.
  • Do put a state that is genuinely needed before it is designed — a focus ring, say — in global.css where it is visible, as the accessibility requirement it is, and flag it.
  • Do run npm run tokens after changing the front matter.
  • Don’t hand-edit src/styles/tokens.css or design/tokens.json. They are generated, and npm run tokens:check will catch it.
  • Do measure contrast on /styleguide and report the number.
  • Don’t repaint the design to make a pairing pass.

Worked example — the two invented states.

What was invented What the frames drew What it cost
A “Run the prototype” submit chip No submit control at all Two rounds of review
A lemon fill on a ticked option No selected-option state Two rounds of review

Both were flagged honestly as invented, both were carried as open questions for two rounds, and both vanished when the interaction model was corrected — the cost was two rounds of review, not a wrong colour.

/DESIGN.md

Credits

I enjoy trying out different tools and playing around with them. And also as part of this portfolio website I took to several helpers and here is a small way to say thank you and at least credit your work so hopefully people read this and try them out themselves:

Release Notes

I have tried multiple approaches for my portfolio. To give you some idea of my iterations, I created this collection of release notes, clustered to user experience, user interface and tools.

  1. Learned already a lot in my first weeks at CODE University, not technically about the craft of design, but about me, what fascinates me about design? Which role do I want to play in the vast world of design? All that lead to the second iteration of my portfolio website.

    user experience

    I dropped my persona-approach, it felt like too much duplicates happening over time and also looking at other inspirational portfolios, I felt like I also would like to switch to a clean format of slogan, intro, projects and the possibility to connect or download my CV. Apart from that, decided to break down the projects into the challenge I was presented and the possibility to dive deeper for more information.

    user interface

    I’m sticking with the minimalistic design to give my projects the space for attention. I found my font for now: Poppins. I enjoy it because of its wide set of font weights and somehow feels right for now. I took away a lot of colors and focused more on black and white, as the projects have various colors which I don’t want to conflict with. I’m deeply missing some “design system” to standardise headings and body, which brings us to Tools.

    tools

    I still enjoy using Canva for this website, as it is super easy to create these iterations. Nonetheless, I’m reaching a limit. For example a simple design system to define text types, font and color would be helpful and the website isn’t mobile-friendly and only barely responsive for different desktop/notebook screens. I will look into good old Figma and their Website builder as a next version. The ultimate goal is to try something like Framer or even something vibe-coded with Cursor, but first focusing on iterating and getting to a stage where I feel confident with the website version.

  2. My first shot at a portfolio to represent the work and projects I’ve accomplished so far.

    user experience

    A very difficult task to nail down what the user should experience in my portfolio. Generally speaking, obviosuly me and my style to help them evaluate and understand me. But what is my style? And who am I? And if yes, how many? I started from scratch and thought of who would visit my page and what they might be looking for:

    Moritz: A slightly cringe anecdote to my childhood “Max & Moritz”, which nowadays is probably highly questionable in multiple ways. Moritz is my persona for the fellow designer, looking for connection or inspiration.

    Karina: Hands down the best recruiting experience I had so far, I wasn’t even fully interested in the role and just because of the well-designed recruiting process almost ended up in Sales at Finmas. So Karina is my persona for a typical recruiter, browsing my profile and hopefully forwarding my profile for the recruiting process.

    Paula / Jonathan: When I created this first version, I was binge-watching abstract on Netflix, the designer version of Chef’s Table. Getting a glimpse into the career and work of designers from multiple disciplines really inspired me and I therefore chose Paula Scher and Jonathan Hoeflinger as my persona for my future boss. Paula or Jonathan want to understand my skillset and preferably see my work and projects, probably being the second to the party after the recruiter, so already having a general idea of my CV.

    With these personas, I created separate landing pages and set the main objective for a clear and focused website interaction.

    user interface

    I wanted to create a minimalistic feeling and I want to be honest about my rookie design skills. So for example not overthinking a color palette but going with plain black, 100% red, 100% green, 100% blue and light grey. Or choosing just one font and playing with its weights instead of finding a solid font pairing. Apart from that the use of a handwritten font felt right to play with the brainstorming phase of each design and to leave a “blank” font to be replaced by something properly curated and designed later on.

    tools

    During my internship with BuchhaltungsButler, I had the luck to have a design buddy, Victor, who was working at a sister company of the Visma ecosystem. He recommended to give Canva a try, as a quick tool to publish a portfolio. That heavily lowered the starting barrier and freed me of over-thinking the whole portfolio/website task.