2026-10-21. Read: https://kozowood.com/en/building-with-kozowood, https://kozowood.com/en/why-wood, https://kozowood.com/en/about, https://kozowood.com/en/journal (HTML with inline CSS once each, 18 JS chunks once each), plus 14 headless capture runs on fsit2 (1440 × 900 desktop, 390 × 844 phone at 2x). Serves inspiration row 18 (fluid page transitions); the menu, scroll and Lottie items are the parts of that flow the studio brief asked to isolate.
Checked by a verifier on 2026-10-22: VERIFY.md lists what held, what was corrected in place here and what stays unconfirmed.
| Layer | Finding | Evidence |
|---|---|---|
| Framework | Nuxt 2, full static (nuxt generate), Vue 2.7.14 |
window.__NUXT__ is the only global the probe finds; data-n-head="ssr" attributes (vue-meta 2.4.0); per-route /_nuxt/static/1781253132/en/<route>/state.js and payload.js (static target; the timestamp decodes to 2026-06-12 08:32 UTC, the static asset version of this build, not the last deploy: the HTML answers with last-modified: Mon, 19 Oct 2026 22:23:08 GMT); webpack 4 JSONP chunks with runtime /_nuxt/28f4770.js; version strings in /_nuxt/cc3ed3d.js: Vue 2.7.14, vue-router 3.6.5, vuex 3.6.2, vue-meta 2.4.0 |
| Nuxt modules | i18n (English, Portuguese), Nuxt Image (static provider), a $screen helper (vue-screen) |
vue-i18n 8.28.2 in 08c1294.js; localePath, $nuxtI18nHead; images at /_nuxt/image/<hash>.jpg and <nuxt-img provider="static">; breakpoints {xxsmall:410, xsmall:743, small:1023, medium:1290, large:1440, xlarge:1700, xxlarge:2000} |
| CMS | Sanity | CSP allows cdn.sanity.io and *.apicdn.sanity.io; GROQ strings in every page chunk (*[_type == "settings" && language == …]), $sanity.fetch, $urlFor; JSON-LD logo at cdn.sanity.io/images/kdijhcxf/develop/…svg |
| Hosting, CDN | Vercel behind Cloudflare; video on DigitalOcean Spaces | headers x-vercel-cache: HIT, x-vercel-id: <edge region>::… (pdx1 on the agent's read, iad1 on the verifier's), server: cloudflare, cf-ray, speculation-rules: "/cdn-cgi/speculation"; <video src="https://burospaces1.fra1.cdn.digitaloceanspaces.com/kozowood/…mp4"> |
| Animation | GSAP 3.15.0 with ScrollTrigger and SplitText, bundled, no global | "3.15.0" in 08c1294.js, 090cfbe.js, 9e0dbb9.js (E.version="3.15.0" on SplitText); live, the page's own module 0 answered gsap.version === "3.15.0" (steps slowmo-*.meta.json, eval slowmo) |
| Smooth scroll | Lenis 1.0.11, default options, ticked by GSAP | window.lenisVersion="1.0.11" in 08c1294.js; new Lenis() with no options in 1d56b20.js; live html class lenis lenis-smooth (lenis-scrolling while moving) |
| Vector animation | lottie-web 5.13.0, SVG renderer, files at /lotties/<slug>.json |
lottie.version="5.13.0"; loadAnimation({renderer:"svg", path:"/lotties/"+slug+".json"}) in 1d56b20.js; four live instances (eval lotties in menu-open-close.meta.json) |
| Sound | Pizzicato.js on Web Audio, muted by default | Pizzicato=r.Pz in 08c1294.js; notes /audio/<theme>/note.mp3 on menu hover, /audio/intro-note.mp3, a low-pass filter while the menu is open, an ambient "field" track whose volume falls to 0 over the first two screens of scroll |
| 3D | three.js for one section only (why-wood "comparison": a GLB block with a wood texture), lazy | chunk /_nuxt/9a6eb3c.js (WebGLRenderer, GLTFLoader, DRACOLoader, KTX2); loader in ffd8939.js reads /webgl/block/block0.3.glb; mounted only if a detect-gpu style check passes (fps > 28, an Apple GPU or no GPU string, and WebGL2, and not reduced motion; the verifier found the WebGL2 and reduced-motion test in 3f34d05.js, which every page loads); never rendered on fsit2 (probe: no canvas) |
| Fonts | PP Mori variable (Pangram Pangram) and Ogg 400, 500 (Sharp Type), self-hosted | @font-face /fonts/PPMori-Variable.ttf, /fonts/OggRegular.woff2, /fonts/OggMedium.woff2; probe fonts PPMori, Ogg 400, Ogg 500 |
| Analytics, tags | In the HTML: GTM, Ahrefs, Umami on Büro's host, a second Büro stats script, the Awwwards ribbon and the Cloudflare Web Analytics beacon. Loaded at runtime by GTM (in the live DOM, not in the HTML): GA4, Google Ads remarketing, Meta Pixel, HubSpot (EU1) and Leadfeeder | HTML scripts analytics.ahrefs.com/analytics.js, analytics.madebyburo.com/umami.js, stats.withburo.com/script.js, assets.awwwards.com/assets/js/embed_ribbon.js, static.cloudflareinsights.com/beacon.min.js, inline GTM GTM-KGD4WFGL; the five live probes list connect.facebook.net/en_US/fbevents.js, six HubSpot EU1 scripts (js-eu1.hs-scripts.com/143937239.js with its banner, analytics, forms, ads pixel and web interactives), sc.lfeeder.com, gtag G-RQBB0MEHZW and AW-16846997892, and neither Büro script nor the Awwwards script (gone after hydration; whether they ran first is unknown); console shows rmkt/collect; CSP lists hubspot and connect.facebook.net |
| Studio | Büro | inline script that silences the "Made by Büro" console signature; Büro analytics hosts |
| Not used | View Transitions API, Barba, Swup, Highway, Taxi, CSS-only page swaps, styled page scrollbars | probe: 0 view-transition rules while startViewTransition exists, and no chunk read calls it; the page change is the Nuxt transition (read live by the verifier); a case-insensitive search for the four library names has one uninspected hit in 08c1294.js, with no globals or transition code from them |
What it is. On a desktop the next page is a full-height panel that rises from below over the current page while the current page drifts up 100 px underneath it. While still rising, the new page builds its own hero: a clip-path wipe upward, an oversized full-bleed background that settles and then crops to the right half, the giant initial letter scaling in, the text column sliding in, the description last. The fixed chrome (logo, audio pill, section chip, burger) never leaves; the chip only changes its word. The old page stays painted until the new one covers it, so there is never an empty frame. From click to fully settled about 6 s; the swap itself is done at about 1.8 s.
Mechanism, in one line. Nuxt's default page transition is set to mode: "in-out", css: false with JavaScript hooks: the new page mounts while the old one is still in the DOM, the hook pins the old page as a fixed layer at its scroll offset, waits for the new page's assets, then runs the old page's transitionLeave and the new page's transitionEnter(done) at the same time; done at the end of the enter removes the old page. The code is quoted in sources/transition-stack-excerpts.js (sections 1 to 4).
DOM (server-rendered, trimmed):
<div id="__nuxt"><div id="__layout"><div> <!-- default layout -->
<div data-slug="building" …> <!-- page A: the Page component root -->
<div data-component="building-page">
<div data-component="secondary-header" class="building">
<div class="row top-row">
<div class="col xxlarge-16 small-32 left-col"><h1 class="-h2 title">…</h1><div data-component="figure-thumb" class="thumb">…</div></div>
<div class="col xxlarge-16 small-32 right-col"><div data-component="background">…video…</div></div>
</div>
<div class="row bottom-row align-center"><h2 data-type-enter class="-h1 subtitle">…</h2></div>
<div class="letter-container"><div class="letter-wrapper">
<div data-component="letter" class="letter building"><div data-component="lottie" class="letter"></div></div>
</div></div>
</div>
… sections … <div data-component="footer" data-section-intersect data-intersect-type="hide">…</div>
</div>
</div>
<!-- during a change, page B (data-slug="why-wood") sits here at the same time -->
<div data-component="menu">…</div> <div data-component="mouse-cursor">…</div> <div data-component="cookies-warning">…</div> <div data-component="loading">…</div>
</div></div></div>
Styles that matter (computed live during a change, steps/timing-evals-menu-nav.meta.json):
position: fixed; top: -569px; z-index: 0; width: 100%; overflow: hidden; (569 was the scroll offset at the click), then transform: translateY(0 → -100px).opacity: 0 until loaded, then position: absolute; top: 0; left: 0; opacity: 1; transform: translateY(900px → 0); inline styles cleared at the end..top-row clip-path polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%) to polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%); [data-component=background] forced to the viewport width (1440 px) and shifted translateX(-729.97px) so it starts full-bleed, scale 1.4 to 1.1 to 1, then clip-path to polygon(50% 0, 100% 0, 100% 100%, 50% 100%); the letter's opacity 0 to 1 and scale 1.4 to 1. At rest the letter Lottie box is 692.6 × 465.7 px.Behaviour: the measured order (desktop, menu link from the building page to why-wood, on fsit2. Times run from the click event, read every animation frame by the verifier's recorder (shots/verify/meta.json, eval rec-realtime); where a tween started between frames its start is back-computed from its ease. The agent's eval series (steps/timing-evals-menu-nav.meta.json, scroll 569 px, 24 readings 105 to 165 ms apart, timed from the first reading rather than the click) shows the same order and gaps: rise at about 130 ms and ready at about 430 ms after that first reading. Source durations and easings in brackets):
| t from the click (ms) | What happens |
|---|---|
| by 222 | first frame after the click: URL is already /en/why-wood; the old page is pinned fixed at -scrollY (-569 px in the agent's run); the new page is mounted at opacity 0; window scroll is 0 |
| about 280 | the new page's loaded promise resolves (its Lotties' DOMLoaded and other registered loads): the old page starts drifting up and the new page starts rising together [1.5 s, expo.inOut each] |
| about 585 | the new page dispatches ready: its hero timeline starts and menu-close is emitted, so the menu's leave timeline starts (links out, sheet lifting) [1.5 s expo.inOut]. The source sets a 64 ms timer after the rise starts; on fsit2 ready came about 300 ms after the rise in both runs |
| 280 to 1780 | new page 900 to 0 px, old page 0 to -100 px (agent's readings, timed from its first reading: 199 px at 979 ms, 4.8 px at 1338 ms, old page -99.9 at 1457 ms) |
| 1772 to 1829 | old page removed, new page's inline styles cleared, ScrollTrigger.refresh() |
| about 1840 | the menu sheet takes the destination's colour (rgb 62, 54, 46 for why-wood) when it is already 689 of 690.5 px off-screen; this switch runs on real time, not on GSAP |
| about 2100 | Lenis restarts (lenis-stopped class removed); also on real time: it happened at 1.9 s with GSAP slowed 20 times |
| 585 to 2585 | hero top row wipes up [clip-path, 2 s power4.inOut]; background settles 1.4 to 1.1 [2 s] |
| 2085 to 4085 | background crops to the right half and settles to 1; giant letter fades in from 1.4; left column slides in from -5 %; thumb image 1.2 to 1 [2 s or 2.5 s, power4.inOut, offset 1.5 s] |
| 3585 to 6085 | description slides in from -10 % [2.5 s power4.out, offset 3 s; first non-zero opacity at 3601 ms]; when the letter finishes (about 4085) the background parallax starts, ScrollTrigger.refresh() runs and the letter's onComplete emits a scroller event (scroller-unlock in the quoted source) |
What drives it. Not CSS, not View Transitions, not a router library: GSAP tweens inside Vue's <transition> JavaScript hooks, a per-page loader object (promises mounted, loaded, ready, destroyed, plus deferLoad(promise) that components such as the Lottie wrapper use), a small event emitter (scroller-lock, scroller-unlock, scroller-reset, menu-close, curtain, curtain-open), and a Vuex store holding the page slug, name and theme. Every component waits for this.$getActivePageContext().loader.loaded or .ready before it builds its tweens, so nothing animates before the page is in place. The layout wrapper has its own <transition name="layout" mode="out-in">, unused between pages of one layout.
Assets. No SVG filters, no masks, no canvas: transforms, opacity and clip-path polygons on DOM layers. Video keeps playing through the change. Lottie letters load as JSON before the change starts.
Phone. At 1023 px wide and below there is no slide. The same hook pins the old page, then the old page fades out (1 s power4.out after 0.5 s) while the new page fades in from 20 px lower (1 s power4.out after 0.5 s); ready fires at once, the hero uses a 0.75 s offset instead of 1.5 s, and the background stays full width behind the letter. A menu link on the phone closes the menu with its plain reverse, then the crossfade shows through it; the sheet turns dark only in the slowed shots/slowmo-phone-nav-sheet.png, where the real-time colour switch lands early (see 3.2).
Accessibility and reduced motion. No reduced-motion path for page changes, the menu, the hero or the scroll effects: no prefers-reduced-motion media query in the inline CSS of any of the four pages, and the only matchMedia("(prefers-reduced-motion: reduce)") in the code gates the three.js block on why-wood. Focus is not moved to the new page (no focus call in the hook). The back button replays the same upward rise (no direction awareness), seen in shots/slowmo-navback-sheet.png.
How to rebuild it for ETS27 (library-agnostic):
<Transition mode="in-out" :css="false"> with JS hooks. React: Motion's AnimatePresence (sync mode) or two keyed layers handled by hand. Plain HTML: a two-slot stage. A multi-page build: Barba.js or Swup give the same two-container moment.position: fixed; top: -scrollY; z-index: 0; width: 100%), reset scroll to 0 at once, stop the smooth scroller.mounted, loaded (assets registered by components: Lottie DOMLoaded, first video frame, fonts), ready (choreography may start), destroyed. Components animate only on ready. This is what keeps the motion from stuttering.expo.inOut (CSS stand-in: cubic-bezier(0.87, 0, 0.13, 1)); 64 ms in, mark the incoming layer ready so its hero builds while it rises.power4.out) so the page keeps settling after it has arrived.::view-transition-old/new, but it snapshots the outgoing page as an image, which loses the live video and the in-flight hero build.
For the ETS27 flow: the stage composition is the page; the bar is the fixed chrome that never transitions (its words change like Kōzōwood's section chip); a drawer can use the same rise from below at 0.6 to 0.9 s with the reverse on close; going back in the thread should reverse the direction, which Kōzōwood does not do.Gotchas. The change waits on loaded, so a slow Lottie or payload delays the start with no feedback (about 300 ms of nothing measured on fsit2). Two full pages with video are composited for 1.5 s, heavy on weak GPUs. The old page's scroll memory has to be cleared (ScrollTrigger.clearScrollMemory("manual"), history.scrollRestoration = "manual") or the pinned copy jumps. One flat frame of the destination's colour appeared once on browser back in a capture (shots/smback-00.png); not seen elsewhere. Six seconds of motion with no reduced-motion fallback.
What it is. A burger pill at the top right opens a cream sheet (rgb 238, 233, 204) that drops from the top edge, 1440 × 690.5 px at a 900 px viewport, bottom corners rounded 21.66 px, over a dark veil on the rest of the page. Three columns with hairline separators: left the copyright, socials and a "Watch the film" video thumbnail; centre a language pill and six links set large in Ogg (Homepage, About, Building with Kōzōwood, Why Wood, Sustainability, Journal), the current page lighter with an arrow; right the address, hours and contact numbers in PP Mori. The fixed controls stay on top and flip to their alternate colours: the logo opens to the full wordmark, the section chip fills brown, the burger turns into an X (shots/menu-02-hover-item.png).
DOM (server-rendered, trimmed; it is always in the page, hidden by transforms when closed):
<div data-component="menu" class="mounted building active">
<span class="close-bg" data-mouse-cursor-action="close" data-mouse-cursor-theme="light"></span> <!-- veil: click closes, custom cursor -->
<span class="bg"></span> <!-- the sheet -->
<div class="row main-header">
<a href="/en" aria-label="Home" class="home-link"><div data-component="lottie" class="logo" data-element-intersect data-hide-direction="top"></div></a>
<div class="controls-container">
<div class="controls-left"><div data-component="dropdown" class="lang-btn">English / Português</div></div>
<div class="controls-right">
<button data-component="audio-button" aria-label="Audio Button" class="audio-btn"><div data-component="lottie" class="lottie"></div></button>
<div data-component="section-label" class="section-btn"><span class="-caption label"></span></div>
<button data-component="burger" aria-label="Menu Button" class="burger-btn"><span class="btn-container"><div data-component="lottie" class="lottie"></div></span></button>
</div></div></div>
<div class="menu-container">
<div class="left">…© 2026, socials, video-thumb "Watch the film"…</div>
<div class="center"><ul data-component="menu-items" class="menu-items">
<li class="item"><div data-component="icon" class="active-arrow xlarge">…</div><a href="/en/about" data-theme="about" class="-h5 link">About</a></li> …
</ul></div>
<div class="right"><div data-component="address">…</div></div>
</div>
</div>
Behaviour (one GSAP timeline, sources/transition-stack-excerpts.js section 5):
yPercent -100 → 0 [1 s expo.inOut]; at 0.3 s the links come in from x -40 with opacity, staggered 75 ms [0.6 s power4.out]; at 0.4 s the burger Lottie plays to the X, the logo Lottie opens, the controls take .color-alternate; at 0.6 s the right column drops 10 px into place; at 0.7 s the video thumbnail fades down and its image settles from scale 1.2 [1.4 s]; links accept pointer events at 1 s. Lenis stops (scroller-lock), the ambient sound gets a low-pass filter and pauses. shots/slowmo-menu-open-sheet.png shows the order at 0.3×.shots/menu-02-hover-item.png).shots/slowmo-menu-close-sheet.png).ready (the menu-close event) the leave timeline plays: the links slide out (x -10, opacity 0, 0.3 s expo.inOut) and the sheet lifts away upward (1.5 s expo.inOut) together, while the new page rises from below: two panels parting, one up and one up from below, with the old page never visibly moving (shots/slowmo-navmenu-sheet.png). The sheet takes the destination's colour about 1.8 s after the click on real time; at full speed it has left by then, so the dark sheet in the slowed shots/slowmo-navmenu-sheet.png and shots/smnav-a-02.png comes from slowing GSAP and not the colour switch (shots/verify/verify-03-slowed-3s.png shows the switch landing on a sheet that has not moved). The sheet's colour follows the page theme: on why-wood the menu opens dark.shots/phone-01-menu-open.png, shots/slowmo-phone-nav-sheet.png).Accessibility. Real links; the burger is a <button> with aria-label="Menu Button" but no aria-expanded; Escape closes. Closed, the six links stay in the tab order (visible, display: block, tabIndex 0, not inert, no aria-hidden, only pointer-events: none; verifier), so keyboard focus can land on the off-screen menu; whether focus is trapped while open was not tested.
How to rebuild it for ETS27. One timeline per sheet, played forward to open and reversed at double speed to close, with a separate "leave by navigation" timeline that hands over to the page change; pin pointer-events until the links are in. For the ETS27 bar the useful piece is the hand-over: the menu does not close and then navigate, it becomes part of the page change (its colour turns to the destination, it leaves on the destination's ready). The same applies when the bar's sheet launches a stage composition. GSAP timelines (or the Web Animations API with playbackRate = -2) make the reverse free.
Gotchas. The sheet height is fixed by content (690.5 px here), not by the viewport; the veil is a second element that has to animate with it; the theme colour switch is not on the timelines: it lands about 1.8 s after the click on real time, after the next page exists, so anything that slows the motion (a late loaded, a slowed GSAP) shows a dark sheet that a visitor at full speed barely sees.
Scroller. Lenis 1.0.11 created with no options (library defaults: smooth wheel on, touch left native), ticked by gsap.ticker (lenis.raf(time * 1000)), ScrollTrigger updated on every Lenis scroll; stopped and started through the emitter around page changes and the menu; inner scrollers carry data-lenis-prevent through a v-inner-scrollable directive. The scroller also publishes velocity and acceleration to the store.
What moves, in order (shots/scroll-building-sheet.png, shots/scroll-why-wood-sheet.png, measured with the evals in steps/scroll-*.meta.json):
.letter-wrapper is position: sticky, top: calc((100vh - letterHeight) / 2) (155.7 px at 1440 × 900; the wrapper is 588.5 px tall) inside a .letter-container one screen taller than the letter, so the letter holds the middle of the screen while the header scrolls past. Its transform is scrubbed from yPercent -10, scale 1 to yPercent 0, scale 0.95 (measured 0.9628 at 435 px, 0.95 from 794 px).shots/scrollb-02.png).power4.out]; back above 100 px it opens again.[data-type-enter]: y 40, scaleY 1.1, opacity 0 to rest, scrubbed from "top bottom" to "center center", desktop only.[data-reading-text]: SplitText words start at opacity 0.2 and light to 1 one after another, scrubbed from "top bottom" to "bottom center".[data-box-enter]: yPercent 40, scale 0.9, rotateX 4° to rest under 1000 px perspective, staggered 0.1, scrubbed, desktop only (the why-wood article cards, shots/scrollw-06.png).photos-parallax holds a full-bleed photo in a fixed .background-container (opacity and visibility switched on for the section) while framed photo cards drift up over it at different speeds (shots/scrollb-09.png to scrollb-11.png, shots/scrollw-08.png to scrollw-11.png).data-section-intersect flip the colour of the fixed controls marked data-element-intersect as they pass beneath them (one ScrollTrigger per control at top top+=<control bottom>); the footer (data-intersect-type="hide") hides them upward (data-hide-direction="top").Sticky elements seen by the probe besides the letter: a title row and two columns on the building page (row title-container, left-col, right-col).
Scrollbar. There is no custom page scrollbar. The page keeps the native bar; the CSS measures it into --native-scrollbar-width and uses --computed-100vw: calc(100vw - var(--native-scrollbar-width)) so full-bleed layers fit beside it. Horizontal sliders (.slider, [data-component=cards-container], [data-component=categories-list]) hide their own bars with ::-webkit-scrollbar { display: none } and draw a thin progress bar whose handle follows scrollLeft on the GSAP ticker (smooth 0.64, power2, drag multiplier 1.28). The probe's "6 scrollbar rules" are these rules counted per stylesheet, the :root custom property included. The fsit2 captures run with --hide-scrollbars, so no shot shows the native bar.
Phone. Same order in one column (shots/phone-scroll-sheet.png): the letter sticks at 272 px (300 px tall) and keeps scale 1, its Lottie still grows (the W sprig by 573 px), the logo still collapses, the background stays full width; type-enter, box-enter and the tilt are off at 1023 px and below; --initial-100vh is frozen to innerHeight on touch screens so the address bar does not make the hero jump. Touch scrolling is native.
How to rebuild it for ETS27. Lenis (current major, same API) or no smooth scroll at all, ScrollTrigger or CSS scroll-driven animations (animation-timeline: view()) for the scrubbed reveals, position: sticky for the held glyph, a scrubbed Lottie (lottie-web goToAndStop(frame, true) or dotLottie) for the growing letter. The owner wants less scrolling (row 9), so the useful parts are the held glyph that changes while you move and the chrome that answers the section under it, more than the long page.
Gotchas. Everything waits for loader.ready; scroll-scrubbed Lottie frames are expensive on long SVGs; scrub: true plus Lenis means two smoothing layers; the desktop-only reveals leave the phone flatter.
The probe counted 2 because it matches the .lottie class (the burger and the audio icons); a direct count of [data-component=lottie] finds 4 (eval lotties in steps/menu-open-close.meta.json). One wrapper component for all: lottie-web, SVG renderer, /lotties/<slug>.json, no autoplay, DOMLoaded handed to the page loader so a page change waits for its Lotties.
| Instance | Slug | Size (desktop) | What it animates | When |
|---|---|---|---|---|
| Giant initial letter in the hero | <theme>-<locale>, e.g. building-en, why-wood-en (themes with a letter: building, why-wood, sustainability) |
692.6 × 465.7 px | the serif initial grows leaves and vines, side labels appear | scrubbed by scroll, frame 0 to 100 of its 120 frames, from half a screen down to the end of the header (shots/scrollb-00.png to scrollb-02.png, shots/scrollw-00.png to scrollw-02.png) |
| Logo in the menu header | logo |
180 × 44 px | full KŌZŌWOOD wordmark (frame 0) to the barred K monogram (frame 30) | tweened [2 s power4.out] when the page passes 100 px of scroll, back on return to the top and on menu open |
| Burger | burger |
31.25 px | two lines to an X and back | played forward on menu open (0.4 s into the open timeline), reversed on close |
| Audio | audio |
31.25 px | a line that becomes a moving wave | rests at frame 134 (muted); on play segments 1 to 60, then loops 61 to 121 while sound plays; segments 122 to 135 on mute; hidden on touch screens |
How to rebuild it for ETS27. For the marker look (row 8) this is the most direct lift: a hand-drawn Lottie (or an SVG stroke animation) whose frame is driven by scroll or by the conversation instead of by time, so the drawing advances as the visitor moves. dotLottie or lottie-web; goToAndStop on every scroll update; one wrapper that reports when its file is loaded.
All transitions are GSAP; durations are source values, real-time order from shots/verify/meta.json (eval rec-realtime) and steps/timing-evals-menu-nav.meta.json. The slowed sheets were taken with GSAP's global timeline at 0.3× through the page's own webpack loader. The page has a single GSAP instance (verified), so every tween was slowed and their labels are wall-clock, about 3.3 times real time for GSAP motion. Not slowed: network waits, CSS transitions (the veil's 0.5 s opacity), Lottie playback started with play() (the burger), timers (the 64 ms ready timer, the menu colour switch at about 1.8 s, the Lenis restart at about 2 s) and video, so these land early against the motion in the slowed sheets. Burst labels are stamped when each screenshot finished, so a frame shows a moment up to one frame interval before its label.
| Transition | What moves, in order | Real duration | Contact sheet |
|---|---|---|---|
| Footer link, building to why-wood (the first burst, by the coordinator) | the click scrolls to the footer; the footer wordmark slides down out of view (not the leave tween, which moves the old page up; most likely the footer's scroll-scrubbed reveal running back when the scroll resets to 0); the tan why-wood page rises from below; its hero image wipes up; the image settles full-bleed, then crops right as the text and the W come in | about 6 s | ../_examples/kozowood-transition-sheet.png |
| Menu link, building to why-wood, measured | old page pinned and drifting up 100 px, new page rising 100vh, menu sheet lifting away, then hero wipe, background crop, letter, text, description | swap about 1.8 s, settled about 6.1 s after the click | (no frames) shots/verify/meta.json eval rec-realtime and steps/timing-evals-menu-nav.meta.json, end state shots/timing-99-settled.png |
| Menu link, building to about | links out and the sheet lifts away while the About panel rises (the sheet is dark only in the slowed sheet, see 3.2); About's type header ("IN BALANCE WITH NATURE") settles and its inline image strip widens | about 2.5 to 3 s | shots/slowmo-navmenu-sheet.png (0.3×), real time shots/navmenu-sheet.png |
| In-page link "View more articles", why-wood to journal | off-white journal panel rises over the scrolled why-wood page; the journal hero photo wipes up inside it; the featured card and the running caption frame settle | about 3 s | shots/slowmo-navinpage-sheet.png (0.3×) |
| Browser back, journal to why-wood | one flat frame of the why-wood colour, the journal pinned, the tan why-wood panel rises; hero wipe, full-bleed lattice photo, crop right, text and W | about 6 s | shots/slowmo-navback-sheet.png (0.3×); building back from why-wood in real time shots/navback-sheet.png |
| Menu link on a phone, building to why-wood | links fade, the new page crossfades in from 20 px lower, the sheet clears (it darkens only in the slowed sheet, see 3.2) | about 1.5 s | shots/slowmo-phone-nav-sheet.png (0.3×), real time shots/phone-nav-sheet.png |
| Menu open and close | sheet drops, links stagger in, columns fade in; close is the reverse at double speed | 1 s open (2.1 s with the thumbnail), about 1 s close | shots/slowmo-menu-open-sheet.png, shots/slowmo-menu-close-sheet.png; real time shots/menu-open-sheet.png, shots/menu-close-sheet.png, shots/phone-menu-open-sheet.png |
| First load | a pale loader screen, then a brown panel with the hero image rising at its foot, the video full-bleed, then the split hero | about 6 s after DOMContentLoaded | shots/intro-sheet.png (real time) |
All PNG, in shots/. Desktop is 1440 × 900, phone 390 × 844 at 2x. Frames inside a series were looked at through their contact sheet.
| File | What it shows | Device |
|---|---|---|
states-desktop-sheet.png |
every desktop state shot below on one sheet | desktop |
states-phone-sheet.png |
the four phone state shots | phone |
menu-00-rest.png, smmenu-00-rest.png |
building hero at rest: brown text column, cream B, video right | desktop |
menu-01-open.png, smmenu-01-open.png, navmenu-00-menu-open.png, smnav-00-menu-open.png |
menu open over the building page | desktop |
menu-02-hover-item.png |
menu open, pointer on Why Wood: arrow and lighter word | desktop |
menu-03-closed.png, smmenu-02-closed.png |
after closing | desktop |
menu-open-sheet.png, menu-close-sheet.png (+ menu-open-00.png, menu-open-01.png, menu-close-01.png) |
real-time menu bursts; frames 0.4 to 1 s apart, so mostly start and end | desktop |
slowmo-menu-open-sheet.png (smmenu-open-00 to 15) |
menu open at 0.3×: sheet drop, links stagger, columns | desktop |
slowmo-menu-close-sheet.png (smmenu-close-00 to 11) |
menu close at 0.3× (reverse at 2×) | desktop |
navmenu-sheet.png (+ navmenu-a-00.png), navmenu-99-end.png |
real-time menu link to About: first frame already late in the change; About settled | desktop |
slowmo-navmenu-sheet.png (smnav-a-00 to 11, smnav-b-00 to 07), smnav-a-02.png, smnav-99-settled.png |
menu link to About at 0.3×: dark sheet lifting, About rising from below | desktop |
timing-99-settled.png |
why-wood settled after the measured change | desktop |
navinpage-00-before.png, sminpage-00-before.png |
why-wood scrolled to the articles block before "View more articles" | desktop |
slowmo-navinpage-sheet.png (sminpage-00 to 19), sminpage-99-journal.png |
in-page link to Journal at 0.3×; journal hero settled | desktop |
navback-00-on-why-wood.png, navback-sheet.png (+ navback-a-00 to 02), navback-99-end.png |
real-time browser back from why-wood to building | desktop |
slowmo-navback-sheet.png (smback-00 to 19), smback-00.png, smback-99-settled.png |
browser back from Journal to why-wood at 0.3×, including the one flat frame | desktop |
intro-sheet.png (+ intro-00 to 02), intro-99-settled.png |
first load from DOMContentLoaded | desktop |
scroll-building-sheet.png (scrollb-00 to 11), scrollb-02.png |
building page in 450 px steps: sticky B growing leaves, logo to monogram, cards, photo stack | desktop |
scroll-why-wood-sheet.png (scrollw-00 to 11) |
why-wood in 520 px steps: W sprig, coal section, list, tilted cards, photo stack | desktop |
phone-00-top.png, phone-01-menu-open.png, phone-99-end.png, smphone-99-settled.png |
phone: building top, menu open, why-wood after the change | phone |
phone-menu-open-sheet.png, phone-nav-sheet.png |
real-time phone menu open and menu link to why-wood | phone |
slowmo-phone-nav-sheet.png (smphone-00 to 19) |
phone menu link at 0.3×: sheet darkens, crossfade | phone |
phone-scroll-sheet.png (phonescroll-00 to 06) |
why-wood on a phone in 300 px steps | phone |
/_nuxt/28f4770.js (runtime), 08c1294.js (vendor: Nuxt client, GSAP, Lenis, lottie-web, Pizzicato, vue-i18n, vue-screen), cc3ed3d.js (Vue, router, Vuex, vue-meta), 1d56b20.js (app: page wrapper, loader, menu, Lenis wrapper, Lottie wrapper, audio), faa8d24.js, c38c5f0.js, 090cfbe.js (secondary header, letter), 22d8ca1.js (building), 900c8cb.js (why-wood), ef45739.js (newsletter; GPU check per the agent, not re-read by the verifier, who found the WebGL2 and reduced-motion test in 3f34d05.js), 0a93fda.js (about), 9e0dbb9.js (SplitText), 3269820.js (journal), and the lazy why-wood WebGL chunks 186e634.js, 9a6eb3c.js, bcd0e48.js, 249cf16.js, ffd8939.js.sources/transition-stack-excerpts.js: trimmed, reformatted excerpts of the code quoted above.steps/*.json: every steps file run; steps/*.meta.json: each run's outcome (step results, burst timestamps, evals, CSS, probe).design/inspiration/INSPIRATION.md (row 18), bar-stage-drawers.md, components-to-copy.md (no Kōzōwood notes yet), teardowns/_examples/.shots/verify/meta.json) now gives real-time numbers from the click for the menu link to why-wood. What would settle it: a 60 fps screen recording on a machine with a GPU, or a CDP screencast step in capture.cjs.reducedMotion: "reduce" context option in capture.cjs would settle it.intro-sheet.png, but the loading component and the curtain events were not read and not timed (the [data-component=loading] element was already gone when queried).0a93fda.js, 3269820.js) were not read for exact values.SET_NEXTTHEME_DATA on click per the agent); what fires the switch and the colours of the other themes were not extracted.../_examples/kozowood-transition-sheet.png): the footer's own code was not read; the scroll-reset explanation is an inference.smback-00.png) appeared once; whether a real browser shows it is unknown.[data-component=mouse-cursor], fixed, z-index 102; actions such as "close" on the menu veil) was not captured; no hover run was made for it.shots/verify/meta.json eval stack): the live instance runs the 1.0.11 defaults: lerp 0.1, smoothWheel true, smoothTouch false, syncTouch false, wheelMultiplier and touchMultiplier 1, normalizeWheel false, no duration.2026-10-22 · verifier pass over README.md and component.json (teardown of 2026-10-21) · every stack, mechanism, timing and measurement claim was checked against the folder's evidence, one fresh capture on fsit2 and 23 screenshots looked at with the Read tool.
Result. 69 claims checked: 54 held, 12 were wrong or imprecise and are corrected in place, 3 stay unconfirmed. The core finding stands and is now read from the live page: the page change is Nuxt 2's default <transition mode="in-out" :css="false"> with GSAP hooks, on a single GSAP 3.15.0 instance, Lenis 1.0.11 on its defaults and lottie-web 5.13.0. The errors sit around it: the measured-order table (anchored on the wrong moment, and the 64 ms ready took about 300 ms on fsit2), the menu's colour switch (not on click, and at full speed it lands after the sheet has left), the Lenis restart (about 2 s after the click, not at the end of the hero), the order of the menu's leave, the sticky release point, the tracker inventory, what the slowed sheets slow, and five precision points.
README.md, component.json, sources/transition-stack-excerpts.js, the 14 steps files and their *.meta.json outcomes (step results, evals, CSS, DOM dumps and the five probes in menu-open-close, nav-menu-to-about, phone-menu-nav, scroll-building and scroll-why-wood). Every PNG in shots/ maps to a shot or burst in a meta file or is a contact sheet.menu-01-open, menu-02-hover-item, menu-03-closed, menu-open-sheet, slowmo-menu-open-sheet, slowmo-navmenu-sheet, smnav-a-02, slowmo-navback-sheet, smback-00, smback-13, smback-15, timing-99-settled, scrollb-02, scrollw-06, phonescroll-02, phone-01-menu-open, phone-nav-sheet, slowmo-phone-nav-sheet, intro-sheet and the four new shots/verify/verify-01 to verify-04.steps/verify-stack-timing.json through design/tools/capture-remote.sh (headless on fsit2, desktop 1440 × 900, 54 s, 30 steps, no step errors, no bot wall; results in shots/verify/): one load of the building page and two in-app navigations. Inside the page it scanned the webpack module cache for libraries and GSAP instances; read Nuxt's transition object, the page mixin, and the menu and hero Vue instances with their live GSAP timelines; read the four Lottie players' in-memory metadata (frame counts and sizes, no Lottie file fetched); read computed CSS (font-face sources, reduced-motion and scrollbar rules); re-read the document and every loaded /_nuxt/ chunk from the browser's own HTTP cache (cache: "only-if-cached"), so no extra request reached the site and no curl was needed; recorded every animation frame from the click event through the menu link to why-wood; then took the menu link back to building with GSAP's global timeline at 0.05× to separate what runs on GSAP from what runs on real time.ef45739.js, 9e0dbb9.js, 0a93fda.js, 900c8cb.js, 3269820.js, which were not in this run's resource list.Stack (19 of 26). Nuxt 2 full static: __NUXT__ is the only global in all five probes, 34 data-n-head="ssr" attributes in the HTML, /_nuxt/static/1781253132/…/state.js, payload.js and manifest.js in the probes; the timestamp decodes to 2026-06-12 08:32:12 UTC. Vue 2.7.14, vue-router 3.6.5, Vuex 3.6.2 and vue-meta 2.4.0, both as literals in cc3ed3d.js (ur.version="2.7.14", qt.version="3.6.5", version:"3.6.2", {version:"2.4.0") and live from the module cache. webpack 4 JSONP (window.webpackJsonp.push works; 28f4770.js is the 3 KB runtime). vue-i18n 8.28.2 (literal in 08c1294.js, live $i18n), locales en and pt, localePath and $nuxtI18nHead live. Nuxt Image: all 44 images on the building page come from /_nuxt/image/, $img is live. vue-screen breakpoints exactly {xxsmall:410, xsmall:743, small:1023, medium:1290, large:1440, xlarge:1700, xxlarge:2000}. Sanity: CSP lists cdn.sanity.io and *.apicdn.sanity.io, GROQ *[_type strings in 22d8ca1.js, faa8d24.js, 1d56b20.js and 3f34d05.js, $sanity and $urlFor live, JSON-LD logo cdn.sanity.io/images/kdijhcxf/develop/927c43f…-229x34.svg. Vercel behind Cloudflare: x-vercel-cache: HIT, x-vercel-id, server: cloudflare, cf-ray, speculation-rules: "/cdn-cgi/speculation". Video from burospaces1.fra1.cdn.digitaloceanspaces.com (live and in the HTML). GSAP 3.15.0 with ScrollTrigger and SplitText, no global: the module scan finds one GSAP core (module 0), ScrollTrigger (module 29) and SplitText (module 677), all 3.15.0. Lenis 1.0.11 (window.lenisVersion live and as a literal in 08c1294.js; html class lenis lenis-smooth). lottie-web 5.13.0 (module 357, literal in 08c1294.js), SVG renderer, files /lotties/building-en, logo, audio, burger. Pizzicato in 08c1294.js; the store starts with muted: true; the menu calls lowpass() on open and close. Fonts /fonts/PPMori-Variable.ttf, /fonts/OggRegular.woff2, /fonts/OggMedium.woff2 in the HTML's @font-face and the live CSSOM. Büro: an inline script drops console messages containing "Made by Büro"; Büro hosts in the HTML. View Transitions unused: 0 rules in 54 stylesheets and no startViewTransition call in the 22 chunks read. No styled page scrollbar: the scrollbar rules are :root variables plus display: none on .slider, cards-container and inner-lenis bars.
Mechanism (17 of 22). The live $nuxt.nuxt.transitions[0] is {name: "default", mode: "in-out", css: false, appear: false}; its enter pins the first [data-slug] with position:fixed; top: -scrollY; z-index: 0; width: 100%; overflow: hidden;, sets the new page to opacity 0, runs transitionLeave() and transitionEnter(done) on loader.loaded and emits menu-close on loader.ready; leave only reads data-slug. The live page mixin matches the excerpt: desktop fromTo(y: "100vh" → 0, 1.5 s, expo.inOut) with setTimeout(ready, 64) in onStart and ScrollTrigger.refresh(), clearProps, done() on complete; leave y: "-=100px", 1.5 s; at 1023 px and below ready at once and a 1 s power4.out crossfade after 0.5 s from y 20. The live hero enterTl (5.5 s) matches every value quoted: top-row clip 2 s power4.inOut; background from width 1440, x -729.97, scale 1.4 to 1.1; at 1.5 s the video to xPercent 20, the background to scale 1 and polygon(50% 0, 100% 0, 100% 100%, 50% 100%), the letter from 1.4 and opacity 0, the left column from xPercent -5, the thumb image 1.2 to 1 over 2.5 s; at 3 s the description from xPercent -10, 2.5 s power4.out. $getPageContext and $getActivePageContext are live; the emitter carries menu-close, scroller-lock, scroller-unlock, scroller-reset and more; the store holds slug, name, theme, nextTheme, menuState and a scroller object with velocity and acceleration. history.scrollRestoration is manual. The hook has no focus call. Browser back replays the upward rise (slowmo-navback-sheet.png frames 02 to 04: the tan panel rises from below over the journal). The menu's live enterAnimation (2.1 s) matches every value: sheet yPercent -100 to 0 in 1 s expo.inOut; six links from x -40 and opacity 0 at 0.3 s, 0.6 s each power4.out, stagger 0.075; callback at 0.4 s; right column from y -10 at 0.6 s; thumbnail at 0.7 s with its image 1.2 to 1 over 1.4 s; pointer events back at 1 s. open() is seek(0).timeScale(1).play(), close() is timeScale(2).reverse() (1.05 s). On 1023 px and below a link calls handleClose(), the plain reverse. Escape calls handleClose() (and menu-03-closed.png shows the closed state after it). The burger has aria-label="Menu Button" and no aria-expanded, open or closed. lenis-stopped is set while the menu is open, and the menu pauses the ambient sound and applies the low-pass. The scroll code matches: the letter fromTo yPercent -10, scale 1 to scale 0.95 on desktop and 1 on touch, scrubbed; the description container to opacity 0 and x 20 by a quarter screen; the logo Lottie to frame 30 over 2 s power4.out with scale 1.2 from the left past 100 px. One Lottie wrapper with autoplay: false, SVG renderer and slugs as props.
Timing and measurement (17 of 20). Menu sheet 1440 × 690.53 px, rgb(238, 233, 204), radius 0 0 21.66px 21.66px, translateY(-690.531px) closed. Lottie boxes: letter 692.56 × 465.70, logo 180 × 44, burger and audio 31.25. Sticky wrapper top 155.73 px and 588.5 px tall. Letter scale 0.9628 at 435 px and 0.9505 at 794 px; description opacity 0 and x 20 at 435 px. Phone: wrapper top 272 px, 300 px tall, scale 1; the W sprig is out at 573 px (phonescroll-02.png). The old and new pages' inline styles during the change, the background's 1440 px width, -729.97 px shift and 1.4, 1.1, 1 scales and 50 % crop, all in timing-evals-menu-nav.meta.json. The agent's readings of 199 px at 979 ms, 4.8 px at 1338 ms and -99.9 px at 1457 ms. "Swap about 1.8 s, settled about 6 s" (measured 1.8 s and 6.1 s from the click). "About 300 ms of nothing" before the change starts (280 ms from the click). Menu open 1 s and 2.1 s with the thumbnail, close about 1 s. The audio Lottie rests at frame 134 of 135; the logo Lottie has exactly 30 frames. The phone sheet covers 68 % of the screen. First load: the split hero is in the frame stamped 6051 ms after DOMContentLoaded. Six scrollbar rules.
Cross-reference (1 of 1). The owner's words match row 18 of INSPIRATION.md word for word.
Each is corrected in place in README.md and, where it appeared, in component.json.
duration_ms). The table put t 0 at the agent's first reading after the click, which came up to 670 ms after it (the click step took 673 ms), and then gave rise "about 300" and ready "about 364 (64 ms after the rise)". Back-computed from the ease, the agent's own readings put the rise at about 130 ms and ready at about 430 ms after that first reading. The verifier's per-frame recorder, timed from the click event (shots/verify/meta.json, eval rec-realtime), gives: first frame 222 ms (new page mounted at opacity 0, old page pinned); rise about 282 ms (first movement seen at 519 ms; samples from 778 to 1024 ms all back-compute to 281 to 287); ready about 585 ms (menu lift and top-row wipe both back-compute to 584 to 586), so about 300 ms after the rise in both runs, not the source's 64 ms; old page removed between 1772 and 1829 ms; crop, letter and left column from about 2086 ms (letter opacity 0.51 at 3090 ms); description from about 3581 ms (first non-zero at 3601 ms, 0.5 at 3905 ms) to about 6085 ms. The table now runs from the click with these values.scroller-unlock)". The lenis-stopped class dropped between 2091 and 2106 ms after the click, about 2 s before the letter finishes, and between 1785 and 1921 ms in the run with GSAP slowed 20 times, so the restart runs on real time, not on the hero. The letter's onComplete still emits a scroller event later.ready the sheet lifts". The live transitionAnimation (1.7 s) starts the links (x -10, opacity 0, 0.3 s expo.inOut), the sheet (yPercent -100, 1.5 s expo.inOut), the thumbnail and the right column together at 0, with callbacks at 0.5 s (burger and logo back) and 1.7 s (reset); transition() plays it from transitionClose, the menu-close handler, which the page hook emits on ready. Nothing in the menu moves at the click.verify-03-slowed-3s.png: a cream sheet that has not moved). The switch takes the destination theme's colour on real time, and the sheet's colour follows the page theme (the menu opens dark on why-wood). So the dark sheets in slowmo-navmenu-sheet.png, smnav-a-02.png and slowmo-phone-nav-sheet.png come from slowing GSAP alone; at full speed on a desktop the visitor barely sees it.opacity 0.5s cubic-bezier(0.165, 0.84, 0.44, 1)), Lottie playback started with play() (the burger), timers (the 64 ms ready, the colour switch, the Lenis restart) and video were not. Also, capture.cjs stamps each burst frame after its screenshot returns, so a frame shows a moment up to one frame interval before its label.assets.awwwards.com/assets/js/embed_ribbon.js) and the Cloudflare Web Analytics beacon. HubSpot and Meta are not only allowed but loaded: all five probes list connect.facebook.net/en_US/fbevents.js and six HubSpot EU1 scripts (js-eu1.hs-scripts.com/143937239.js and its banner, analytics, forms, ads pixel and web interactives), plus Leadfeeder (sc.lfeeder.com), none of which is in the HTML, so GTM injects them. The two Büro scripts and the Awwwards script are in the HTML but absent from the live DOM in all five probes.last-modified: Mon, 19 Oct 2026 22:23:08 GMT, so June 12 is not the last deploy.08c1294.js; its context was not captured. Nothing else points to these libraries (no globals, and the page change is the Nuxt transition read live), so only the wording changed.x-vercel-id: pdx1. The prefix is the edge region of the request: iad1 on the verifier's read from fsit2..lottie only); the 4 come from the eval lotties, as the README already said.|| !gpu), and the WebGL2 and reduced-motion test the verifier found sits in 3f34d05.js, which every page loads; ef45739.js was not re-read.Also settled or sharpened in place, not counted as errors: the Lenis options are now read live (the 1.0.11 defaults: lerp 0.1, smoothWheel true, smoothTouch false, syncTouch false, multipliers 1, normalizeWheel false, no duration); closed menu links stay in the tab order (visible, display: block, tabIndex 0, not inert, no aria-hidden, only pointer-events: none); the letter Lottie has 120 frames, so the quoted scroll scrub to frame 100 leaves its last 20 unshown.
9a6eb3c.js, ffd8939.js, the GLB path, what the scene shows): not in the browser cache on fsit2 and not re-fetched.data-mouse-cursor-action="close") was seen; it was not clicked.ready timer on a machine with a GPU (about 300 ms on fsit2 in both runs).curtain and curtain-open events: not among the emitter's live listeners on a settled page.gsap.ticker, the 0.75 s hero offset on phones, which themes have a letter, SET_NEXTTHEME_DATA on click.nav-inpage-why-wood-to-journal) never navigated: the click on "View more articles" timed out after 8 s, so navinpage-00-before.png is its only shot; the 0.3× run with a forced click is the evidence for that transition, as the README's table already implies.'nonce-rAnd0m2'), and the response passes a Cloudflare Worker (server-timing: … cfWorker, cf-cache-status: DYNAMIC).scrollb-02.png, scrollw-06.png, verify-01-menu-open.png); which tool draws it was not checked.tmp/ left: the verifier's scratch folder was deleted after use.component.json), one quoted excerpt (sources/transition-stack-excerpts.js, 8.6 KB) and 194 PNGs, all screenshots or contact sheets (every PNG maps to a capture step or burst, or is one of the 18 contact sheets built from them).slowmo-navback-sheet.png, 6.6 MB).shots/verify/meta.json (115 KB) is capture output; its quoted function sources are short (the longest about 1.5 KB).