# Julkee × Lempee — Web
> How JxL sites are built: structure, layout, components, motion, interaction, accessibility and the rules that keep it robust.
> Brand rules (logo, colour, type, imagery, voice) live in **jxl-brand.md**; CSS variables in **jxl-tokens.css**.
> Reference implementation: the online brand book (`jxl-online-brand-book`, `src/`) — every rule here is running code there. Motion first tested in production on the DICE landing page.
> Updated: 2026-09-23 · Replaces jxl-web.md (2026-04), jxl-components.md (web parts) and jxl-motion.md

---

## 1. SITE STRUCTURE

### Canvas and sections
- The page canvas is **Infinity Black**. Every section is a **rounded card** on it: inset by the gutter, `border-radius: clamp(20px, 3vw, 48px)`, `clamp(16px, 2.5vw, 28px)` between cards.
- Card grounds alternate: white · off-white · Julkee Blue · black. A **black card gets a 1.5px white border** — otherwise its edge vanishes into the canvas (typography, the line generator).
- The last card before the footer is always white.
- Hero: full-bleed brand video (heartbeat), no overlay text.

### Section heads
- Eyebrow = the category (e.g. "03 — Logo", "Valokuvat"); the big black title = an **active, descriptive line** ("Lataa logo oikeassa värissä", "Musta vaalealle, valkoinen tummalle").
- **Never repeat** the eyebrow as the title. Titles always ALL CAPS, with **no periods or colons** — join two parts with a comma or rephrase.
- Headings enter letter by letter, the lede line by line (see §4).

### Navigation — sidebar
- Desktop: fixed white sidebar, 276px. Each page is a group with its section list; any group expands independently (peek at another page without leaving).
- Current page: pulsing blue dot; current section: scroll-spy dot. Link hover/focus fills with a random brand colour (blue, red, black — never white on white).
- ≤900px: a 64px sticky top bar with a slide-down panel; the panel scrolls natively, the page behind is frozen, Escape closes it.
- Logo hover (and keyboard focus): the wordmark slides up and out, the flat loop + slogan "Jatkuvan markkinoinnin toimisto" rise in on the spring (§3, Brand moments).
- Footer lang toggle (FI / EN) and a cart button that opens the contact dialog.

### Page transition — the loop loader
1. **Leaving:** a click on a link to another page fades a black cover in (250ms), then navigates; a `sessionStorage` flag marks the next page.
2. **Arriving:** an inline `<head>` script sees the flag and sets `html.is-arriving` **before first paint**, so the page opens already covered. A small white loop draws its outline (500ms, `--ease-ui`) and fills (200ms, 400ms in); at ~700ms the cover lifts (350ms fade). The first scroll entrances wait until it has lifted.
3. **Critical paint:** a tiny inline `<style>` makes the canvas black and the arrival cover opaque from the very first frame — the browser's default white never shows, even before the stylesheet loads.
4. **Safety:** a CSS failsafe lifts the cover after 2.5s whatever happens to the script; a back/forward-cache restore never returns covered; no transition under reduced motion; downloads, new-tab and modified clicks are left alone.

### Footer
- A **Julkee Blue rounded card**: wordmark, tagline, link columns; the cut-out team photo bleeds to the card's side and bottom edges with a faint bottom shade (B&W people on blue — on black their clothes vanish).
- Bottom line: "Brändiohjeisto — 2026" left; a link to **julkeexlempee.fi** with the flat white loop in front, right. The shaded loop rests directly on the separator line, left, no gap.
- Footer text at .85 white keeps small links above 4.5:1 on blue.

### Pages of the brand book
| Page | Content |
|---|---|
| Brändi (home) | Video hero · origin · Luuppi (wordmark video reveal + loop build) · ads marquee · guidelines teaser · imagery marquee · merch · product carousel |
| Ohjeisto | 01 Konsepti ja slogan · 02 Arvot · 03 Logo · 04 Värit · 05 Typografia · 06 Kuvasto · 07 Sävy (+ line generator) · 08 Liike (live motion demos) · 09 Sovellukset · 10 Verkkosivut |
| Materiaalipankki | Tunnus · Valokuvat · Mainosesimerkit · Video · Dokumentit |

---

## 2. LAYOUT TOKENS

| Token | Value |
|---|---|
| Content max-width | `1280px` |
| Gutter | `clamp(20px, 5vw, 64px)` |
| Section padding | `clamp(56px, 9vw, 128px)` |
| Section radius | `clamp(20px, 3vw, 48px)` |
| Card / media radius | `18–20px` |
| Body line-height | `1.6` |
| Breakpoints | `900px` (sidebar → top bar) · `700px` (grids to 1–2 columns) |
| Anchor landing | `scroll-margin-top` = the mobile top bar (64px) |

---

## 3. COMPONENTS

