From c4513588b1dcdd93b92f07954a0ca55a0dbfecae Mon Sep 17 00:00:00 2001 From: Felix Klement Date: Sat, 19 Sep 2026 11:18:31 +0200 Subject: [PATCH] product page --- README.md | 26 +- scripts/panel-align-test.mjs | 37 ++ scripts/pinned-steps-test.mjs | 40 ++ scripts/step-exit-test.mjs | 24 ++ src/components/verifier/HowItWorks.astro | 468 +++++++++++------------ src/i18n/ui.ts | 6 - 6 files changed, 332 insertions(+), 269 deletions(-) create mode 100644 scripts/panel-align-test.mjs create mode 100644 scripts/pinned-steps-test.mjs create mode 100644 scripts/step-exit-test.mjs diff --git a/README.md b/README.md index d972d64..902b917 100644 --- a/README.md +++ b/README.md @@ -127,17 +127,21 @@ Motion on the product page, all of it on wide screens: the hero's seventeen line and meet in one point (off under `prefers-reduced-motion`); hovering a node in a schematic dims everything that does not belong to it. The drawings themselves do not animate. -The "How CipherVerifier works" cards are `position: sticky` with a growing top offset, so they -stack into a deck under the pinned heading. Every card has the same height, bound to the viewport -and capped at 37rem, and the drawing fills what the text leaves, so a card always fits and fully -covers the one beneath it. The page snaps to each card (`scroll-snap-type: y proximity` on the -root while this component is on the page, `scroll-snap-stop: always`) via invisible anchors placed -at each card's natural position: snapping to the sticky cards themselves would create phantom snap -points at the current offset in Chrome. The pinned bar carries one dot per card; the filled one is -the card currently on top, and each dot links to its card. A small script sets `--covered` on each -card as the next one slides over it (the card darkens under an opaque veil) and moves the filled -dot. The heading is pinned inside a track that ends one card before the deck does, so heading and -deck leave the screen together. Phones get the same drawings in a plain flow without any of this. This goes +"How CipherVerifier works" follows the pattern of arcos.eu/technology. On wide screens the +title and a bordered panel hold still while the page scrolls; the step list under the title +advances, one step open at a time, and the panel crossfades to that step's drawing. The section is +as tall as one pitch per step plus one panel height, so the panel stays pinned until the last step +has been read. Which step is open is decided by invisible triggers tiling that height: whichever +crosses the middle of the viewport wins, and the triggers start half a viewport down so the first +step turns over exactly when the panel pins. A step's title is a button that scrolls to it. + +The panel is tied to the text, not to the pinned frame: it is positioned against +`.how__text-inner`, so it starts at the title and its width is stated as 200% of the text column +(4 of 12 against 8 of 12). Its height is measured once by script as the collapsed list plus the +tallest step's text, so it never resizes as steps change while each step's text still takes only +the room it needs. Without script the first step stays open, the panel takes the text's own +height, and the section reads as a plain sequence. Phones get that plain sequence in any case: +every step open, the upright drawing under its text. This goes beyond the home page's single load moment on purpose, at the client's request; the home page is unchanged. Without JavaScript every sector panel and its image is shown in full; the `html.js` class set in `Base.astro` switches to one at a time. diff --git a/scripts/panel-align-test.mjs b/scripts/panel-align-test.mjs new file mode 100644 index 0000000..528eb87 --- /dev/null +++ b/scripts/panel-align-test.mjs @@ -0,0 +1,37 @@ +import { chromium } from 'playwright'; +/* Does the panel start at the title and end with the list, and hold that box across steps? */ +const [,, url, prefix, width = '1440', height = '900'] = process.argv; +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } }); +await page.goto(url, { waitUntil: 'networkidle' }); +const count = await page.evaluate(() => document.querySelectorAll('[data-trigger]').length); +const rows = []; +for (let i = 0; i < count; i++) { + await page.evaluate((i) => { + const t = document.querySelectorAll('[data-trigger]')[i]; + const r = t.getBoundingClientRect(); + window.scrollTo(0, window.scrollY + r.top + r.height / 2 - window.innerHeight / 2); + }, i); + await page.waitForTimeout(900); + rows.push(await page.evaluate(() => { + const title = document.querySelector('.how__title').getBoundingClientRect(); + const steps = Array.from(document.querySelectorAll('[data-step]')); + const last = steps[steps.length - 1].getBoundingClientRect(); + const fig = document.querySelector('[data-figure][data-active]').getBoundingClientRect(); + const sticky = document.querySelector('.how__sticky').getBoundingClientRect(); + const container = document.querySelector('.how__sticky'); + const cs = getComputedStyle(container); + const rightEdge = sticky.right - parseFloat(cs.paddingRight); + return { + active: steps.findIndex((s) => s.hasAttribute('data-active')), + panel: `${Math.round(fig.width)}x${Math.round(fig.height)}`, + topVsTitle: Math.round(fig.top - title.top), + bottomVsList: Math.round(fig.bottom - last.bottom), + rightVsEdge: Math.round(fig.right - rightEdge), + listFits: Math.round(sticky.bottom - last.bottom), + }; + })); + await page.screenshot({ path: `${prefix}-${i}.png` }); +} +console.table(rows); +await browser.close(); diff --git a/scripts/pinned-steps-test.mjs b/scripts/pinned-steps-test.mjs new file mode 100644 index 0000000..a342c4a --- /dev/null +++ b/scripts/pinned-steps-test.mjs @@ -0,0 +1,40 @@ +import { chromium } from 'playwright'; +/* Walks the pinned section step by step: which step is active, does the text column fit + inside the pinned area, and is the panel where it should be. */ +const [,, url, prefix, width = '1440', height = '900'] = process.argv; +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } }); +await page.goto(url, { waitUntil: 'networkidle' }); +const count = await page.evaluate(() => document.querySelectorAll('[data-trigger]').length); +const rows = []; +for (let i = 0; i < count; i++) { + await page.evaluate((i) => { + const t = document.querySelectorAll('[data-trigger]')[i]; + const r = t.getBoundingClientRect(); + window.scrollTo(0, window.scrollY + r.top + r.height / 2 - window.innerHeight / 2); + }, i); + await page.waitForTimeout(900); + const info = await page.evaluate(() => { + const steps = Array.from(document.querySelectorAll('[data-step]')); + const active = steps.findIndex((s) => s.hasAttribute('data-active')); + const sticky = document.querySelector('.how__sticky').getBoundingClientRect(); + const text = document.querySelector('.how__text'); + const first = text.getBoundingClientRect(); + const last = steps[steps.length - 1].getBoundingClientRect(); + const fig = document.querySelector('[data-figure][data-active]'); + const f = fig ? fig.getBoundingClientRect() : null; + const svg = fig ? fig.querySelector('svg').getBoundingClientRect() : null; + return { + active, + textTop: Math.round(first.top - sticky.top), + spaceBelowList: Math.round(sticky.bottom - last.bottom), + panel: f ? `${Math.round(f.width)}x${Math.round(f.height)}` : null, + drawing: svg ? `${Math.round(svg.width)}x${Math.round(svg.height)}` : null, + panelInside: f ? (f.top >= sticky.top - 1 && f.bottom <= sticky.bottom + 1) : null, + }; + }); + rows.push({ step: i, ...info }); + await page.screenshot({ path: `${prefix}-${i}.png` }); +} +console.table(rows); +await browser.close(); diff --git a/scripts/step-exit-test.mjs b/scripts/step-exit-test.mjs new file mode 100644 index 0000000..95f62f0 --- /dev/null +++ b/scripts/step-exit-test.mjs @@ -0,0 +1,24 @@ +import { chromium } from 'playwright'; +/* Does the pinned panel hold from the first step to the last, and let go cleanly after? */ +const [,, url, prefix, width = '1440', height = '900'] = process.argv; +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } }); +await page.goto(url, { waitUntil: 'networkidle' }); +const geo = await page.evaluate(() => { + const sc = document.querySelector('.how__scroller').getBoundingClientRect(); + return { top: Math.round(sc.top + window.scrollY), height: Math.round(sc.height) }; +}); +const marks = { 'enter': geo.top, 'mid': geo.top + geo.height / 2, 'lastStep': geo.top + geo.height - (window?.innerHeight ?? 900), 'exit': geo.top + geo.height - 200 }; +for (const [name, y] of Object.entries(marks)) { + await page.evaluate((y) => window.scrollTo(0, y), y); + await page.waitForTimeout(800); + const info = await page.evaluate(() => { + const st = document.querySelector('.how__sticky').getBoundingClientRect(); + const active = Array.from(document.querySelectorAll('[data-step]')).findIndex((s) => s.hasAttribute('data-active')); + return { stickyTop: Math.round(st.top), stickyBottom: Math.round(st.bottom), active }; + }); + console.log(name.padEnd(9), JSON.stringify(info)); + await page.screenshot({ path: `${prefix}-${name}.png` }); +} +console.log('scroller', JSON.stringify(geo)); +await browser.close(); diff --git a/src/components/verifier/HowItWorks.astro b/src/components/verifier/HowItWorks.astro index 5f2ab90..0a00188 100644 --- a/src/components/verifier/HowItWorks.astro +++ b/src/components/verifier/HowItWorks.astro @@ -9,294 +9,258 @@ const v = ui.verifier.how; const pairs = [builders[0](v.groups[0]), builders[1](v.groups[1]), builders[2](v.groups[2]), builders[3](v.groups[3]), builders[4](v.groups[4])]; const groups = v.groups.map((g, i) => ({ title: g.title, body: g.body, desc: g.desc, id: `how-${i + 1}`, pair: pairs[i] })); --- -
-
-
-
-

