Kōzōwood teardown

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.

1. What the owner liked

2. The stack

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

3. Component playbooks

3.1 Page-to-page transitions

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):

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):

  1. Keep two stage layers alive during a change: render the next composition into a second layer while the current one stays painted. Vue: <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.
  2. On start, pin the outgoing layer (position: fixed; top: -scrollY; z-index: 0; width: 100%), reset scroll to 0 at once, stop the smooth scroller.
  3. Gate the incoming layer on readiness with a small loader per layer: 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.
  4. Play leave and enter together: outgoing y 0 to -100 px, incoming y 100vh to 0, 1.5 s 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.
  5. The hero build is the "2D video game" part: a clip-path wipe up (2 s), a full-bleed background settling from 1.4 to 1.1 and then cropping into its column, the giant glyph in from 1.4 at +1.5 s, the text from -5 %, one slower last element (2.5 s power4.out) so the page keeps settling after it has arrived.
  6. On complete: clear inline styles, unmount the outgoing layer, refresh scroll triggers, restart the scroller. GSAP (all plugins free since 3.13) makes steps 4 to 6 a few lines; Motion or the Web Animations API can do the same. The View Transitions API can do the pin-and-slide with ::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.

3.2 The menu

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):

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.

3.3 Scroll choreography

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):

  1. The giant letter sticks. .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).
  2. The letter grows. From half a screen down to the end of the header its Lottie is scrubbed frame 0 to 100: leaves and vines grow inside the counters of the B, a sprig grows from the W, and two letter-spaced labels appear beside it ("BUILDING WITH" and "KŌZŌWOOD", shots/scrollb-02.png).
  3. The intro text leaves. The hero description fades to 0 and moves 20 px right over the first quarter screen (opacity 0 at 435 px).
  4. The logo shrinks to its monogram. Past 100 px the logo Lottie tweens frame 0 to 30 (wordmark to the barred K) with scale 1.2 from the left [2 s power4.out]; back above 100 px it opens again.
  5. Headings rise into place. [data-type-enter]: y 40, scaleY 1.1, opacity 0 to rest, scrubbed from "top bottom" to "center center", desktop only.
  6. Words light up as you read. [data-reading-text]: SplitText words start at opacity 0.2 and light to 1 one after another, scrubbed from "top bottom" to "bottom center".
  7. Cards tilt up. [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).
  8. Backgrounds drift. Section backgrounds move yPercent 10 to -10 at scale 1.2, scrubbed (partners block).
  9. Photo stacks over a held image. 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).
  10. The chrome recolours per section. Sections marked 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").
  11. Sound follows scroll. The ambient "field" sound fades from its default volume to 0 over the first two screens.

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.

3.4 The Lottie elements

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.

4. Flow and transitions

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)

5. Shots

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

6. Sources

7. Not confirmed


Verification

Kōzōwood teardown: verification

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.

1. How it was checked

2. What held

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.

3. Corrections, verifier, 2026-10-22

Each is corrected in place in README.md and, where it appeared, in component.json.

  1. The measured order of the menu link to why-wood (README 3.1 table and 4 table; component.json timings, states and 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.
  2. Lenis restart. "When the letter finishes: … Lenis restarts (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.
  3. Order of the menu's leave on desktop. "The links slide out …, the click stores the destination's theme and the sheet turns dark brown, then on the new page's 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.
  4. The menu's colour switch. "The click stores the destination theme and the sheet turns dark" (README 3.1 phone paragraph, 3.2 desktop and phone bullets and gotchas, 4 table, 7; component.json mechanism, rebuild, two transitions' layers, not confirmed). The recorder shows the sheet's background at rgb(238, 233, 204) through its whole lift and rgb(62, 54, 46) from 1845 ms, when it is already at -689.3 of -690.5 px. With GSAP at 0.05×, the sheet, still at -0.1 px, switched from rgb(62, 54, 46) to rgb(238, 233, 204) between 1669 and 1785 ms (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.
  5. What the slowed sheets slow. "Labels are wall-clock and about 3.3 times real time; network waits were not slowed". There is one GSAP instance, so every tween was slowed, but CSS transitions (the veil's 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.
  6. Sticky release. "Letter leaves sticky after about 800 px" (component.json). The wrapper sits at 155.73 px until about 744 px of scroll: its top is 106 px at 794 px and -445 px at 1345 px on building, -31 px at 931 px on why-wood, all giving 744 px.
  7. Trackers. "GTM, GA4, Google Ads remarketing, Ahrefs, Umami on Büro's host, a second Büro stats script; HubSpot and Meta allowed". The HTML also carries the Awwwards ribbon (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.
  8. "The last generate". The static version 1781253132 (2026-06-12 08:32 UTC) dates the build's static assets, but the HTML answers with last-modified: Mon, 19 Oct 2026 22:23:08 GMT, so June 12 is not the last deploy.
  9. "None of these names in any chunk" (Barba, Swup, Highway, Taxi). A case-insensitive search finds one hit in 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.
  10. x-vercel-id: pdx1. The prefix is the edge region of the request: iad1 on the verifier's read from fsit2.
  11. "Probe: … 4 [data-component=lottie]" (component.json evidence). The probe counts 2 (.lottie only); the 4 come from the eval lotties, as the README already said.
  12. The three.js gate. "fps > 28 or Apple GPU, WebGL2, not reduced motion": the quoted gate also passes when there is no GPU string (|| !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.

4. Not confirmed

5. Also noticed, not added to the playbook

6. Folder tidiness