### Buttons
| Variant | Rest | Hover / focus-visible |
|---|---|---|
| Primary | `#0B63F6`, white | `#0950C9` |
| Dark | `#000`, white | `#222` · on black grounds → blue |
| Outline | transparent, 2px black | filled black, white text · on dark/colour grounds → filled white, black text |

- Pill (`999px`), Work Sans Bold 14px, padding 15×32px (small: 10×20px, 12.5px). Short imperative label — never "klikkaa tästä", no gradients.
- **Label roll** on hover/focus: the text leaves upward and a text-shadow copy rises from below on the spring (500ms), clipped by the label box. One text node, so translations still work.
- **Press** `scale .955` (200ms). **Disabled** 40% opacity, no hover, `not-allowed`.
- Every hover state also applies on `:focus-visible` — never a no-op swap.

### Other UI pieces
| Piece | Rule |
|---|---|
| Text link | Blue, bold, underline offset 4px; hover → black |
| Tags | Pill, 1px light border, **regular weight, ALL CAPS**, 12px, letter-spacing .06em |
| Product-card button | Full-width outline pill with label + "+"; added state = blue fill + "✓" |
| Form field | Label above in small caps; off-white field; focus = 2px blue outline, white fill |
| Toast | Small black box (6px radius), bottom centre, 1.8s |
| Do / don't cards | Straight (no tilt); do = light blue with blue ✓, don't = light pink with red ✕ |

### Content patterns
- **Marquee** (auto-scrolling image strip): two identical sets scroll by −50%; trailing padding equals the gap (18px) so the loop has no seam. Items carry an off-grid tilt using the individual `translate`/`rotate` properties (not `transform`), so the entrance never overrides and snaps them. Pauses on hover; static under reduced motion.
- **Product carousel** (endless): a clone set either side, silent re-centre after every settle; arrows step one card on the spring (750ms); drag coasts with inertia and snaps; autoplay 5s pauses on hover, focus, off-screen and hidden tab, never under reduced motion. Clones are `aria-hidden` and out of the tab order; cart clicks are delegated so clones work.
- **Phone showcase**: a line-drawn phone (2px black outline, 46px radius, notch) showing **one item at a time** — each post or story fills the screen and snaps into place (square posts centred, stories cover). Arrow buttons sit beside the phone on wide screens and under it on phones, with an "n / total" counter; they wrap round at either end, and ←/→ work when the screen has focus. Swiping still works. Give the phone an explicit height (an `aspect-ratio` box grows to fit its content); screens use `data-lenis-prevent`.
- **Cut-outs**: transparent PNGs (merch, mockups) sit straight on the card — no tile, no shadow.
- **Merch showcase** (home): text left, one product right. Every 3s the next image wipes up from the bottom (clip-path on `--ease-ui`) with a small settle on the spring; the list beside it marks the item on show and jumps on click. Cycles only on screen and in a visible tab; static under reduced motion.
- **Website showcase**: one laptop and one phone mockup (transparent PNGs), then a primary "Kokeile itse ↗" button that opens julkeexlempee.fi in a new tab — let people use the real site rather than study screenshots.
- **Contact dialog**: focus moves to the first field and is trapped; Escape and the scrim close it; focus returns to the trigger; Lenis stops so the page can't scroll behind it; exits never spring. Sending: a serverless function posts to Mailgun (Reply-To = visitor), with a honeypot and a minimum fill time against bots; states "Lähetetään… / Kiitos! … / Tarkista …"; if sending fails it falls back to opening the visitor's email app, so nothing is lost. One Mailgun sending domain can serve several sites — give each its own domain-scoped sending key and From name.

### Material bank patterns
- **Logo downloads**: a Musta / Valkoinen / Väri filter over preview cards (logo, logo + loop, loop). Each card downloads SVG, or a 2400px transparent PNG rendered from the SVG in the browser. Väri shows only assets that exist in colour.
- **Crop on download**: Alkuperäinen / 16:9 / 4:5 / 1:1 previews every photo as a centred crop; each tile downloads that one image cut to the format (canvas crop of the full-size file, JPEG 92%), or the original file. Ad examples stay originals-only.
- **Show more**: 8 tiles, then "Näytä lisää (n)".
- **Video tiles**: real `<video>` with metadata preload only; play on hover with a mouse, toggle on click/tap; no hover-play under reduced motion.