{v.title}

-
    - {groups.map((g, i) => ( -
  1. - -
  2. - ))} -
+
+
+
+
+
+
+

{v.title}

+ +
    + {groups.map((g, i) => ( +
  1. +

    + +

    +
    +
    +

    {g.body}

    +
    + +
    +
    +
    + +
    + +
    +
  2. + ))} +
+
+
-
diff --git a/src/i18n/ui.ts b/src/i18n/ui.ts index 95f0679..1eba4d3 100644 --- a/src/i18n/ui.ts +++ b/src/i18n/ui.ts @@ -162,9 +162,6 @@ const en = { imageAlt: 'A software engineer at a desk in the evening, source code reflected in the dark window beside the monitors.', how: { title: 'How CipherVerifier works.', - position: 'Position in the sequence', - of: (n: number, total: number) => `${n} of ${total}`, - next: 'Next card', groups: [ { title: 'One run, five stages.', @@ -383,9 +380,6 @@ const de: typeof en = { imageAlt: 'Ein Software-Engineer am Schreibtisch am Abend, der Quellcode spiegelt sich im dunklen Fenster neben den Monitoren.', how: { title: 'So arbeitet CipherVerifier.', - position: 'Position in der Abfolge', - of: (n: number, total: number) => `${n} von ${total}`, - next: 'Nächste Karte', groups: [ { title: 'Ein Lauf, fünf Stufen.',