Implementation plan & handoff
Read this first. It is the single source of truth for the project’s decisions, the target architecture, and the traps. It exists because the prototype was built in one long session and that context must not live in one machine’s head.
Companion docs, both current:
| Doc | What |
|---|---|
Everything invented as a stand-in, and what to ask Veronika for |
|
German legal requirements - the protected title, DSGVO, Impressum |
|
|
How to run the browser checks |
1. What this is
A single-page scrolling portfolio for Veronika Markstädter, an architecture graduate (B.Sc., currently doing a Master’s). Her projects are university work from 2021-2026, not commissions. The site exists to get her interviews.
Two people to keep distinct in your head:
-
Veronika - the artist. The design is hers; her Affinity file is the source of truth. She reviews visually and her notes are about feel ("looks like a soft cover", "the page has a dent"). Those notes have consistently been correct even when the stated cause wasn’t - diagnose before acting.
-
Stoffel - the developer, who relays her notes and makes the engineering calls.
Target stack: Rust + Axum + Leptos, with shadcn-like components written into this codebase. No component library. No icon library dependency. No CDN.
Current state: mockup/ is a complete static prototype of the desktop design. Its CSS is meant to transfer into the Leptos components, not be thrown away. Nothing Rust exists yet.
2. The working agreement - do not re-litigate these
These were decided with reasons. Changing them needs a new decision from Veronika or Stoffel, not a refactor.
2.1 She defines it, we build it
The booklet is not an image she exports. It’s a component we build to her spec from materials she supplies.
| She supplies | We build |
|---|---|
Paper texture ✅ (kraftpapier.jpg, in use) |
Cover / sheets / inner page structure |
Clip artwork (we drew stand-ins from her photos) |
Title, type, year - as real text |
Per-project cover drawings ❌ (one placeholder repeated) |
Accent band driven by a colour prop |
Measurements, light direction ❌ |
Open/close animation, all shadows |
A designed example page ❌ |
Layout, responsiveness, accessibility |
Per-project images would need re-exporting on every title change, couldn’t animate the cover independently of the pages, and would make text unselectable, untranslatable and invisible to screen readers.
2.2 Booklets are literal booklets
The central metaphor. Interactions that break it read as wrong to her even when they "work".
-
Stacked shadows behind a cover are pages inside the booklet, not card shadows.
-
The white A4 frame is a page from that booklet, not a generic panel.
-
Closed on load. Click opens; clicking the same one closes. Disclosures (
aria-expanded), not tabs - tabs cannot close on re-click, sorole="tab"would promise behaviour the page doesn’t have. -
Opening is a cover swing:
rotateY(-24deg)off the left spine under a short 1400px perspective. The strong foreshortening is what reads as a rigid hard cover - she chose this specifically.
Rejected, with reasons - do not "improve" back:
-
A wider 42° swing under a flat 2200px perspective, with a raking light gradient: read as a limp soft cover. Shallow angle + short perspective + flat even shading = hard board.
-
Rotating the cover over nothing: reads as a warped card. A cover swing is only legible if a different surface is revealed underneath.
-
Per-project colour nuance (shifting each booklet off its category hue): she wants one flat colour per category.
-
Desaturating unpicked booklets; growing the accent band on the open one.
The hard-cover trade-off (measured): at 24° the revealed strip is 11px and inner-page content sits ~30px behind the cover edge. You never see inside the booklet. So inner-page artwork is pointless at this angle and was withdrawn as an ask. Rigid hard cover or visible interior - not both. Don’t add interior detail without widening the angle, and don’t widen the angle without asking her.
Loose sheets must be smaller than the cover and set askew. Same-size sheets merely offset read as one oversized page sticking out - she flagged exactly that.
2.3 Zero external requests
No CDN, no Google Fonts, no reCAPTCHA, no embeds, no webfont service, no third-party form service. Fonts self-hosted; icons inlined.
Not performance nitpicking. Serving fonts from Google transmits the visitor’s IP to Google with no legal basis - LG München I, 20.01.2022, Az. 3 O 17493/20, rechtskräftig. The court rejected legitimate interest precisely because self-hosting is possible, which is why that holding survives the EU-US Data Privacy Framework. Nothing in the reasoning is Google-specific: cdnjs is Cloudflare, a US company, and raises the same issue.
The Datenschutzerklärung states as fact that no third party is contacted. If someone adds a CDN, that page becomes a false statement in a legal document. mockup/tests/net-audit.mjs asserts it. Keep that test alive in the Rust build.
Watch for @import url('https://fonts.googleapis…') buried inside a CSS file. That’s how it comes back.
2.4 "Architektur" is a protected designation
Veronika is not entitled to it - that needs chamber registration (degree + 2 years' practice). Protection reaches "Architekturbüro", compounds with "Architektur", and the English "Architect".
-
✅ "Bachelor of Science, Fachrichtung Architektur" (current wording), "Masterstudentin"
-
🚫 "Architektin" anywhere; a domain or
<title>or OG title likeveronika-architektur.de
Full detail and the open questions in LEGAL §0.
2.5 Nothing invented ships as hers
Placeholder copy carries data-placeholder and renders visibly faded/italic so it cannot quietly ship as real. Preserve this in the Leptos port. Every stand-in is tracked in ASSET-REQUESTS.
3. Design tokens
Sampled from her Affinity exports, not eyeballed.
| Role | Hex | Notes |
|---|---|---|
Brand orange |
|
Large display text only |
Orange text |
|
Small text + button fills - see below |
Cream (page) |
|
|
White (panel) |
|
|
Ink |
|
Why two oranges. Her brand orange fails WCAG AA for normal-size text on every surface here: 3.87 on cream, 4.39 on white, 3.66 on the kraft cover, 4.42 for white-on-orange buttons (AA needs 4.5). It passes only as large display text. --orange-text is the minimum darkening that passes everywhere while holding her hue (18.6°) and saturation (84%) exactly - lightness 44% → 38%, measuring 5.00 / 5.67 / 4.73. To revert to one colour, point both tokens at --orange.
3.1 Layout container
One centred content box. --content-max: 1600px, --gutter: clamp(20px, 4vw, 60px), --content-inset: max(var(--gutter), (100% - var(--content-max)) / 2).
This replaced the 10.4vw / 4.8vw / 36vw margins reverse-engineered from her 1920 artboard. Those were never a design decision - they were an artefact of measuring one canvas, and they never actually centred anything.
⚠️ 100%, not 100vw. 100vw includes the scrollbar and would throw everything ~7px off centre. % resolves against the containing block, which is what you want. Verified: left inset equals right inset to the pixel at 1920 / 1600 / 1280 / 1024 / 768, with every block sharing one left edge (tests/centre-test.mjs).
The nav is now genuinely centred (left: 50%; translateX(-50%)) rather than her artboard’s 36vw which merely looked it. The identity no longer travels sideways during the collapse - both states sit on the container’s left edge, so it only shrinks and rises.
Category colours - from docs/design/doc.odt. Colour belongs to the category, not the project, and repeats:
| Category | Her word | Our hex |
|---|---|---|
Abschlussarbeiten |
Rot |
|
Entwurfsprojekte |
Gelb |
|
Wahlfächer |
Grün |
|
Praktische Arbeiten |
Blau |
|
Her constraint: "alle Farben sollten erdig und kräftig sein." These four hexes are ours - the tints in her mockup read pastel, not kräftig. She should confirm or replace them. The mapping itself is settled.
Fonts. The Affinity original is Bradley Hand, which will not ship (their decision). Kalam 400 is a knowing stand-in - match any replacement at weight 400, not bold; every handwriting font’s Bold reads as a cartoon beside her thin marker stroke. Card typography is a separate pair, confirmed by her Kritzl concept: serif for titles, sans for text (currently EB Garamond + Inter).
Paper. Her own kraftpapier.jpg, mirror-tiled to 384px (her scan is not seamless), flattened to luminance, compressed to mean 245 / stddev 4, multiplied over cream with every base lifted to compensate. Deliberately not used at its own brown: on real kraft #BA9572 her orange titles measure 1.61:1 and the ochre band 1.01:1 - ochre is four of eight booklets and would vanish, taking the category system with it. If she wants brown paper, the accent colours and title colour must be re-picked. See ASSET-REQUESTS §6.
4. Target architecture - Axum + Leptos
4.1 Why SSR, and the constraint it imposes
SSR + hydration (cargo-leptos). A portfolio needs to be indexable and to render instantly on a bad connection at a firm.
The constraint that shaped the prototype: anything visual derived per-item must be deterministic, never Math.random(). A random value differs between server and client and is a hydration mismatch. The prototype already obeys this - clip type, clip offset/rotation, and paper-grain offset are all fixed per index. Keep it that way.
First paint renders with --t: 0, i.e. the full hero. That is the correct initial state, so SSR needs no special casing.
4.2 Layout
Cargo.toml # workspace; leptos ssr/hydrate features
src/
main.rs # Axum: leptos_axum routes, static files, fallback
app.rs # <App/>, Router, <Routes/>
data/
projects.rs # PROJECTS + CATEGORIES - port verbatim from mockup/app.js
components/
panel.rs # collapsing hero/header + the scroll driver
shelf.rs # scroll container + arrows
booklet.rs # <Booklet/> - the component boundary already exists in the mockup
project_page.rs # the A4 page
about.rs cv.rs contact.rs footer.rs
icon.rs # <Icon name="..."/> -> <svg><use href="/assets/icons.svg#..."/></svg>
disclosure.rs # the primitive: aria-expanded + ESC + inert + focus return
routes/
home.rs impressum.rs datenschutz.rs
style/main.css # port mockup/styles.css wholesale
public/assets/ # fonts/, icons.svg, kraft-grain.jpg, sketches, PDFs
mockup/index.html maps almost 1:1 onto these components - it was built with that split in mind.
4.3 Component notes
<Disclosure/> - build this first. Stoffel’s point, and it’s right: ESC-to-close is not framework configuration, it’s a keyboard handler someone writes. It belongs inside the primitive so every consumer inherits it, rather than being re-implemented per page. That’s the shadcn/Radix pattern this codebase is meant to follow. It owns: aria-expanded, aria-controls, ESC to close, focus return to the trigger (or the keyboard user is stranded), and inert on the closed panel so it leaves the tab order and a11y tree.
<Booklet/> props - already the shape in mockup/app.js:
title: String, type_: String, year: String,
category: Category, // -> accent colour
sketch: String,
clip: ClipStyle, // { kind: Wire|Binder, right_pct: f32, rot_deg: f32 }
grain_offset: (i32, i32),
The panel scroll driver. One window_event_listener(ev::scroll, …) writing --t onto document.documentElement, rAF-throttled. All interpolation stays in CSS calc() - do not recompute layout in Rust. Read --collapse-distance from CSS; the spacer geometry depends on that exact value and two copies will drift.
Optional: CSS scroll-driven animations (animation-timeline: scroll()) could remove the JS entirely. Check Safari support before committing; the JS path is the safe default.
4.4 Contact backend - the only real server work
A #[server] fn or a plain Axum POST /kontakt.
-
Legal basis is Art. 6(1)(f), not consent. No consent checkbox - see LEGAL §3. An Art. 13 notice by the submit button is what’s required.
-
Validate server-side and check the honeypot server-side. The client checks are UX only.
-
Mail via
lettre+ SMTP, credentials from env. No Formspree/Google Forms - that needs an AVV and a privacy-policy entry, and reintroduces §2.3. -
Rate-limit it (
tower_governoror a simple in-memory bucket). -
⚠️ Test that it actually delivers and doesn’t land in spam. A silently failing contact form on a job-seeking portfolio is the worst possible bug: it fails invisibly and costs her exactly the opportunities the site exists to create.
4.5 Build phases
-
Scaffold - cargo-leptos, Axum, static serving,
/,/impressum,/datenschutz. Portstyles.cssand the assets unchanged. Runnet-auditimmediately and keep it green. -
<Disclosure/>+<Booklet/>+<Shelf/>- the hard part; port the CSS verbatim. -
Panel - scroll driver + spacer geometry.
-
About / CV / Contact / Footer - mostly markup.
-
Contact backend - server fn, mail, rate limit, delivery test.
-
Responsiveness (§5) - needs a design decision from Veronika first.
-
Project content page (§7) - the biggest remaining feature.
5. Responsiveness - the biggest gap
The prototype is desktop-only and will break on a phone. This is not a polish pass; parts of the hero are structurally desktop-bound:
-
.identity,.nav,.contact-link,.introare still absolutely positioned inside the panel. They now anchor to the centred container rather than raw vw coordinates (§3.1), which helps, but they still do not reflow: on a phone the nav and the name will collide. -
The panel is
78vhtall with the hero name at6.8vw- on a phone that’s a 26px name in a panel taller than the content. -
The intro sits at
right: var(--content-inset); width: min(29vw, 30rem)- better, but it is still a second column that has nowhere to go on a narrow screen.
Already responsive: the shelf (columns step 4→3→2→1, arrows hide below 640px where the margins can’t hold them without covering a cover), the values grid, the skill list, the CV.
Needed before this can be built properly:
-
A mobile design from Veronika. There is none. Her Affinity file is 1920 only.
-
A decision on the mobile nav - the three links don’t fit a phone header. Hamburger? Stacked? Drop the nav and rely on scroll?
-
A decision on the hero on mobile - does the collapse still happen? At 78vh a phone hero fills the screen.
Suggested approach when the design exists: rebuild the panel’s interior as a flex/grid layout with the vw coordinates as a @media (min-width: 1200px) enhancement, rather than trying to patch the absolute positioning downward. The collapse itself (one panel, --t, CSS calc()) is resolution-independent and should survive.
Don’t guess this one. The whole design is hers.
6. Accessibility
BFSG does not legally bind a private non-commercial portfolio (LEGAL §5 - and note the agency blogs claiming a contact form triggers it are fear-selling). Build it accessible anyway: for an architecture portfolio the entire product is images.
Already done:
-
lang="de"; semantic heading order; real<label>s, not placeholder-as-label -
aria-expandeddisclosures;inerton the closed page; ESC with focus return -
Visible
:focus-visibleon every interactive element; the shelf is focusable and arrow-key driven -
prefers-reduced-motionhonoured - the cover swing is disabled, not just un-animated -
Contrast fixed:
--orange-text, see §3 -
All eight booklets stay in the DOM and tab order even when scrolled off the shelf
Still needed:
-
⚠️
alttext on every project image. Real work for drawings - "Grundriss EG, M 1:100" is useful;alt="image1"is not. Decorative images getalt="". -
⚠️ A skip-to-content link. Missing. With a fixed header and a long scroller, keyboard users have no way past the chrome.
-
⚠️ Her hero intro paragraphs are set in the handwriting font. That is her design, and it is the single worst case for dyslexic and low-vision readers. Raise it with her; don’t change it unilaterally.
-
Re-check contrast whenever a colour changes -
--orange-textwas derived, not chosen. -
Real screen-reader pass once content exists.
7. Open work
7.1 Pending notes from Veronika
Both done 2026-07-16:
-
Software next to / part of the Lebenslauf - moved in, between Praxis and Sprachen, sharing the CV’s column geometry.
-
All content centred - the artboard-derived margins are gone, see §3.1.
7.2 The project content page - the biggest feature left
Her Kritzl concept (docs/design/03-booklet-concept-kritzl.jpg, panel 3) gives the running order, and it is a spec:
-
Title block - title, subtitle, metadata (Leistungen / Ort / Jahr / Größe)
-
Hero image (full-bleed photo)
-
Konzept - text beside concept sketches
-
Grundriss · 5. Schnitt · 6. Materialität (swatch row) · 7. Impressionen (photo grid)
The current page (eyebrow, title, sketch, one paragraph) predates that reference and does not follow it. One fully-populated project is enough to build and validate the component.
⚠️ Note the reference disagrees with our booklet: its panel 2 shows the folder opening flat into a two-page spread. We use the 24° hard cover instead, on her instruction - so the content page must carry what that spread was carrying.
7.3 Waiting on Veronika
Portrait · per-project cover drawings · every line of real copy · CV facts (Hochschule, Abitur, internships, language levels) · her actual software list · the four accent hexes · a downloadable PDF portfolio + Lebenslauf PDF · light direction · does the cover open left or right.
Full list with export instructions: ASSET-REQUESTS.
8. Verification
mockup/tests/ - five suites driving a real browser over CDP. Not a framework; just the checks that caught actual bugs. Port or re-point them at the Leptos dev server.
| Suite | Guards |
|---|---|
|
Zero third-party requests. The legally load-bearing one. |
|
Disclosure semantics: closed+inert on load, swap, close, ESC |
|
No consent checkbox, Art. 13 notice, data minimisation, honeypot |
|
Arrows, one-booklet stepping, clips not sliced (open and closed) |
|
The section tracks the collapsing hero |
|
One centred content box, same left edge, at every width |
Run net-audit on every dependency change. It is the only thing standing between the Datenschutzerklärung and a false statement.
9. Traps - hard-won, don’t rediscover these
CSS
-
overflow-x: autoclips the Y axis too, at the padding box. The shelf needs92pxheadroom (--shelf-pad-top) because an open booklet’s clip reaches 79px above its own top: −31px offset, −22px lift, ~26px perspective throw. A negative margin claws it back. -
Never
width: max-contenton a grid whose columns are sized in%.100%then resolves against the grid’s own content width - circular. Columns silently shrink until everything fits and the scroller never scrolls. -
scroll-padding-leftis mandatory withscroll-snap+ a padded scroll container, or snap drags past the container’s own padding and parks atscrollLeft: 200. -
The hero spacer must track the collapse, or a ~290px blank band opens:
spacer = heroH − t·(heroH − headerH − collapseDistance). -
Multiply-blending a texture darkens the base. A mean-216 tile over cream 242 gives 205 - grey felt. Compress the tile toward white and lift the base to compensate.
-
Card
<button>s centre their text by default; her covers are left-aligned. -
emgaps in two grids that share a column geometry will not align if the font sizes differ. The CV and the Software list were 4px out for exactly this. Userem. -
100vwincludes the scrollbar;100%doesn’t. Centring withvwis off by ~7px.
Process - these cost the most time
-
Screenshot with the cache disabled. A stale asset made a fixed bug look unfixed for several rounds, and nearly made a broken cut-out ship while I was looking at proof it was fine.
-
A test that only checks the resting state is worse than no test. The clip-clipping check passed for days because it only looked at closed booklets, while the bug was plainly visible on open ones.
-
When a test goes red, suspect the test. Twice the code was right: stale assertions (hardcoded 4 booklets, old titles), and CDP not delivering keys to an unfocused headless window (a probe listener showed zero Escape events arriving - the feature was fine).
-
Measure before diagnosing. "Second row doesn’t fit" was vertical, not overflow. "The page has a dent" was the scrollbar. Her notes were right; the obvious cause wasn’t.
-
Dead code that renders nothing is invisible. Three layers were built that were geometrically impossible to see (an inner page behind an 11px reveal, a gradient under the cover, an accent edge on the bottom of a 2000px page). If you can’t see it on screen, delete it.