### Brand moments
- **Brand-mark swap** (sidebar + footer logo): on hover, or keyboard focus of the sidebar logo link, the wordmark slides up and out on `--ease-ui` and the flat loop + slogan rise in on `--ease-spring`, the loop leading by 50ms. The box clips only top and bottom, so the wider slogan causes no layout shift. The loop is flat `currentColor` (black in the sidebar, white on the blue footer) — never the shaded PNG.
- **Loop build**: the loop draws its outline on scroll-in, fills solid, then holds still. Hover or click replays it (reset with transitions off, then rebuild; locked until a build finishes). A small "Klikkaa" hint sits underneath and fades while it plays.
- **Wordmark video**: hover/focus/click plays the wordmark reveal on desktop; autoplays on a loop on phones.
- **Typography specimen**: breathes between Work Sans Black 900 and Regular 400 on the variable axis (1.8s alternate), paused at Black until it's on screen; holds at Black under reduced motion.
- **Guidelines 08 Liike**: the three brand motions as live demos on Julkee Blue — the heading letter reveal (replayable), the page-loader loop (cycles while on screen), and the home page's logo reveal (wordmark video + loop build). They run on the site's own parameters, so the spec can't drift. The logo demo must sit on exactly `#0B63F6`: the wordmark video's MP4 fallback has a blue matte.
- **Favicon**: square SVG with the loop centred at its true ratio (black; white under a dark browser theme), 32px PNG fallback, 180px apple-touch icon (white loop on Julkee Blue).

---

## 4. MOTION

Direct. Confident. One spring. Two curves, one entrance primitive, and nothing may ever be left invisible.

### Eases — the only two
| CSS | GSAP | Value | Use |
|---|---|---|---|
| `--ease-ui` | `jxl-ui` | `cubic-bezier(.16, 1, .3, 1)` | Anything that must not overshoot: colour, opacity, exits, scroll, layout/height |
| `--ease-spring` | `jxl-spring` | `linear()` spring (jxl-tokens.css); easeOutBack fallback | Entrances and transforms only |

Spring shape: peaks at 1.1424 (24.5% in), dips to 0.9842 (58.8%), settled by ~81% — one confident overshoot, no wobble.

```js
CustomEase.create("jxl-spring", "M0,0 L0.076,0.5737 L0.1187,0.8382 L0.1419,0.9463 L0.1654,1.0292 L0.1897,1.0886 L0.2153,1.1258 L0.2297,1.137 L0.2448,1.1424 L0.261,1.1423 L0.2786,1.1366 L0.3101,1.1165 L0.3862,1.0507 L0.4257,1.0219 L0.4699,0.9995 L0.5163,0.9872 L0.5877,0.9842 L0.8126,1.0011 L1,1");
CustomEase.create("jxl-ui", "0.16, 1, 0.3, 1");
gsap.defaults({ ease: "jxl-ui", duration: 0.5 });
```

### Timing
| Moment | Duration | Ease | Notes |
|---|---|---|---|
| Press | 200ms | ui | `scale .955` |
| Colour (hover/focus) | 200ms | ui | |
| Interface | 500ms | ui / spring | Label roll and brand-mark swap on the spring |
| Entrance | 850ms | spring | Opacity on its own 300ms `power2.out` track |
| Children stagger | 90ms each | — | 0.7s total at most per group |
| Heading letters | 30ms each | spring, 800ms | 0.6s total at most; each letter rises `0.45em` and tips forward `rotateX -70` from its baseline |
| Body lines | 55ms each | spring, 700ms | 0.55s total at most; rise `0.7em` |
| Entrance threshold | — | — | `start: "top 80%"`, `once: true` — one line sitewide |
| Page transition | 250ms out · ~700ms in | ui | See §1 |

### Entrance primitive
```js
// Transforms animate FROM the offset TO wherever the element rests (read off the element),
// so a resting CSS offset or tilt is landed on exactly — never arrived at 0 and snapped.
// Opacity gets its own short track: a spring may only ever drive a transform.
enter(targets, { from: { y: "2em" }, duration: 0.85, stagger: 0, fade: 0.3, delay: 0, scrollTrigger });
```

| Class | Behaviour |
|---|---|
| `.reveal` | Rises in (`y 2em`) |
| `.reveal-scale` | Scales in (`0.85`; `data-reveal-scale="0.94"` to soften), 1.1s |
| `.reveal-stagger` | Direct children cascade; `h1`/`h2` children letter by letter, `.lede` children line by line |

- Heading letter splits (SplitText `words,chars`) **stay split** once settled — reverting shifts the letters. Split words are `white-space: nowrap`, so letters never wrap inside a word; size a heading for its column instead. The heading carries an `aria-label` while split.
- Body **line** splits are reverted once settled (line boxes go stale on resize).

### Always / Never
**Always:** spring on transforms only · entrances on the spring, exits never overshoot · every hover state also on `:focus-visible`
**Never:** a spring on opacity, blur or colour · an overshooting ease on exits or scroll · hidden content without a safety valve · rotating the loop

---

## 5. ROBUSTNESS (non-negotiable)

