51 Commits

Author SHA1 Message Date
Dorian
b845957314 fix: don't require report_id/token on submit success
The deployed storage-free worker returns { ok: true } without
report_id/report_token, but the submit handler threw 'Your plan could
not be saved' whenever they were absent — showing an error even though
the email sent. Treat the worker's ok response as success and only
capture the tokens when the D1 worker provides them (restore link).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:09:45 +01:00
Dorian
0d3efe4139 refactor: remove dev-only paint/SVG-filter modifier panel
The hidden modifier (gear button + mod-panel aside, paint swatches and
filter-variable sliders) flashed as unstyled content on slow loads
before CSS/JS hid it. Removed its markup, the initPaintPicker() wiring,
and all .mod-*/.paint-* styles plus the now-dead paint-green/white
variants. Hero keeps the default dark glossy paint. Bundle: CSS -3.3kB,
JS -8kB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:04:57 +01:00
Dorian
97cc44a22e fix: repair lockfile sync and bump Docker Node for Vite 8
npm ci failed in the Portainer build: the lockfile had inconsistent
@emnapi native-binding entries. Regenerated package-lock.json so
'npm ci' is clean. Also bump the build image to node 22.12.0, the
minimum Vite 8 requires (22.11 was below its engines floor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:58:22 +01:00
Dorian
7e4da32c0c feat: add Cloudflare email worker and template updates
Worker (worker/) renders and sends the plan email via Resend, with
the matching email-template.html and a VITE_WORKER_URL example env.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:53:35 +01:00
Dorian
b0b4d41571 feat: add SEO meta, OG image, favicons and sitemap
- Full meta suite in index.html: description, canonical, robots,
  Open Graph + Twitter cards, theme-color and JSON-LD structured data
- 1200x630 OG share card matching the homepage Plan-B wordmark
- Favicon set generated from the intro storm icon (svg + png + apple
  touch + 192/512 PWA icons) and a web manifest
- robots.txt + sitemap.xml with hreflang alternates
- setLang() now syncs title/description/og tags on EN/DE toggle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:52:58 +01:00
Dorian
ff8331c54d chore: upgrade vite to 8 and plugin-vue to 6
Resolves the high-severity esbuild advisory (GHSA-gv7w-rqvm-qjhr).
vite 6/7 still pin vulnerable esbuild; vite 8.0.16 ships the patched
0.28.x. npm audit now reports 0 vulnerabilities and the build passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:52:49 +01:00
Dorian
722d9da968 chore: ignore temp SSH keys and env files
Keep .codex-temp-keys/ (local SSH key) and .env out of git and the
Docker build context so they never reach a commit or image layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:52:49 +01:00
Dorian
85810a4625 Improve email submit error state 2026-06-07 07:55:30 +01:00
Dorian
9857ec3f57 Handle worker email send failures 2026-06-07 07:51:20 +01:00
Dorian
2e4db34d2e Use site language for emails 2026-06-07 07:34:06 +01:00
Dorian
e6cddbf3e2 Add full Cloudflare worker replacement 2026-06-07 07:00:13 +01:00
Dorian
f335d0dc94 Add D1 report restore links 2026-06-07 06:52:22 +01:00
Dorian
91ca1db006 Add intro skip control 2026-06-07 06:48:22 +01:00
Dorian
7a94fe442d Fix email report section links 2026-06-07 06:45:29 +01:00
Dorian
959dcf1b1e Keep existing compose deployment identity 2026-05-18 08:52:40 -05:00
Dorian
8f00cb4327 Update Plan-B branding and email design 2026-05-18 08:48:38 -05:00
Dorian
35a64aa830 assessment intro tweaks 2026-05-10 19:11:58 +01:00
Dorian
390b795560 intro sentences change 2026-05-10 16:06:12 +01:00
Dorian
2cbd0b03fe change one line on desktop 2026-05-10 15:42:40 +01:00
Dorian
3b1ba36f97 intro timing refinements and such 2026-05-10 15:41:09 +01:00
Dorian
d7e33e24f8 removed vertical movement from intro texts 2026-05-10 15:36:20 +01:00
Dorian
efec720012 intro updates 2026-05-10 15:33:31 +01:00
Dorian
97005f1959 intro updates 2026-05-10 15:29:46 +01:00
Dorian
068d333c8a intro updates 2026-05-10 15:26:07 +01:00
Dorian
63b1d4a30e debossed instead of embossed background pattern 2026-05-10 14:01:44 +01:00
Dorian
464a236f58 background pattern tweaks 2026-05-10 13:23:55 +01:00
Dorian
185afdd5b1 language selector and visual tweaks 2026-05-10 13:16:38 +01:00
Dorian
e3851603ab intro animations and background animation 2026-05-10 12:43:41 +01:00
Dorian
acd3d2fa61 feat: hero intro overlay + embossed Plan-B background
Three-stage typographic intro that plays once per browser session
before the hero reveals, plus a subtle embossed-text pattern that
fades in underneath the wordmark afterwards.

Intro overlay
- Full-viewport #FAFAFA panel with three sequential stages: "Wisdom is
  to prepare" → "Even if crisis is not here yet" → "Figure out your
  Plan B in less than two minutes." (DE: "Weise ist, wer vorsorgt." →
  "Auch wenn noch keine Krise da ist." → "Finden Sie Ihren Plan B in
  weniger als zwei Minuten."). Both languages added to the i18n table.
- Each stage word-cascades — text is split per-space into <span class
  ="word"> at play time with an inline animation-delay; each word
  smoke-rises into place (0 → 1 opacity, blur 8px → 0, translateY 14
  → 0). 0.32s stagger × 1.5s per-word duration = half-speed cinematic
  pacing.
- Stages 1 + 2 exit with the introOut smoke-out (re-blur to 16px,
  drift up 14px, fade to 0 over 0.8s). Stage 3 stays on screen and
  rides the overlay's 1.1s fade as a single dissolve so we don't
  double-outro the closing line.
- During the intro the body lacks .intro-done; the hero/header/lang
  toggle are pinned to opacity 0 + animation-name: none. Once the
  loop completes the body picks up .intro-done and the original hero
  fadeUp animations fire from scratch.
- Replay is gated on sessionStorage.kammergut.intro.shown.v1 — runs
  once per tab. Skipped automatically when loadState() restores a
  saved quiz/results stage so a refresh mid-flow doesn't replay.

Embossed Plan-B background
- 7 rows × ~6 phrases of "Plan B" in mixed scripts (Plan B, Piano B,
  Plano B, План Б, プランB, 备用计划, 플랜 B, خطة ب, योजना बी, תוכנית ב,
  Σχέδιο Β) tiled diagonally at -18°.
- Embossed effect via text-shadow only: glyph colour = bg colour
  (#F0F0F0) so only the 1px white top-left highlight and ~6%-black
  bottom-right shadow render, looking pressed into the panel.
- font-size clamps from 42px (mobile) to 60px (desktop) so the
  pattern reads comfortably on small screens. DM Serif Display
  matches the wordmark family.
- Pattern is opacity 0 until body.intro-done, then fades in over
  1.6s with a 0.3s delay so it emerges underneath the cascading
  wordmark rather than appearing as a separate layer.

Hero / misc
- Removed the bg-1.jpg background — the hero is now flat #F0F0F0
  matching the white-paint inner-card colour. Mobile @media override
  (which mixed a 50% white veil over the JPG) dropped.
- Test page at public/intro-test.html with 10 cinematic styles plus
  matching outros for picking entry/exit variants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:27:38 +01:00
Dorian
67344976fe intro and bg 2026-05-10 12:18:48 +01:00
Dorian
e12faddb5d intro and bg 2026-05-10 12:13:03 +01:00
Dorian
6dca131724 fix: priority badges use proper traffic-light colours
The CRITICAL / HIGH / MEDIUM pills on the recommendation cards were
all routed through the brand-green palette (the --red, --orange,
--green-bright variables all resolve to greens), so urgency wasn't
readable at a glance. Switched to literal red / amber / yellow text
on matching soft-tinted backgrounds:

- CRITICAL  #C03030 on rgba(192,48,48,0.10)
- HIGH      #C16A18 on rgba(220,124,32,0.10)
- MEDIUM    #A48010 on rgba(212,168,32,0.12)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:55:21 +01:00
Dorian
8f74118a28 fix: stack the affiliate button full-width on mobile rec items
Replaced the inline-styled cost + button row inside each .rec-item with
a .rec-item-foot class so it can respond to media queries. Desktop
(>=768px) keeps the existing layout — cost left, button right via
space-between. Mobile (<768px) breaks the button onto its own row
beneath the cost and stretches it to full width with chunkier vertical
padding for a comfortable tap target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:52:40 +01:00
Dorian
78cfb14a77 feat: budget total row + timeline polish + paint slider thumb + auto-advance
Budget panel
- Removed the inner "Budget Allocation" subtitle and the inline total
  from bm-title — the outer panel header "Budget Plan" already names
  the section, the inner subtitle was redundant.
- Added a Total Allocation row at the end of the breakdown using the
  same .bcat layout as the category lines so label + amount columns
  align. Top divider + heavier weight mark it as the summary line.
  DE: Gesamtbudget. The bar slot is collapsed to height:0 on the
  total row so only label + amount render.
- Bumped .bm-title (when it existed) and .bcat-label / .bcat-cost so
  the budget figures read at a comfortable size.

Timeline
- Each .tl-item gets generous top + bottom padding (14px / 28px) so
  the steps read as distinct moments. Padding is consistent across
  every item so the connector positioning stays stable.
- Connector line is now a light dotted left-border drawn on a
  zero-width pseudo, anchored top:46px / bottom:-14px so the dotted
  line crosses the gap between items and reaches each next dot.
- .tl-when (step title) typeface switched from Space Mono to Barlow
  (matching the CTA buttons), weight 600, 0.16em tracking, dark green
  paint colour (#2a3010), vertically centred against the 32px dot via
  min-height + flex centring so the title sits at the dot's mid-line.

Quiz
- Slider thumb is now a CSS-painted dark green circle: linear
  gradient (#3a4220 → #2a3010 → #161a08) with an inset cream highlight
  and a soft drop shadow + halo ring, approximating the SVG paint
  gloss filter the CTA buttons use. Both -webkit and -moz pseudo
  elements styled.
- Single-choice questions now auto-advance 220ms after a tap so the
  Continue button is no longer needed on those steps. renderQuestion
  drops the Continue button on single-choice, keeps Back when not on
  the first question. .quiz-footer:empty hides cleanly on the very
  first single-choice screen.

Result panels
- .result-panel-body top padding bumped from 0 to 20px so expanded
  content has clear breathing room below the summary header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:39:08 +01:00
Dorian
302c8aae83 feat: green-paint icon system for results page
Replace the remaining emoji indicators on the post-quiz / assessment
side with inline SVG line-icons (Lucide-style, vendored as path
strings) sitting in dark-green-paint containers that match the CTA
button family.

Icons
- 13-icon set in ICON_PATHS — droplet, sprout, zap, cart, flame, leaf,
  package, radio, brain, lock, check, mail, shield. Single-stroke
  paths, currentColor for stroke so each surface tints them.
- icon(name, size) returns the SVG markup; iconForEmoji() maps the
  legacy emoji values used by the rec-cards / timeline data tables to
  the icon names so the data didn't need a wholesale rewrite.

Surfaces re-skinned
- Recommendation card chips (.rec-icon): 40×40 dark-green-paint chip
  with the paintGlossBtn filter, warm-cream icon stroke. Per-card
  inline category colour (`card.color`) dropped — every chip now reads
  as one family.
- Timeline dots (.tl-dot): same green paint, 50% radius. Per-step
  rainbow colours dropped.
- Affiliate button: tiny inline cart icon next to "View on Amazon".
- Narrative ("Your Personal Analysis"): brain icon in green replaces
  the 🧠 emoji header.
- Protein-offer badge: lock icon prefix replaces 🔒.
- Capture-success state: 56px green check-circle replaces , and the
  spam-folder note becomes a flex row with a mail icon — translation
  keys cleaned of the inline 📬 prefix.

Form-field emojis (e.g. "🥩 Protein access (optional)"), country flags,
and the in-form privacy 🔒 stay as-is per the explicit ask: "in
assessment, not the form fields that have them earlier".

Other tidy-up
- Scenario tabs: dropped 💀⚠️📈🌿 prefixes from tab_s1–4 (en + de) and
  the inline template defaults.
- Risk banner: removed the 🔴🟠🟡🟢 dot from the eyebrow line — the
  painted card colour already conveys severity.
- Panel headers stripped of emoji: "Recommendations" / "Empfehlungen",
  "Budget Plan" / "Budgetplan", "Action Timeline" / "Aktionsplan"; the
  📊 prefix dropped from the inner budget-meter title.
- "Tap a scenario to compare" hint moved out of the collapsed panel
  header into the expanded body, restyled as a centred full-width
  light-green paint banner.
- Modifier (⚙) toggle hidden via a single display:none rule — styles
  preserved so we can re-enable later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:03:28 +01:00
Dorian
eae55ddc3a fix: re-render JS-generated results on language switch
Several pieces of the results page were rendered once via JS and never
walked by the [data-i18n] selector, so toggling EN ↔ DE only updated
static text and left the dynamic chunks in their original language until
the user reloaded.

- Hooked previously-untranslated UI through data-i18n / data-ph-*: hero
  subtitle and CTA, country dropdown options (full DE list incl.
  Österreich, Deutschland, Schweiz, Vereinigtes Königreich, …), form
  input placeholders for first name, last name, email, city, phone, and
  the protein detail textarea, plus the inline "Sending…" submit state.
- Extracted the risk banner render into renderRiskBanner() and added
  rerenderResults() — called from setLang when body.results-active is
  on. It re-renders the risk banner, scenario rec cards, budget meter,
  timeline, and swaps the narrative to the new-language fallback so the
  whole page reflects the active language without a reload.
- Trimmed the German hero_sub from a long quiz-explainer paragraph back
  to the slogan ("Vorsorge, verfeinert.") to match the en hero_sub, and
  aligned hero_cta with the visible "Begin" / "Beginnen" button text.

The AI narrative is intentionally not re-fetched on language switch —
the fallback swap keeps the toggle instant and the AI version regenerates
on retake.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 09:34:17 +01:00
Dorian
25e7e29ad7 feat: results page redesign — modal form, paint-style cards, session save
The post-quiz results page is restructured around progressive disclosure
and the embossed-paint visual language used on the homepage CTAs.

Capture form
- Moved into a viewport-scaled modal with sticky header (title + close)
  and sticky footer (submit). Body scrolls between them.
- Privacy note now sits inside the form, above the newsletter checkbox,
  so the message is part of the consent flow rather than a footnote.
- Close button is the secondary white-with-border style; the X glyph is
  drawn from two CSS strokes so it stays pixel-centred regardless of
  font metrics.
- iOS-Safari-safe scroll lock: body becomes position:fixed with the
  saved scrollY pinned via inline top, restored on close.

Result panels
- Recommendations / Budget / Timeline use the white-paint container
  (#FAFAFA) and inner cards switch to the #F0F0F0 paint look that the
  quiz q-opt items already used. Text colours flip to the dark palette
  for legibility.
- Risk banner gets four paint variants — maroon (critical), amber
  (high), olive (medium), green (low / "PREPARED") — each rendered via
  ::before with the paintGlossBtn filter. Text is warm cream #f4ecd8.
- Narrative ("Your Personal Analysis") wears the same white-paint frame
  with a slightly greyer header band.
- Headings (risk title, protein offer, panel headers, narrative title)
  switched from DM Serif Display to Barlow to share the button typeface.

Scenario tabs
- Container loses its border + padding; inactive tabs are quiet text
  buttons. Active tab wears the green-paint CTA fill.
- Mobile lays them out as a 2×2 grid; desktop is a single row of 4.

Other tweaks
- Results section bumped 15% wider on desktop (max-width 690px).
- Site header gets a soft white fade on results-active so content
  scrolling underneath doesn't clash with the logo.
- Modifier (⚙) toggle hidden via a single display:none rule, styles
  preserved so we can re-enable it later.
- Protein button has a responsive label: "Secure Source Now" on mobile,
  "Secure protein source now" on desktop. Arrow icons removed from
  protein, retake, send-plan buttons. Unified primary-button typography
  across the site (var(--font-body), 400 weight, 13px, 0.2em tracking).

Session persistence
- Quiz progress (answers, currentQ, lang, scenario, stage) is saved to
  sessionStorage under "kammergut.state.v1" — purely client-side, dies
  when the tab closes. Stale sessions (>4h) are discarded on load.
- Reload mid-quiz or mid-results re-enters the right view. restartQuiz
  clears the saved session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:00:03 +01:00
Dorian
acfc7a0bde Merge branch 'results-progressive-disclosure' into main 2026-05-10 06:54:11 +01:00
Dorian
d8941485f7 feat: collapse results into progressive-disclosure panels
Wrap Recommendations (scenario tabs + cards), Budget, and Timeline in
<details> accordions. Recommendations stays open by default; Budget and
Timeline collapse closed so the post-quiz page doesn't dump everything at
once. The panel becomes the white-paint container — inner .budget-meter,
.timeline, and .scenario-tabs drop their own box treatment to avoid a
nested-card look. revealSections() now staggers the panel wrappers
instead of the inner containers, and added en/de strings for the panel
headers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 06:53:48 +01:00
Dorian
67dfa38e04 fix: use 100dvh so hero/quiz centre against the visible viewport on mobile
Mobile browsers report 100vh as the viewport at its largest (URL bar
hidden), so flex-centred content sat below the visual centre whenever
the address bar was showing. Add 100dvh declarations after the existing
100vh ones on body, #app, .app and .quiz-section (and on the .mod-panel
max-height) so modern browsers track the visible viewport while older
browsers keep the vh fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:14:11 +01:00
Dorian
d106661f68 fix: remove hover state from hero wordmark
The hero H1 swapped to paintGlossHover (and paintGlossWhiteHover for
the white variant) on pointer hover, repositioning the specular
lights. Drop both hover rules and the now-unused 0.6s filter
transition so the painted wordmark holds its look regardless of
pointer position. The hover-variant SVG filters stay in defs in case
we want hover back later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:38:37 +01:00
Dorian
775f76bd19 fix: drop spec1 from paintGloss so paint-green shows the green fill
specularConstant=0 wasn't producing a fully transparent specular
layer in Chromium — it rendered as opaque-black-masked-to-text and
got merged on top of the underlying paint colour, hiding the green
fill of body.paint-green behind a black silhouette. The dark-paint
default never revealed it because the underlying text was already
near-black.

Removed the upper-left feSpecularLighting / feComposite / feMergeNode
from paintGloss and paintGlossHover. Visible look is unchanged (the
contribution was already 0) and paint-variant background colours now
read through correctly. paintGlossWhite/Btn variants untouched —
their constants are non-zero, no black-mask risk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:30:15 +01:00
Dorian
4902423a88 feat: paint variants drive header logo colour + bake in spec1=0 baseline
- Site-header .logo and quiz-progress-bar .qpb-logo now follow the
  active paint variant (currently green) and read --mod-fill so they
  track live changes from the modifier panel's fill picker. White
  paint is intentionally excluded — a near-white logo would vanish
  against the light header background. Applies on both mobile and
  desktop.

- Paint H1 default updated to spec1Const=0 in both paintGloss and
  paintGlossHover (matches the "Copy snippet" dump), removing the
  upper-left specular highlight from the dark paint baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:25:14 +01:00
Dorian
de9a0f1bc8 fix: hero wordmark fills mobile/tablet width per language
The /7 divisor was calibrated for the wider "Kammergut" wordmark and
left ~25-45px of unused space each side of the narrower "Deepstock".
Switch to language-aware divisors that match each wordmark's actual
rendered-width-to-font-size ratio in DM Serif Display at 0.05em
tracking:

- DE / Kammergut (default): /6 — fills with ~16px gutter at viewport
  widths from 320 to 768.
- EN / Deepstock: /5.2 — narrower wordmark, smaller divisor needed
  to fill the same content area without clipping at 320px.

Desktop (>=900px) uses clamp() with separate ranges per language.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:18:56 +01:00
Dorian
64dc5cef9c fix: hero wordmark fills mobile/tablet width + Portainer pull workaround
CSS: hero h1 used `(100vw - 48px) / 7` on mobile and `-64px / 7` on
tablet — the wider gutter assumption left 12-16px of unused white
space inside the 16px hero padding. Tightened both to `(100vw - 32px)
/ 7` so the wordmark spans the actual hero content area. Divisor 7
preserves the safety margin for the wider "Kammergut" wordmark.

Compose: dropped the `image: kammergut:0.1.0` field. Older Portainer
Compose runtimes (pre-v2.20) ignore `pull_policy: build` and still
ran `compose pull` against the image name, hitting "pull access
denied". Without an image name there's nothing for compose pull to
attempt; build runs during `up`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:08:33 +01:00
Dorian
b7789bcb0c fix: add pull_policy: build so Portainer builds the image locally
Portainer's git-stack deploy runs `docker compose pull` before `up`,
which tried to fetch kammergut:0.1.0 from a registry and got
"pull access denied". The image only exists locally — `pull_policy:
build` tells Compose to build from the Dockerfile instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:55:04 +01:00
Dorian
832f7dd602 fix: revert font-display to swap so DM Serif hero loads bold
display=optional gave browsers only ~100ms to fetch DM Serif Display
before sticking with the system serif fallback — on any uncached
visit that meant the hero "Deepstock" title rendered thin (system
serif at weight 400) and never swapped to the thick display font.

Back to display=swap: brief FOUT on first paint, but the bold serif
is what users actually see.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:22:56 +01:00
Dorian
f4dadf1ec7 fix: restore landing page layout when navigating back from quiz
The qpb-logo's "back to home" path now soft-restarts via Vue's
@click.prevent="restartQuiz", but restartQuiz never reset the inline
styles startQuiz had set on body and .app to make the quiz scrollable
(height:auto / overflow:auto). On the locked-viewport landing page
those inline overrides collapsed the hero to its content height and
left a white half-screen.

restartQuiz now clears those inline styles so the CSS-defined
height:100vh / overflow:hidden chain takes over again. The original
single-file build never hit this because its qpb-logo was a hard
href="/" reload, which reset the inline styles for free.

Also harden the Vue mount layout: #app is now an explicit 100vh flex
column so the wrapper div Vue introduces between body and main.app
forwards the body's full viewport height (fallback for the same
collapse symptom on first load).

Switch Google Fonts to display=optional + add the gstatic preconnect
to eliminate the bold-flash FOUT on cold loads — fonts now render
either immediately (cached) or stay on the system fallback rather
than swapping mid-paint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:16:24 +01:00
Dorian
570333d776 feat: add Portainer deployment via Docker Compose on port 4422
Multi-stage Dockerfile: node:22.13.1-alpine3.21 builds the Vite
bundle, nginx:1.27.4-alpine3.21-slim serves dist/ on container :80.
Compose maps host :4422 to container :80 with a wget healthcheck
and unless-stopped restart policy.

nginx.conf serves Vite's content-addressed /assets/ as immutable,
caches images for 30d, falls back to index.html for SPA routes,
and sets X-Frame-Options / X-Content-Type-Options / Referrer-Policy
/ Permissions-Policy headers. Server tokens off.

.dockerignore keeps the build context lean (no node_modules, dist,
.git, archive, or compose files).

Pin to image digests in Portainer's stack editor for full lock.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:47:54 +01:00
Dorian
07579b5b8c feat: migrate to Vue 3.5 + Vite
Faithful 1:1 port of the single-file index.html to a Vite + Vue 3.5
project. All visuals, behavior, translations, quiz logic, scoring,
SVG paint filters, and modifier panel preserved. The previous
single-file build is archived as index.original.html for A/B compare.

Inline onclick handlers in the static template are converted to
Vue's @click bindings — Vue 3 ignores string-valued on* attributes,
which silently broke the qpb-logo "back to landing" navigation.
Inline handlers inside dynamically-injected innerHTML strings
(quiz options, slider, nav buttons) keep working since the browser
parses them; their target functions are exposed on window from
onMounted.

Pinned: vue 3.5.13, vite 6.4.2, @vitejs/plugin-vue 5.2.1.
Zero npm audit vulnerabilities.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:47:41 +01:00