1. Entrance targets are hidden only under `.js` on `<html>` (inline script before first paint) and only for `prefers-reduced-motion: no-preference`.
2. The module script's `onerror` removes `.js` — a script that never loads leaves the page readable.
3. Any uncaught runtime error adds `.motion-failed`, which forces every target (and split words/letters/lines) visible and untransformed.
4. All entrances are built inside `gsap.matchMedia()` with exhaustive conditions (reduced / desktop / mobile), inside one `gsap.context` so the whole set can be reverted and rebuilt.
5. `ScrollTrigger.refresh()` requests are debounced (150ms); `ScrollTrigger.sort()` after creation.
6. Language swaps: `langwillchange` (revert splits before the text is rewritten) → `langchange` (rebuild, remeasure).
7. Never animate `transform` on an element whose resting look depends on a CSS `transform` transition — the two fight every frame.
8. `hidden` must beat any `display:flex/grid` you set on the same element (`[hidden]{ display:none }` for those classes).

### Smooth scroll
- Lenis (`lerp: 0.12`), driven by GSAP's ticker; `lenis.on("scroll", ScrollTrigger.update)`; `gsap.ticker.lagSmoothing(0)`.
- Native `scroll-behavior: smooth` off while Lenis runs (`html.lenis { scroll-behavior: auto }`).
- Same-page anchors scroll through Lenis and land on `scroll-margin-top` (Lenis reads it — don't add a second offset).
- Overlays stop Lenis; scrollable panels and phone screens carry `data-lenis-prevent`. Off under reduced motion.

---

## 6. ACCESSIBILITY

- Focus ring: 2px `#0B63F6`, 3px offset; white on blue/red grounds.
- `prefers-reduced-motion` read live; durations collapse to 0; no autoplay, no entrances, no hover-play, static specimen and marquee — content visible immediately.
- The language toggle translates text, `<title>`, meta description, `alt` and `aria-label`.
- Decorative media is `aria-hidden`; carousel clones and slogan faces are hidden from assistive tech.
- Brand media is `.protected` (no select/drag/context menu) — but anything interactive (the loop) must re-enable `pointer-events`.

---

## 7. LANGUAGE VERSIONS

```html
<h2 data-i18n="home.origin_title">…</h2>          <!-- text -->
<p data-i18n-html="guidelines.ex3_a"></p>          <!-- trusted markup -->
<img data-i18n-alt="…"> <button data-i18n-aria-label="…"> <meta data-i18n-content="…">
```
One dictionary (`src/i18n.js`), Finnish first. Modules never call each other on a swap — they listen for `langwillchange` / `langchange`.

---

## 8. SEO AND SHARING

Every page carries, **statically in the HTML** (link previews and most crawlers don't run JavaScript):
- `<title>` ≤ 60 characters and a meta description ≤ 160, in Finnish; the language toggle swaps both for English at runtime.
- `rel="canonical"` on the public domain (**brand.jxl.fi**) — the vercel.app aliases are behind team login.
- Open Graph + Twitter/X: `og:title`, `og:description`, `og:url`, `og:site_name`, `og:locale` fi_FI (+ en_GB alternate), and a **1200×630 share image** with `og:image:alt`; `twitter:card = summary_large_image`.
- JSON-LD: Organization (Julkee × Lempee, julkeexlempee.fi, logo, LinkedIn/Instagram) → WebSite → WebPage.
- One `h1` per page (the video-only home hero carries a visually hidden one).
- `robots.txt` (allow all, points to the sitemap), `sitemap.xml` with the share images, `site.webmanifest`, `theme-color` black.

Share image: one image for every page (`/assets/img/share/og-brand-book.jpg`, 1200×630) — the "Online brand book" browser frame showing a collage of the brand book's pages, on white. When it changes, give the file a new name so platforms fetch it fresh.

## 9. STACK AND DEPLOY

| | |
|---|---|
| Build | Vite, three static pages, ES modules, no framework |
| Motion | GSAP 3.13+ from npm (ScrollTrigger, SplitText, CustomEase, Draggable, Inertia, Flip — all free) |
| Scroll | Lenis |
| Fonts | Google Fonts, Work Sans variable `wght@100..900` |
| Assets | `public/assets/…` served at `/assets/…` with stable filenames |
| Deploy | Vercel; every push to `main` is a production deploy (`vercel.json` points the build at `dist/`) |

---

## 10. REFERENCE — julkeexlempee.fi (observed 2026-04)

Observations of the agency site, kept for context; the rules above win for new builds.
- Sticky white header, logo left, nav right, blue pill CTA; black footer with logo, nav columns and social icons.
- Homepage: black hero with blue/red radial glow and centred headline → intro with B&W team photo → client logos (greyscale strip) → text + image blocks alternating sides → dark testimonials → article cards → colour pre-footer CTA.
- Experts page: black ground, B&W portraits on Julkee Blue squares with name, role and text.
- Services page ("Huvipuisto"): irregular bento grid of colour blocks — `FLAG:` uses a yellow (~`#F5C800`) that is not in the brand palette.
