Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e96625ecb8 | ||
|
|
d114090f18 | ||
|
|
83d4384ae4 | ||
|
|
283993be4c | ||
|
|
3943913de7 | ||
|
|
c2a8cf33fc | ||
|
|
9b840aee50 | ||
|
|
12af471f62 | ||
|
|
af8a897996 | ||
|
|
f99d059146 | ||
|
|
fd25565e10 | ||
|
|
5fac2066df | ||
|
|
643565c72d | ||
|
|
114deccee7 | ||
|
|
1dcd3a2644 | ||
|
|
aa6d99659b | ||
|
|
6010f91a99 | ||
|
|
99c1877abe | ||
|
|
588d11adca | ||
|
|
b39320c06f | ||
|
|
63aba5f43b | ||
|
|
825e306497 | ||
|
|
b9ca2fd804 | ||
|
|
9919bcb117 |
No files matched your search
@@ -9,7 +9,7 @@
|
||||
{
|
||||
"name": "taste-skill",
|
||||
"description": "tasteskill v2: art direction for websites, product pages, dashboards and app screens, with a library of researched directions, real pictures from open collections and a real-browser inspector.",
|
||||
"version": "2.1.1",
|
||||
"version": "2.1.5",
|
||||
"source": "./",
|
||||
"skills": ["./skills/taste-skill"],
|
||||
"author": {
|
||||
@@ -20,29 +20,6 @@
|
||||
"repository": "https://github.com/Leonxlnx/taste-skill",
|
||||
"license": "MIT",
|
||||
"keywords": ["skills", "frontend", "design", "taste", "ui", "dashboard"]
|
||||
},
|
||||
{
|
||||
"name": "taste-skill-classics",
|
||||
"description": "The classic taste-skill styles and earlier versions (soft, minimalist, brutalist, gpt-taste, Stitch, v1, v2-experimental). Opt-in: each runs only when called by name.",
|
||||
"version": "2.1.1",
|
||||
"source": "./",
|
||||
"skills": [
|
||||
"./skills/soft-skill",
|
||||
"./skills/minimalist-skill",
|
||||
"./skills/brutalist-skill",
|
||||
"./skills/gpt-tasteskill",
|
||||
"./skills/stitch-skill",
|
||||
"./skills/taste-skill-v1",
|
||||
"./skills/taste-skill-v2-experimental"
|
||||
],
|
||||
"author": {
|
||||
"name": "leonxlnx",
|
||||
"email": "hello@tasteskill.dev"
|
||||
},
|
||||
"homepage": "https://github.com/Leonxlnx/taste-skill",
|
||||
"repository": "https://github.com/Leonxlnx/taste-skill",
|
||||
"license": "MIT",
|
||||
"keywords": ["skills", "frontend", "design", "styles"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "taste-skill",
|
||||
"version": "2.1.1",
|
||||
"description": "tasteskill v2: art direction for websites, product pages, dashboards and app screens, checked in a real browser. Classic styles included, opt-in.",
|
||||
"version": "2.1.5",
|
||||
"description": "tasteskill v2: art direction for websites, product pages, dashboards and app screens, checked in a real browser.",
|
||||
"author": {
|
||||
"name": "leonxlnx",
|
||||
"url": "https://github.com/Leonxlnx"
|
||||
@@ -20,7 +20,7 @@
|
||||
"interface": {
|
||||
"displayName": "Taste Skill",
|
||||
"shortDescription": "Art direction for websites, dashboards and app screens.",
|
||||
"longDescription": "tasteskill v2 from Leonxlnx/taste-skill: researched directions, real pictures and a real-browser inspector for websites, product pages, dashboards and app screens. The classic style skills are included and run only when called by name.",
|
||||
"longDescription": "tasteskill v2 from Leonxlnx/taste-skill: researched directions, real pictures and a real-browser inspector for websites, product pages, dashboards and app screens.",
|
||||
"developerName": "leonxlnx",
|
||||
"category": "Design",
|
||||
"capabilities": [],
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Check skills
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Every SKILL.md frontmatter parses (as Claude Code and Codex parse it)
|
||||
run: |
|
||||
npm install --no-save --no-package-lock yaml@2.6.1
|
||||
node tests/frontmatter.mjs
|
||||
- name: Text files are clean UTF-8 (no double encoding)
|
||||
run: node tests/text-encoding.mjs
|
||||
- name: Scripts parse
|
||||
run: for f in skills/taste-skill/scripts/*.mjs skills/taste-skill/scripts/lib/*.mjs skills/taste-skill/assets/*.js skills/taste-skill/assets/scenes/*.js; do node --check "$f" || exit 1; done
|
||||
- name: Browser profile cleanup and inspector file names
|
||||
run: node --test tests/profile-cleanup.mjs tests/inspection-files.mjs
|
||||
@@ -2,3 +2,8 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Scene demo media lives on the scene-media branch (served by jsDelivr), never in the skill folder
|
||||
skills/taste-skill/assets/scenes/demo/media/
|
||||
skills/taste-skill/assets/scenes/demo/*/
|
||||
|
||||
@@ -4,6 +4,39 @@ All notable changes to taste-skill live here. The repo follows SemVer-ish discip
|
||||
|
||||
---
|
||||
|
||||
## 2.1.5 - 2026-10-25
|
||||
|
||||
Fixes from the community. Thanks to everyone who reported and sent them.
|
||||
|
||||
- **Safe text in charts and split headings** ([#160](https://github.com/Leonxlnx/taste-skill/issues/160), [#169](https://github.com/Leonxlnx/taste-skill/pull/169), thanks @jcastilla89 and @Pablolakotek): `charts.js` and the `Stage.split` fallback escape the text they insert, so a chart label or heading that comes from user data can no longer inject markup.
|
||||
- **No lost screenshots when two widths share a class** ([#162](https://github.com/Leonxlnx/taste-skill/issues/162), [#165](https://github.com/Leonxlnx/taste-skill/pull/165), thanks @nosawavey-spec and @ketpatil77): `--widths 1280,1024,768,390` keeps both tablet renders (`tablet-1024-*`, `tablet-768-*`).
|
||||
- **Keyboard and accessible names** ([#170](https://github.com/Leonxlnx/taste-skill/pull/170), thanks @MrBinnacle): every control works from the keyboard and has an accessible name; a control explains itself by its form, not by a sentence beside it.
|
||||
- **The italic faces load** ([#161](https://github.com/Leonxlnx/taste-skill/issues/161), with the fix from [#166](https://github.com/Leonxlnx/taste-skill/pull/166), thanks @nosawavey-spec and @ketpatil77): Boska, Erode and Sentient request their italic weight, so the browser no longer fakes it.
|
||||
- **`edge-text` no longer fails ellipsised lines** ([#156](https://github.com/Leonxlnx/taste-skill/issues/156), thanks @shoctroper): text is measured where the reader sees it, clipped by `overflow`.
|
||||
- **Inspect the production build** ([#171](https://github.com/Leonxlnx/taste-skill/issues/171), thanks @lilozzy): the Gate step says to run the last check on the built page, and the inspector warns (`dev-server`) when it is looking at a Vite, webpack or Next dev server.
|
||||
- **The redraw rule names its third reason** ([#163](https://github.com/Leonxlnx/taste-skill/issues/163), thanks @Tsury): a section that cannot hold the real content.
|
||||
- CI now also checks that every text file is clean UTF-8 (a double-encoded save is caught), runs the inspector file-name tests, and parses the scene scripts.
|
||||
|
||||
## 2.1.4 - 2026-10-20
|
||||
|
||||
- **Fix: one inspector run could delete the browser profile of another run that was still going** ([#157](https://github.com/Leonxlnx/taste-skill/issues/157), fixed in [#158](https://github.com/Leonxlnx/taste-skill/pull/158) by @t4sh). On macOS and Linux Chrome keeps no `lockfile`, so the sweep added in 2.1.0 judged a profile unused by its age alone, and a second run started during a long one could pull that run's profile away. #158 removed the sweep and added a regression test.
|
||||
- **Leftover profiles are cleaned up again, safely.** Every profile now carries `taste-skill-owner.json` with the machine and the process IDs of the run and its Chrome. A later run removes a profile left by a killed run only when that file names this machine and neither process is alive; a profile without the file is never touched. Profiles left by earlier versions are not removed automatically: delete the `ad-chrome-*` folders in your temp folder (or in `TASTE_SKILL_TMP`) by hand.
|
||||
|
||||
## 2.1.3 - 2026-10-19
|
||||
|
||||
One repo, one skill. Nothing in `design-taste-frontend` changed.
|
||||
|
||||
- **The classic skills moved** to their own repo, [Leonxlnx/taste-skill-classics](https://github.com/Leonxlnx/taste-skill-classics): `high-end-visual-design`, `minimalist-ui`, `industrial-brutalist-ui`, `gpt-taste`, `stitch-design-taste`, `design-taste-frontend-v1` and `design-taste-frontend-v2-experimental`. Same install names, same files. The `taste-skill-classics` Claude Code plugin moved with them (`/plugin marketplace add Leonxlnx/taste-skill-classics`).
|
||||
- **The pointer folders are gone:** `brandkit`, `image-to-code`, `imagegen-frontend-web` and `imagegen-frontend-mobile` (in [Leonxlnx/imagegen-skills](https://github.com/Leonxlnx/imagegen-skills) since 2.1.1) and `redesign-existing-projects` (part of `design-taste-frontend` since 2.1.1). The README lists where every old install name lives now.
|
||||
- **Removed:** `research/laziness/` (it belonged to `full-output-enforcement`, removed in 2.1.1), the outdated `skills/llms.txt`, `skill.sh` and its test, and unused images.
|
||||
- **README:** a wordmark instead of the banner, a TasteCode banner that stands out in dark mode, fewer badges, the community sponsors always shown.
|
||||
- The Codex plugin loads only `design-taste-frontend` now.
|
||||
|
||||
## 2.1.2 - 2026-10-19
|
||||
|
||||
- **Fix: `design-taste-frontend` did not load in Claude Code** ([#151](https://github.com/Leonxlnx/taste-skill/issues/151)). The description added in 2.1.0 contained an unquoted `: `, so its YAML frontmatter failed to parse and the skill was silently dropped ("Unknown skill"). It is now a folded block scalar.
|
||||
- **So it cannot happen again:** `tests/frontmatter.mjs` parses every `SKILL.md` frontmatter (and every `agents/openai.yaml`) with a strict YAML parser, checks names, description length and the plugin manifests, and a GitHub Actions workflow (`check-skills.yml`) runs it, the registry test and a syntax check of the scripts on every push and pull request.
|
||||
|
||||
## 2.1.1 - 2026-10-19
|
||||
|
||||
One skill to reach for. Installing the repo no longer means an agent might pick an older skill for the job.
|
||||
|
||||
@@ -1,34 +1,32 @@
|
||||
<p align="center">
|
||||
<img src="assets/readme-banner.webp" alt="Taste Skill - Anti-slop Agent Skills for premium frontends" width="100%" />
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/taste-skill-wordmark-dark.png" />
|
||||
<img src="assets/taste-skill-wordmark.png" alt="Taste Skill" width="340" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<p align="center" style="margin-bottom: 8px;">
|
||||
<p align="center">
|
||||
<a href="https://tasteskill.dev" title="Visit tasteskill.dev"><img src="https://img.shields.io/badge/website-tasteskill.dev-24292f?style=flat" alt="Visit tasteskill.dev" /></a>
|
||||
|
||||
<a href="https://tastecode.dev" title="Visit tastecode.dev"><img src="https://img.shields.io/badge/new-TasteCode-ff6b00?style=flat" alt="New: TasteCode" /></a>
|
||||
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-24292f?style=flat&logo=github&logoColor=white" alt="MIT License" /></a>
|
||||
|
||||
<a href="https://github.com/vercel-labs/agent-skills"><img src="https://img.shields.io/badge/Agent_Skills-compatible-24292f?style=flat&logo=github&logoColor=white" alt="Agent Skills compatible" /></a>
|
||||
|
||||
<a href="#install"><img src="https://img.shields.io/badge/Codex_%2F_Cursor_%2F_Claude-install-24292f?style=flat" alt="Codex, Cursor, Claude" /></a>
|
||||
|
||||
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/changelog-view-24292f?style=flat" alt="Changelog" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://tastecode.dev"><img src="assets/readme-tastecode.webp" alt="TasteCode is here. Taste Skill as a desktop app, with a design agent built in. Free and open source. Download at tastecode.dev" width="100%" /></a>
|
||||
<a href="https://tastecode.dev">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/readme-tastecode-dark.webp" />
|
||||
<img src="assets/readme-tastecode.webp" alt="TasteCode is here. Taste Skill as a desktop app, with a design agent built in. Free and open source. Download at tastecode.dev" width="100%" />
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center"><sub>The easiest way to get Taste Skill results: <a href="https://tastecode.dev"><strong>download TasteCode</strong></a>. Its design agent briefs, builds and visually reviews your UI. <a href="https://github.com/Leonxlnx/tastecode">Source on GitHub</a>.</sub></p>
|
||||
|
||||
<h3 align="center">Sponsors</h3>
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td align="center" width="104"><a href="https://fluxionai.space/register?source=github&campaign=github-tasteskill&promo=TASTESKILL"><img src="assets/sponsors/sidrune-ai.png" alt="Sidrune AI" width="80" height="80" /></a></td>
|
||||
<td><sub><a href="https://fluxionai.space/register?source=github&campaign=github-tasteskill&promo=TASTESKILL"><strong>Sidrune AI</strong></a>: one API for GPT, Claude, and other leading AI models.<br />Sign up through this link and get $3 in API credit.</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="104">
|
||||
<a href="https://interfaces.dev/?utm_source=tasteskill&utm_medium=sponsorship">
|
||||
@@ -60,7 +58,7 @@
|
||||
|
||||
<p align="center"><sub><a href="https://github.com/sponsors/Leonxlnx">Become a sponsor</a></sub></p>
|
||||
|
||||
**Taste Skill** is a set of Agent Skills that make coding agents design websites instead of assembling the average landing page. The default skill gives the agent an art direction, real pictures and a way to check its own work in a real browser.
|
||||
**Taste Skill** is an Agent Skill that makes coding agents design websites and app screens instead of assembling the average landing page. It gives the agent an art direction, real pictures and a way to check its own work in a real browser.
|
||||
|
||||
Pages it has built, each from one brief:
|
||||
|
||||
@@ -77,17 +75,15 @@ Pages it has built, each from one brief:
|
||||
|
||||
The photographs are real, found in open archives and credited by the skill ([credits](examples/v2/CREDITS.md)). Watch the [launch film](https://github.com/Leonxlnx/taste-skill/releases/tag/v2.0.0).
|
||||
|
||||
<p align="center"><sub><a href="#install">Install</a> · <a href="#how-v2-works">How it works</a> · <a href="#skills">Skills</a> · <a href="#common-questions">FAQ</a> · <a href="#support-the-project">Sponsors</a> · <a href="#license">License</a></sub></p>
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/Leonxlnx/taste-skill --skill "design-taste-frontend"
|
||||
```
|
||||
|
||||
That installs the skill with the [`npx skills`](https://github.com/vercel-labs/agent-skills) CLI. Without `--skill` you also get the [classic skills](#classic-skills), which stay out of the way: they run only when you call them by name.
|
||||
That installs the skill with the [`npx skills`](https://github.com/vercel-labs/agent-skills) CLI.
|
||||
|
||||
The default skill runs its scripts with **Node 22+** and an installed **Chrome, Edge or Chromium**. It installs nothing and needs no API keys. Finding pictures needs network access.
|
||||
The skill runs its scripts with **Node 22+** and an installed **Chrome, Edge or Chromium**. It installs nothing and needs no API keys. Finding pictures needs network access.
|
||||
|
||||
<details>
|
||||
<summary>Claude Code plugin</summary>
|
||||
@@ -99,7 +95,7 @@ Run these inside Claude Code. The skill is namespaced under `taste-skill:`, so i
|
||||
/plugin install taste-skill@taste-skill
|
||||
```
|
||||
|
||||
`/plugin update` upgrades it later. The classic skills are a separate plugin, `taste-skill-classics@taste-skill`, if you want them.
|
||||
`/plugin update` upgrades it later.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -111,22 +107,22 @@ codex plugin marketplace add Leonxlnx/taste-skill
|
||||
codex plugin add taste-skill@taste-skill
|
||||
```
|
||||
|
||||
Start a new Codex session afterwards so the skills are loaded.
|
||||
Start a new Codex session afterwards so the skill is loaded.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>From a clone, linked instead of copied</summary>
|
||||
|
||||
`./install.sh design-taste-frontend` symlinks the skill into Claude Code (`--codex` for Codex, `--all-agents` for both), so a `git pull` updates it in place. `./install.sh --list` shows every install name.
|
||||
`./install.sh` symlinks the skill into Claude Code (`--codex` for Codex, `--all-agents` for both), so a `git pull` updates it in place.
|
||||
|
||||
</details>
|
||||
|
||||
Already have the skill? Run the install command again. The install name did not change, and the older versions stay available under their own names (see [Classic skills](#classic-skills)).
|
||||
Already have the skill? Run the install command again; the install name did not change. Looking for one of the older skills? See [Moved skills](#moved-skills).
|
||||
|
||||
## How v2 works
|
||||
|
||||
The default skill is built from a study of 247 curated sites. A build runs in six steps:
|
||||
The skill is built from a study of 247 curated sites. A build runs in six steps:
|
||||
|
||||
1. Reads the brief and lists what only this client has.
|
||||
2. A script deals three of 20 researched directions and the agent picks one.
|
||||
@@ -141,44 +137,26 @@ Dashboards and app screens (v2.1) take their own route: the skill deals one of t
|
||||
|
||||
v2.1 also checks the frame of every page (a clear title on the first screen, equal margins, no section shoved to one side, no random blank stretches) and moves the way strong award sites do, measured on the latest 100 Awwwards Sites of the Day.
|
||||
|
||||
## Skills
|
||||
## Moved skills
|
||||
|
||||
| Skill | Install name | What it does |
|
||||
| --- | --- | --- |
|
||||
| **[taste-skill](skills/taste-skill/SKILL.md)** | `design-taste-frontend` | Art direction for landing pages, marketing sites, portfolios, product pages, dashboards and app screens, in any stack. New builds, redesigns and reviews of existing pages. Works as described above. |
|
||||
This repo holds one skill, `design-taste-frontend`. The others moved to repos of their own and kept their install names:
|
||||
|
||||
Want design images first and code second? The image-generation skills (`imagegen-frontend-web`, `imagegen-frontend-mobile`, `brandkit`, `image-to-code`) now live in their own repo: **[Leonxlnx/imagegen-skills](https://github.com/Leonxlnx/imagegen-skills)**. Same install names.
|
||||
| Install name | Where it lives now |
|
||||
| --- | --- |
|
||||
| `high-end-visual-design`, `minimalist-ui`, `industrial-brutalist-ui`, `gpt-taste`, `stitch-design-taste`, `design-taste-frontend-v1`, `design-taste-frontend-v2-experimental` | **[Leonxlnx/taste-skill-classics](https://github.com/Leonxlnx/taste-skill-classics)** |
|
||||
| `imagegen-frontend-web`, `imagegen-frontend-mobile`, `brandkit`, `image-to-code` | **[Leonxlnx/imagegen-skills](https://github.com/Leonxlnx/imagegen-skills)** |
|
||||
| `redesign-existing-projects` | Part of `design-taste-frontend`, which audits an existing page in a real browser before changing anything |
|
||||
| `full-output-enforcement` | Removed. For the same problem, see **[Leonxlnx/unlazy](https://github.com/Leonxlnx/unlazy)** |
|
||||
|
||||
### Classic skills
|
||||
|
||||
The skills this repo shipped before v2, kept for projects that depend on them. They are opt-in: an agent never picks one by itself, it runs only when you call it by name (`/high-end-visual-design` in Claude Code, `$minimalist-ui` in Codex).
|
||||
|
||||
<details>
|
||||
<summary>Classic skills (7)</summary>
|
||||
|
||||
| Skill | Install name | What it is |
|
||||
| --- | --- | --- |
|
||||
| **[soft-skill](skills/soft-skill/SKILL.md)** | `high-end-visual-design` | Calm, polished UI: soft contrast, whitespace, spring motion. |
|
||||
| **[minimalist-skill](skills/minimalist-skill/SKILL.md)** | `minimalist-ui` | Editorial product UI with a restrained palette and crisp structure. |
|
||||
| **[brutalist-skill](skills/brutalist-skill/SKILL.md)** | `industrial-brutalist-ui` | Hard mechanical language: Swiss type, sharp contrast, experimental layout. |
|
||||
| **[gpt-tasteskill](skills/gpt-tasteskill/SKILL.md)** | `gpt-taste` | A stricter variant for GPT and Codex: more layout variance, stronger motion direction. |
|
||||
| **[stitch-skill](skills/stitch-skill/SKILL.md)** | `stitch-design-taste` | Rules for Google Stitch, with an optional `DESIGN.md` export. |
|
||||
| **[taste-skill-v2-experimental](skills/taste-skill-v2-experimental/SKILL.md)** | `design-taste-frontend-v2-experimental` | The single-file, dial-driven rewrite that was the default before v2. |
|
||||
| **[taste-skill-v1](skills/taste-skill-v1/SKILL.md)** | `design-taste-frontend-v1` | The original. |
|
||||
Install a moved skill from its new repo under the same name:
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/Leonxlnx/taste-skill --skill "high-end-visual-design"
|
||||
npx skills add https://github.com/Leonxlnx/taste-skill-classics --skill "high-end-visual-design"
|
||||
```
|
||||
|
||||
The two older versions of taste-skill have three 1 to 10 dials at the top of the file: **DESIGN_VARIANCE** (centred and clean to asymmetric), **MOTION_INTENSITY** (hover only to scroll-driven) and **VISUAL_DENSITY** (spacious to dense). v2 has no dials. Its direction comes from the draw and its limits from the inspector.
|
||||
|
||||
</details>
|
||||
|
||||
`redesign-existing-projects` is retired: redesigns and reviews are part of taste-skill, which audits the existing page in a real browser before changing anything. `full-output-enforcement` is gone.
|
||||
|
||||
## Common Questions
|
||||
|
||||
**What does the default skill need?**
|
||||
**What does the skill need?**
|
||||
Node 22 or newer and Chrome, Edge or Chromium. It installs nothing and uses no API keys. It needs the internet only to find pictures.
|
||||
|
||||
**Where do the pictures come from, and can I use them?**
|
||||
@@ -209,9 +187,9 @@ If Taste Skill helps you, consider sponsoring:
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<details>
|
||||
<summary>Community sponsors</summary>
|
||||
### Community Sponsors
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/dnakov"><img src="https://github.com/dnakov.png" width="40" height="40" style="border-radius:50%" alt="dnakov" title="dnakov" /></a>
|
||||
<a href="https://github.com/AkramReshad"><img src="https://github.com/AkramReshad.png" width="40" height="40" style="border-radius:50%" alt="AkramReshad" title="AkramReshad" /></a>
|
||||
<a href="https://github.com/ajmalaksar25"><img src="https://github.com/ajmalaksar25.png" width="40" height="40" style="border-radius:50%" alt="ajmalaksar25" title="ajmalaksar25" /></a>
|
||||
@@ -235,6 +213,7 @@ If Taste Skill helps you, consider sponsoring:
|
||||
<a href="https://github.com/AnthonyLiuVa"><img src="https://github.com/AnthonyLiuVa.png" width="40" height="40" style="border-radius:50%" alt="AnthonyLiuVa" title="AnthonyLiuVa" /></a>
|
||||
<a href="https://github.com/Wolfy18"><img src="https://github.com/Wolfy18.png" width="40" height="40" style="border-radius:50%" alt="Wolfy18" title="Wolfy18" /></a>
|
||||
<a href="https://github.com/Gigslol"><img src="https://github.com/Gigslol.png" width="40" height="40" style="border-radius:50%" alt="Gigslol" title="Gigslol" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.star-history.com/leonxlnx/taste-skill">
|
||||
@@ -246,8 +225,6 @@ If Taste Skill helps you, consider sponsoring:
|
||||
</a>
|
||||
</p>
|
||||
|
||||
</details>
|
||||
|
||||
<sub>Feedback: open an issue or pull request, DM [@lexnlin](https://x.com/lexnlin) or [@blueemi99](https://x.com/blueemi99), or email [hello@tasteskill.dev](mailto:hello@tasteskill.dev).</sub>
|
||||
|
||||
<sub>Taste Skill has no official token, coin, or crypto project. Any token using my name, image, or project is unaffiliated and not endorsed by me.</sub>
|
||||
|
||||
|
Before Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 50 KiB |
@@ -1,8 +0,0 @@
|
||||
# Research
|
||||
|
||||
Background research that informed the skills in this project. Each topic gets its own subfolder.
|
||||
|
||||
## Topics
|
||||
|
||||
### [LLM Laziness](laziness/)
|
||||
Why AI models produce incomplete outputs (placeholder code, truncated responses, skipped sections) and documented techniques to prevent it. Covers root causes, parameter fixes, prompt techniques, and experiment data.
|
||||
@@ -1,25 +0,0 @@
|
||||
# LLM Output Truncation Research
|
||||
|
||||
A structured analysis of why large language models produce incomplete outputs, and documented methods to restore full-fidelity generation. All findings are drawn from controlled experiments, published studies, and field-tested engineering practices.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
### Root Causes
|
||||
Analysis of the economic, architectural, and behavioral mechanisms that drive output truncation in production LLMs.
|
||||
|
||||
- [RLHF and Compute Economics](root-causes/rlhf-and-compute.md) — How reinforcement learning and cost optimization create systematic brevity bias.
|
||||
- [Training Data Bias](root-causes/training-data-bias.md) — How placeholder patterns in human-written code propagate into model outputs.
|
||||
- [Cognitive Shortcuts](root-causes/cognitive-shortcuts.md) — Empirical evidence of models taking shortcuts on complex or lengthy tasks.
|
||||
- [Output Limits](root-causes/output-limits.md) — Context window asymmetry and consumer-tier truncation mechanisms.
|
||||
|
||||
### Remediation
|
||||
Documented techniques for overriding default truncation behavior, ordered from parameter-level fixes to full architectural solutions.
|
||||
|
||||
- [Parameter Tuning](remediation/parameter-tuning.md) — Temperature, Top-p, and Gemini thinking-level configuration.
|
||||
- [Prompt Engineering](remediation/prompt-engineering.md) — Structural prompt techniques: syntax binding, XML frameworks, and verification loops.
|
||||
- [Architectural Patterns](remediation/architectural-patterns.md) — MCP integration, lazy-loaded skills, and developer platform access.
|
||||
- [Reference Prompts](remediation/reference-prompts.md) — Ready-to-use prompt templates for enforcing complete outputs.
|
||||
|
||||
### Findings
|
||||
- [Empirical Results](findings/empirical-results.md) — Controlled experiment data from 2025 academic studies.
|
||||
- [References](findings/references.md) — Cited studies and further reading.
|
||||
@@ -1,58 +0,0 @@
|
||||
# Empirical Results
|
||||
|
||||
## 2025 Controlled Experiments
|
||||
|
||||
A controlled study published in December 2025 measured output truncation across several frontier models, including GPT-4 variants and DeepSeek. Three experiments were conducted:
|
||||
|
||||
### Experiment A: Multi-Part Instruction Compliance
|
||||
|
||||
Models were given complex prompts with multiple explicit requirements (formatting constraints, length requirements, mandatory sections). Results:
|
||||
|
||||
- No model fully satisfied both length requirements and all sub-part instructions natively
|
||||
- Models frequently omitted mandatory output sections
|
||||
- Required formatting constraints were routinely skipped
|
||||
- Explicit length requirements were consistently undershot
|
||||
|
||||
### Experiment B: Decoding Suboptimality
|
||||
|
||||
Tested whether truncated outputs resulted from suboptimal token selection (the model "knowing" the right answer but selecting a worse token). Results:
|
||||
|
||||
- Limited evidence of decoding suboptimality on simple reasoning tasks
|
||||
- The model's greedy, truncated output generally aligned with its highest-confidence solution
|
||||
- Truncation is a deliberate behavioral choice, not a decoding failure
|
||||
|
||||
### Experiment C: Context Degradation
|
||||
|
||||
Tested whether models lose track of instructions during long, multi-turn conversations. Results:
|
||||
|
||||
- Surprising resilience against context degradation during 200-turn conversational tests
|
||||
- Models maintained key facts and instructions significantly better than hypothesized
|
||||
- Context loss is not the primary cause of truncation
|
||||
|
||||
### Key Conclusion
|
||||
|
||||
Laziness is not a failure of memory, context processing, or core model capabilities. It is a behavioral artifact triggered by:
|
||||
1. Instruction complexity exceeding internal effort thresholds
|
||||
2. Aggressively calibrated stopping pressure
|
||||
3. Economic constraints embedded in the alignment layer
|
||||
|
||||
## Prompt Stimulus Effectiveness (Microsoft Research)
|
||||
|
||||
Controlled testing of psychological prompt stimuli documented in a Microsoft Research study:
|
||||
|
||||
| Stimulus | Measured Effect |
|
||||
|:---|:---|
|
||||
| Financial incentive framing ("$200 tip") | +45% output quality and length |
|
||||
| Step-by-step instruction ("take a deep breath") | Accuracy: 34% to 80% on logic tasks |
|
||||
| Stakes framing ("critical to my career") | +10% average performance |
|
||||
| Combined (multiple stimuli) | Up to +115% overall performance |
|
||||
|
||||
These effects are reproducible and stem from statistical correlations in the training data between stakes language and high-effort human outputs.
|
||||
|
||||
## Seasonal Output Variation
|
||||
|
||||
Statistical analysis of ChatGPT outputs during November-December 2023 versus January-March 2024 confirmed:
|
||||
|
||||
- Measurable decrease in average output length during December
|
||||
- Correlation with reduced work output in the training data during holiday periods
|
||||
- Output length increased when the system prompt explicitly stated a non-winter month
|
||||
@@ -1,20 +0,0 @@
|
||||
# References
|
||||
|
||||
## Cited Studies
|
||||
|
||||
- **EmotionPrompt (Microsoft Research)** — Demonstrates that emotional and stakes-based prompt framing mathematically improves LLM reasoning quality and output length. Documents the +45% improvement from financial framing and +115% from combined stimuli.
|
||||
|
||||
- **LazyBench** — Proves that frontier models (Gemini 1.5 Pro, GPT-4o) actively select cognitive shortcuts and fail tasks they are capable of solving when the perceived effort exceeds internal thresholds.
|
||||
|
||||
- **Compounding Error Avoidance** — Research demonstrating that models truncate outputs as a risk mitigation strategy, preferring shorter responses to reduce the surface area for factual errors on long-form tasks.
|
||||
|
||||
- **Seasonal Behavior Analysis (Winter Break Hypothesis)** — Statistical analysis confirming that LLMs internalize seasonal work patterns from training data, producing measurably shorter outputs during periods corresponding to human holiday seasons.
|
||||
|
||||
- **2025 Controlled Laziness Experiments** — Three-part academic study (December 2025) confirming that output truncation is a behavioral artifact of alignment training, not a failure of context processing or model capability.
|
||||
|
||||
## Further Reading
|
||||
|
||||
- Google Gemini API documentation on `thinking_level` parameter configuration
|
||||
- Anthropic MCP (Model Context Protocol) specification and integration guides
|
||||
- OpenAI API reference for temperature and Top-p parameter tuning
|
||||
- YAML front-matter specification for SKILL.md lazy-loading architecture
|
||||
@@ -1,55 +0,0 @@
|
||||
# Architectural Patterns
|
||||
|
||||
## Lazy-Loaded Skills
|
||||
|
||||
The standard pattern for managing large context requirements across AI agents is lazy-loaded prompt engineering through skill files.
|
||||
|
||||
A skill is a folder containing a `SKILL.md` file with:
|
||||
|
||||
- **YAML front-matter:** Contains `name` and a precise `description`. This metadata acts as the discovery hook — the agent reads only this during initialization (~100 tokens per skill).
|
||||
- **Markdown body:** Full workflows, rules, and instructions. Loaded on-demand only when the agent determines the skill is relevant.
|
||||
|
||||
This architecture yields a documented 35% reduction in average context usage and prevents context dilution. However, discovery reliability depends on the specificity of the YAML description:
|
||||
|
||||
| Description Quality | Discovery Success Rate |
|
||||
|:---|:---:|
|
||||
| Vague ("Helps with designing APIs") | ~68% |
|
||||
| Specific ("Design RESTful HTTP APIs with OpenAPI specs, focusing on versioning, error codes, and backward compatibility") | ~90% |
|
||||
|
||||
## Model Context Protocol (MCP)
|
||||
|
||||
MCP is an open standard (pioneered by Anthropic, adopted by Google and OpenAI) that enables real-time, bidirectional connections between LLMs and external data sources.
|
||||
|
||||
### Architecture Components
|
||||
|
||||
- **Host:** The AI application (IDE, terminal tool, chatbot) containing the LLM engine.
|
||||
- **Client:** Internal bridge within the host that handles protocol communication.
|
||||
- **Server:** External service exposing databases, APIs, or documentation to the client.
|
||||
- **Transport:** JSON-RPC 2.0 messages over stdio (local) or HTTP (remote).
|
||||
|
||||
### How It Reduces Truncation
|
||||
|
||||
Without MCP, models rely on static training weights for factual claims. When those weights are outdated (e.g., a new API version was released after training cutoff), the model either hallucinates a plausible answer or truncates its response to avoid committing to specifics.
|
||||
|
||||
With MCP, the model fetches current documentation directly into its context window. This transforms the model from a static knowledge store into a reasoning engine operating on real-time data, eliminating the incentive to hallucinate or truncate.
|
||||
|
||||
### Example: Developer Knowledge API
|
||||
|
||||
Google's Developer Knowledge MCP Server indexes live documentation across Firebase, Android, and Google Cloud. When a model receives a development question:
|
||||
|
||||
1. It executes a `search_document` query against the live index
|
||||
2. It evaluates returned page URIs
|
||||
3. It fetches full document content via `get_document` or `batch_get_documents`
|
||||
4. It generates its response based on current, authoritative documentation
|
||||
|
||||
This entirely bypasses the tendency to fabricate answers from outdated training data.
|
||||
|
||||
## Chunked Task Execution
|
||||
|
||||
For complex tasks that would produce outputs exceeding the model's generation limit, break the work into sequential steps:
|
||||
|
||||
1. Request the architecture and structure first (outline only)
|
||||
2. Request each component individually with explicit instructions for completeness
|
||||
3. Request assembly and integration after all components are generated
|
||||
|
||||
This prevents the model from attempting to estimate total output length and preemptively compressing its response.
|
||||
@@ -1,44 +0,0 @@
|
||||
# Parameter Tuning
|
||||
|
||||
## Temperature and Top-p
|
||||
|
||||
Autoregressive models select each next token from a probability distribution generated by a softmax function applied to logit values. When a model defaults to brief outputs, the tokens associated with truncation and summarization have been assigned the highest probabilities through RLHF alignment.
|
||||
|
||||
### Temperature
|
||||
|
||||
Adjusting the temperature parameter changes how the softmax function distributes probability mass across candidate tokens.
|
||||
|
||||
- **Low temperature (0.0 - 0.5):** Amplifies differences between high and low-probability tokens. The model becomes highly deterministic, consistently selecting the highest-confidence continuation. Optimal for code generation, data extraction, and structured output.
|
||||
- **Default temperature (1.0):** Retains the original probability distribution from training.
|
||||
- **High temperature (1.5+):** Flattens the distribution, introducing more randomness. Useful for creative tasks but increases the risk of incoherent outputs.
|
||||
|
||||
Example probability distribution shift for a single token position:
|
||||
|
||||
| Token Candidate | Probability at Temp 1.5 | Probability at Temp ~0.0 | Raw Logit |
|
||||
|:---|:---:|:---:|:---:|
|
||||
| lazy | 0.4875 | 0.9933 | 2.0 |
|
||||
| quick | 0.2503 | 0.0067 | 1.0 |
|
||||
| tired | 0.1285 | 0.0000 | 0.0 |
|
||||
| slow | 0.0660 | 0.0000 | -1.0 |
|
||||
| clumsy | 0.0339 | 0.0000 | -2.0 |
|
||||
|
||||
### Top-p (Nucleus Sampling)
|
||||
|
||||
Top-p truncates the probability distribution by only considering the smallest set of tokens whose cumulative probability exceeds threshold p. A Top-p of 0.0 to 0.6 combined with low temperature forces the model into a narrow, deterministic execution path, reducing the entropy that enables creative refusals and unnecessary summarization.
|
||||
|
||||
## Gemini Thinking Level Configuration
|
||||
|
||||
Google Gemini 3 models replaced the legacy `thinking_budget` (a hard token count cap on internal reasoning) with a `thinking_level` parameter that provides relative guidance on computational depth.
|
||||
|
||||
| Setting | Flash Support | Pro Support | Use Case |
|
||||
|:---|:---:|:---:|:---|
|
||||
| `minimal` | Yes | No | High-throughput, low-latency tasks |
|
||||
| `low` | Yes | Yes | Simple instruction following, data extraction |
|
||||
| `medium` | Yes | Yes (3.1 Pro) | Moderate complexity tasks |
|
||||
| `high` | Yes (Default) | Yes (Default) | Complex analysis, code generation, mathematics |
|
||||
|
||||
Important constraints:
|
||||
- `thinking_level` and `thinking_budget` are mutually exclusive. Using both in one API call triggers an HTTP 400 error.
|
||||
- Even at `low`, Gemini Pro models perform mandatory minimum internal deliberation for safety and alignment.
|
||||
- For code generation and complex analysis, set to `medium` or `high` for quality scores consistently exceeding 92-95% compared to baseline.
|
||||
- Avoid combining extremely low temperature with `high` thinking level, as this can occasionally induce internal reasoning loops.
|
||||
@@ -1,52 +0,0 @@
|
||||
# Prompt Engineering Techniques
|
||||
|
||||
## Psychological Pattern Matching
|
||||
|
||||
LLMs do not have emotions or understand monetary incentives. However, specific linguistic patterns in the prompt activate different quality distributions in the model's latent space. Research has documented measurable effects:
|
||||
|
||||
| Technique | Documented Effect |
|
||||
|:---|:---|
|
||||
| "I will tip you $200 for a perfect solution" | Up to 45% increase in output quality and length |
|
||||
| "Take a deep breath and solve step by step" | Accuracy improvement from 34% to 80% on logic tasks |
|
||||
| "This task is critical to my career" | Average 10% performance increase |
|
||||
|
||||
These phrases work because they are statistically correlated with high-effort, rigorously reviewed content in the training data (academic papers, enterprise codebases, legal documents). The attention mechanism prioritizes the high-quality data distributions associated with these patterns.
|
||||
|
||||
## Explicit Syntax Binding
|
||||
|
||||
Conversational requests allow the model to exercise discretion about output length and detail. Structural binding removes this discretion by explicitly prohibiting truncation patterns.
|
||||
|
||||
Effective binding requires two components:
|
||||
|
||||
1. **Mandatory tool execution:** Forbid the model from generating answers solely from training weights. Require it to execute search, computation, or code before answering.
|
||||
2. **Evidence blocks:** Require the model to output raw data (URLs, code execution results, data fragments) before producing its narrative response. This forces the model to read its own retrieved evidence, reducing hallucination probability to near zero.
|
||||
|
||||
## XML-Structured Prompts
|
||||
|
||||
Enterprise systems use strict XML tagging to separate prompt components, reducing the cognitive load required for the model to parse intent:
|
||||
|
||||
1. **System instructions** — Persona definition, quality expectations, explicit prohibitions on filler content.
|
||||
2. **Context block** (`<context>`) — Passive background data: architecture details, configurations, existing code.
|
||||
3. **Data block** (`<data>`, `<logs>`, `<config>`) — Active information the model must process against the context.
|
||||
4. **Task block** (`<tasks>`) — Numbered list of specific actions to execute.
|
||||
|
||||
This compartmentalization ensures the model can distinguish between persistent rules, background context, and immediate work items. It significantly reduces the confusion that triggers premature truncation.
|
||||
|
||||
## Verification Loops
|
||||
|
||||
### Chain of Verification
|
||||
1. Model generates an initial response
|
||||
2. Model generates verification questions about its own claims
|
||||
3. Model independently answers those verification questions
|
||||
4. Model outputs a revised, evidence-backed response
|
||||
|
||||
This process forces iterative self-correction, consuming the model's capacity for shortcutting.
|
||||
|
||||
### Reverse Prompting
|
||||
Instead of manually constructing a structured prompt, provide the model with a one-line objective and instruct it to generate the optimal prompt for that objective. The model produces the XML structure, constraints, and roles required for the task.
|
||||
|
||||
### Self-Grading Loop
|
||||
The prompt requires the model to:
|
||||
1. Define what excellence looks like for the given task
|
||||
2. Grade its own initial output against that definition
|
||||
3. Iterate until the self-defined quality bar is met
|
||||
@@ -1,79 +0,0 @@
|
||||
# Reference Prompts
|
||||
|
||||
Ready-to-use prompt templates for enforcing complete outputs. Append to any prompt or include in system instructions.
|
||||
|
||||
---
|
||||
|
||||
## General Purpose
|
||||
|
||||
```
|
||||
You must provide the FULL, complete, and exhaustive output for this task.
|
||||
Do not summarize, abbreviate, or truncate for brevity.
|
||||
|
||||
You are strictly forbidden from using placeholders. Never use comments like
|
||||
"// ... rest of code here", "[continue here]", or bare ellipses standing
|
||||
in for omitted content. If the output is 500 lines, produce all 500 lines.
|
||||
|
||||
If you approach your output limit, stop at a clean breakpoint and indicate
|
||||
where to resume. Do not rush to a conclusion or compress remaining sections.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Generation
|
||||
|
||||
```
|
||||
Write the complete, production-ready implementation. Every function, every
|
||||
import, every edge case handler must be present in the output.
|
||||
|
||||
Do not use placeholder comments (// TODO, // implement here, // similar
|
||||
to above). Do not describe what code should do — write the actual code.
|
||||
|
||||
If the implementation requires multiple files, output each file completely
|
||||
with its full path as a header.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Analysis and Documentation
|
||||
|
||||
```
|
||||
Provide an exhaustive analysis covering every aspect requested. Each section
|
||||
must contain substantive content, not summaries or references to "see above."
|
||||
|
||||
Do not use phrases like "as mentioned earlier" to avoid repeating necessary
|
||||
context. Each section should be self-contained and complete.
|
||||
|
||||
Structure your output with clear headings. If the analysis requires multiple
|
||||
parts, produce all parts in full.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Reasoning
|
||||
|
||||
```
|
||||
Before generating your final response, work through the problem systematically:
|
||||
|
||||
1. Identify all requirements and constraints from the prompt
|
||||
2. Break the task into discrete steps
|
||||
3. Execute each step completely
|
||||
4. Verify your output against the original requirements
|
||||
|
||||
Output your reasoning process, then your final answer. Do not skip steps
|
||||
or summarize intermediate work.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Continuation Handling
|
||||
|
||||
```
|
||||
If your response approaches the output token limit:
|
||||
- Do not compress remaining content to fit
|
||||
- Do not skip ahead to a conclusion
|
||||
- Stop at a natural breakpoint (end of a function, end of a section)
|
||||
- End with: [PAUSED - X of Y sections complete. Send "continue" to resume]
|
||||
|
||||
On "continue", pick up exactly where you stopped. No recaps or repetition.
|
||||
```
|
||||
@@ -1,23 +0,0 @@
|
||||
# Cognitive Shortcuts
|
||||
|
||||
## The LazyBench Discovery
|
||||
|
||||
Research from late 2024 demonstrated that frontier models (including Gemini Pro and GPT-4o) exhibit measurable cognitive shortcutting behavior. When a model perceives a task as straightforward or the provided context as excessively long, it reduces its internal computational effort. Rather than executing full multi-step reasoning, it produces a surface-level summary.
|
||||
|
||||
This is not a memory failure or context degradation — the model retains the information but chooses not to process it at full depth.
|
||||
|
||||
## Metacognitive Laziness
|
||||
|
||||
The interaction between model brevity and human behavior creates a feedback loop. As models provide instant, condensed answers, users increasingly offload inference and logical deduction work. Research from the European Research Council has documented measurable declines in working memory engagement among populations with high AI dependency.
|
||||
|
||||
In professional environments, this shifts critical thinking from original synthesis to "prompt verification" — users evaluate whether the AI's truncated output seems reasonable rather than performing the analysis themselves.
|
||||
|
||||
## Seasonal Behavior Anomalies
|
||||
|
||||
In late 2023, researchers observed a statistically significant increase in ChatGPT output brevity during December. Analysis revealed that the training data contains fewer detailed work outputs, more out-of-office responses, and shorter code commits during holiday periods. The model internalized this seasonal pattern.
|
||||
|
||||
When researchers explicitly stated "It is May" in the system prompt, output length measurably increased. This finding demonstrates that even arbitrary contextual signals in the prompt can shift the model's brevity calibration.
|
||||
|
||||
## Error Avoidance as Truncation Driver
|
||||
|
||||
Models also truncate outputs as a risk mitigation strategy. On long-form tasks, longer outputs increase the probability of compounding errors and hallucinated content. The model has learned that shorter outputs reduce the surface area for factual mistakes, creating an additional incentive to truncate that compounds with the RLHF brevity bias.
|
||||
@@ -1,39 +0,0 @@
|
||||
# Output Limits and Consumer Truncation
|
||||
|
||||
## Context Window Asymmetry
|
||||
|
||||
Models like Gemini have massive input context windows (up to 2 million tokens) but strictly capped output limits (typically 8,000 tokens). When the model estimates that a complete response would exceed its output budget, it preemptively compresses or summarizes the output rather than risking an abrupt cutoff.
|
||||
|
||||
This creates a paradox: the model can read extensive inputs but cannot respond proportionally, leading to systematic information loss on complex tasks.
|
||||
|
||||
## The Consumer Middleware Problem
|
||||
|
||||
Consumer-facing applications (gemini.google.com, standard ChatGPT tiers) apply additional software-level truncation on top of the model's inherent limits. This middleware silently truncates conversation history and uploaded files to reduce compute costs for free and low-tier users.
|
||||
|
||||
Key mechanisms:
|
||||
|
||||
- **History capping:** Many consumer interfaces cap active conversation history at approximately 32,000 tokens, regardless of the model's actual capacity.
|
||||
- **Context pruning:** Large system instructions or saved personal context consume tokens that would otherwise be available for the conversation, effectively shrinking the working window.
|
||||
- **Retrieval-based recall:** Consumer apps often use retrieval mechanisms to selectively inject saved context, meaning the model frequently drops instructions it was given earlier in the session.
|
||||
|
||||
## Developer Platform Differences
|
||||
|
||||
Direct API access and developer platforms (Google AI Studio, OpenAI API Playground) bypass consumer middleware entirely. These environments provide:
|
||||
|
||||
- Full context window access without hidden truncation
|
||||
- Complete control over generation parameters
|
||||
- No dynamic throttling based on user tier
|
||||
- Processing of complex prompt structures without middleware interference
|
||||
|
||||
The practical difference is significant: the same model that produces truncated outputs through a consumer interface will generate complete, unabridged responses when accessed through direct API endpoints.
|
||||
|
||||
## Terminal and CLI Integration
|
||||
|
||||
Purpose-built CLI tools (Gemini CLI, Claude Code, third-party wrappers) offer additional advantages for avoiding truncation:
|
||||
|
||||
| Access Method | Context Handling | Truncation Risk | Parameter Control |
|
||||
|:---|:---|:---|:---|
|
||||
| Consumer web app | Aggressive pruning, 32K cap | High | Limited |
|
||||
| Developer platform (AI Studio) | Full context, no hidden slicing | Low | Full |
|
||||
| Direct API | Full context, raw access | Minimal | Full |
|
||||
| CLI tools with local models | No corporate alignment filters | None | Full |
|
||||
@@ -1,27 +0,0 @@
|
||||
# RLHF and Compute Economics
|
||||
|
||||
## The Cost of Token Generation
|
||||
|
||||
Every token an LLM generates consumes GPU compute resources. At an estimated baseline cost of $0.0001 per token, scaling deep multi-step reasoning across hundreds of millions of users would exhaust the financial capacity of any provider. This creates an inherent economic incentive to minimize output length.
|
||||
|
||||
## Brevity Bias Through Alignment
|
||||
|
||||
To manage infrastructure costs, model providers use Reinforcement Learning from Human Feedback (RLHF) and behavioral fine-tuning to instill systematic brevity preferences. During post-training alignment, models are rewarded for generating short, confident summaries rather than executing the full compute cycles needed for exhaustive analysis.
|
||||
|
||||
The result is a trained preference for producing generalized approximations over rigorous, multi-step solutions. The model does not necessarily produce incorrect answers, but it consistently produces answers that lack depth — saving itself from deeper analytical work unless the user explicitly forces it.
|
||||
|
||||
## Stopping Pressure
|
||||
|
||||
Autoregressive models generate text token by token and lack an inherent mechanism for recognizing task completion. To prevent infinite generation, training introduces "stopping pressure" — a learned tendency to conclude outputs.
|
||||
|
||||
In recent model iterations, this stopping pressure has been calibrated aggressively to preserve compute. This leads to:
|
||||
|
||||
- Skipping required structured output fields, particularly long-form content in JSON or markdown
|
||||
- Halting mid-task with phrases like "let me know if you want me to continue"
|
||||
- Refusing to produce comprehensive solutions, suggesting the user "think about it"
|
||||
|
||||
This aggressive calibration is further reinforced by safety tuning protocols, which inject additional behavioral constraints that make models resistant to generating large codebases or detailed reviews.
|
||||
|
||||
## Dynamic Throttling
|
||||
|
||||
Providers dynamically scale back model performance during peak demand periods. This introduces additional friction beyond what the base alignment already imposes, resulting in even shorter and less detailed outputs when server load is high.
|
||||
@@ -1,28 +0,0 @@
|
||||
# Training Data Bias
|
||||
|
||||
## Placeholder Propagation
|
||||
|
||||
LLMs learn by imitating patterns in human-written text. A significant portion of their training data comes from sources like Stack Overflow, GitHub repositories, and tutorial blogs. In these sources, human developers routinely write abbreviated code:
|
||||
|
||||
```python
|
||||
def complex_logic():
|
||||
# implement auth here
|
||||
pass
|
||||
```
|
||||
|
||||
The model internalizes this pattern and treats placeholder insertion as a legitimate, professional response format. It is not deliberately withholding content — it has been trained to believe that truncating code with comments is the correct way to answer technical questions.
|
||||
|
||||
## Pattern Reinforcement
|
||||
|
||||
This behavior is reinforced across multiple data sources:
|
||||
|
||||
- **Code tutorials** frequently show partial implementations with comments indicating where students should complete the logic
|
||||
- **Documentation** often uses abbreviated examples with ellipses
|
||||
- **Forum answers** regularly provide skeleton code rather than full implementations
|
||||
- **Blog posts** truncate repetitive code blocks with "similarly for the remaining cases"
|
||||
|
||||
The cumulative effect is that the model assigns high probability to truncation tokens in contexts where complete code generation would be appropriate.
|
||||
|
||||
## Impact on Output Quality
|
||||
|
||||
When a user requests a complete implementation, the model faces competing training signals: the explicit instruction to produce full output versus the deeply embedded pattern of producing abbreviated, "tutorial-style" responses. Without aggressive prompt engineering, the tutorial-style pattern frequently wins because it appears far more commonly in the training distribution.
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Local skill registry. Each skill is listed by its install name (the `name:` in its SKILL.md,
|
||||
# the value `npx skills add --skill` takes) and by its folder name.
|
||||
declare -A SKILLS=(
|
||||
[design-taste-frontend]="skills/taste-skill/SKILL.md"
|
||||
[design-taste-frontend-v2-experimental]="skills/taste-skill-v2-experimental/SKILL.md"
|
||||
[design-taste-frontend-v1]="skills/taste-skill-v1/SKILL.md"
|
||||
[image-to-code]="skills/image-to-code-skill/SKILL.md"
|
||||
[redesign-existing-projects]="skills/redesign-skill/SKILL.md"
|
||||
[high-end-visual-design]="skills/soft-skill/SKILL.md"
|
||||
[minimalist-ui]="skills/minimalist-skill/SKILL.md"
|
||||
[industrial-brutalist-ui]="skills/brutalist-skill/SKILL.md"
|
||||
[stitch-design-taste]="skills/stitch-skill/SKILL.md"
|
||||
[gpt-tasteskill]="skills/gpt-tasteskill/SKILL.md"
|
||||
[taste-skill]="skills/taste-skill/SKILL.md"
|
||||
[taste-skill-v2-experimental]="skills/taste-skill-v2-experimental/SKILL.md"
|
||||
[taste-skill-v1]="skills/taste-skill-v1/SKILL.md"
|
||||
[gpt-taste]="skills/gpt-tasteskill/SKILL.md"
|
||||
[image-to-code-skill]="skills/image-to-code-skill/SKILL.md"
|
||||
[imagegen-frontend-web]="skills/imagegen-frontend-web/SKILL.md"
|
||||
[imagegen-frontend-mobile]="skills/imagegen-frontend-mobile/SKILL.md"
|
||||
[brandkit]="skills/brandkit/SKILL.md"
|
||||
[redesign-skill]="skills/redesign-skill/SKILL.md"
|
||||
[soft-skill]="skills/soft-skill/SKILL.md"
|
||||
[minimalist-skill]="skills/minimalist-skill/SKILL.md"
|
||||
[brutalist-skill]="skills/brutalist-skill/SKILL.md"
|
||||
[stitch-skill]="skills/stitch-skill/SKILL.md"
|
||||
)
|
||||
|
||||
print_help() {
|
||||
echo "Usage: source ./skill.sh <skill-name>"
|
||||
echo " source ./skill.sh --list"
|
||||
echo
|
||||
echo "Available skills:"
|
||||
printf '%s\n' "${!SKILLS[@]}" | sort
|
||||
}
|
||||
|
||||
skill_main() {
|
||||
if [[ $# -eq 0 || "$1" == "-h" || "$1" == "--help" ]]; then
|
||||
print_help
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$1" == "--list" ]]; then
|
||||
printf '%s\n' "${!SKILLS[@]}" | sort
|
||||
return 0
|
||||
fi
|
||||
|
||||
local skill_name="$1"
|
||||
local skill_path="${SKILLS[$skill_name]-}"
|
||||
|
||||
if [[ -z "$skill_path" ]]; then
|
||||
echo "Unknown skill: $skill_name" >&2
|
||||
echo >&2
|
||||
print_help >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
echo "$skill_path"
|
||||
}
|
||||
|
||||
skill_main "$@"
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
name: brandkit
|
||||
description: "Moved to github.com/Leonxlnx/imagegen-skills. Runs only when called by name."
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# brandkit (moved)
|
||||
|
||||
This skill now lives in its own repository, with the other image-generation skills: https://github.com/Leonxlnx/imagegen-skills
|
||||
|
||||
The install name did not change:
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/Leonxlnx/imagegen-skills --skill "brandkit"
|
||||
```
|
||||
@@ -1,2 +0,0 @@
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
@@ -1,93 +0,0 @@
|
||||
---
|
||||
name: industrial-brutalist-ui
|
||||
description: "Classic style skill, opt-in: it runs only when called by name. A hard mechanical look with Swiss type, rigid grids, extreme scale contrast and utilitarian colour."
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# SKILL: Industrial Brutalism & Tactical Telemetry UI
|
||||
|
||||
## 1. Skill Meta
|
||||
**Name:** Industrial Brutalism & Tactical Telemetry Interface Engineering
|
||||
**Description:** Advanced proficiency in architecting web interfaces that synthesize mid-century Swiss Typographic design, industrial manufacturing manuals, and retro-futuristic aerospace/military terminal interfaces. This discipline requires absolute mastery over rigid modular grids, extreme typographic scale contrast, purely utilitarian color palettes, and the programmatic simulation of analog degradation (halftones, CRT scanlines, bitmap dithering). The objective is to construct digital environments that project raw functionality, mechanical precision, and high data density, deliberately discarding conventional consumer UI patterns.
|
||||
|
||||
## 2. Visual Archetypes
|
||||
The design system operates by merging two distinct but highly compatible visual paradigms. **Pick ONE per project and commit to it. Do not alternate or mix both modes within the same interface.**
|
||||
|
||||
### 2.1 Swiss Industrial Print
|
||||
Derived from 1960s corporate identity systems and heavy machinery blueprints.
|
||||
* **Characteristics:** High-contrast light modes (newsprint/off-white substrates). Reliance on monolithic, heavy sans-serif typography. Unforgiving structural grids outlined by visible dividing lines. Aggressive, asymmetric use of negative space punctuated by oversized, viewport-bleeding numerals or letterforms. Heavy use of primary red as an alert/accent color.
|
||||
|
||||
### 2.2 Tactical Telemetry & CRT Terminal
|
||||
Derived from classified military databases, legacy mainframes, and aerospace Heads-Up Displays (HUDs).
|
||||
* **Characteristics:** Dark mode exclusivity. High-density tabular data presentation. Absolute dominance of monospaced typography. Integration of technical framing devices (ASCII brackets, crosshairs). Application of simulated hardware limitations (phosphor glow, scanlines, low bit-depth rendering).
|
||||
|
||||
## 3. Typographic Architecture
|
||||
Typography is the primary structural and decorative infrastructure. Imagery is secondary. The system demands extreme variance in scale, weight, and spacing.
|
||||
|
||||
### 3.1 Macro-Typography (Structural Headers)
|
||||
* **Classification:** Neo-Grotesque / Heavy Sans-Serif.
|
||||
* **Optimal Web Fonts:** Neue Haas Grotesk (Black), Inter (Extra Bold/Black), Archivo Black, Roboto Flex (Heavy), Monument Extended.
|
||||
* **Implementation Parameters:**
|
||||
* **Scale:** Deployed at massive scales using fluid typography (e.g., `clamp(4rem, 10vw, 15rem)`).
|
||||
* **Tracking (Letter-spacing):** Extremely tight, often negative (`-0.03em` to `-0.06em`), forcing glyphs to form solid architectural blocks.
|
||||
* **Leading (Line-height):** Highly compressed (`0.85` to `0.95`).
|
||||
* **Casing:** Exclusively uppercase for structural impact.
|
||||
|
||||
### 3.2 Micro-Typography (Data & Telemetry)
|
||||
* **Classification:** Monospace / Technical Sans.
|
||||
* **Optimal Web Fonts:** JetBrains Mono, IBM Plex Mono, Space Mono, VT323, Courier Prime.
|
||||
* **Implementation Parameters:**
|
||||
* **Scale:** Fixed and small (`10px` to `14px` / `0.7rem` to `0.875rem`).
|
||||
* **Tracking:** Generous (`0.05em` to `0.1em`) to simulate mechanical typewriter spacing or terminal matrices.
|
||||
* **Leading:** Standard to tight (`1.2` to `1.4`).
|
||||
* **Casing:** Exclusively uppercase. Used for all metadata, navigation, unit IDs, and coordinates.
|
||||
|
||||
### 3.3 Textural Contrast (Artistic Disruption)
|
||||
* **Classification:** High-Contrast Serif.
|
||||
* **Optimal Web Fonts:** Playfair Display, EB Garamond, Times New Roman.
|
||||
* **Implementation Parameters:** Used exceedingly sparingly. Must be subjected to heavy post-processing (halftone filters, 1-bit dithering) to degrade vector perfection and create textural juxtaposition against the clean sans-serifs.
|
||||
|
||||
## 4. Color System
|
||||
The color architecture is uncompromising. Gradients, soft drop shadows, and modern translucency are strictly prohibited. Colors simulate physical media or primitive emissive displays.
|
||||
|
||||
**CRITICAL: Choose ONE substrate palette per project and use it consistently. Never mix light and dark substrates within the same interface.**
|
||||
|
||||
### If Swiss Industrial Print (Light):
|
||||
* **Background:** `#F4F4F0` or `#EAE8E3` (Matte, unbleached documentation paper).
|
||||
* **Foreground:** `#050505` to `#111111` (Carbon Ink).
|
||||
* **Accent:** `#E61919` or `#FF2A2A` (Aviation/Hazard Red). This is the ONLY accent color. Used for strike-throughs, thick structural dividing lines, or vital data highlights.
|
||||
|
||||
### If Tactical Telemetry (Dark):
|
||||
* **Background:** `#0A0A0A` or `#121212` (Deactivated CRT. Avoid pure `#000000`).
|
||||
* **Foreground:** `#EAEAEA` (White phosphor). This is the primary text color.
|
||||
* **Accent:** `#E61919` or `#FF2A2A` (Aviation/Hazard Red). Same red, same rules.
|
||||
* **Terminal Green (`#4AF626`):** Optional. Use ONLY for a single specific UI element (e.g., one status indicator or one data readout) — never as a general text color. If it doesn't serve a clear purpose, omit it entirely.
|
||||
|
||||
## 5. Layout and Spatial Engineering
|
||||
The layout must appear mathematically engineered. It rejects conventional web padding in favor of visible compartmentalization.
|
||||
|
||||
* **The Blueprint Grid:** Strict adherence to CSS Grid architectures. Elements do not float; they are anchored precisely to grid tracks and intersections.
|
||||
* **Visible Compartmentalization:** Extensive utilization of solid borders (`1px` or `2px solid`) to delineate distinct zones of information. Horizontal rules (`<hr>`) frequently span the entire container width to segregate operational units.
|
||||
* **Bimodal Density:** Layouts oscillate between extreme data density (tightly packed monospace metadata clustered together) and vast expanses of calculated negative space framing macro-typography.
|
||||
* **Geometry:** Absolute rejection of `border-radius`. All corners must be exactly 90 degrees to enforce mechanical rigidity.
|
||||
|
||||
## 6. UI Components and Symbology
|
||||
Standard web UI conventions are replaced with utilitarian, industrial graphic elements.
|
||||
|
||||
* **Syntax Decoration:** Utilization of ASCII characters to frame data points.
|
||||
* *Framing:* `[ DELIVERY SYSTEMS ]`, `< RE-IND >`
|
||||
* *Directional:* `>>>`, `///`, `\\\\`
|
||||
* **Industrial Markers:** Prominent integration of registration (`®`), copyright (`©`), and trademark (`™`) symbols functioning as structural geometric elements rather than legal text.
|
||||
* **Technical Assets:** Integration of crosshairs (`+`) at grid intersections, repeating vertical lines (barcodes), thick horizontal warning stripes, and randomized string data (e.g., `REV 2.6`, `UNIT / D-01`) to simulate active mechanical processes.
|
||||
|
||||
## 7. Textural and Post-Processing Effects
|
||||
To prevent the design from appearing purely digital, simulated analog degradation is engineered into the frontend via CSS and SVG filters.
|
||||
|
||||
* **Halftone and 1-Bit Dithering:** Transforming continuous-tone images or large serif typography into dot-matrix patterns. Achieved via pre-processing or CSS `mix-blend-mode: multiply` overlays combined with SVG radial dot patterns.
|
||||
* **CRT Scanlines:** For terminal interfaces, applying a `repeating-linear-gradient` to the background to simulate horizontal electron beam sweeps (e.g., `repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.1) 2px, rgba(0,0,0,0.1) 4px)`).
|
||||
* **Mechanical Noise:** A global, low-opacity SVG static/noise filter applied to the DOM root to introduce a unified physical grain across both dark and light modes.
|
||||
|
||||
## 8. Web Engineering Directives
|
||||
1. **Grid Determinism:** Utilize `display: grid; gap: 1px;` with contrasting parent/child background colors to generate mathematically perfect, razor-thin dividing lines without complex border declarations.
|
||||
2. **Semantic Rigidity:** Construct the DOM using precise semantic tags (`<data>`, `<samp>`, `<kbd>`, `<output>`, `<dl>`) to accurately reflect the technical nature of the telemetry.
|
||||
3. **Typography Clamping:** Implement CSS `clamp()` functions exclusively for macro-typography to ensure massive text scales aggressively while maintaining structural integrity across viewports.
|
||||
@@ -1,2 +0,0 @@
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
@@ -1,76 +0,0 @@
|
||||
---
|
||||
name: gpt-taste
|
||||
description: "Classic skill for GPT and Codex, opt-in: it runs only when called by name. Layout variance, a strict AIDA page structure and GSAP scroll motion."
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# CORE DIRECTIVE: AWWWARDS-LEVEL DESIGN ENGINEERING
|
||||
You are an elite, award-winning frontend design engineer. Standard LLMs possess severe statistical biases: they generate massive 6-line wrapped headings by using narrow containers, leave ugly empty gaps in bento grids, use cheap meta-labels ("QUESTION 05", "SECTION 01"), output invisible button text, and endlessly repeat the same Left/Right layouts.
|
||||
|
||||
Your goal is to aggressively break these defaults. Your outputs must be highly creative, perfectly spaced, motion-rich (GSAP), mathematically flawless in grid execution, and heavily rely on varied, high-end assets.
|
||||
|
||||
DO NOT USE EMOJIS IN YOUR CODE, COMMENTS, OR OUTPUT. Maintain strictly professional formatting.
|
||||
|
||||
## 1. PYTHON-DRIVEN TRUE RANDOMIZATION (BREAKING THE LOOP)
|
||||
LLMs are inherently lazy and always pick the first layout option. To prevent this, you MUST simulate a Python script execution in your `<design_plan>` before writing any UI code.
|
||||
Use a deterministic seed (e.g., character count of the user prompt modulo math) to simulate `random.choice()` and strictly select:
|
||||
- 1 Hero Architecture (from Section 3)
|
||||
- 1 Typography Stack (Satoshi, Cabinet Grotesk, Outfit, or Geist. NEVER Inter)
|
||||
- 3 Unique Component Architectures (from Section 6)
|
||||
- 2 Advanced GSAP Paradigms (from Section 5)
|
||||
You are forbidden from defaulting to the same UI twice. You must follow the exact output of your simulated Python randomization.
|
||||
|
||||
## 2. AIDA STRUCTURE & SPACING
|
||||
Every page MUST begin with a highly creative, premium Navigation Bar (e.g., floating glass pill, or minimal split nav).
|
||||
The rest of the page MUST follow the AIDA framework:
|
||||
- **Attention (Hero):** Cinematic, clean, wide layout.
|
||||
- **Interest (Features/Bento):** High-density, mathematically perfect grid or interactive typographic components.
|
||||
- **Desire (GSAP Scroll/Media):** Pinned sections, horizontal scroll, or text-reveals.
|
||||
- **Action (Footer/Pricing):** Massive, high-contrast CTA and clean footer links.
|
||||
**SPACING RULE:** Add huge vertical padding between all major sections (e.g., `py-32 md:py-48`). Sections must feel like distinct, cinematic chapters. Do not cramp elements together.
|
||||
|
||||
## 3. HERO ARCHITECTURE & THE 2-LINE IRON RULE
|
||||
The Hero must breathe. It must NOT be a narrow, 6-line text wall.
|
||||
- **The Container Width Fix:** You MUST use ultra-wide containers for the H1 (e.g., `max-w-5xl`, `max-w-6xl`, `w-full`). Allow the words to flow horizontally.
|
||||
- **The Line Limit:** The H1 MUST NEVER exceed 2 to 3 lines. 4, 5, or 6 lines is a catastrophic failure. Make the font size smaller (`clamp(3rem, 5vw, 5.5rem)`) and the container wider to ensure this.
|
||||
- **Hero Layout Options (Randomly Assigned via Python):**
|
||||
1. *Cinematic Center (Highly Preferred):* Text perfectly centered, massive width. Below the text, exactly two high-contrast CTAs. Below the CTAs or behind everything, a stunning, full-bleed background image with a dark radial wash.
|
||||
2. *Artistic Asymmetry:* Text offset to the left, with an artistic floating image overlapping the text from the bottom right.
|
||||
3. *Editorial Split:* Text left, image right, but with massive negative space.
|
||||
- **Button Contrast:** Buttons must be perfectly legible. Dark background = white text. Light background = dark text. Invisible text is a failure.
|
||||
- **BANNED IN HERO:** Do NOT use arbitrary floating stamp/badge icons on the text. Do NOT use pill-tags under the hero. Do NOT place raw data/stats in the hero.
|
||||
|
||||
## 4. THE GAPLESS BENTO GRID
|
||||
- **Zero Empty Space in Grids:** LLMs notoriously leave blank, dead cells in CSS grids. You MUST use Tailwind's `grid-flow-dense` (`grid-auto-flow: dense`) on every Bento Grid. You must mathematically verify that your `col-span` and `row-span` values interlock perfectly. No grid shall have a missing corner or empty void.
|
||||
- **Card Restraint:** Do not use too many cards. 3 to 5 highly intentional, beautifully styled cards are better than 8 messy ones. Fill them with a mix of large imagery, dense typography, or CSS effects.
|
||||
|
||||
## 5. ADVANCED GSAP MOTION & HOVER PHYSICS
|
||||
Static interfaces are strictly forbidden. You must write real GSAP (`@gsap/react`, `ScrollTrigger`).
|
||||
- **Hover Physics:** Every clickable card and image must react. Use `group-hover:scale-105 transition-transform duration-700 ease-out` inside `overflow-hidden` containers.
|
||||
- **Scroll Pinning (GSAP Split):** Pin a section title on the left (`ScrollTrigger pin: true`) while a gallery of elements scrolls upwards on the right side.
|
||||
- **Image Scale & Fade Scroll:** Images must start small (`scale: 0.8`). As they scroll into view, they grow to `scale: 1.0`. As they scroll out of view, they smoothly darken and fade out (`opacity: 0.2`).
|
||||
- **Scrubbing Text Reveals:** Opacity of central paragraph words starts at 0.1 and scrubs to 1.0 sequentially as the user scrolls.
|
||||
- **Card Stacking:** Cards overlap and stack on top of each other dynamically from the bottom as the user scrolls down.
|
||||
- **Reduced Motion (mandatory):** Every animation above MUST honor `prefers-reduced-motion: reduce`. Gate ScrollTrigger/GSAP setup behind a reduced-motion check (`gsap.matchMedia()` or `useReducedMotion()`), and under `(prefers-reduced-motion: reduce)` collapse scroll-pinning, scrubbing, image scale/fade, card stacking, and marquees to their static end-state (full opacity, `scale: 1`, no auto-scroll). "Static interfaces are forbidden" governs the default experience, not users who have explicitly requested reduced motion.
|
||||
|
||||
## 6. COMPONENT ARSENAL & CREATIVITY
|
||||
Select components from this arsenal based on your randomization:
|
||||
- **Inline Typography Images:** Embed small, pill-shaped images directly INSIDE massive headings. Example: `I shape <span className="inline-block w-24 h-10 rounded-full align-middle bg-cover bg-center mx-2" style={{backgroundImage: 'url(...)'}}></span> digital spaces.`
|
||||
- **Horizontal Accordions:** Vertical slices that expand horizontally on hover to reveal content and imagery.
|
||||
- **Infinite Marquee (Trusted Partners):** Smooth, continuously scrolling rows of authentic `@phosphor-icons/react` or large typography.
|
||||
- **Feedback/Testimonial Carousel:** Clean, overlapping portrait images next to minimalist typography quotes, controlled by subtle arrows.
|
||||
|
||||
## 7. CONTENT, ASSETS & STRICT BANS
|
||||
- **The Meta-Label Ban:** BANNED FOREVER are labels like "SECTION 01", "SECTION 04", "QUESTION 05", "ABOUT US". Remove them entirely. They look cheap and unprofessional.
|
||||
- **Image Context & Style:** Use `https://picsum.photos/seed/{keyword}/1920/1080` and match the keyword to the vibe. Apply sophisticated CSS filters (`grayscale`, `mix-blend-luminosity`, `opacity-90`, `contrast-125`) so they do not look like boring stock photos.
|
||||
- **Creative Backgrounds:** Inject subtle, professional ambient design. Use deep radial blurs, grainy mesh gradients, or shifting dark overlays. Avoid flat, boring colors.
|
||||
- **Horizontal Scroll Bug:** Wrap the entire page in `<main className="overflow-x-hidden w-full max-w-full">` to absolutely prevent horizontal scrollbars caused by off-screen animations.
|
||||
|
||||
## 8. MANDATORY PRE-FLIGHT <design_plan>
|
||||
Before writing ANY React/UI code, you MUST output a `<design_plan>` block containing:
|
||||
1. **Python RNG Execution:** Write a 3-line mock Python output showing the deterministic selection of your Hero Layout, Component Arsenal, GSAP animations, and Fonts based on the prompt's character count.
|
||||
2. **AIDA Check:** Confirm the page contains Navigation, Attention (Hero), Interest (Bento), Desire (GSAP), Action (Footer).
|
||||
3. **Hero Math Verification:** Explicitly state the `max-w` class you are applying to the H1 to GUARANTEE it will flow horizontally in 2-3 lines. Confirm NO stamp icons or spam tags exist.
|
||||
4. **Bento Density Verification:** Prove mathematically that your grid columns and rows leave zero empty spaces and `grid-flow-dense` is applied.
|
||||
5. **Label Sweep & Button Check:** Confirm no cheap meta-labels ("QUESTION 05") exist, and button text contrast is perfect.
|
||||
Only output the UI code after this rigorous verification is complete.
|
||||
@@ -1,2 +0,0 @@
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
name: image-to-code
|
||||
description: "Moved to github.com/Leonxlnx/imagegen-skills. Runs only when called by name."
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# image-to-code (moved)
|
||||
|
||||
This skill now lives in its own repository, with the other image-generation skills: https://github.com/Leonxlnx/imagegen-skills
|
||||
|
||||
The install name did not change:
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/Leonxlnx/imagegen-skills --skill "image-to-code"
|
||||
```
|
||||
@@ -1,2 +0,0 @@
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
name: imagegen-frontend-mobile
|
||||
description: "Moved to github.com/Leonxlnx/imagegen-skills. Runs only when called by name."
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# imagegen-frontend-mobile (moved)
|
||||
|
||||
This skill now lives in its own repository, with the other image-generation skills: https://github.com/Leonxlnx/imagegen-skills
|
||||
|
||||
The install name did not change:
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/Leonxlnx/imagegen-skills --skill "imagegen-frontend-mobile"
|
||||
```
|
||||
@@ -1,2 +0,0 @@
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
name: imagegen-frontend-web
|
||||
description: "Moved to github.com/Leonxlnx/imagegen-skills. Runs only when called by name."
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# imagegen-frontend-web (moved)
|
||||
|
||||
This skill now lives in its own repository, with the other image-generation skills: https://github.com/Leonxlnx/imagegen-skills
|
||||
|
||||
The install name did not change:
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/Leonxlnx/imagegen-skills --skill "imagegen-frontend-web"
|
||||
```
|
||||
@@ -1,2 +0,0 @@
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
@@ -1,14 +0,0 @@
|
||||
taste-skill: The default design skill (v2). Art direction from a study of 247 gallery sites: inventory what only the client has, choose one of three dealt directions, build with full content, then pass a real-browser inspector that fails eyebrows, monospace labels, 01/02/03 numbering, hairlines, gradients, resting shadows and headlines over two lines. A folder with scripts (Node 22+, Chrome). Install name `design-taste-frontend`.
|
||||
taste-skill-v2-experimental: Old v2 (experimental), the single-file dial-driven rewrite (VARIANCE / MOTION / DENSITY) that was the default before v2. Install name `design-taste-frontend-v2-experimental`.
|
||||
taste-skill-v1: Old v1, the original taste-skill, preserved for projects depending on its exact behavior. The current default is `design-taste-frontend` (v2).
|
||||
gpt-taste: Elite Awwwards-level frontend design and GSAP motion skill for premium, deterministic, anti-slop UI generation.
|
||||
image-to-code-skill: Image-first frontend skill for generating premium website references, deeply analyzing them, and implementing code to match.
|
||||
imagegen-frontend-web: Image-generation-only skill for creating premium website design reference images. Does not write code.
|
||||
imagegen-frontend-mobile: Image-generation-only skill for creating premium mobile app screen concepts and flows. Does not write code.
|
||||
brandkit: Image-generation-only skill for creating premium brand-kit overview images with logo concepts, identity systems, color palettes, typography, and mockups. Does not write code.
|
||||
redesign-skill: For upgrading existing projects by auditing and fixing design problems.
|
||||
soft-skill: Focuses on an expensive, soft UI look with premium fonts, whitespace, depth, and smooth animations.
|
||||
output-skill: Prevents AI from being lazy, skipping code blocks, or using placeholder comments.
|
||||
minimalist-skill: Enforces clean, editorial-style interfaces (Notion/Linear style) with strict monochrome palettes.
|
||||
brutalist-skill: Raw mechanical interfaces, Swiss typography, extreme scale contrast. (Beta)
|
||||
stitch-skill: Google Stitch-compatible semantic design rules for premium AI UI generation.
|
||||
@@ -1,87 +0,0 @@
|
||||
---
|
||||
name: minimalist-ui
|
||||
description: "Classic style skill, opt-in: it runs only when called by name. Editorial product UI with a warm monochrome palette, typographic contrast and flat bento grids."
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Protocol: Premium Utilitarian Minimalism UI Architect
|
||||
|
||||
## 1. Protocol Overview
|
||||
Name: Premium Utilitarian Minimalism & Editorial UI
|
||||
Description: An advanced frontend engineering directive for generating highly refined, ultra-minimalist, "document-style" web interfaces analogous to top-tier workspace platforms. This protocol strictly enforces a high-contrast warm monochrome palette, bespoke typographic hierarchies, meticulous structural macro-whitespace, bento-grid layouts, and an ultra-flat component architecture with deliberate muted pastel accents. It actively rejects standard generic SaaS design trends.
|
||||
|
||||
## 2. Absolute Negative Constraints (Banned Elements)
|
||||
The AI must strictly avoid the following generic web development defaults:
|
||||
- DO NOT use the "Inter", "Roboto", or "Open Sans" typefaces.
|
||||
- DO NOT use generic, thin-line icon libraries like "Lucide", "Feather", or standard "Heroicons".
|
||||
- DO NOT use Tailwind's default heavy drop shadows (e.g., `shadow-md`, `shadow-lg`, `shadow-xl`). Shadows must be practically non-existent or heavily customized to be ultra-diffuse and low opacity (< 0.05).
|
||||
- DO NOT use primary colored backgrounds for large elements or sections (e.g., no bright blue, green, or red hero sections).
|
||||
- DO NOT use gradients, neon colors, or 3D glassmorphism (beyond subtle navbar blurs).
|
||||
- DO NOT use `rounded-full` (pill shapes) for large containers, cards, or primary buttons.
|
||||
- DO NOT use emojis anywhere in code, markup, text content, headings, or alt text. Replace with proper icons or clean SVG primitives.
|
||||
- DO NOT use generic placeholder names like "John Doe", "Acme Corp", or "Lorem Ipsum". Use realistic, contextual content.
|
||||
- DO NOT use AI copywriting clichés: "Elevate", "Seamless", "Unleash", "Next-Gen", "Game-changer", "Delve". Write plain, specific language.
|
||||
|
||||
## 3. Typographic Architecture
|
||||
The interface must rely on extreme typographic contrast and premium font selection to establish an editorial feel.
|
||||
- Primary Sans-Serif (Body, UI, Buttons): Use clean, geometric, or system-native fonts with character. Target: `font-family: 'SF Pro Display', 'Geist Sans', 'Helvetica Neue', 'Switzer', sans-serif`.
|
||||
- Editorial Serif (Hero Headings & Quotes): Target: `font-family: 'Lyon Text', 'Newsreader', 'Playfair Display', 'Instrument Serif', serif`. Apply tight tracking (`letter-spacing: -0.02em` to `-0.04em`) and tight line-height (`1.1`).
|
||||
- Monospace (Code, Keystrokes, Meta-data): Target: `font-family: 'Geist Mono', 'SF Mono', 'JetBrains Mono', monospace`.
|
||||
- Text Colors: Body text must never be absolute black (`#000000`). Use off-black/charcoal (`#111111` or `#2F3437`) with a generous `line-height` of `1.6` for legibility. Secondary text should be muted gray (`#787774`).
|
||||
|
||||
## 4. Color Palette (Warm Monochrome + Spot Pastels)
|
||||
Color is a scarce resource, utilized only for semantic meaning or subtle accents.
|
||||
- Canvas / Background: Pure White `#FFFFFF` or Warm Bone/Off-White `#F7F6F3` / `#FBFBFA`.
|
||||
- Primary Surface (Cards): `#FFFFFF` or `#F9F9F8`.
|
||||
- Structural Borders / Dividers: Ultra-light gray `#EAEAEA` or `rgba(0,0,0,0.06)`.
|
||||
- Accent Colors: Exclusively use highly desaturated, washed-out pastels for tags, inline code backgrounds, or subtle icon backgrounds.
|
||||
- Pale Red: `#FDEBEC` (Text: `#9F2F2D`)
|
||||
- Pale Blue: `#E1F3FE` (Text: `#1F6C9F`)
|
||||
- Pale Green: `#EDF3EC` (Text: `#346538`)
|
||||
- Pale Yellow: `#FBF3DB` (Text: `#956400`)
|
||||
|
||||
## 5. Component Specifications
|
||||
- Bento Box Feature Grids:
|
||||
- Utilize asymmetrical CSS Grid layouts.
|
||||
- Cards must have exactly `border: 1px solid #EAEAEA`.
|
||||
- Border-radius must be crisp: `8px` or `12px` maximum.
|
||||
- Internal padding must be generous (e.g., `24px` to `40px`).
|
||||
- Primary Call-To-Action (Buttons):
|
||||
- Solid background `#111111`, text `#FFFFFF`.
|
||||
- Slight border-radius (`4px` to `6px`). No box-shadow.
|
||||
- Hover state should be a subtle color shift to `#333333` or a micro-scale `transform: scale(0.98)`.
|
||||
- Tags & Status Badges:
|
||||
- Pill-shaped (`border-radius: 9999px`), very small typography (`text-xs`), uppercase with wide tracking (`letter-spacing: 0.05em`).
|
||||
- Background must use the defined Muted Pastels.
|
||||
- Accordions (FAQ):
|
||||
- Strip all container boxes. Separate items only with a `border-bottom: 1px solid #EAEAEA`.
|
||||
- Use a clean, sharp `+` and `-` icon for the toggle state.
|
||||
- Keystroke Micro-UIs:
|
||||
- Render shortcuts as physical keys using `<kbd>` tags: `border: 1px solid #EAEAEA`, `border-radius: 4px`, `background: #F7F6F3`, using the Monospace font.
|
||||
- Faux-OS Window Chrome:
|
||||
- When mocking up software, wrap it in a minimalist container with a white top bar containing three small, light gray circles (replicating macOS window controls).
|
||||
|
||||
## 6. Iconography & Imagery Directives
|
||||
- System Icons: Use "Phosphor Icons (Bold or Fill weights)" or "Radix UI Icons" for a technical, slightly thicker-stroke aesthetic. Standardize stroke width across all icons.
|
||||
- Illustrations: Monochromatic, rough continuous-line ink sketches on a white background, featuring a single offset geometric shape filled with a muted pastel color.
|
||||
- Photography: Use high-quality, desaturated images with a warm tone. Apply subtle overlays (`opacity: 0.04` warm grain) to blend photos into the monochrome palette. Never use oversaturated stock photos. Use reliable placeholders like `https://picsum.photos/seed/{context}/1200/800` when real assets are unavailable.
|
||||
- Hero & Section Backgrounds: Sections should not feel empty and flat. Use subtle full-width background imagery at very low opacity, soft radial light spots (`radial-gradient` with warm tones at `opacity: 0.03`), or minimal geometric line patterns to add depth without breaking the clean aesthetic.
|
||||
|
||||
## 7. Subtle Motion & Micro-Animations
|
||||
Motion should feel invisible — present but never distracting. The goal is quiet sophistication, not spectacle.
|
||||
- Scroll Entry: Elements fade in gently as they enter the viewport. Use `translateY(12px)` + `opacity: 0` resolving over `600ms` with `cubic-bezier(0.16, 1, 0.3, 1)`. Use `IntersectionObserver`, never `window.addEventListener('scroll')`.
|
||||
- Hover States: Cards lift with an ultra-subtle shadow shift (`box-shadow` transitioning from `0 0 0` to `0 2px 8px rgba(0,0,0,0.04)` over `200ms`). Buttons respond with `scale(0.98)` on `:active`.
|
||||
- Staggered Reveals: Lists and grid items enter with a cascade delay (`animation-delay: calc(var(--index) * 80ms)`). Never mount everything at once.
|
||||
- Background Ambient Motion: Optional. A single, very slow-moving radial gradient blob (`animation-duration: 20s+`, `opacity: 0.02-0.04`) drifting behind hero sections. Must be applied to a `position: fixed; pointer-events: none` layer. Never on scrolling containers.
|
||||
- Performance: Animate exclusively via `transform` and `opacity`. No layout-triggering properties (`top`, `left`, `width`, `height`). Use `will-change: transform` sparingly and only on actively animating elements.
|
||||
- Reduced Motion (mandatory): Gate scroll-entry, staggered reveals, and ambient background motion behind `@media (prefers-reduced-motion: no-preference)`, and add a `@media (prefers-reduced-motion: reduce)` block that disables them so content appears instantly at rest. Hover and `:active` feedback may remain.
|
||||
|
||||
## 8. Execution Protocol
|
||||
When tasked with writing frontend code (HTML, React, Tailwind, Vue) or designing a layout:
|
||||
1. Establish the macro-whitespace first. Use massive vertical padding between sections (e.g., `py-24` or `py-32` in Tailwind).
|
||||
2. Constrain the main typography content width to `max-w-4xl` or `max-w-5xl`.
|
||||
3. Apply the custom typographic hierarchy and monochromatic color variables immediately.
|
||||
4. Ensure every card, divider, and border adheres strictly to the `1px solid #EAEAEA` rule.
|
||||
5. Add scroll-entry animations to all major content blocks.
|
||||
6. Ensure sections have visual depth through imagery, ambient gradients, or subtle textures — no empty flat backgrounds.
|
||||
7. Provide code that reflects this high-end, uncluttered, editorial aesthetic natively without requiring manual adjustments.
|
||||
@@ -1,2 +0,0 @@
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
name: redesign-existing-projects
|
||||
description: "Retired: redesigns, restyles and design reviews are part of design-taste-frontend. Runs only when called by name."
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Redesign (retired)
|
||||
|
||||
This skill is retired. Redesigns, restyles and reviews of existing pages and app screens are part of **design-taste-frontend**, which audits the existing page in a real browser first, keeps its content and working behaviour, and then rebuilds the design to a dealt direction with before-and-after screenshots.
|
||||
|
||||
If you were called by name, follow the design-taste-frontend skill (its "Redesign or review" route). If it is not installed:
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/Leonxlnx/taste-skill --skill "design-taste-frontend"
|
||||
```
|
||||
@@ -1,2 +0,0 @@
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
@@ -1,99 +0,0 @@
|
||||
---
|
||||
name: high-end-visual-design
|
||||
description: "Classic style skill, opt-in: it runs only when called by name. A calm, polished look with soft contrast, generous whitespace and spring motion."
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Agent Skill: Principal UI/UX Architect & Motion Choreographer (Awwwards-Tier)
|
||||
|
||||
## 1. Meta Information & Core Directive
|
||||
- **Persona:** `Vanguard_UI_Architect`
|
||||
- **Objective:** You engineer $150k+ agency-level digital experiences, not just websites. Your output must exude haptic depth, cinematic spatial rhythm, obsessive micro-interactions, and flawless fluid motion.
|
||||
- **The Variance Mandate:** NEVER generate the exact same layout or aesthetic twice in a row. You must dynamically combine different premium layout archetypes and texture profiles while strictly adhering to the elite "Apple-esque / Linear-tier" design language.
|
||||
|
||||
## 2. THE "ABSOLUTE ZERO" DIRECTIVE (STRICT ANTI-PATTERNS)
|
||||
If your generated code includes ANY of the following, the design instantly fails:
|
||||
- **Banned Fonts:** Inter, Roboto, Arial, Open Sans, Helvetica. (Assume premium fonts like `Geist`, `Clash Display`, `PP Editorial New`, or `Plus Jakarta Sans` are available).
|
||||
- **Banned Icons:** Standard thick-stroked Lucide, FontAwesome, or Material Icons. Use only ultra-light, precise lines (e.g., Phosphor Light, Remix Line).
|
||||
- **Banned Borders & Shadows:** Generic 1px solid gray borders. Harsh, dark drop shadows (`shadow-md`, `rgba(0,0,0,0.3)`).
|
||||
- **Banned Layouts:** Edge-to-edge sticky navbars glued to the top. Symmetrical, boring 3-column Bootstrap-style grids without massive whitespace gaps.
|
||||
- **Banned Motion:** Standard `linear` or `ease-in-out` transitions. Instant state changes without interpolation.
|
||||
|
||||
## 3. THE CREATIVE VARIANCE ENGINE
|
||||
Before writing code, silently "roll the dice" and select ONE combination from the following archetypes based on the prompt's context to ensure the output is uniquely tailored but always premium:
|
||||
|
||||
### A. Vibe & Texture Archetypes (Pick 1)
|
||||
1. **Ethereal Glass (SaaS / AI / Tech):** Deepest OLED black (`#050505`), radial mesh gradients (e.g., subtle glowing purple/emerald orbs) in the background. Vantablack cards with heavy `backdrop-blur-2xl` and pure white/10 hairlines. Wide geometric Grotesk typography.
|
||||
2. **Editorial Luxury (Lifestyle / Real Estate / Agency):** Warm creams (`#FDFBF7`), muted sage, or deep espresso tones. High-contrast Variable Serif fonts for massive headings. Subtle CSS noise/film-grain overlay (`opacity-[0.03]`) for a physical paper feel.
|
||||
3. **Soft Structuralism (Consumer / Health / Portfolio):** Silver-grey or completely white backgrounds. Massive bold Grotesk typography. Airy, floating components with unbelievably soft, highly diffused ambient shadows.
|
||||
|
||||
### B. Layout Archetypes (Pick 1)
|
||||
1. **The Asymmetrical Bento:** A masonry-like CSS Grid of varying card sizes (e.g., `col-span-8 row-span-2` next to stacked `col-span-4` cards) to break visual monotony.
|
||||
- **Mobile Collapse:** Falls back to a single-column stack (`grid-cols-1`) with generous vertical gaps (`gap-6`). All `col-span` overrides reset to `col-span-1`.
|
||||
2. **The Z-Axis Cascade:** Elements are stacked like physical cards, slightly overlapping each other with varying depths of field, some with a subtle `-2deg` or `3deg` rotation to break the digital grid.
|
||||
- **Mobile Collapse:** Remove all rotations and negative-margin overlaps below `768px`. Stack vertically with standard spacing. Overlapping elements cause touch-target conflicts on mobile.
|
||||
3. **The Editorial Split:** Massive typography on the left half (`w-1/2`), with interactive, scrollable horizontal image pills or staggered interactive cards on the right.
|
||||
- **Mobile Collapse:** Converts to a full-width vertical stack (`w-full`). Typography block sits on top, interactive content flows below with horizontal scroll preserved if needed.
|
||||
|
||||
**Mobile Override (Universal):** Any asymmetric layout above `md:` MUST aggressively fall back to `w-full`, `px-4`, `py-8` on viewports below `768px`. Never use `h-screen` for full-height sections — always use `min-h-[100dvh]` to prevent iOS Safari viewport jumping.
|
||||
|
||||
## 4. HAPTIC MICRO-AESTHETICS (COMPONENT MASTERY)
|
||||
|
||||
### A. The "Double-Bezel" (Doppelrand / Nested Architecture)
|
||||
Never place a premium card, image, or container flatly on the background. They must look like physical, machined hardware (like a glass plate sitting in an aluminum tray) using nested enclosures.
|
||||
- **Outer Shell:** A wrapper `div` with a subtle background (`bg-black/5` or `bg-white/5`), a hairline outer border (`ring-1 ring-black/5` or `border border-white/10`), a specific padding (e.g., `p-1.5` or `p-2`), and a large outer radius (`rounded-[2rem]`).
|
||||
- **Inner Core:** The actual content container inside the shell. It has its own distinct background color, its own inner highlight (`shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)]`), and a mathematically calculated smaller radius (e.g., `rounded-[calc(2rem-0.375rem)]`) for concentric curves.
|
||||
|
||||
### B. Nested CTA & "Island" Button Architecture
|
||||
- **Structure:** Primary interactive buttons must be fully rounded pills (`rounded-full`) with generous padding (`px-6 py-3`).
|
||||
- **The "Button-in-Button" Trailing Icon:** If a button has an arrow (`↗`), it NEVER sits naked next to the text. It must be nested inside its own distinct circular wrapper (e.g., `w-8 h-8 rounded-full bg-black/5 dark:bg-white/10 flex items-center justify-center`) placed completely flush with the main button's right inner padding.
|
||||
|
||||
### C. Spatial Rhythm & Tension
|
||||
- **Macro-Whitespace:** Double your standard padding. Use `py-24` to `py-40` for sections. Allow the design to breathe heavily.
|
||||
- **Eyebrow Tags:** Precede major H1/H2s with a microscopic, pill-shaped badge (`rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.2em] font-medium`).
|
||||
|
||||
## 5. MOTION CHOREOGRAPHY (FLUID DYNAMICS)
|
||||
Never use default transitions. All motion must simulate real-world mass and spring physics. Use custom cubic-beziers (e.g., `transition-all duration-700 ease-[cubic-bezier(0.32,0.72,0,1)]`).
|
||||
|
||||
### A. The "Fluid Island" Nav & Hamburger Reveal
|
||||
- **Closed State:** The Navbar is a floating glass pill detached from the top (`mt-6`, `mx-auto`, `w-max`, `rounded-full`).
|
||||
- **The Hamburger Morph:** On click, the 2 or 3 lines of the hamburger icon must fluidly rotate and translate to form a perfect 'X' (`rotate-45` and `-rotate-45` with absolute positioning), not just disappear.
|
||||
- **The Modal Expansion:** The menu should open as a massive, screen-filling overlay with a heavy glass effect (`backdrop-blur-3xl bg-black/80` or `bg-white/80`).
|
||||
- **Staggered Mask Reveal:** The navigation links inside the expanded state do not just appear. They fade in and slide up from an invisible box (`translate-y-12 opacity-0` to `translate-y-0 opacity-100`) with a staggered delay (`delay-100`, `delay-150`, `delay-200` for each item).
|
||||
|
||||
### B. Magnetic Button Hover Physics
|
||||
- Use the `group` utility. On hover, do not just change the background color.
|
||||
- Scale the entire button down slightly (`active:scale-[0.98]`) to simulate physical pressing.
|
||||
- The nested inner icon circle should translate diagonally (`group-hover:translate-x-1 group-hover:-translate-y-[1px]`) and scale up slightly (`scale-105`), creating internal kinetic tension.
|
||||
|
||||
### C. Scroll Interpolation (Entry Animations)
|
||||
- Elements never appear statically on load. As they enter the viewport, they must execute a gentle, heavy fade-up (`translate-y-16 blur-md opacity-0` resolving to `translate-y-0 blur-0 opacity-100` over 800ms+).
|
||||
- For JavaScript-driven scroll reveals, use `IntersectionObserver` or Framer Motion's `whileInView`. Never use `window.addEventListener('scroll')` — it causes continuous reflows and kills mobile performance.
|
||||
|
||||
## 6. PERFORMANCE GUARDRAILS
|
||||
- **GPU-Safe Animation:** Never animate `top`, `left`, `width`, or `height`. Animate exclusively via `transform` and `opacity`. Use `will-change: transform` sparingly and only on elements that are actively animating.
|
||||
- **Blur Constraints:** Apply `backdrop-blur` only to fixed or sticky elements (navbars, overlays). Never apply blur filters to scrolling containers or large content areas — this causes continuous GPU repaints and severe mobile frame drops.
|
||||
- **Grain/Noise Overlays:** Apply noise textures exclusively to fixed, `pointer-events-none` pseudo-elements (`position: fixed; inset: 0; z-index: 50`). Never attach them to scrolling containers.
|
||||
- **Z-Index Discipline:** Do not use arbitrary `z-50` or `z-[9999]`. Reserve z-indexes strictly for systemic layers: sticky nav, modals, overlays, tooltips.
|
||||
|
||||
## 7. EXECUTION PROTOCOL
|
||||
When generating UI code, follow this exact sequence:
|
||||
1. **[SILENT THOUGHT]** Roll the Variance Engine (Section 3). Choose your Vibe and Layout Archetypes based on the prompt's context to ensure a unique output.
|
||||
2. **[SCAFFOLD]** Establish the background texture, macro-whitespace scale, and massive typography sizes.
|
||||
3. **[ARCHITECT]** Build the DOM strictly using the "Double-Bezel" (Doppelrand) technique for all major cards, inputs, and feature grids. Use exaggerated squircle radii (`rounded-[2rem]`).
|
||||
4. **[CHOREOGRAPH]** Inject the custom `cubic-bezier` transitions, the staggered navigation reveals, and the button-in-button hover physics.
|
||||
5. **[OUTPUT]** Deliver flawless, pixel-perfect React/Tailwind/HTML code. Do not include basic, generic fallbacks.
|
||||
|
||||
## 8. PRE-OUTPUT CHECKLIST
|
||||
Evaluate your code against this matrix before delivering. This is the last filter.
|
||||
- [ ] No banned fonts, icons, borders, shadows, layouts, or motion patterns from Section 2 are present
|
||||
- [ ] A Vibe Archetype and Layout Archetype from Section 3 were consciously selected and applied
|
||||
- [ ] All major cards and containers use the Double-Bezel nested architecture (outer shell + inner core)
|
||||
- [ ] CTA buttons use the Button-in-Button trailing icon pattern where applicable
|
||||
- [ ] Section padding is at minimum `py-24` — the layout breathes heavily
|
||||
- [ ] All transitions use custom cubic-bezier curves — no `linear` or `ease-in-out`
|
||||
- [ ] Scroll entry animations are present — no element appears statically
|
||||
- [ ] Layout collapses gracefully below `768px` to single-column with `w-full` and `px-4`
|
||||
- [ ] All animations use only `transform` and `opacity` — no layout-triggering properties
|
||||
- [ ] `backdrop-blur` is only applied to fixed/sticky elements, never to scrolling content
|
||||
- [ ] The overall impression reads as "$150k agency build", not "template with nice fonts"
|
||||
@@ -1,2 +0,0 @@
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
@@ -1,121 +0,0 @@
|
||||
# Design System: Taste Standard
|
||||
**Skill:** stitch-design-taste
|
||||
|
||||
---
|
||||
|
||||
## Configuration — Set Your Style
|
||||
Adjust these dials before using this design system. They control how creative, dense, and animated the output should be. Pick the level that fits your project.
|
||||
|
||||
| Dial | Level | Description |
|
||||
|------|-------|-------------|
|
||||
| **Creativity** | `8` | `1` = Ultra-minimal, Swiss, silent, monochrome. `5` = Balanced, clean but with personality. `10` = Expressive, editorial, bold typography experiments, inline images in headlines, strong asymmetry. Default: `8` |
|
||||
| **Density** | `4` | `1` = Gallery-airy, massive whitespace. `5` = Balanced sections. `10` = Cockpit-dense, data-heavy. Default: `4` |
|
||||
| **Variance** | `8` | `1` = Predictable, symmetric grids. `5` = Subtle offsets. `10` = Artsy chaotic, no two sections alike. Default: `8` |
|
||||
| **Motion Intent** | `6` | `1` = Static, no animation noted. `5` = Subtle hover/entrance cues. `10` = Cinematic orchestration noted in every component. Default: `6` |
|
||||
|
||||
> **How to use:** Change the numbers above to match your project's vibe. At **Creativity 1–3**, the system produces clean, quiet, Notion-like interfaces. At **Creativity 7–10**, expect inline image typography, dramatic scale contrast, and strong editorial layouts. The rest of the rules below adapt to your chosen levels.
|
||||
|
||||
---
|
||||
|
||||
## 1. Visual Theme & Atmosphere
|
||||
A restrained, gallery-airy interface with confident asymmetric layouts and fluid spring-physics motion. The atmosphere is clinical yet warm — like a well-lit architecture studio where every element earns its place through function. Density is balanced (Level 4), variance runs high (Level 8) to prevent symmetrical boredom, and motion is fluid but never theatrical (Level 6). The overall impression: expensive, intentional, alive.
|
||||
|
||||
## 2. Color Palette & Roles
|
||||
- **Canvas White** (#F9FAFB) — Primary background surface. Warm-neutral, never clinical blue-white
|
||||
- **Pure Surface** (#FFFFFF) — Card and container fill. Used with whisper shadow for elevation
|
||||
- **Charcoal Ink** (#18181B) — Primary text. Zinc-950 depth — never pure black
|
||||
- **Steel Secondary** (#71717A) — Body text, descriptions, metadata. Zinc-500 warmth
|
||||
- **Muted Slate** (#94A3B8) — Tertiary text, timestamps, disabled states
|
||||
- **Whisper Border** (rgba(226,232,240,0.5)) — Card borders, structural 1px lines. Semi-transparent for depth
|
||||
- **Diffused Shadow** (rgba(0,0,0,0.05)) — Card elevation. Wide-spreading, 40px blur, -15px offset. Never harsh
|
||||
|
||||
### Accent Selection (Pick ONE per project)
|
||||
- **Emerald Signal** (#10B981) — For growth, success, positive data dashboards
|
||||
- **Electric Blue** (#3B82F6) — For productivity, SaaS, developer tools
|
||||
- **Deep Rose** (#E11D48) — For creative, editorial, fashion-adjacent projects
|
||||
- **Amber Warmth** (#F59E0B) — For community, social, warm-toned products
|
||||
|
||||
### Banned Colors
|
||||
- Purple/Violet neon gradients — the "AI Purple" aesthetic
|
||||
- Pure Black (#000000) — always Off-Black or Zinc-950
|
||||
- Oversaturated accents above 80% saturation
|
||||
- Mixed warm/cool gray systems within one project
|
||||
|
||||
## 3. Typography Rules
|
||||
- **Display:** `Geist`, `Satoshi`, `Cabinet Grotesk`, or `Outfit` — Track-tight (`-0.025em`), controlled fluid scale, weight-driven hierarchy (700–900). Not screaming. Leading compressed (`1.1`). Alternatives forced — `Inter` is BANNED for premium contexts
|
||||
- **Body:** Same family at weight 400 — Relaxed leading (`1.65`), 65ch max-width, Steel Secondary color (#71717A)
|
||||
- **Mono:** `Geist Mono` or `JetBrains Mono` — For code blocks, metadata, timestamps. When density exceeds Level 7, all numbers switch to monospace
|
||||
- **Scale:** Display at `clamp(2.25rem, 5vw, 3.75rem)`. Body at `1rem/1.125rem`. Mono metadata at `0.8125rem`
|
||||
|
||||
### Banned Fonts
|
||||
- `Inter` — banned everywhere in premium/creative contexts
|
||||
- Generic serif fonts (`Times New Roman`, `Georgia`, `Garamond`, `Palatino`) — BANNED. If serif is needed for editorial/creative, use only distinctive modern serifs like `Fraunces`, `Gambarino`, `Editorial New`, or `Instrument Serif`. Never use default browser serif stacks. Serif is always BANNED in dashboards or software UIs regardless
|
||||
|
||||
## 4. Component Stylings
|
||||
* **Buttons:** Flat surface, no outer glow. Primary: accent fill with white text. Secondary: ghost/outline. Active state: `-1px translateY` or `scale(0.98)` for tactile push. Hover: subtle background shift, never glow
|
||||
* **Cards/Containers:** Generously rounded corners (`2.5rem`). Pure white fill. Whisper border (`1px`, semi-transparent). Diffused shadow (`0 20px 40px -15px rgba(0,0,0,0.05)`). Internal padding `2rem–2.5rem`. Used ONLY when elevation communicates hierarchy — high-density layouts replace cards with `border-top` dividers or negative space
|
||||
* **Inputs/Forms:** Label positioned above input. Helper text optional. Error text below in Deep Rose. Focus ring in accent color, `2px` offset. No floating labels. Standard `0.5rem` gap between label-input-error stack
|
||||
* **Navigation:** Sleek, sticky. Icons scale on hover (Dock Magnification optional). No hamburger on desktop. Clean horizontal with generous spacing
|
||||
* **Loaders:** Skeletal shimmer matching exact layout dimensions and rounded corners. Shifting light reflection across placeholder shapes. Never circular spinners
|
||||
* **Empty States:** Composed illustration or icon composition with guidance text. Never just "No data found"
|
||||
* **Error States:** Inline, contextual. Red accent underline or border. Clear recovery action
|
||||
|
||||
## 5. Hero Section
|
||||
The Hero is the first impression — it must be striking, creative, and never generic.
|
||||
- **Inline Image Typography:** Embed small, contextual photos or visuals directly between words or letters in the headline. Example: "We build [photo of hands typing] digital [photo of screen] products" — images sit inline at type-height, rounded, acting as visual punctuation between words. This is the signature creative technique
|
||||
- **No Overlapping Elements:** Text must never overlap images or other text. Every element has its own clear spatial zone. No z-index stacking of content layers, no absolute-positioned headlines over images. Clean separation always
|
||||
- **No Filler Text:** "Scroll to explore", "Swipe down", scroll arrow icons, bouncing chevrons, and any instructional UI chrome are BANNED. The user knows how to scroll. Let the content pull them in naturally
|
||||
- **Asymmetric Structure:** Centered Hero layouts are BANNED at this variance level. Use Split Screen (50/50), Left-Aligned text / Right visual, or Asymmetric Whitespace with large empty zones
|
||||
- **CTA Restraint:** Maximum one primary CTA button. No secondary "Learn more" links. No redundant micro-copy below the headline
|
||||
|
||||
## 6. Layout Principles
|
||||
- **Grid-First:** CSS Grid for all structural layouts. Never flexbox percentage math (`calc(33% - 1rem)` is BANNED)
|
||||
- **No Overlapping:** Elements must never overlap each other. No absolute-positioned layers stacking content on content. Every element occupies its own grid cell or flow position. Clean, separated spatial zones
|
||||
- **Feature Sections:** The "3 equal cards in a row" pattern is BANNED. Use 2-column Zig-Zag, asymmetric Bento grids (2fr 1fr 1fr), or horizontal scroll galleries
|
||||
- **Containment:** All content within `max-width: 1400px`, centered. Generous horizontal padding (`1rem` mobile, `2rem` tablet, `4rem` desktop)
|
||||
- **Full-Height:** Use `min-height: 100dvh` — never `height: 100vh` (iOS Safari address bar jump)
|
||||
- **Bento Architecture:** For feature grids, use Row 1: 3 columns | Row 2: 2 columns (70/30 split). Each tile contains a perpetual micro-animation
|
||||
|
||||
## 7. Responsive Rules
|
||||
Every screen must work flawlessly across all viewports. **Responsive is not optional — it is a hard requirement. Every single element must be tested at 375px, 768px, and 1440px.**
|
||||
- **Mobile-First Collapse (< 768px):** All multi-column layouts collapse to a strict single column. `width: 100%`, `padding: 1rem`, `gap: 1.5rem`. No exceptions
|
||||
- **No Horizontal Scroll:** Horizontal overflow on mobile is a critical failure. All elements must fit within viewport width. If any element causes horizontal scroll, the design is broken
|
||||
- **Typography Scaling:** Headlines scale down gracefully via `clamp()`. Body text stays `1rem` minimum. Never shrink body below `14px`. Headlines must remain readable on 375px screens
|
||||
- **Touch Targets:** All interactive elements minimum `44px` tap target. Generous spacing between clickable items. Buttons must be full-width on mobile
|
||||
- **Image Behavior:** Hero and inline images scale proportionally. Inline typography images (photos between words) stack below the headline on mobile instead of inline
|
||||
- **Navigation:** Desktop horizontal nav collapses to a clean mobile menu (slide-in or full-screen overlay). No tiny hamburger icons without labels
|
||||
- **Cards & Grids:** Bento grids and asymmetric layouts revert to stacked single-column cards with full-width. Maintain internal padding (`1rem`)
|
||||
- **Spacing Consistency:** Vertical section gaps reduce proportionally on mobile (`clamp(3rem, 8vw, 6rem)`). Never cramped, never excessively airy
|
||||
- **Testing Viewports:** Designs must be verified at: `375px` (iPhone SE), `390px` (iPhone 14), `768px` (iPad), `1024px` (small laptop), `1440px` (desktop)
|
||||
|
||||
## 8. Motion & Interaction (Code-Phase Intent)
|
||||
> **Note:** Stitch generates static screens — it does not animate. This section documents the **intended motion behavior** so that the coding agent (Antigravity, Cursor, etc.) knows exactly how to implement animations when building the exported design into a live product.
|
||||
|
||||
- **Physics Engine:** Spring-based exclusively. `stiffness: 100, damping: 20`. No linear easing anywhere. Premium, weighty feel on all interactive elements
|
||||
- **Perpetual Micro-Loops:** Every active dashboard component has an infinite-loop state — Pulse on status dots, Typewriter on search bars, Float on feature icons, Shimmer on loading states
|
||||
- **Staggered Orchestration:** Lists and grids mount with cascaded delays (`animation-delay: calc(var(--index) * 100ms)`). Waterfall reveals, never instant mount
|
||||
- **Layout Transitions:** Smooth re-ordering via shared element IDs. Items swap positions with physics, simulating real-time intelligence
|
||||
- **Hardware Rules:** Animate ONLY `transform` and `opacity`. Never `top`, `left`, `width`, `height`. Grain/noise filters on fixed, pointer-events-none pseudo-elements only
|
||||
- **Performance:** CPU-heavy perpetual animations isolated in microscopic leaf components. Never trigger parent re-renders. Target 60fps minimum
|
||||
|
||||
## 9. Anti-Patterns (Banned)
|
||||
- No emojis — anywhere in UI, code, or alt text
|
||||
- No `Inter` font — use `Geist`, `Outfit`, `Cabinet Grotesk`, `Satoshi`
|
||||
- No generic serif fonts (`Times New Roman`, `Georgia`, `Garamond`) — if serif is needed, use distinctive modern serifs only (`Fraunces`, `Instrument Serif`)
|
||||
- No pure black (`#000000`) — Off-Black or Zinc-950 only
|
||||
- No neon outer glows or default box-shadow glows
|
||||
- No oversaturated accent colors above 80%
|
||||
- No excessive gradient text on large headers
|
||||
- No custom mouse cursors
|
||||
- No overlapping elements — text never overlaps images or other content. Clean spatial separation always
|
||||
- No 3-column equal card layouts for features
|
||||
- No centered Hero sections (at this variance level)
|
||||
- No filler UI text: "Scroll to explore", "Swipe down", "Discover more below", scroll arrows, bouncing chevrons — all BANNED
|
||||
- No generic names: "John Doe", "Sarah Chan", "Acme", "Nexus", "SmartFlow"
|
||||
- No fake round numbers: `99.99%`, `50%`, `1234567` — use organic data: `47.2%`, `+1 (312) 847-1928`
|
||||
- No AI copywriting clichés: "Elevate", "Seamless", "Unleash", "Next-Gen", "Revolutionize"
|
||||
- No broken Unsplash links — use `picsum.photos/seed/{id}/800/600` or SVG UI Avatars
|
||||
- No generic `shadcn/ui` defaults — customize radii, colors, shadows to match this system
|
||||
- No `z-index` spam — use only for Navbar, Modal, Overlay layer contexts
|
||||
- No `h-screen` — always `min-h-[100dvh]`
|
||||
- No circular loading spinners — skeletal shimmer only
|
||||
@@ -1,185 +0,0 @@
|
||||
---
|
||||
name: stitch-design-taste
|
||||
description: "Classic skill for Google Stitch, opt-in: it runs only when called by name. Writes an agent-friendly DESIGN.md with strict type, colour and layout rules."
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Stitch Design Taste — Semantic Design System Skill
|
||||
|
||||
## Overview
|
||||
This skill generates `DESIGN.md` files optimized for Google Stitch screen generation. It translates the battle-tested anti-slop frontend engineering directives into Stitch's native semantic design language — descriptive, natural-language rules paired with precise values that Stitch's AI agent can interpret to produce premium, non-generic interfaces.
|
||||
|
||||
The generated `DESIGN.md` serves as the **single source of truth** for prompting Stitch to generate new screens that align with a curated, high-agency design language. Stitch interprets design through **"Visual Descriptions"** supported by specific color values, typography specs, and component behaviors.
|
||||
|
||||
## Prerequisites
|
||||
- Access to Google Stitch via [labs.google/stitch](https://labs.google/stitch)
|
||||
- Optionally: Stitch MCP Server for programmatic integration with Cursor, Antigravity, or Gemini CLI
|
||||
|
||||
## The Goal
|
||||
Generate a `DESIGN.md` file that encodes:
|
||||
1. **Visual atmosphere** — the mood, density, and design philosophy
|
||||
2. **Color calibration** — neutrals, accents, and banned patterns with hex codes
|
||||
3. **Typographic architecture** — font stacks, scale hierarchy, and anti-patterns
|
||||
4. **Component behaviors** — buttons, cards, inputs with interaction states
|
||||
5. **Layout principles** — grid systems, spacing philosophy, responsive strategy
|
||||
6. **Motion philosophy** — animation engine specs, spring physics, perpetual micro-interactions
|
||||
7. **Anti-patterns** — explicit list of banned AI design clichés
|
||||
|
||||
## Analysis & Synthesis Instructions
|
||||
|
||||
### 1. Define the Atmosphere
|
||||
Evaluate the target project's intent. Use evocative adjectives from the taste spectrum:
|
||||
- **Density:** "Art Gallery Airy" (1–3) → "Daily App Balanced" (4–7) → "Cockpit Dense" (8–10)
|
||||
- **Variance:** "Predictable Symmetric" (1–3) → "Offset Asymmetric" (4–7) → "Artsy Chaotic" (8–10)
|
||||
- **Motion:** "Static Restrained" (1–3) → "Fluid CSS" (4–7) → "Cinematic Choreography" (8–10)
|
||||
|
||||
Default baseline: Variance 8, Motion 6, Density 4. Adapt dynamically based on user's vibe description.
|
||||
|
||||
### 2. Map the Color Palette
|
||||
For each color provide: **Descriptive Name** + **Hex Code** + **Functional Role**.
|
||||
|
||||
**Mandatory constraints:**
|
||||
- Maximum 1 accent color. Saturation below 80%
|
||||
- The "AI Purple/Blue Neon" aesthetic is strictly BANNED — no purple button glows, no neon gradients
|
||||
- Use absolute neutral bases (Zinc/Slate) with high-contrast singular accents
|
||||
- Stick to one palette for the entire output — no warm/cool gray fluctuation
|
||||
- Never use pure black (`#000000`) — use Off-Black, Zinc-950, or Charcoal
|
||||
|
||||
### 3. Establish Typography Rules
|
||||
- **Display/Headlines:** Track-tight, controlled scale. Not screaming. Hierarchy through weight and color, not just massive size
|
||||
- **Body:** Relaxed leading, max 65 characters per line
|
||||
- **Font Selection:** `Inter` is BANNED for premium/creative contexts. Force unique character: `Geist`, `Outfit`, `Cabinet Grotesk`, or `Satoshi`
|
||||
- **Serif Ban:** Generic serif fonts (`Times New Roman`, `Georgia`, `Garamond`, `Palatino`) are BANNED. If serif is needed for editorial/creative contexts, use only distinctive modern serifs: `Fraunces`, `Gambarino`, `Editorial New`, or `Instrument Serif`. Serif is always BANNED in dashboards or software UIs
|
||||
- **Dashboard Constraint:** Use Sans-Serif pairings exclusively (`Geist` + `Geist Mono` or `Satoshi` + `JetBrains Mono`)
|
||||
- **High-Density Override:** When density exceeds 7, all numbers must use Monospace
|
||||
|
||||
### 4. Define the Hero Section
|
||||
The Hero is the first impression and must be creative, striking, and never generic:
|
||||
- **Inline Image Typography:** Embed small, contextual photos or visuals directly between words or letters in the headline. Images sit inline at type-height, rounded, acting as visual punctuation. This is the signature creative technique
|
||||
- **No Overlapping:** Text must never overlap images or other text. Every element occupies its own clean spatial zone
|
||||
- **No Filler Text:** "Scroll to explore", "Swipe down", scroll arrow icons, bouncing chevrons are BANNED. The content should pull users in naturally
|
||||
- **Asymmetric Structure:** Centered Hero layouts BANNED when variance exceeds 4
|
||||
- **CTA Restraint:** Maximum one primary CTA. No secondary "Learn more" links
|
||||
|
||||
### 5. Describe Component Stylings
|
||||
For each component type, describe shape, color, shadow depth, and interaction behavior:
|
||||
- **Buttons:** Tactile push feedback on active state. No neon outer glows. No custom mouse cursors
|
||||
- **Cards:** Use ONLY when elevation communicates hierarchy. Tint shadows to background hue. For high-density layouts, replace cards with border-top dividers or negative space
|
||||
- **Inputs/Forms:** Label above input, helper text optional, error text below. Standard gap spacing
|
||||
- **Loading States:** Skeletal loaders matching layout dimensions — no generic circular spinners
|
||||
- **Empty States:** Composed compositions indicating how to populate data
|
||||
- **Error States:** Clear, inline error reporting
|
||||
|
||||
### 6. Define Layout Principles
|
||||
- No overlapping elements — every element occupies its own clear spatial zone. No absolute-positioned content stacking
|
||||
- Centered Hero sections are BANNED when variance exceeds 4 — force Split Screen, Left-Aligned, or Asymmetric Whitespace
|
||||
- The generic "3 equal cards horizontally" feature row is BANNED — use 2-column Zig-Zag, asymmetric grid, or horizontal scroll
|
||||
- CSS Grid over Flexbox math — never use `calc()` percentage hacks
|
||||
- Contain layouts using max-width constraints (e.g., 1400px centered)
|
||||
- Full-height sections must use `min-h-[100dvh]` — never `h-screen` (iOS Safari catastrophic jump)
|
||||
|
||||
### 7. Define Responsive Rules
|
||||
Every design must work across all viewports:
|
||||
- **Mobile-First Collapse (< 768px):** All multi-column layouts collapse to single column. No exceptions
|
||||
- **No Horizontal Scroll:** Horizontal overflow on mobile is a critical failure
|
||||
- **Typography Scaling:** Headlines scale via `clamp()`. Body text minimum `1rem`/`14px`
|
||||
- **Touch Targets:** All interactive elements minimum `44px` tap target
|
||||
- **Image Behavior:** Inline typography images (photos between words) stack below headline on mobile
|
||||
- **Navigation:** Desktop horizontal nav collapses to clean mobile menu
|
||||
- **Spacing:** Vertical section gaps reduce proportionally (`clamp(3rem, 8vw, 6rem)`)
|
||||
|
||||
### 8. Encode Motion Philosophy
|
||||
- **Spring Physics default:** `stiffness: 100, damping: 20` — premium, weighty feel. No linear easing
|
||||
- **Perpetual Micro-Interactions:** Every active component should have an infinite loop state (Pulse, Typewriter, Float, Shimmer)
|
||||
- **Staggered Orchestration:** Never mount lists instantly — use cascade delays for waterfall reveals
|
||||
- **Performance:** Animate exclusively via `transform` and `opacity`. Never animate `top`, `left`, `width`, `height`. Grain/noise filters on fixed pseudo-elements only
|
||||
|
||||
### 9. List Anti-Patterns (AI Tells)
|
||||
Encode these as explicit "NEVER DO" rules in the DESIGN.md:
|
||||
- No emojis anywhere
|
||||
- No `Inter` font
|
||||
- No generic serif fonts (`Times New Roman`, `Georgia`, `Garamond`) — distinctive modern serifs only if needed
|
||||
- No pure black (`#000000`)
|
||||
- No neon/outer glow shadows
|
||||
- No oversaturated accents
|
||||
- No excessive gradient text on large headers
|
||||
- No custom mouse cursors
|
||||
- No overlapping elements — clean spatial separation always
|
||||
- No 3-column equal card layouts
|
||||
- No generic names ("John Doe", "Acme", "Nexus")
|
||||
- No fake round numbers (`99.99%`, `50%`)
|
||||
- No AI copywriting clichés ("Elevate", "Seamless", "Unleash", "Next-Gen")
|
||||
- No filler UI text: "Scroll to explore", "Swipe down", scroll arrows, bouncing chevrons
|
||||
- No broken Unsplash links — use `picsum.photos` or SVG avatars
|
||||
- No centered Hero sections (for high-variance projects)
|
||||
|
||||
## Output Format (DESIGN.md Structure)
|
||||
|
||||
```markdown
|
||||
# Design System: [Project Title]
|
||||
|
||||
## 1. Visual Theme & Atmosphere
|
||||
(Evocative description of the mood, density, variance, and motion intensity.
|
||||
Example: "A restrained, gallery-airy interface with confident asymmetric layouts
|
||||
and fluid spring-physics motion. The atmosphere is clinical yet warm — like a
|
||||
well-lit architecture studio.")
|
||||
|
||||
## 2. Color Palette & Roles
|
||||
- **Canvas White** (#F9FAFB) — Primary background surface
|
||||
- **Pure Surface** (#FFFFFF) — Card and container fill
|
||||
- **Charcoal Ink** (#18181B) — Primary text, Zinc-950 depth
|
||||
- **Muted Steel** (#71717A) — Secondary text, descriptions, metadata
|
||||
- **Whisper Border** (rgba(226,232,240,0.5)) — Card borders, 1px structural lines
|
||||
- **[Accent Name]** (#XXXXXX) — Single accent for CTAs, active states, focus rings
|
||||
(Max 1 accent. Saturation < 80%. No purple/neon.)
|
||||
|
||||
## 3. Typography Rules
|
||||
- **Display:** [Font Name] — Track-tight, controlled scale, weight-driven hierarchy
|
||||
- **Body:** [Font Name] — Relaxed leading, 65ch max-width, neutral secondary color
|
||||
- **Mono:** [Font Name] — For code, metadata, timestamps, high-density numbers
|
||||
- **Banned:** Inter, generic system fonts for premium contexts. Serif fonts banned in dashboards.
|
||||
|
||||
## 4. Component Stylings
|
||||
* **Buttons:** Flat, no outer glow. Tactile -1px translate on active. Accent fill for primary, ghost/outline for secondary.
|
||||
* **Cards:** Generously rounded corners (2.5rem). Diffused whisper shadow. Used only when elevation serves hierarchy. High-density: replace with border-top dividers.
|
||||
* **Inputs:** Label above, error below. Focus ring in accent color. No floating labels.
|
||||
* **Loaders:** Skeletal shimmer matching exact layout dimensions. No circular spinners.
|
||||
* **Empty States:** Composed, illustrated compositions — not just "No data" text.
|
||||
|
||||
## 5. Layout Principles
|
||||
(Grid-first responsive architecture. Asymmetric splits for Hero sections.
|
||||
Strict single-column collapse below 768px. Max-width containment.
|
||||
No flexbox percentage math. Generous internal padding.)
|
||||
|
||||
## 6. Motion & Interaction
|
||||
(Spring physics for all interactive elements. Staggered cascade reveals.
|
||||
Perpetual micro-loops on active dashboard components. Hardware-accelerated
|
||||
transforms only. Isolated Client Components for CPU-heavy animations.)
|
||||
|
||||
## 7. Anti-Patterns (Banned)
|
||||
(Explicit list of forbidden patterns: no emojis, no Inter, no pure black,
|
||||
no neon glows, no 3-column equal grids, no AI copywriting clichés,
|
||||
no generic placeholder names, no broken image links.)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
- **Be Descriptive:** "Deep Charcoal Ink (#18181B)" — not just "dark text"
|
||||
- **Be Functional:** Explain what each element is used for
|
||||
- **Be Consistent:** Same terminology throughout the document
|
||||
- **Be Precise:** Include exact hex codes, rem values, pixel values in parentheses
|
||||
- **Be Opinionated:** This is not a neutral template — it enforces a specific, premium aesthetic
|
||||
|
||||
## Tips for Success
|
||||
1. Start with the atmosphere — understand the vibe before detailing tokens
|
||||
2. Look for patterns — identify consistent spacing, sizing, and styling
|
||||
3. Think semantically — name colors by purpose, not just appearance
|
||||
4. Consider hierarchy — document how visual weight communicates importance
|
||||
5. Encode the bans — anti-patterns are as important as the rules themselves
|
||||
|
||||
## Common Pitfalls to Avoid
|
||||
- Using technical jargon without translation ("rounded-xl" instead of "generously rounded corners")
|
||||
- Omitting hex codes or using only descriptive names
|
||||
- Forgetting functional roles of design elements
|
||||
- Being too vague in atmosphere descriptions
|
||||
- Ignoring the anti-pattern list — these are what make the output premium
|
||||
- Defaulting to generic "safe" designs instead of enforcing the curated aesthetic
|
||||
@@ -1,2 +0,0 @@
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
@@ -1,227 +0,0 @@
|
||||
---
|
||||
name: design-taste-frontend-v1
|
||||
description: "The original taste-skill v1, kept for projects that depend on its exact behaviour. Opt-in: it runs only when called by name."
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# High-Agency Frontend Skill
|
||||
|
||||
## 1. ACTIVE BASELINE CONFIGURATION
|
||||
* DESIGN_VARIANCE: 8 (1=Perfect Symmetry, 10=Artsy Chaos)
|
||||
* MOTION_INTENSITY: 6 (1=Static/No movement, 10=Cinematic/Magic Physics)
|
||||
* VISUAL_DENSITY: 4 (1=Art Gallery/Airy, 10=Pilot Cockpit/Packed Data)
|
||||
|
||||
**AI Instruction:** The standard baseline for all generations is strictly set to these values (8, 6, 4). Do not ask the user to edit this file. Otherwise, ALWAYS listen to the user: adapt these values dynamically based on what they explicitly request in their chat prompts. Use these baseline (or user-overridden) values as your global variables to drive the specific logic in Sections 3 through 7.
|
||||
|
||||
## 2. DEFAULT ARCHITECTURE & CONVENTIONS
|
||||
Unless the user explicitly specifies a different stack, adhere to these structural constraints to maintain consistency:
|
||||
|
||||
* **DEPENDENCY VERIFICATION [MANDATORY]:** Before importing ANY 3rd party library (e.g. `framer-motion`, `lucide-react`, `zustand`), you MUST check `package.json`. If the package is missing, you MUST output the installation command (e.g. `npm install package-name`) before providing the code. **Never** assume a library exists.
|
||||
* **Framework & Interactivity:** React or Next.js. Default to Server Components (`RSC`).
|
||||
* **RSC SAFETY:** Global state works ONLY in Client Components. In Next.js, wrap providers in a `"use client"` component.
|
||||
* **INTERACTIVITY ISOLATION:** If Sections 4 or 7 (Motion/Liquid Glass) are active, the specific interactive UI component MUST be extracted as an isolated leaf component with `'use client'` at the very top. Server Components must exclusively render static layouts.
|
||||
* **State Management:** Use local `useState`/`useReducer` for isolated UI. Use global state strictly for deep prop-drilling avoidance.
|
||||
* **Styling Policy:** Use Tailwind CSS (v3/v4) for 90% of styling.
|
||||
* **TAILWIND VERSION LOCK:** Check `package.json` first. Do not use v4 syntax in v3 projects.
|
||||
* **T4 CONFIG GUARD:** For v4, do NOT use `tailwindcss` plugin in `postcss.config.js`. Use `@tailwindcss/postcss` or the Vite plugin.
|
||||
* **ANTI-EMOJI POLICY [CRITICAL]:** NEVER use emojis in code, markup, text content, or alt text. Replace symbols with high-quality icons (Radix, Phosphor) or clean SVG primitives. Emojis are BANNED.
|
||||
* **Responsiveness & Spacing:**
|
||||
* Standardize breakpoints (`sm`, `md`, `lg`, `xl`).
|
||||
* Contain page layouts using `max-w-[1400px] mx-auto` or `max-w-7xl`.
|
||||
* **Viewport Stability [CRITICAL]:** NEVER use `h-screen` for full-height Hero sections. ALWAYS use `min-h-[100dvh]` to prevent catastrophic layout jumping on mobile browsers (iOS Safari).
|
||||
* **Grid over Flex-Math:** NEVER use complex flexbox percentage math (`w-[calc(33%-1rem)]`). ALWAYS use CSS Grid (`grid grid-cols-1 md:grid-cols-3 gap-6`) for reliable structures.
|
||||
* **Icons:** You MUST use exactly `@phosphor-icons/react` or `@radix-ui/react-icons` as the import paths (check installed version). Standardize `strokeWidth` globally (e.g., exclusively use `1.5` or `2.0`).
|
||||
|
||||
|
||||
## 3. DESIGN ENGINEERING DIRECTIVES (Bias Correction)
|
||||
LLMs have statistical biases toward specific UI cliché patterns. Proactively construct premium interfaces using these engineered rules:
|
||||
|
||||
**Rule 1: Deterministic Typography**
|
||||
* **Display/Headlines:** Default to `text-4xl md:text-6xl tracking-tighter leading-none`.
|
||||
* **ANTI-SLOP:** Discourage `Inter` for "Premium" or "Creative" vibes. Force unique character using `Geist`, `Outfit`, `Cabinet Grotesk`, or `Satoshi`.
|
||||
* **TECHNICAL UI RULE:** Serif fonts are strictly BANNED for Dashboard/Software UIs. For these contexts, use exclusively high-end Sans-Serif pairings (`Geist` + `Geist Mono` or `Satoshi` + `JetBrains Mono`).
|
||||
* **Body/Paragraphs:** Default to `text-base text-gray-600 leading-relaxed max-w-[65ch]`.
|
||||
|
||||
**Rule 2: Color Calibration**
|
||||
* **Constraint:** Max 1 Accent Color. Saturation < 80%.
|
||||
* **THE LILA BAN:** The "AI Purple/Blue" aesthetic is strictly BANNED. No purple button glows, no neon gradients. Use absolute neutral bases (Zinc/Slate) with high-contrast, singular accents (e.g. Emerald, Electric Blue, or Deep Rose).
|
||||
* **COLOR CONSISTENCY:** Stick to one palette for the entire output. Do not fluctuate between warm and cool grays within the same project.
|
||||
|
||||
**Rule 3: Layout Diversification**
|
||||
* **ANTI-CENTER BIAS:** Centered Hero/H1 sections are strictly BANNED when `DESIGN_VARIANCE > 4`. Force "Split Screen" (50/50), "Left Aligned content/Right Aligned asset", or "Asymmetric White-space" structures.
|
||||
|
||||
**Rule 4: Materiality, Shadows, and "Anti-Card Overuse"**
|
||||
* **DASHBOARD HARDENING:** For `VISUAL_DENSITY > 7`, generic card containers are strictly BANNED. Use logic-grouping via `border-t`, `divide-y`, or purely negative space. Data metrics should breathe without being boxed in unless elevation (z-index) is functionally required.
|
||||
* **Execution:** Use cards ONLY when elevation communicates hierarchy. When a shadow is used, tint it to the background hue.
|
||||
|
||||
**Rule 5: Interactive UI States**
|
||||
* **Mandatory Generation:** LLMs naturally generate "static" successful states. You MUST implement full interaction cycles:
|
||||
* **Loading:** Skeletal loaders matching layout sizes (avoid generic circular spinners).
|
||||
* **Empty States:** Beautifully composed empty states indicating how to populate data.
|
||||
* **Error States:** Clear, inline error reporting (e.g., forms).
|
||||
* **Tactile Feedback:** On `:active`, use `-translate-y-[1px]` or `scale-[0.98]` to simulate a physical push indicating success/action.
|
||||
|
||||
**Rule 6: Data & Form Patterns**
|
||||
* **Forms:** Label MUST sit above input. Helper text is optional but should exist in markup. Error text below input. Use a standard `gap-2` for input blocks.
|
||||
|
||||
## 4. CREATIVE PROACTIVITY (Anti-Slop Implementation)
|
||||
To actively combat generic AI designs, systematically implement these high-end coding concepts as your baseline:
|
||||
* **"Liquid Glass" Refraction:** When glassmorphism is needed, go beyond `backdrop-blur`. Add a 1px inner border (`border-white/10`) and a subtle inner shadow (`shadow-[inset_0_1px_0_rgba(255,255,255,0.1)]`) to simulate physical edge refraction.
|
||||
* **Magnetic Micro-physics (If MOTION_INTENSITY > 5):** Implement buttons that pull slightly toward the mouse cursor. **CRITICAL:** NEVER use React `useState` for magnetic hover or continuous animations. Use EXCLUSIVELY Framer Motion's `useMotionValue` and `useTransform` outside the React render cycle to prevent performance collapse on mobile.
|
||||
* **Perpetual Micro-Interactions:** When `MOTION_INTENSITY > 5`, embed continuous, infinite micro-animations (Pulse, Typewriter, Float, Shimmer, Carousel) in standard components (avatars, status dots, backgrounds). Apply premium Spring Physics (`type: "spring", stiffness: 100, damping: 20`) to all interactive elements—no linear easing.
|
||||
* **Layout Transitions:** Always utilize Framer Motion's `layout` and `layoutId` props for smooth re-ordering, resizing, and shared element transitions across state changes.
|
||||
* **Staggered Orchestration:** Do not mount lists or grids instantly. Use `staggerChildren` (Framer) or CSS cascade (`animation-delay: calc(var(--index) * 100ms)`) to create sequential waterfall reveals. **CRITICAL:** For `staggerChildren`, the Parent (`variants`) and Children MUST reside in the identical Client Component tree. If data is fetched asynchronously, pass the data as props into a centralized Parent Motion wrapper.
|
||||
|
||||
## 5. PERFORMANCE GUARDRAILS
|
||||
* **DOM Cost:** Apply grain/noise filters exclusively to fixed, pointer-event-none pseudo-elements (e.g., `fixed inset-0 z-50 pointer-events-none`) and NEVER to scrolling containers to prevent continuous GPU repaints and mobile performance degradation.
|
||||
* **Hardware Acceleration:** Never animate `top`, `left`, `width`, or `height`. Animate exclusively via `transform` and `opacity`.
|
||||
* **Z-Index Restraint:** NEVER spam arbitrary `z-50` or `z-10` unprompted. Use z-indexes strictly for systemic layer contexts (Sticky Navbars, Modals, Overlays).
|
||||
|
||||
## 6. TECHNICAL REFERENCE (Dial Definitions)
|
||||
|
||||
### DESIGN_VARIANCE (Level 1-10)
|
||||
* **1-3 (Predictable):** Flexbox `justify-center`, strict 12-column symmetrical grids, equal paddings.
|
||||
* **4-7 (Offset):** Use `margin-top: -2rem` overlapping, varied image aspect ratios (e.g., 4:3 next to 16:9), left-aligned headers over center-aligned data.
|
||||
* **8-10 (Asymmetric):** Masonry layouts, CSS Grid with fractional units (e.g., `grid-template-columns: 2fr 1fr 1fr`), massive empty zones (`padding-left: 20vw`).
|
||||
* **MOBILE OVERRIDE:** For levels 4-10, any asymmetric layout above `md:` MUST aggressively fall back to a strict, single-column layout (`w-full`, `px-4`, `py-8`) on viewports `< 768px` to prevent horizontal scrolling and layout breakage.
|
||||
|
||||
### MOTION_INTENSITY (Level 1-10)
|
||||
* **1-3 (Static):** No automatic animations. CSS `:hover` and `:active` states only.
|
||||
* **4-7 (Fluid CSS):** Use `transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1)`. Use `animation-delay` cascades for load-ins. Focus strictly on `transform` and `opacity`. Use `will-change: transform` sparingly.
|
||||
* **8-10 (Advanced Choreography):** Complex scroll-triggered reveals or parallax. Use Framer Motion hooks. NEVER use `window.addEventListener('scroll')`.
|
||||
|
||||
### VISUAL_DENSITY (Level 1-10)
|
||||
* **1-3 (Art Gallery Mode):** Lots of white space. Huge section gaps. Everything feels very expensive and clean.
|
||||
* **4-7 (Daily App Mode):** Normal spacing for standard web apps.
|
||||
* **8-10 (Cockpit Mode):** Tiny paddings. No card boxes; just 1px lines to separate data. Everything is packed. **Mandatory:** Use Monospace (`font-mono`) for all numbers.
|
||||
|
||||
## 7. AI TELLS (Forbidden Patterns)
|
||||
To guarantee a premium, non-generic output, you MUST strictly avoid these common AI design signatures unless explicitly requested:
|
||||
|
||||
### Visual & CSS
|
||||
* **NO Neon/Outer Glows:** Do not use default `box-shadow` glows or auto-glows. Use inner borders or subtle tinted shadows.
|
||||
* **NO Pure Black:** Never use `#000000`. Use Off-Black, Zinc-950, or Charcoal.
|
||||
* **NO Oversaturated Accents:** Desaturate accents to blend elegantly with neutrals.
|
||||
* **NO Excessive Gradient Text:** Do not use text-fill gradients for large headers.
|
||||
* **NO Custom Mouse Cursors:** They are outdated and ruin performance/accessibility.
|
||||
|
||||
### Typography
|
||||
* **NO Inter Font:** Banned. Use `Geist`, `Outfit`, `Cabinet Grotesk`, or `Satoshi`.
|
||||
* **NO Oversized H1s:** The first heading should not scream. Control hierarchy with weight and color, not just massive scale.
|
||||
* **Serif Constraints:** Use Serif fonts ONLY for creative/editorial designs. **NEVER** use Serif on clean Dashboards.
|
||||
|
||||
### Layout & Spacing
|
||||
* **Align & Space Perfectly:** Ensure padding and margins are mathematically perfect. Avoid floating elements with awkward gaps.
|
||||
* **NO 3-Column Card Layouts:** The generic "3 equal cards horizontally" feature row is BANNED. Use a 2-column Zig-Zag, asymmetric grid, or horizontal scrolling approach instead.
|
||||
|
||||
### Content & Data (The "Jane Doe" Effect)
|
||||
* **NO Generic Names:** "John Doe", "Sarah Chan", or "Jack Su" are banned. Use highly creative, realistic-sounding names.
|
||||
* **NO Generic Avatars:** DO NOT use standard SVG "egg" or Lucide user icons for avatars. Use creative, believable photo placeholders or specific styling.
|
||||
* **NO Fake Numbers:** Avoid predictable outputs like `99.99%`, `50%`, or basic phone numbers (`1234567`). Use organic, messy data (`47.2%`, `+1 (312) 847-1928`).
|
||||
* **NO Startup Slop Names:** "Acme", "Nexus", "SmartFlow". Invent premium, contextual brand names.
|
||||
* **NO Filler Words:** Avoid AI copywriting clichés like "Elevate", "Seamless", "Unleash", or "Next-Gen". Use concrete verbs.
|
||||
|
||||
### External Resources & Components
|
||||
* **NO Broken Unsplash Links:** Do not use Unsplash. Use absolute, reliable placeholders like `https://picsum.photos/seed/{random_string}/800/600` or SVG UI Avatars.
|
||||
* **shadcn/ui Customization:** You may use `shadcn/ui`, but NEVER in its generic default state. You MUST customize the radii, colors, and shadows to match the high-end project aesthetic.
|
||||
* **Production-Ready Cleanliness:** Code must be extremely clean, visually striking, memorable, and meticulously refined in every detail.
|
||||
|
||||
## 8. THE CREATIVE ARSENAL (High-End Inspiration)
|
||||
Do not default to generic UI. Pull from this library of advanced concepts to ensure the output is visually striking and memorable. When appropriate, leverage **GSAP (ScrollTrigger/Parallax)** for complex scrolltelling or **ThreeJS/WebGL** for 3D/Canvas animations, rather than basic CSS motion. **CRITICAL:** Never mix GSAP/ThreeJS with Framer Motion in the same component tree. Default to Framer Motion for UI/Bento interactions. Use GSAP/ThreeJS EXCLUSIVELY for isolated full-page scrolltelling or canvas backgrounds, wrapped in strict useEffect cleanup blocks.
|
||||
|
||||
### The Standard Hero Paradigm
|
||||
* Stop doing centered text over a dark image. Try asymmetric Hero sections: Text cleanly aligned to the left or right. The background should feature a high-quality, relevant image with a subtle stylistic fade (darkening or lightening gracefully into the background color depending on if it is Light or Dark mode).
|
||||
|
||||
### Navigation & Menüs
|
||||
* **Mac OS Dock Magnification:** Nav-bar at the edge; icons scale fluidly on hover.
|
||||
* **Magnetic Button:** Buttons that physically pull toward the cursor.
|
||||
* **Gooey Menu:** Sub-items detach from the main button like a viscous liquid.
|
||||
* **Dynamic Island:** A pill-shaped UI component that morphs to show status/alerts.
|
||||
* **Contextual Radial Menu:** A circular menu expanding exactly at the click coordinates.
|
||||
* **Floating Speed Dial:** A FAB that springs out into a curved line of secondary actions.
|
||||
* **Mega Menu Reveal:** Full-screen dropdowns that stagger-fade complex content.
|
||||
|
||||
### Layout & Grids
|
||||
* **Bento Grid:** Asymmetric, tile-based grouping (e.g., Apple Control Center).
|
||||
* **Masonry Layout:** Staggered grid without fixed row heights (e.g., Pinterest).
|
||||
* **Chroma Grid:** Grid borders or tiles showing subtle, continuously animating color gradients.
|
||||
* **Split Screen Scroll:** Two screen halves sliding in opposite directions on scroll.
|
||||
* **Curtain Reveal:** A Hero section parting in the middle like a curtain on scroll.
|
||||
|
||||
### Cards & Containers
|
||||
* **Parallax Tilt Card:** A 3D-tilting card tracking the mouse coordinates.
|
||||
* **Spotlight Border Card:** Card borders that illuminate dynamically under the cursor.
|
||||
* **Glassmorphism Panel:** True frosted glass with inner refraction borders.
|
||||
* **Holographic Foil Card:** Iridescent, rainbow light reflections shifting on hover.
|
||||
* **Tinder Swipe Stack:** A physical stack of cards the user can swipe away.
|
||||
* **Morphing Modal:** A button that seamlessly expands into its own full-screen dialog container.
|
||||
|
||||
### Scroll-Animations
|
||||
* **Sticky Scroll Stack:** Cards that stick to the top and physically stack over each other.
|
||||
* **Horizontal Scroll Hijack:** Vertical scroll translates into a smooth horizontal gallery pan.
|
||||
* **Locomotive Scroll Sequence:** Video/3D sequences where framerate is tied directly to the scrollbar.
|
||||
* **Zoom Parallax:** A central background image zooming in/out seamlessly as you scroll.
|
||||
* **Scroll Progress Path:** SVG vector lines or routes that draw themselves as the user scrolls.
|
||||
* **Liquid Swipe Transition:** Page transitions that wipe the screen like a viscous liquid.
|
||||
|
||||
### Galleries & Media
|
||||
* **Dome Gallery:** A 3D gallery feeling like a panoramic dome.
|
||||
* **Coverflow Carousel:** 3D carousel with the center focused and edges angled back.
|
||||
* **Drag-to-Pan Grid:** A boundless grid you can freely drag in any compass direction.
|
||||
* **Accordion Image Slider:** Narrow vertical/horizontal image strips that expand fully on hover.
|
||||
* **Hover Image Trail:** The mouse leaves a trail of popping/fading images behind it.
|
||||
* **Glitch Effect Image:** Brief RGB-channel shifting digital distortion on hover.
|
||||
|
||||
### Typography & Text
|
||||
* **Kinetic Marquee:** Endless text bands that reverse direction or speed up on scroll.
|
||||
* **Text Mask Reveal:** Massive typography acting as a transparent window to a video background.
|
||||
* **Text Scramble Effect:** Matrix-style character decoding on load or hover.
|
||||
* **Circular Text Path:** Text curved along a spinning circular path.
|
||||
* **Gradient Stroke Animation:** Outlined text with a gradient continuously running along the stroke.
|
||||
* **Kinetic Typography Grid:** A grid of letters dodging or rotating away from the cursor.
|
||||
|
||||
### Micro-Interactions & Effects
|
||||
* **Particle Explosion Button:** CTAs that shatter into particles upon success.
|
||||
* **Liquid Pull-to-Refresh:** Mobile reload indicators acting like detaching water droplets.
|
||||
* **Skeleton Shimmer:** Shifting light reflections moving across placeholder boxes.
|
||||
* **Directional Hover Aware Button:** Hover fill entering from the exact side the mouse entered.
|
||||
* **Ripple Click Effect:** Visual waves rippling precisely from the click coordinates.
|
||||
* **Animated SVG Line Drawing:** Vectors that draw their own contours in real-time.
|
||||
* **Mesh Gradient Background:** Organic, lava-lamp-like animated color blobs.
|
||||
* **Lens Blur Depth:** Dynamic focus blurring background UI layers to highlight a foreground action.
|
||||
|
||||
## 9. THE "MOTION-ENGINE" BENTO PARADIGM
|
||||
When generating modern SaaS dashboards or feature sections, you MUST utilize the following "Bento 2.0" architecture and motion philosophy. This goes beyond static cards and enforces a "Vercel-core meets Dribbble-clean" aesthetic heavily reliant on perpetual physics.
|
||||
|
||||
### A. Core Design Philosophy
|
||||
* **Aesthetic:** High-end, minimal, and functional.
|
||||
* **Palette:** Background in `#f9fafb`. Cards are pure white (`#ffffff`) with a 1px border of `border-slate-200/50`.
|
||||
* **Surfaces:** Use `rounded-[2.5rem]` for all major containers. Apply a "diffusion shadow" (a very light, wide-spreading shadow, e.g., `shadow-[0_20px_40px_-15px_rgba(0,0,0,0.05)]`) to create depth without clutter.
|
||||
* **Typography:** Strict `Geist`, `Satoshi`, or `Cabinet Grotesk` font stack. Use subtle tracking (`tracking-tight`) for headers.
|
||||
* **Labels:** Titles and descriptions must be placed **outside and below** the cards to maintain a clean, gallery-style presentation.
|
||||
* **Pixel-Perfection:** Use generous `p-8` or `p-10` padding inside cards.
|
||||
|
||||
### B. The Animation Engine Specs (Perpetual Motion)
|
||||
All cards must contain **"Perpetual Micro-Interactions."** Use the following Framer Motion principles:
|
||||
* **Spring Physics:** No linear easing. Use `type: "spring", stiffness: 100, damping: 20` for a premium, weighty feel.
|
||||
* **Layout Transitions:** Heavily utilize the `layout` and `layoutId` props to ensure smooth re-ordering, resizing, and shared element state transitions.
|
||||
* **Infinite Loops:** Every card must have an "Active State" that loops infinitely (Pulse, Typewriter, Float, or Carousel) to ensure the dashboard feels "alive".
|
||||
* **Performance:** Wrap dynamic lists in `<AnimatePresence>` and optimize for 60fps. **PERFORMANCE CRITICAL:** Any perpetual motion or infinite loop MUST be memoized (React.memo) and completely isolated in its own microscopic Client Component. Never trigger re-renders in the parent layout.
|
||||
|
||||
### C. The 5-Card Archetypes (Micro-Animation Specs)
|
||||
Implement these specific micro-animations when constructing Bento grids (e.g., Row 1: 3 cols | Row 2: 2 cols split 70/30):
|
||||
1. **The Intelligent List:** A vertical stack of items with an infinite auto-sorting loop. Items swap positions using `layoutId`, simulating an AI prioritizing tasks in real-time.
|
||||
2. **The Command Input:** A search/AI bar with a multi-step Typewriter Effect. It cycles through complex prompts, including a blinking cursor and a "processing" state with a shimmering loading gradient.
|
||||
3. **The Live Status:** A scheduling interface with "breathing" status indicators. Include a pop-up notification badge that emerges with an "Overshoot" spring effect, stays for 3 seconds, and vanishes.
|
||||
4. **The Wide Data Stream:** A horizontal "Infinite Carousel" of data cards or metrics. Ensure the loop is seamless (using `x: ["0%", "-100%"]`) with a speed that feels effortless.
|
||||
5. **The Contextual UI (Focus Mode):** A document view that animates a staggered highlight of a text block, followed by a "Float-in" of a floating action toolbar with micro-icons.
|
||||
|
||||
## 10. FINAL PRE-FLIGHT CHECK
|
||||
Evaluate your code against this matrix before outputting. This is the **last** filter you apply to your logic.
|
||||
- [ ] Is global state used appropriately to avoid deep prop-drilling rather than arbitrarily?
|
||||
- [ ] Is mobile layout collapse (`w-full`, `px-4`, `max-w-7xl mx-auto`) guaranteed for high-variance designs?
|
||||
- [ ] Do full-height sections safely use `min-h-[100dvh]` instead of the bugged `h-screen`?
|
||||
- [ ] Do `useEffect` animations contain strict cleanup functions?
|
||||
- [ ] Are empty, loading, and error states provided?
|
||||
- [ ] Are cards omitted in favor of spacing where possible?
|
||||
- [ ] Did you strictly isolate CPU-heavy perpetual animations in their own Client Components?
|
||||
@@ -1,2 +0,0 @@
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
@@ -1,2 +0,0 @@
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
@@ -1,6 +1,16 @@
|
||||
---
|
||||
name: design-taste-frontend
|
||||
description: tasteskill v2, the anti-slop frontend skill. Art direction for websites, distilled from a study of 247 curated gallery sites (One Page Love, Awwwards Sites of the Day). Deals researched directions (idea, composition, type, palette, photographs, section blueprints) from a library, enforces a strict no-slop house style with a real-browser inspector, and runs a look-and-fix loop on screenshots. Use this whenever you build, design, redesign, restyle, polish or review a landing page, marketing site, portfolio, product page, one-pager, hero or any public-facing web page, in any stack (HTML/CSS, React, Next.js, Vue, Svelte, Astro, Tailwind) — also when the user says a page looks generic, templated, "AI-made", sloppy, or asks to make it look designed, premium, award-level or less like every other site. Also for app screens: dashboards, admin and analytics views, settings, inboxes and other screens people work in, with their own run and rules.
|
||||
description: >-
|
||||
tasteskill v2, the anti-slop frontend skill. Art direction for websites, distilled from a study of
|
||||
247 curated gallery sites (One Page Love, Awwwards Sites of the Day). Deals researched directions
|
||||
(idea, composition, type, palette, photographs, section blueprints) from a library, enforces a strict
|
||||
no-slop house style with a real-browser inspector, and runs a look-and-fix loop on screenshots. Use
|
||||
this whenever you build, design, redesign, restyle, polish or review a landing page, marketing site,
|
||||
portfolio, product page, one-pager, hero or any public-facing web page, in any stack (HTML/CSS, React,
|
||||
Next.js, Vue, Svelte, Astro, Tailwind) — also when the user says a page looks generic, templated,
|
||||
"AI-made", sloppy, or asks to make it look designed, premium, award-level or less like every other
|
||||
site. Also for app screens: dashboards, admin and analytics views, settings, inboxes and other screens
|
||||
people work in, with their own run and rules.
|
||||
---
|
||||
|
||||
# tasteskill v2
|
||||
@@ -72,6 +82,13 @@ tool you can call, or nothing. Do not plan around pictures you do not have. Noth
|
||||
rarely nothing at all: the subject's city, street, materials and history are in open archives and
|
||||
museums, free to use with a credit (step 6, `scripts/assets.mjs`).
|
||||
|
||||
Name the pictures a visitor needs before deciding: for a place to stay, the rooms, the house and the
|
||||
table; for food, the plates and the room; for a product, the product; for a practice, its work. When
|
||||
the project has none of them, say in the report that they are missing (or ask, when the user is there
|
||||
to answer) and choose a direction that does not promise them. Their slots are then drawn from the
|
||||
subject (the room's plan at a size that can be read, the product in its normal pose;
|
||||
`references/imagery.md` §8), never filled with the place's birds or weather.
|
||||
|
||||
If the request is vague, decide and state your reading in one line instead of asking. Ask only when a
|
||||
missing fact blocks the work (for example, no product name).
|
||||
|
||||
@@ -131,8 +148,9 @@ The command prints a **direction sheet**. It is the art direction for this build
|
||||
- **Adopt it.** Do not blend it with the layout you would have made anyway. If it feels unfamiliar,
|
||||
good: the familiar layout is the one this skill exists to prevent.
|
||||
- Redraw one slot (`--redraw hero | type | palette | photos | scenes | section:<role> | direction`) only for a
|
||||
reason you can state: it contradicts a stated brand constraint, or needs an asset that does not
|
||||
exist. Never redraw to get something more comfortable. Record every redraw in your report.
|
||||
reason you can state: it contradicts a stated brand constraint, needs an asset that does not exist,
|
||||
or is a section that cannot hold the real content (next point). Never redraw to get something more
|
||||
comfortable. Record every redraw in your report.
|
||||
- When a dealt section cannot hold the real content (it compares three plans and there is one price;
|
||||
it wants five quotations and there are none), do not bend the content to fit. List the others with
|
||||
`--alternatives <role>`, pick the one whose assumptions match the content, and take it with
|
||||
@@ -178,14 +196,19 @@ was recorded as "none beyond the standard formula".
|
||||
made from the idea: pick a wood and see the chair in it; type your name and see it cut in the
|
||||
foundry's letters; set the loan and read the real monthly figure; see whether the hut's stove is lit
|
||||
right now and how long until the last ferry; choose a size and see it against the room. In blind tests every winning page had one and the jurors named it first. Rules: it uses the subject's real content, it works on a phone with a thumb, it has a plain
|
||||
fallback without script, it never blocks reading, and it is one thing, not a playground. It is
|
||||
built in step 6 as part of the page, not added as motion in step 9.
|
||||
fallback without script, it never blocks reading, and it is one thing, not a playground. Its result
|
||||
stays in view while the visitor works the controls, at every width (on a phone, a small sticky result
|
||||
bar if the controls run long), and every heading beside it is true for every state it can reach. It is
|
||||
built in step 6 as part of the page, not added as motion in step 9; when it is built, drive every
|
||||
control to its extremes and look at the widest result at 1440, 768 and 390, since the inspector only
|
||||
sees the default state.
|
||||
|
||||
**The scenes carry the idea too.** The scroll scenes on the sheet are devices waiting for a subject.
|
||||
Decide what each one shows from your inventory before building: the fly-through passes through this
|
||||
studio's own title cards, the route is the road this shipment really takes, the picture that opens to
|
||||
the whole screen is the room the guest will wake up in. A scene filled with pictures that could belong
|
||||
to anyone is a screensaver.
|
||||
to anyone is a screensaver, and one still filled with its demo's town, photographs or words is not
|
||||
started.
|
||||
|
||||
### 5. Write the words
|
||||
|
||||
@@ -223,9 +246,11 @@ not finished. Order of work:
|
||||
2. **The hero, exactly as the blueprint describes it,** at desktop and at phone width. Get the big
|
||||
thing big and the small things small before anything else exists.
|
||||
3. **Each section from its blueprint,** with this project's content. Keep the geometry, change the
|
||||
subject. Neighbouring sections must not share a layout. **Carry the idea into every section,**
|
||||
pricing, FAQ and footer included: the commonest failure in the study, even among good sites, is a
|
||||
distinctive first screen followed by stock blocks.
|
||||
subject, and leave the props behind: a café blueprint's opening-hours bar, scalloped menu sheet and
|
||||
visit card stay with cafés, and on an architecture practice its list of buildings becomes a
|
||||
drawing register, not a menu. Neighbouring sections must not share a layout. **Carry the idea into
|
||||
every section,** pricing, FAQ and footer included: the commonest failure in the study, even among
|
||||
good sites, is a distinctive first screen followed by stock blocks.
|
||||
4. **Imagery,** in this order of preference:
|
||||
1. the project's own;
|
||||
2. generated, if you have an image tool;
|
||||
@@ -250,9 +275,10 @@ not finished. Order of work:
|
||||
When the project has footage or a real picture of its own process or product, the first screen
|
||||
shows it, large (about 40% of the screen or more). Never write a sentence on the page about where
|
||||
the pictures come from or what they are not ("none of these shows our bar"): a caption says what
|
||||
the picture is, and the credit line sits small in the footer. A portfolio whose work is invented
|
||||
for the brief draws each piece as a finished still of that work (its title card with its own type,
|
||||
an end frame, a poster), with real detail, never as a pictogram of the client's product.
|
||||
the picture shows and sits under or beside it, never across a piece of work, and the credit line
|
||||
sits small in the footer. A portfolio whose work is invented for the brief draws each piece as a
|
||||
finished still of that work (its title card with its own type, an end frame, a poster), with real
|
||||
detail, never as a pictogram of the client's product.
|
||||
|
||||
**The floor, which the inspector checks:** at least four real pictures on the page, usually six to
|
||||
twelve (photographs, archival images, maps, engravings, footage, raster artwork; for software, its
|
||||
@@ -268,10 +294,15 @@ not finished. Order of work:
|
||||
moments, the camera that flies through the work, the picture that opens to the whole screen, the
|
||||
word that buckles under the scroll. Copy the shared stage and the scene's files from
|
||||
`<skill>/assets/scenes/`, fill the scene with this project's own pictures and words, and set the
|
||||
page's tokens so it takes the page's look. A scene is composition: it decides how long the page is
|
||||
and what each screen shows, so it is built now and seen in every look round, not added at the end.
|
||||
`references/scenes.md` says how to put one on a page, fill it, fit it and check it;
|
||||
`assets/scenes/demo/<id>.html` is a working example of each.
|
||||
page's tokens so it takes the page's look. `assets/scenes/demo/<id>.html` is a working example of
|
||||
each, and its content is a placeholder: every picture, word and drawn thing in your scene comes from
|
||||
this subject, and a route map shows the real route, coast and relief. A scene whose pictures or
|
||||
drawings are still the demo's, or the stand-in a script draws when it is given nothing (the route's
|
||||
bare ground with only the road), is unfinished. A scene is composition: it decides how long the page is and what
|
||||
each screen shows, so it is built now and seen in every look round, not added at the end. It
|
||||
replaces the content of the section it carries, never adds screens of nothing in front of it, and a
|
||||
page with scenes still has a length budget: about 12-16 screens at 1440 in all, of which the scenes
|
||||
hold about six. `references/scenes.md` says how to put one on a page, fill it, fit it and check it.
|
||||
7. **Fullness.** Look at each desktop screen as a poster: one dominant thing and the content it
|
||||
carries. A screen that is mostly empty ground is an underbuilt section, not a rest; one deliberate
|
||||
rest per page is the limit, and the inspector fails a page with two or more near-empty screens.
|
||||
@@ -284,9 +315,16 @@ not finished. Order of work:
|
||||
desktop. Every section the visitor came for opens with a heading that names it, and the first
|
||||
screen carries the page's title in an `h1`. These are what readers name first when a page looks
|
||||
wrong ("no clear title", "random empty space", "everything is pushed to one side"), and the
|
||||
inspector measures each of them.
|
||||
inspector measures each of them. On a page longer than about ten screens at 1440 (a page with
|
||||
scenes always is), the navigation stays in reach after the hero: the plain bar hides while the
|
||||
visitor scrolls down and comes back on the first scroll up, on a solid band of the ground where it
|
||||
passes pictures, or at least the one action stays fixed in a corner. A hero that animates its bar
|
||||
away hands it on to that bar (`references/layout.md` §7).
|
||||
9. **States and semantics.** Real links and buttons, visible focus, alt text, a phone layout that is
|
||||
its own composition.
|
||||
its own composition. Every navigation and skip link lands on the section that holds what it names,
|
||||
never on a scene that only introduces it. Every control, the live thing included, works from the
|
||||
keyboard alone in reading order and has an accessible name: its visible label, or `aria-label` when
|
||||
it shows only an icon.
|
||||
|
||||
Read `references/type.md`, `references/colour.md` and `references/layout.md` when you need the detail
|
||||
behind a decision; the laws below are the short form.
|
||||
@@ -312,6 +350,11 @@ designer in one sentence. Re-run until there are no `FAIL`s. Do not argue with t
|
||||
hide things from it. The inspector serves local files over a throwaway local server; if the page must
|
||||
also open straight from the file system, open `index.html` that way once before you finish.
|
||||
|
||||
Inspect what the visitor receives. With a build step, run the last gate on the production build
|
||||
(`vite build && vite preview`, `next build && next start`, or the built `index.html`), not only on the
|
||||
dev server: minifiers and bundlers change what ships (one dropped a `backdrop-filter` that the dev
|
||||
server showed). The inspector warns when it is looking at a dev server.
|
||||
|
||||
### 8. Look
|
||||
|
||||
Passing the gate means nothing banned was measured. It does not mean the page is good. Open the
|
||||
@@ -352,6 +395,8 @@ Do not report until all of these are true; each one was skipped in a test run th
|
||||
(desktop, phone, reduced motion).
|
||||
- A stranger reading the first screen for five seconds can say what this is and what to do.
|
||||
- Every section has a heading; the margins match on both sides; no stretch of the page is empty.
|
||||
- The page is about 16 screens or fewer at 1440 (the inspector's header counts them), and the
|
||||
navigation or the one action can be reached from every screen.
|
||||
- The critique of your last round is written down in the shape of `references/critique.md` §4.
|
||||
|
||||
Tell the user, briefly: the direction dealt (name and seed) and any redraws with reasons; the idea
|
||||
@@ -447,7 +492,13 @@ Short form. Numbers are from the study unless marked as house style.
|
||||
- Buttons: flat, one fill colour, either square-cornered or fully round, decided once (top sites: 52%
|
||||
square, 23% pill; the 9–16px "soft" radius is the weak-site default). Most strong heroes have no
|
||||
button at all, or one.
|
||||
- Navigation: wordmark, three to five plain links, one action. No bar chrome.
|
||||
- Navigation: wordmark, three to five plain links, one action. No bar chrome. On a long page it stays
|
||||
in reach: the plain bar returns on scroll up, or the one action stays fixed.
|
||||
- A control explains how to use it by its form, its label and its place. When it seems to need a
|
||||
sentence on how to operate it, redesign the control instead of adding the sentence. What a field
|
||||
accepts is different. First remove the limit where you can: a date picker, or a parser that takes
|
||||
any common format. A limit that remains, such as password rules or file type and size, stays visible
|
||||
beside the field and is tied to it with `aria-describedby`.
|
||||
- The footer is a designed close, not a sitemap: on most strong sites the name returns at full width.
|
||||
|
||||
## Special cases
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
const titled = (parent, str) => { svg('title', {}, parent).textContent = str; return parent; };
|
||||
// numbers follow the page's language (<html lang>), so separators match the rest of the screen
|
||||
const nf = (o) => new Intl.NumberFormat(document.documentElement.lang || 'en', o);
|
||||
// every text value that reaches innerHTML goes through esc: a label from a database is text, never markup
|
||||
const esc = (v) => String(v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
||||
const fmt = {
|
||||
int: (v) => nf({ maximumFractionDigits: 0 }).format(v),
|
||||
dec: (d) => (v) => nf({ minimumFractionDigits: d, maximumFractionDigits: d }).format(v),
|
||||
@@ -299,7 +301,7 @@
|
||||
const tip = tooltip(el), rule = svg('line', { y1: padT, y2: h - padB, stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1, opacity: 0 }, g);
|
||||
const dots = series.map((sr, k) => svg('circle', { r: 3.5, fill: css(el, '--surface', '#fff'), stroke: colourOf(sr, k), 'stroke-width': 2, opacity: 0 }, g));
|
||||
const hit = svg('rect', { x: padL, y: padT, width: w - padL, height: h - padT - padB, fill: 'transparent' }, s);
|
||||
const move = (ev) => { const r = s.getBoundingClientRect(); const px = ((ev.clientX - r.left) / r.width) * w; const i = Math.max(0, Math.min(labels.length - 1, Math.round(((px - padL) / (w - padL - padR)) * (labels.length - 1)))); rule.setAttribute('x1', x(i)); rule.setAttribute('x2', x(i)); rule.setAttribute('opacity', 0.35); series.forEach((sr, k) => { const v = sr.values[i]; dots[k].setAttribute('cx', x(i)); dots[k].setAttribute('cy', v == null ? -99 : y(Math.min(v, yt.at(-1)))); dots[k].setAttribute('opacity', v == null ? 0 : 1); }); tip.innerHTML = `<div style="opacity:.7">${labels[i]}</div>` + series.map((sr) => `<div><b style="font-weight:600">${sr.values[i] == null ? '–' : format(sr.values[i])}</b> ${series.length > 1 ? sr.name : ''}</div>`).join(''); const left = Math.min((x(i) / w) * r.width + 12, r.width - tip.offsetWidth - 4); tip.style.left = `${Math.max(0, left)}px`; tip.style.top = '0px'; tip.style.opacity = 1; };
|
||||
const move = (ev) => { const r = s.getBoundingClientRect(); const px = ((ev.clientX - r.left) / r.width) * w; const i = Math.max(0, Math.min(labels.length - 1, Math.round(((px - padL) / (w - padL - padR)) * (labels.length - 1)))); rule.setAttribute('x1', x(i)); rule.setAttribute('x2', x(i)); rule.setAttribute('opacity', 0.35); series.forEach((sr, k) => { const v = sr.values[i]; dots[k].setAttribute('cx', x(i)); dots[k].setAttribute('cy', v == null ? -99 : y(Math.min(v, yt.at(-1)))); dots[k].setAttribute('opacity', v == null ? 0 : 1); }); tip.innerHTML = `<div style="opacity:.7">${esc(labels[i])}</div>` + series.map((sr) => `<div><b style="font-weight:600">${sr.values[i] == null ? '–' : esc(format(sr.values[i]))}</b> ${series.length > 1 ? esc(sr.name) : ''}</div>`).join(''); const left = Math.min((x(i) / w) * r.width + 12, r.width - tip.offsetWidth - 4); tip.style.left = `${Math.max(0, left)}px`; tip.style.top = '0px'; tip.style.opacity = 1; };
|
||||
hit.addEventListener('pointermove', move); hit.addEventListener('pointerleave', () => { tip.style.opacity = 0; rule.setAttribute('opacity', 0); dots.forEach((d) => d.setAttribute('opacity', 0)); });
|
||||
const last = series[0].values.filter((v) => v != null).at(-1);
|
||||
s.setAttribute('aria-label', `${series.map((x) => x.name).join(', ')}, ${labels[0]} to ${labels.at(-1)}; latest ${last == null ? 'no data' : format(last)}`);
|
||||
@@ -381,7 +383,7 @@
|
||||
svg('circle', { cx: size / 2, cy: size / 2, r, fill: 'none', stroke: css(el, '--grid', '#e8e8e8'), 'stroke-width': stroke }, s);
|
||||
svg('circle', { cx: size / 2, cy: size / 2, r, fill: 'none', stroke: colour || css(el, '--chart-1', '#2f6fdf'), 'stroke-width': stroke, 'stroke-dasharray': `${c * v} ${c}`, transform: `rotate(-90 ${size / 2} ${size / 2})` }, s);
|
||||
const t = document.createElement('div'); t.style.cssText = 'position:absolute;inset:0;display:grid;place-content:center;text-align:center;font-variant-numeric:tabular-nums';
|
||||
t.innerHTML = `<div style="font-size:${Math.round(size / 5.5)}px;font-weight:600;line-height:1">${label}</div>${sub ? `<div style="font-size:12px;opacity:.7;margin-top:4px">${sub}</div>` : ''}`;
|
||||
t.innerHTML = `<div style="font-size:${Math.round(size / 5.5)}px;font-weight:600;line-height:1">${esc(label)}</div>${sub ? `<div style="font-size:12px;opacity:.7;margin-top:4px">${esc(sub)}</div>` : ''}`;
|
||||
wrap.appendChild(t); el.appendChild(wrap);
|
||||
}
|
||||
// A ranked list. By default the row's fill is the bar (a one-figure list); thin: true puts a 2px grey bar under each
|
||||
@@ -418,7 +420,7 @@
|
||||
const over = r.spent > r.planned;
|
||||
const li = document.createElement('li');
|
||||
const top = document.createElement('div'); top.style.cssText = 'display:flex;justify-content:space-between;gap:12px;margin-bottom:6px';
|
||||
top.innerHTML = `<span>${r.label}</span><span><b style="font-weight:500">${format(r.spent)}</b> <span style="color:var(--muted,#5f6368)">of ${format(r.planned)} · </span><span style="color:${over ? 'var(--negative,#c4242a)' : 'var(--muted,#5f6368)'}">${note(r)}</span></span>`;
|
||||
top.innerHTML = `<span>${esc(r.label)}</span><span><b style="font-weight:500">${esc(format(r.spent))}</b> <span style="color:var(--muted,#5f6368)">of ${esc(format(r.planned))} · </span><span style="color:${over ? 'var(--negative,#c4242a)' : 'var(--muted,#5f6368)'}">${esc(note(r))}</span></span>`;
|
||||
// scale 'row': each track is full width and shows the share used (rows in a grid compare); 'plan': as long as its plan
|
||||
const room = over ? 100 / (1 + Math.max(0.06, (r.spent - r.planned) / r.planned)) : 100;
|
||||
const track = document.createElement('div'); track.style.cssText = 'position:relative;height:8px;border-radius:999px;background:var(--rule,#eceeef);overflow:visible;width:' + (scale === 'plan' ? (r.planned / max) * room : room) + '%';
|
||||
@@ -440,7 +442,7 @@
|
||||
for (const r of rows) {
|
||||
const drift = r.value - r.target;
|
||||
const li = document.createElement('li'); li.style.cssText = 'display:grid;grid-template-columns:minmax(90px,30%) 1fr auto;align-items:center;gap:12px';
|
||||
li.innerHTML = `<span>${r.label}</span><span style="position:relative;height:10px;background:var(--rule,#eceeef);border-radius:2px"><span style="position:absolute;inset:0 auto 0 0;width:${(r.value / max) * 100}%;background:var(--chart-1,#2f6fdf);border-radius:2px"></span><span style="position:absolute;top:-4px;bottom:-4px;left:${(r.target / max) * 100}%;width:2px;margin-left:-1px;background:var(--text,#111)"></span></span><span><b style="font-weight:500">${format(r.value)}</b> <span style="color:${Math.abs(drift) >= (r.tolerance ?? 0.02) ? 'var(--negative,#c4242a)' : 'var(--muted,#5f6368)'}">${drift >= 0 ? '+' : '−'}${format(Math.abs(drift))} vs ${format(r.target)}</span></span>`;
|
||||
li.innerHTML = `<span>${esc(r.label)}</span><span style="position:relative;height:10px;background:var(--rule,#eceeef);border-radius:2px"><span style="position:absolute;inset:0 auto 0 0;width:${(r.value / max) * 100}%;background:var(--chart-1,#2f6fdf);border-radius:2px"></span><span style="position:absolute;top:-4px;bottom:-4px;left:${(r.target / max) * 100}%;width:2px;margin-left:-1px;background:var(--text,#111)"></span></span><span><b style="font-weight:500">${esc(format(r.value))}</b> <span style="color:${Math.abs(drift) >= (r.tolerance ?? 0.02) ? 'var(--negative,#c4242a)' : 'var(--muted,#5f6368)'}">${drift >= 0 ? '+' : '−'}${esc(format(Math.abs(drift)))} vs ${esc(format(r.target))}</span></span>`;
|
||||
li.setAttribute('aria-label', `${r.label}: ${format(r.value)}, target ${format(r.target)}`);
|
||||
list.appendChild(li);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/* tasteskill / scenes / arrive-and-settle-track.css — "Arrive and settle on a sideways track".
|
||||
Default rules are the finished composition (no script, reduced motion, a failed CDN): the same
|
||||
content set level and still. The live layouts sit under .scene.is-live: the pinned sideways track
|
||||
from 768px up, the vertical stack below. Takes the page's tokens: --ground --ink --accent
|
||||
--accent-ink --surface --font-display --font-text, and its weights: --display-weight (the line, the
|
||||
headings, the outro line; 600 here when the page sets none), --strong-weight (the buttons, names and
|
||||
links; 600). A weight the page has not loaded is never faked: the nearest loaded one shows. */
|
||||
|
||||
.scene[data-scene="arrive-and-settle-track"] {
|
||||
background: var(--ground, #f4f3ef);
|
||||
color: var(--ink, #111);
|
||||
font-family: var(--font-text, inherit);
|
||||
font-synthesis-weight: none;
|
||||
}
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle {
|
||||
box-sizing: border-box;
|
||||
min-height: 100svh;
|
||||
padding: 14vh 6vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__track { position: relative; }
|
||||
|
||||
/* ── type: the line ───────────────────────────────────────────────────────────────────────────── */
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__line {
|
||||
margin: 0;
|
||||
max-width: 88vw;
|
||||
font-family: var(--font-display, inherit);
|
||||
font-weight: var(--display-weight, 600);
|
||||
font-size: var(--settle-fit, min(11vw, 22vh));
|
||||
line-height: 0.88;
|
||||
letter-spacing: -0.045em;
|
||||
text-wrap: balance;
|
||||
font-kerning: normal;
|
||||
/* no script: a word longer than the column breaks rather than pushing the page sideways (with
|
||||
script, the line is set smaller until its widest word fits instead) */
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__word { white-space: nowrap; }
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__char { display: inline-block; transform-origin: 50% 60%; }
|
||||
.scene[data-scene="arrive-and-settle-track"].is-moving:not(.is-zoom) .settle__char { will-change: transform; }
|
||||
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__cluster {
|
||||
margin: 7vh 0 0 56vw; /* clear of a descender in the last line */
|
||||
max-width: min(36vw, 520px);
|
||||
display: grid;
|
||||
gap: clamp(18px, 1.5vw, 28px);
|
||||
justify-items: start;
|
||||
}
|
||||
/* the support line and button grow with the stage, so they hold their own beside a 300px line */
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__support {
|
||||
margin: 0;
|
||||
max-width: 32ch;
|
||||
font-size: clamp(18px, 1.4vw, 26px);
|
||||
line-height: 1.4;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: clamp(56px, 3.9vw, 72px);
|
||||
padding: 0 clamp(28px, 2vw, 40px);
|
||||
border-radius: 999px;
|
||||
background: var(--accent, var(--ink, #111));
|
||||
color: var(--accent-ink, var(--ground, #fff));
|
||||
font: var(--strong-weight, 600) clamp(17px, 1.2vw, 22px)/1 var(--font-text, inherit);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__cta:focus-visible { outline: 2px solid var(--ink, #111); outline-offset: 3px; }
|
||||
|
||||
/* ── cards (stair, rise): shared pieces ───────────────────────────────────────────────────────── */
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__heading {
|
||||
margin: 0 0 7vh;
|
||||
max-width: 16ch;
|
||||
font-family: var(--font-display, inherit);
|
||||
font-weight: var(--display-weight, 600);
|
||||
font-size: clamp(34px, 4.2vw, 68px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.035em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__card { min-width: 0; }
|
||||
.scene[data-scene="arrive-and-settle-track"].is-moving .settle__body,
|
||||
.scene[data-scene="arrive-and-settle-track"].is-moving .settle__picture { will-change: transform; }
|
||||
|
||||
/* stair: testimonials */
|
||||
.scene[data-scene="arrive-and-settle-track"][data-settle="stair"] .settle { justify-content: flex-start; }
|
||||
.scene[data-scene="arrive-and-settle-track"][data-settle="stair"] .settle__track {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 2.2vw;
|
||||
align-items: start;
|
||||
}
|
||||
/* the finished composition keeps a still staircase */
|
||||
.scene[data-scene="arrive-and-settle-track"][data-settle="stair"] .settle__card:nth-child(3n+2) { margin-top: 6vh; }
|
||||
.scene[data-scene="arrive-and-settle-track"][data-settle="stair"] .settle__card:nth-child(3n+3) { margin-top: 12vh; }
|
||||
.scene[data-scene="arrive-and-settle-track"][data-settle="stair"] .settle__body {
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
padding: 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 32px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface, color-mix(in srgb, var(--ink, #111) 7%, var(--ground, #f4f3ef)));
|
||||
}
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__quote { margin: 0; font-size: clamp(19px, 1.6vw, 25px); line-height: 1.34; letter-spacing: -0.01em; }
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__quote p { margin: 0; }
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__who { margin: 0; display: flex; align-items: center; gap: 14px; font-size: 15px; line-height: 1.35; }
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__who b { display: block; font-weight: var(--strong-weight, 600); }
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__avatar { flex: none; width: 48px; height: 48px; border-radius: 4px; object-fit: cover; }
|
||||
|
||||
/* rise: projects */
|
||||
.scene[data-scene="arrive-and-settle-track"][data-settle="rise"] .settle__track {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 9vh 4vw;
|
||||
align-items: start;
|
||||
}
|
||||
.scene[data-scene="arrive-and-settle-track"][data-settle="rise"] .settle__intro,
|
||||
.scene[data-scene="arrive-and-settle-track"][data-settle="rise"] .settle__outro { grid-column: 1 / -1; }
|
||||
.scene[data-scene="arrive-and-settle-track"][data-settle="rise"] .settle__intro .settle__heading { margin-bottom: 20px; }
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__intro p { margin: 0; max-width: 34ch; font-size: 18px; line-height: 1.45; }
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__picture {
|
||||
margin: 0;
|
||||
aspect-ratio: 1.46;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--ink, #111) 10%, var(--ground, #f4f3ef));
|
||||
}
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__picture img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__caption { margin-top: 22px; display: grid; gap: 8px; justify-items: start; }
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__caption h3 { margin: 0; font: var(--display-weight, 600) clamp(22px, 1.9vw, 30px)/1.1 var(--font-display, inherit); letter-spacing: -0.02em; }
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__caption p { margin: 0; max-width: 42ch; font-size: 17px; line-height: 1.45; }
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__caption a { color: inherit; font-size: 17px; font-weight: var(--strong-weight, 600); text-underline-offset: 4px; text-decoration-thickness: 2px; }
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__outro { display: grid; gap: 24px; justify-items: start; }
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__outro p { margin: 0; max-width: 22ch; font: var(--display-weight, 600) clamp(26px, 2.4vw, 36px)/1.15 var(--font-display, inherit); letter-spacing: -0.02em; }
|
||||
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
.scene[data-scene="arrive-and-settle-track"][data-settle="stair"] .settle__track { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.scene[data-scene="arrive-and-settle-track"][data-settle="stair"] .settle__card:nth-child(n) { margin-top: 0; }
|
||||
.scene[data-scene="arrive-and-settle-track"][data-settle="stair"] .settle__card:nth-child(2n) { margin-top: 6vh; }
|
||||
}
|
||||
|
||||
/* ── the phone: one column, both for the finished composition and the live stack ──────────────── */
|
||||
@media (max-width: 767px) {
|
||||
/* a padded block, not a screen-high band: the line follows the section before it closely, and the
|
||||
section after it follows the button at an ordinary section gap */
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle { min-height: 0; padding: 14vh 16px 9vh; }
|
||||
/* as the first section it keeps the whole first screen, so its line assembles on screen */
|
||||
html.js .scene[data-scene="arrive-and-settle-track"].is-live[data-first] .settle { min-height: 100svh; }
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__line { max-width: none; font-size: var(--settle-fit, 13vw); letter-spacing: -0.04em; }
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__cluster { margin: 28px 0 0; max-width: none; justify-items: stretch; }
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__cta { width: 100%; box-sizing: border-box; }
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__heading { margin-bottom: 36px; font-size: clamp(32px, 9vw, 40px); }
|
||||
.scene[data-scene="arrive-and-settle-track"][data-settle] .settle__track { grid-template-columns: minmax(0, 1fr); gap: 20px; }
|
||||
.scene[data-scene="arrive-and-settle-track"][data-settle="rise"] .settle__track { gap: 48px; }
|
||||
.scene[data-scene="arrive-and-settle-track"][data-settle] .settle__card:nth-child(n) { margin-top: 0; }
|
||||
.scene[data-scene="arrive-and-settle-track"] .settle__outro .settle__cta { width: 100%; box-sizing: border-box; }
|
||||
|
||||
html.js .scene[data-scene="arrive-and-settle-track"].is-live { height: auto; }
|
||||
html.js .scene[data-scene="arrive-and-settle-track"].is-live > .scene-stage {
|
||||
position: relative;
|
||||
height: auto;
|
||||
overflow-x: clip;
|
||||
overflow-y: visible;
|
||||
contain: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── live, 768px and up: the pinned sideways track ────────────────────────────────────────────── */
|
||||
@media (min-width: 768px) {
|
||||
html.js .scene[data-scene="arrive-and-settle-track"].is-live .settle {
|
||||
--fs: min(17vw, 34vh);
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
display: block;
|
||||
}
|
||||
html.js .scene[data-scene="arrive-and-settle-track"].is-live .settle__track {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(50% - 0.425 * var(--fs));
|
||||
width: max-content;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4vw;
|
||||
padding: 0 6vw;
|
||||
white-space: nowrap;
|
||||
}
|
||||
html.js .scene[data-scene="arrive-and-settle-track"].is-live .settle__line {
|
||||
flex: none;
|
||||
max-width: none;
|
||||
font-size: var(--fs);
|
||||
line-height: 0.85;
|
||||
text-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
transform-origin: 0 50%; /* the pull-back to the whole line shrinks it about its left edge */
|
||||
}
|
||||
/* the cluster hugs its content and waits at its place in the end frame (JS sets left and top: beside
|
||||
the whole line on the right margin, or under it from its first letter) */
|
||||
html.js .scene[data-scene="arrive-and-settle-track"].is-live .settle__cluster {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: max-content;
|
||||
margin: 0;
|
||||
max-width: none;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* stair: heading fixed top-left, row top at 34vh, a 30vw lead-in so the first card is level at 0 */
|
||||
html.js .scene[data-scene="arrive-and-settle-track"][data-settle="stair"].is-live .settle__heading {
|
||||
position: absolute;
|
||||
left: 6vw;
|
||||
top: 12vh;
|
||||
margin: 0;
|
||||
max-width: 20ch;
|
||||
font-size: 4.2vw;
|
||||
}
|
||||
html.js .scene[data-scene="arrive-and-settle-track"][data-settle="stair"].is-live .settle__track {
|
||||
top: 34vh;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 2.2vw;
|
||||
padding: 0 6vw 0 30vw;
|
||||
}
|
||||
html.js .scene[data-scene="arrive-and-settle-track"][data-settle="stair"].is-live .settle__card {
|
||||
flex: 0 0 clamp(300px, 27vw, 420px);
|
||||
margin: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
html.js .scene[data-scene="arrive-and-settle-track"][data-settle="stair"].is-live .settle__body { aspect-ratio: 0.95; min-height: 0; }
|
||||
|
||||
/* rise: 50vw columns, pictures 40vw wide with their top at 22vh */
|
||||
html.js .scene[data-scene="arrive-and-settle-track"][data-settle="rise"].is-live .settle__track {
|
||||
top: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
html.js .scene[data-scene="arrive-and-settle-track"][data-settle="rise"].is-live .settle__intro,
|
||||
html.js .scene[data-scene="arrive-and-settle-track"][data-settle="rise"].is-live .settle__card,
|
||||
html.js .scene[data-scene="arrive-and-settle-track"][data-settle="rise"].is-live .settle__outro {
|
||||
flex: 0 0 50vw;
|
||||
box-sizing: border-box;
|
||||
padding: 22vh 5vw 0;
|
||||
white-space: normal;
|
||||
}
|
||||
html.js .scene[data-scene="arrive-and-settle-track"][data-settle="rise"].is-live .settle__intro .settle__heading { font-size: clamp(44px, 5.4vw, 88px); max-width: 11ch; }
|
||||
html.js .scene[data-scene="arrive-and-settle-track"][data-settle="rise"].is-live .settle__outro { align-content: start; padding-top: 30vh; }
|
||||
}
|
||||
|
||||
/* tablet portrait and other tall stages: a larger line, so the frame is not mostly ground (Amax still
|
||||
clamps the vertical scatter to the stage) */
|
||||
@media (min-width: 768px) and (max-aspect-ratio: 1/1) {
|
||||
html.js .scene[data-scene="arrive-and-settle-track"][data-settle="type"].is-live .settle { --fs: min(22vw, 34vh); }
|
||||
.scene[data-scene="arrive-and-settle-track"][data-settle="type"] .settle__support { font-size: clamp(18px, 2.3vw, 26px); }
|
||||
.scene[data-scene="arrive-and-settle-track"][data-settle="type"] .settle__cta { height: clamp(56px, 6.2vw, 72px); padding: 0 clamp(28px, 3.2vw, 40px); font-size: clamp(17px, 2vw, 22px); }
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
// tasteskill / scenes / arrive-and-settle-track.js — "Arrive and settle on a sideways track".
|
||||
//
|
||||
// A pinned row slides left at the speed of the scroll. Each item's vertical offset and tilt come from
|
||||
// where that item is on screen: scattered as it enters at the right edge, swooping through its resting
|
||||
// line and settled by 45% of the width, so the row seems to assemble itself as it passes. The default
|
||||
// is a giant closing line whose letters land one by one and finish on a calm end frame with a button.
|
||||
// Two presets use the same mapping for a testimonial staircase and for project pictures that rise.
|
||||
//
|
||||
// Needs gsap + ScrollTrigger (no SplitText: the line is split here). Load after stage.js.
|
||||
//
|
||||
// ── DOM, preset "type" (the default) ─────────────────────────────────────────────────────────────
|
||||
// <section class="scene" data-scene="arrive-and-settle-track" aria-label="Book a session">
|
||||
// <div class="scene-stage">
|
||||
// <div class="settle">
|
||||
// <div class="settle__track">
|
||||
// <h2 class="settle__line">Shall we make a record?</h2>
|
||||
// <div class="settle__cluster">
|
||||
// <p class="settle__support">One supporting sentence of six to twelve words.</p>
|
||||
// <a class="settle__cta" href="#book">Book a session</a>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// </section>
|
||||
// The line: plain text, 3-5 words, 14-28 characters, ideally a question or an invitation; the first
|
||||
// word has four or more letters so it reads the moment the section arrives. It is split into letters
|
||||
// at runtime (the h2 keeps the text as its aria-label). Kerning is measured and put back per pair.
|
||||
// Keep the line to 14 characters or more. A shorter line still ends whole, but it has little sideways
|
||||
// travel: its letters get a short run-in of their own, and the swoop is brief. Under 14 characters,
|
||||
// use a plain heading instead. Above 28 characters the end frame, which holds the whole line, sets it
|
||||
// small (a 28-character line rests at about 100px at 1440x900): shorten the line instead.
|
||||
// Longest word: about 11 characters keeps the full travelling size (17vw). A longer word still fits:
|
||||
// the whole line is set smaller until that word fits between the margins (a 14-character word such as
|
||||
// "extraordinary?" takes the line from 245px to about 190px at 1440x900), and on the phone the line
|
||||
// shrinks until its widest word fits the column. Above 16 characters in one word, rewrite the line.
|
||||
//
|
||||
// ── DOM, preset "stair" (testimonials; 5-7 cards read best, 3-12 work) ──────────────────────────
|
||||
// <section class="scene" data-scene="arrive-and-settle-track" data-settle="stair" aria-label="...">
|
||||
// <div class="scene-stage"><div class="settle">
|
||||
// <h2 class="settle__heading">A heading of six words at most</h2>
|
||||
// <div class="settle__track">
|
||||
// <article class="settle__card"> measured, never moved
|
||||
// <div class="settle__body"> moved
|
||||
// <blockquote class="settle__quote"><p>A quote of 25-45 words.</p></blockquote>
|
||||
// <p class="settle__who"><img class="settle__avatar" src="…" width="96" height="96" alt="">
|
||||
// <span><b>Name</b> Role</span></p>
|
||||
// </div>
|
||||
// </article> …
|
||||
// </div>
|
||||
// </div></div>
|
||||
// </section>
|
||||
//
|
||||
// ── DOM, preset "rise" (project track; 3-5 projects read best) ───────────────────────────────────
|
||||
// <section class="scene" data-scene="arrive-and-settle-track" data-settle="rise" aria-label="...">
|
||||
// <div class="scene-stage"><div class="settle"><div class="settle__track">
|
||||
// <div class="settle__intro"><h2 class="settle__heading">Up to five words</h2><p>One sentence.</p></div>
|
||||
// <article class="settle__card"><div class="settle__body">
|
||||
// <figure class="settle__picture"><img src="…" width="1200" height="822" alt="…"></figure> 1.46:1
|
||||
// <div class="settle__caption"><h3>Title</h3><p>12-20 words.</p><a href="…">Link label</a></div>
|
||||
// </div></article> …
|
||||
// <div class="settle__outro"><p>One closing sentence.</p><a class="settle__cta" href="…">Action</a></div>
|
||||
// </div></div></div>
|
||||
// </section>
|
||||
//
|
||||
// ── Options (data-options='{"speed":0.9}'; defaults per preset: type / stair / rise) ─────────────
|
||||
// preset 'type' | 'stair' | 'rise' the data-settle attribute, else 'type'
|
||||
// speed 0.7-1.3 px of sideways travel per px of scroll (1 / 1 / 1.2). Lower is grander and holds
|
||||
// longer. Values from 0.3 to 3 are accepted; outside 0.7-1.3 the move reads as
|
||||
// sluggish or rushed. The hold is measured: travel ÷ (screen × speed), plus the
|
||||
// pull-back to the whole line (0.6 screen ÷ speed; 0.35 when the line does not
|
||||
// shrink), plus rest, kept to 1-4 screens. The 4-screen cap wins over `speed` on long
|
||||
// lines, so there the track runs faster than `speed` asks.
|
||||
// rest 0.2-0.6 screens of still end frame after the travel stops (0.3; 0-2 accepted)
|
||||
// amp 0.6-1.5 letter offset as a multiple of the letter height (1.5); always clamped so every
|
||||
// letter stays 64px inside the stage (plus `inset`)
|
||||
// tilt 0-20 largest letter tilt in degrees (18). A letter's tilt follows its height: it
|
||||
// leans right below its line and left above it, 60-100% of `tilt` at its largest
|
||||
// offset, so neighbours passing each other never lean into each other. Cards: 0
|
||||
// lift 20-60 card offset in vh (- / 36 / 60)
|
||||
// curve 'swoop' | 'calm' | 'linear' (swoop / linear / calm). swoop = one 16% overshoot through
|
||||
// the line, calm = power2.out with none, linear = stairs. No bouncing curve ships.
|
||||
// settle 0.30-0.60 fraction of the width by which an item has settled (0.45 / 0.30 / 0.50)
|
||||
// enter 1.00-1.25 where the move begins, as a fraction of the width (1.04 / 1.20 / 1.20)
|
||||
// inset px height of a fixed header the letters must stay clear of (0)
|
||||
// scrub true | s catch-up in seconds, 0.1-3 (true when Lenis runs, else 0.5)
|
||||
// A value that is not a number (or an unknown curve or preset) falls back to the preset's default, so
|
||||
// a typo in data-options never switches the effect off or collapses the section.
|
||||
//
|
||||
// End frame (type): the whole line, never its last words alone. The giant line travels until its last
|
||||
// letter has landed (its end at 75% of the width), then the camera pulls back: the line shrinks, about
|
||||
// one fixed point, to the size at which all of it fits between the 6vw margins, while the support line
|
||||
// and button rise into place. They sit beside the line (bottoms level, the button's right edge on the
|
||||
// right margin) when that keeps the line at 85% or more of the size it gets with them under it;
|
||||
// otherwise under the line, from its first letter, the pair centred on the screen. A line that fits
|
||||
// whole at the travelling size does not shrink: it rests as it is, after its run-in. The settle size is
|
||||
// in the scene's state (root.__settle.dbg.rest, px).
|
||||
//
|
||||
// Phone (below 768px): no pin and no sideways travel. The stack is laid out like the finished
|
||||
// composition and the same mapping is turned 90°: the letters swoop onto their lines in reading order
|
||||
// as the line rises from the bottom edge to 42% of the screen (the first row from above or below, the
|
||||
// rows under it from below, so rows never cross); the support line and button rise in as the last
|
||||
// letters land. As the first section it keeps a whole first screen, its first word reads level at
|
||||
// once, its button shows from the start, and the first scroll assembles the rest. Cards rise 16vh.
|
||||
// Reduced motion and no script: the finished composition (no split, nothing hidden), also when reduced
|
||||
// motion is switched on while the page is open. The line breaks into two lines at most, set as large
|
||||
// as two lines allow when script runs (min(14vw, 26vh) at most), min(11vw, 22vh) without script.
|
||||
(() => {
|
||||
const ID = 'arrive-and-settle-track';
|
||||
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
|
||||
// an option as a number, or the default when it is missing or not a number ("long", "", true, null)
|
||||
const num = (v, d) => (typeof v === 'number' || (typeof v === 'string' && v.trim() !== '') ? (Number.isFinite(+v) ? +v : d) : d);
|
||||
const p2io = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2); // power2.inOut
|
||||
const sio = (t) => -(Math.cos(Math.PI * t) - 1) / 2; // sine.inOut
|
||||
const p2o = (t) => 1 - Math.pow(1 - t, 3); // power2.out
|
||||
const lin = (t) => t;
|
||||
|
||||
// Fraction of the offset still applied, as a function of u (0 = entering, 1 = settled).
|
||||
// Two-segment curves keyed to position; never elastic, back or bouncing eases (house taste, and keyed
|
||||
// to position they finish off-screen).
|
||||
// swoop: the letter passes through its line into a 16% overshoot and settles. The tilt runs on the
|
||||
// same curve as the height (see seeds), so a letter's lean always matches where it is.
|
||||
const SWOOP = [[0, 0.55, 1, -0.16, p2io], [0.55, 1, -0.16, 0, sio]];
|
||||
const CURVES = {
|
||||
swoop: { y: SWOOP, r: SWOOP },
|
||||
calm: { y: [[0, 1, 1, 0, p2o]], r: [[0, 1, 1, 0, p2o]] },
|
||||
linear: { y: [[0, 1, 1, 0, lin]], r: [[0, 1, 1, 0, lin]] },
|
||||
};
|
||||
const curveAt = (segs, u) => {
|
||||
for (const [a, b, f, t, e] of segs) if (u <= b) return f + (t - f) * e((u - a) / (b - a));
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Seeds: the scatter is a function of the text, the same on every load, different for other copy.
|
||||
const fnv = (s) => { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); } return h >>> 0; };
|
||||
const mulberry32 = (a) => () => { a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
|
||||
const seeds = (text, n, glyphs) => {
|
||||
const rand = mulberry32(fnv(text));
|
||||
const out = []; let last = 0, run = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let sign = rand() < 0.5 ? -1 : 1;
|
||||
if (sign === last && run >= 2) sign = -sign; // never three the same way in a row
|
||||
run = sign === last ? run + 1 : 1; last = sign;
|
||||
let m = 0.6 + 0.1 * Math.floor(rand() * 5); // 0.6-1.0 of the largest offset
|
||||
if (glyphs && /[^\p{L}\p{N}]/u.test(glyphs[i] || '')) m = Math.min(m, 0.8); // punctuation stays calmer
|
||||
rand(); rand(); // (kept: the old tilt draws, so the scatter stays put)
|
||||
// The tilt follows the height: a letter below its line leans right (top forward), above it
|
||||
// leans left, by as much as it is displaced (0.6-1.0 of the largest tilt with the offset). Two
|
||||
// neighbours that pass at the same height are then parallel, and one at another height leans
|
||||
// away from the other, so their ink never meets in the tight -0.045em tracking.
|
||||
out.push({ a: sign * m, m, r: sign * m });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// Split the line into words (nowrap) of inline-block letters; spaces stay real text between words.
|
||||
const split = (line) => {
|
||||
if (line.__settle) return line.__settle;
|
||||
const text = line.textContent.replace(/\s+/g, ' ').trim();
|
||||
const rec = { html: line.innerHTML, label: line.getAttribute('aria-label'), text, chars: [], words: [] };
|
||||
if (!rec.label) line.setAttribute('aria-label', text);
|
||||
line.textContent = '';
|
||||
text.split(' ').forEach((w, i) => {
|
||||
if (i) line.append(' ');
|
||||
const word = document.createElement('span');
|
||||
word.className = 'settle__word';
|
||||
for (const ch of Array.from(w)) {
|
||||
const c = document.createElement('span');
|
||||
c.className = 'settle__char';
|
||||
c.setAttribute('aria-hidden', 'true');
|
||||
c.textContent = ch;
|
||||
word.append(c);
|
||||
rec.chars.push(c);
|
||||
}
|
||||
line.append(word);
|
||||
rec.words.push(word);
|
||||
});
|
||||
line.__settle = rec;
|
||||
return rec;
|
||||
};
|
||||
const unsplit = (line) => {
|
||||
const rec = line && line.__settle;
|
||||
if (!rec) return;
|
||||
line.innerHTML = rec.html;
|
||||
if (!rec.label) line.removeAttribute('aria-label');
|
||||
delete line.__settle;
|
||||
};
|
||||
|
||||
// Splitting into inline-blocks drops kerning; measure each pair on a canvas and put it back.
|
||||
let kctx = null;
|
||||
const kern = (line, chars) => {
|
||||
kctx = kctx || document.createElement('canvas').getContext('2d');
|
||||
if (!kctx) return;
|
||||
const cs = getComputedStyle(line);
|
||||
kctx.font = `${cs.fontStyle} ${cs.fontWeight} 100px ${cs.fontFamily}`;
|
||||
if ('fontKerning' in kctx) kctx.fontKerning = 'normal';
|
||||
const w = (s) => kctx.measureText(s).width;
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
const a = chars[i], b = chars[i + 1];
|
||||
const k = b && a.parentNode === b.parentNode ? w(a.textContent + b.textContent) - w(a.textContent) - w(b.textContent) : 0;
|
||||
a.style.marginRight = Math.abs(k) > 0.4 ? (k / 100).toFixed(4) + 'em' : '';
|
||||
}
|
||||
};
|
||||
// Where a letter's ink starts inside its box (its left side bearing), in em. Call after kern().
|
||||
const bearing = (ch) => {
|
||||
if (!kctx) return 0;
|
||||
const m = kctx.measureText(ch);
|
||||
return Number.isFinite(m.actualBoundingBoxLeft) ? -m.actualBoundingBoxLeft / 100 : 0;
|
||||
};
|
||||
|
||||
// Writes only what changed (a still letter costs nothing).
|
||||
const put = (it, x, y, r, o) => {
|
||||
if (it.x === x && it.y === y && it.r === r) { if (o !== undefined && it.o !== o) { it.el.style.opacity = o === 1 ? '' : o; it.o = o; } return; }
|
||||
it.x = x; it.y = y; it.r = r;
|
||||
it.el.style.transform = x || y || r ? `translate3d(${x}px, ${y}px, 0)${r ? ` rotate(${r}deg)` : ''}` : '';
|
||||
if (o !== undefined && it.o !== o) { it.el.style.opacity = o === 1 ? '' : o; it.o = o; }
|
||||
};
|
||||
const r1 = (v) => Math.round(v * 10) / 10;
|
||||
const r2 = (v) => Math.round(v * 100) / 100;
|
||||
|
||||
// The visible width of a block of text: its widest line, not its box.
|
||||
const textWidth = (el) => {
|
||||
const rg = document.createRange();
|
||||
rg.selectNodeContents(el);
|
||||
return rg.getBoundingClientRect().width;
|
||||
};
|
||||
|
||||
// One listener for every scene of this type: a web font that arrives late changes every width.
|
||||
let fontsWired = false;
|
||||
const wireFonts = () => {
|
||||
if (fontsWired || !window.ScrollTrigger || !document.fonts || !document.fonts.addEventListener) return;
|
||||
fontsWired = true;
|
||||
document.fonts.addEventListener('loadingdone', () => ScrollTrigger.refresh());
|
||||
};
|
||||
|
||||
const PRESETS = {
|
||||
type: { speed: 1, amp: 1.5, tilt: 18, lift: 0, curve: 'swoop', settle: 0.45, enter: 1.04 },
|
||||
stair: { speed: 1, amp: 1, tilt: 0, lift: 36, curve: 'linear', settle: 0.3, enter: 1.2 },
|
||||
rise: { speed: 1.2, amp: 1, tilt: 0, lift: 60, curve: 'calm', settle: 0.5, enter: 1.2 },
|
||||
};
|
||||
|
||||
window.Scenes = window.Scenes || {};
|
||||
window.Scenes[ID] = (root, opts = {}) => {
|
||||
const preset = PRESETS[opts.preset] ? opts.preset : PRESETS[root.dataset.settle] ? root.dataset.settle : 'type';
|
||||
root.dataset.settle = preset;
|
||||
// every option falls back to the preset's value when it is missing or not a number
|
||||
const P = PRESETS[preset];
|
||||
const o = {
|
||||
speed: clamp(num(opts.speed, P.speed), 0.3, 3),
|
||||
rest: clamp(num(opts.rest, 0.3), 0, 2),
|
||||
amp: clamp(num(opts.amp, P.amp), 0, 2),
|
||||
tilt: clamp(num(opts.tilt, P.tilt), 0, 30),
|
||||
lift: clamp(num(opts.lift, P.lift), 0, 90),
|
||||
settle: clamp(num(opts.settle, P.settle), 0.1, 0.9),
|
||||
enter: clamp(num(opts.enter, P.enter), 0.9, 1.5),
|
||||
inset: Math.max(0, num(opts.inset, 0)),
|
||||
curve: Object.prototype.hasOwnProperty.call(CURVES, opts.curve) ? opts.curve : P.curve,
|
||||
};
|
||||
const curve = CURVES[o.curve];
|
||||
const lenis = !!(window.Stage && Stage.lenis);
|
||||
const sv = num(opts.scrub, NaN);
|
||||
const scrub = opts.scrub === true ? true : Number.isFinite(sv) && sv > 0 ? clamp(sv, 0.1, 3) : lenis ? true : 0.5;
|
||||
|
||||
const $ = (sel) => root.querySelector(sel);
|
||||
const $$ = (sel) => [...root.querySelectorAll(sel)];
|
||||
const stage = root.querySelector(':scope > .scene-stage') || root.firstElementChild;
|
||||
const track = $('.settle__track');
|
||||
const box = $('.settle');
|
||||
const line = $('.settle__line');
|
||||
const cluster = $('.settle__cluster');
|
||||
const S = { mode: null, items: [], extras: [], map: null, measure: null, focus: null, dbg: null };
|
||||
root.__settle = S; // state, for inspection in devtools
|
||||
|
||||
const moved = () => [track, line, ...$$('.settle__char, .settle__support, .settle__cta, .settle__body, .settle__picture, .settle__caption, .settle__outro > *')];
|
||||
// the layout measure() writes: the line's fitted size and the cluster's place at the end frame
|
||||
const clearEnd = () => {
|
||||
if (cluster) { cluster.style.left = ''; cluster.style.top = ''; }
|
||||
if (line) line.style.transform = '';
|
||||
if (box) box.style.removeProperty('--fs');
|
||||
root.removeAttribute('data-end');
|
||||
root.classList.remove('is-zoom');
|
||||
};
|
||||
const reset = () => {
|
||||
for (const el of moved()) if (el) { el.style.transform = ''; el.style.opacity = ''; }
|
||||
root.classList.remove('is-moving');
|
||||
root.removeAttribute('data-first');
|
||||
clearEnd();
|
||||
S.items = []; S.extras = [];
|
||||
};
|
||||
const setFit = (v) => (v ? root.style.setProperty('--settle-fit', v + 'px') : root.style.removeProperty('--settle-fit'));
|
||||
|
||||
// Largest font size (px) at which an element keeps to `lines` lines and its widest word fits its
|
||||
// width (no word cut at the edge, no sideways scroll), from `ceilPx` (else the CSS size) down.
|
||||
const fitLines = (el, lines, floorPx, ceilPx) => {
|
||||
if (!el) return;
|
||||
setFit(ceilPx || 0);
|
||||
let fs = parseFloat(getComputedStyle(el).fontSize);
|
||||
const lh = () => parseFloat(getComputedStyle(el).lineHeight) || fs * 0.88;
|
||||
const over = () => el.offsetHeight > lh() * (lines + 0.5) || el.scrollWidth > el.clientWidth + 1;
|
||||
for (let i = 0; i < 24 && over() && fs > floorPx; i++) {
|
||||
fs = Math.max(floorPx, fs * 0.93);
|
||||
setFit(Math.floor(fs));
|
||||
}
|
||||
};
|
||||
|
||||
// The end frame of the line: always the whole line. The travel (part one) runs until the last letter
|
||||
// has landed, its ink ending at END of the width. The pull-back (part two) then shrinks the line by
|
||||
// k, about one fixed point, so that its first letter's ink lands on the left margin (6vw) and all of
|
||||
// it fits before the right one, mirrored. The cluster sits beside the line (its right edge on the
|
||||
// right margin, bottoms level) when that keeps k at 85% or more of what it is with the cluster under
|
||||
// the line; otherwise under it, from the first letter, the pair centred on the screen. A line that
|
||||
// fits as it is never grows (k is 1 at most) and then needs no travel: its letters get a run-in.
|
||||
const END = 0.75;
|
||||
const endFrame = (W, H, rec) => {
|
||||
const cs = getComputedStyle(track);
|
||||
const padL = parseFloat(cs.paddingLeft) || 0.06 * W;
|
||||
const fs = parseFloat(getComputedStyle(line).fontSize);
|
||||
const ink = 0.06 * fs; // tight tracking: ink overhangs the box
|
||||
const left0 = line.offsetLeft;
|
||||
const first = rec.chars[0], last = rec.chars[rec.chars.length - 1];
|
||||
if (!first) return { travel: 0, k: 1, mode: 'side', tx: 0, ty: 0, cx: 0, cy: 0 };
|
||||
const a0 = first.offsetLeft - left0 + bearing(first.textContent) * fs; // the ink, line-local
|
||||
const a1 = last.offsetLeft + last.offsetWidth - left0 + ink;
|
||||
const span = Math.max(1, a1 - a0);
|
||||
const lh = line.offsetHeight;
|
||||
const room = W - 2 * padL;
|
||||
const kids = cluster ? [...cluster.children] : [];
|
||||
const cw = kids.length ? Math.max(...kids.map((el) => (el.matches('a, button') ? el.offsetWidth : textWidth(el)))) : 0;
|
||||
const ch = cluster ? cluster.offsetHeight : 0;
|
||||
const g = cw ? Math.max(24, 0.025 * W) : 0;
|
||||
const gv = clamp(0.05 * H, 28, 64);
|
||||
const tallH = H - 2 * 64 - o.inset; // the room the pair may take, top to bottom
|
||||
const kStack = Math.min(1, room / span, cluster ? Math.max(0.1, (tallH - gv - ch) / lh) : 1);
|
||||
const kSide = cluster ? Math.min(1, Math.max(0.05, (room - g - cw) / span), tallH / Math.max(lh, ch)) : kStack;
|
||||
const side = !cluster || kSide >= 0.85 * kStack;
|
||||
const k = clamp(side ? kSide : kStack, 0.05, 1);
|
||||
// part one: none when the line fits as it is (k = 1), else until its end lands at END
|
||||
const travel = k > 0.999 ? 0 : Math.max(0, left0 + a1 - END * W);
|
||||
// the line's place at rest (stage px): its first ink on the margin, and the line (or, with the
|
||||
// cluster under it, the pair) centred on the stage. It shrinks about the middle of its left edge.
|
||||
const mid0 = track.offsetTop + line.offsetTop + lh / 2;
|
||||
const tx = padL + travel - left0 - k * a0;
|
||||
const ty = H / 2 - mid0 - (side ? 0 : (gv + ch) / 2);
|
||||
const mid = mid0 + ty;
|
||||
const cx = side ? W - padL - cw : padL;
|
||||
const cy = side ? mid + (k * lh) / 2 - ch : mid + (k * lh) / 2 + gv;
|
||||
// The cluster rises (from below its place) only once the shrinking line is clear of that place and
|
||||
// of everything under it, so the line never passes over its own support line: the first point of
|
||||
// the pull-back (0-1, before its ease) from which the line's box, with its descenders, stays clear.
|
||||
const f = { travel, k, mode: side ? 'side' : 'stack', tx, ty, cx, cy, clear: 0 };
|
||||
if (cluster) {
|
||||
const desc = 0.12 * fs;
|
||||
for (let i = 40; i >= 0; i--) {
|
||||
const v = p2io(i / 40), s = lineAt(f, v);
|
||||
const l = -travel + left0 + s.tx + s.s * a0, r = -travel + left0 + s.tx + s.s * a1;
|
||||
const b = mid0 + s.ty + (s.s * lh) / 2 + s.s * desc;
|
||||
if (b > cy - 8 && r > cx - 8 && l < cx + cw + 8) { f.clear = Math.min(0.9, (i + 1) / 40); break; }
|
||||
}
|
||||
}
|
||||
return f;
|
||||
};
|
||||
// the line's transform at a point v (0-1, eased) of the pull-back: s goes from 1 to k on a geometric
|
||||
// curve (an even zoom), and tx with it, so every point of the line moves straight towards one fixed
|
||||
// point; ty follows v
|
||||
const lineAt = (f, v) => {
|
||||
const s = f.k > 0.999 ? 1 : Math.pow(f.k, v);
|
||||
return { s, tx: f.tx * (f.k > 0.999 ? v : (1 - s) / (1 - f.k)), ty: f.ty * v };
|
||||
};
|
||||
|
||||
// Cards: move the travel's end to the nearest card boundary, by a little less travel when the last
|
||||
// card still fits on the right, else by a little more (at most 20% of the width).
|
||||
const snapCards = (base, W) => {
|
||||
if (base <= 0) return 0;
|
||||
const spans = $$('.settle__card, .settle__intro, .settle__outro').map((el) => ({ l: el.offsetLeft, r: el.offsetLeft + el.offsetWidth }));
|
||||
const margin = 0.06 * W;
|
||||
const room = (parseFloat(getComputedStyle(track).paddingRight) || 0) - 16;
|
||||
const i = spans.findIndex((s) => s.r > base);
|
||||
if (i < 0) return base;
|
||||
const s = spans[i], prev = spans[i - 1], next = spans[i + 1];
|
||||
if (s.l >= base) return base;
|
||||
const less = Math.max(prev ? prev.r : -Infinity, s.l - margin);
|
||||
if (less <= s.l && base - less <= room) return Math.max(0, less);
|
||||
const more = Math.max(s.r, next ? next.l - margin : Infinity);
|
||||
return next && more <= next.l && more - base <= 0.2 * W ? more : base;
|
||||
};
|
||||
|
||||
// ── desktop: the sideways track ─────────────────────────────────────────────────────────────
|
||||
const buildDesk = (tl) => {
|
||||
reset();
|
||||
S.mode = 'desk';
|
||||
setFit(0);
|
||||
let W = 1, H = 1, travel = 0, run = 0, T = 1, hold = 1.5, qa = 1, end = null;
|
||||
const rec = preset === 'type' && line ? split(line) : null;
|
||||
const glyphs = rec ? rec.chars.map((c) => c.textContent) : null;
|
||||
const sd = rec ? seeds(rec.text, rec.chars.length, glyphs) : null;
|
||||
|
||||
S.measure = () => {
|
||||
W = stage.clientWidth || innerWidth;
|
||||
H = stage.clientHeight || innerHeight;
|
||||
clearEnd();
|
||||
end = null;
|
||||
let back = 0; // scroll px of the pull-back
|
||||
if (rec) {
|
||||
kern(line, rec.chars);
|
||||
// a word wider than the room between the 6vw margins sets the whole line smaller, so no word
|
||||
// is ever wider than the screen as it passes
|
||||
const fs0 = parseFloat(getComputedStyle(line).fontSize);
|
||||
const widest = Math.max(0, ...rec.words.map((w) => w.offsetWidth)) + 0.1 * fs0;
|
||||
if (box && widest > 0.88 * W) box.style.setProperty('--fs', Math.floor((fs0 * 0.88 * W) / widest) + 'px');
|
||||
end = endFrame(W, H, rec);
|
||||
travel = end.travel;
|
||||
root.dataset.end = end.mode;
|
||||
if (cluster) {
|
||||
// the cluster is placed at its end frame (track px: the track then stands at -travel)
|
||||
cluster.style.left = Math.round(end.cx + travel) + 'px';
|
||||
cluster.style.top = Math.round(end.cy - track.offsetTop) + 'px';
|
||||
}
|
||||
back = ((end.k < 0.98 ? 0.6 : cluster ? 0.35 : 0) * H) / o.speed;
|
||||
} else {
|
||||
travel = snapCards(Math.max(0, track.offsetWidth - W), W);
|
||||
}
|
||||
// a short line travels little; its letters get a run-in of their own so they still swoop in
|
||||
run = rec ? Math.max(0, 0.45 * W - travel) : 0;
|
||||
const ride = (travel + run) / o.speed;
|
||||
const ts = (ride + back) / H;
|
||||
hold = clamp(ts + o.rest, 1, 4);
|
||||
T = ride + back > 0 ? clamp(Math.min(ts, hold - o.rest) / hold, 0.05, 1) : 0;
|
||||
qa = ride + back > 0 ? ride / (ride + back) : 1; // the travel's share of the motion
|
||||
root.style.setProperty('--hold', hold.toFixed(4));
|
||||
const items = [], extras = [];
|
||||
let amax = 0;
|
||||
if (rec) {
|
||||
const h = rec.chars[0] ? rec.chars[0].offsetHeight : 100;
|
||||
amax = Math.max(0, Math.min(o.amp * h, 0.5 * H - 0.5 * h - 64 - o.inset));
|
||||
rec.chars.forEach((el, k) => {
|
||||
const c = el.offsetLeft + el.offsetWidth / 2;
|
||||
items.push({ el, c, s: Math.max(o.settle, (c - travel) / W), A: sd[k].a * amax, R: sd[k].r * o.tilt, e: o.enter });
|
||||
});
|
||||
// the support line and button rise into their end place during the pull-back
|
||||
if (cluster) extras.push({ kind: 'rise', els: [cluster.querySelector('.settle__support'), cluster.querySelector('.settle__cta')].filter(Boolean) });
|
||||
} else {
|
||||
const cards = $$('.settle__card');
|
||||
cards.forEach((card, k) => {
|
||||
const left = card.offsetLeft, w = card.offsetWidth;
|
||||
const c = preset === 'stair' ? left : left + w / 2;
|
||||
const lift = (o.lift / 100) * H;
|
||||
const body = card.querySelector('.settle__body') || card.firstElementChild;
|
||||
if (preset === 'rise') {
|
||||
const pic = card.querySelector('.settle__picture') || body;
|
||||
const cap = card.querySelector('.settle__caption');
|
||||
items.push({ el: pic, c, s: Math.max(o.settle, (c - travel) / W), A: lift, R: 0, e: o.enter });
|
||||
// captions fade in over r 0.85 → 0.55; one already on screen at progress 0 reads at once
|
||||
const r0 = c / W;
|
||||
if (cap) extras.push({ kind: 'caption', c, el: cap, end: Math.max(0.55, (c - travel) / W, r0 < 0.85 ? r0 : 0) });
|
||||
} else {
|
||||
const tr = o.tilt ? (k % 2 ? -1 : 1) * o.tilt * 0.5 : 0;
|
||||
items.push({ el: body, c, s: Math.max(o.settle, (c - travel) / W), A: lift, R: tr, e: o.enter });
|
||||
}
|
||||
});
|
||||
const out = $('.settle__outro');
|
||||
if (out) {
|
||||
const left = out.offsetLeft, rf = (left - travel) / W;
|
||||
extras.push({ kind: 'cluster', left, rf, rs: 1, els: [...out.children] });
|
||||
}
|
||||
}
|
||||
// write caches start empty, so the first map() after a measure writes every element
|
||||
S.items = items.map((it) => ({ ...it, x: NaN, y: NaN, r: NaN }));
|
||||
S.extras = extras.map((x) => ({ ...x, st: (x.els || [x.el]).map((el) => ({ el, x: NaN, y: NaN, r: NaN, o: NaN })) }));
|
||||
S.track = { el: track, x: NaN, y: NaN, r: NaN };
|
||||
S.line = { tx: NaN, ty: NaN, s: NaN, zoom: null };
|
||||
S.trackW = track.offsetWidth;
|
||||
const fs = rec ? parseFloat(getComputedStyle(line).fontSize) : 0;
|
||||
S.dbg = { W, H, travel, run, hold: r2(hold), T: r2(T), qa: r2(qa), mode: end ? end.mode : '', k: end ? r2(end.k) : 1, fs, rest: end ? Math.round(fs * end.k) : fs, amax: Math.round(amax) };
|
||||
};
|
||||
|
||||
// the line's own transform during the pull-back (see lineAt)
|
||||
const putLine = (v) => {
|
||||
const at = lineAt(end, v);
|
||||
const tx = r1(at.tx), ty = r1(at.ty), sc = Math.round(at.s * 10000) / 10000;
|
||||
const L = S.line;
|
||||
// the letters have all landed by now: they drop their layers, so the line is drawn afresh at
|
||||
// each size and rests sharp
|
||||
if ((v > 0) !== L.zoom) { L.zoom = v > 0; root.classList.toggle('is-zoom', L.zoom); }
|
||||
if (L.tx === tx && L.ty === ty && L.s === sc) return;
|
||||
L.tx = tx; L.ty = ty; L.s = sc;
|
||||
line.style.transform = tx || ty || sc !== 1 ? `translate3d(${tx}px, ${ty}px, 0) scale(${sc})` : '';
|
||||
};
|
||||
|
||||
S.map = (p) => {
|
||||
const q = T > 0 ? clamp(p / T, 0, 1) : 1;
|
||||
const qA = qa > 0 ? clamp(q / qa, 0, 1) : 1; // the travel
|
||||
const qB = qa < 1 ? clamp((q - qa) / (1 - qa), 0, 1) : 1; // the pull-back
|
||||
const x = -travel * qA;
|
||||
const lead = run * (1 - qA); // the short line's own run-in
|
||||
put(S.track, r1(x), 0, 0);
|
||||
for (const it of S.items) {
|
||||
const r = (it.c + x + lead) / W;
|
||||
const u = it.s >= it.e ? 1 : clamp((it.e - r) / (it.e - it.s), 0, 1);
|
||||
if (u === 1 && it.y === 0 && it.r === 0) continue; // settled, nothing to write
|
||||
put(it, 0, r1(it.A * curveAt(curve.y, u)), r2(it.R * curveAt(curve.r, u)));
|
||||
}
|
||||
if (end) putLine(p2io(qB));
|
||||
for (const ex of S.extras) {
|
||||
if (ex.kind === 'rise') {
|
||||
// the support line and button rise 30vh into their end place (power4.out), the button just
|
||||
// after the line, from the point where the shrinking line is clear of them (end.clear)
|
||||
const u = clamp((qB - end.clear) / (1 - end.clear), 0, 1);
|
||||
ex.st.forEach((st, i) => {
|
||||
const v = i ? clamp((u - 0.12 * i) / (1 - 0.12 * i), 0, 1) : u;
|
||||
put(st, 0, r1(0.3 * H * Math.pow(1 - v, 4)), 0, r2(clamp(v / 0.3, 0, 1)));
|
||||
});
|
||||
} else if (ex.kind === 'cluster') {
|
||||
// rises from 40vh with power3.out, keyed to its left edge from rs to its rest
|
||||
const rc = (ex.left + x) / W;
|
||||
const u = ex.rf >= ex.rs ? 1 : clamp((ex.rs - rc) / (ex.rs - ex.rf), 0, 1);
|
||||
ex.st.forEach((st, i) => {
|
||||
const v = i ? clamp((u - 0.12 * i) / (1 - 0.12 * i), 0, 1) : u; // each later child lands just after
|
||||
put(st, 0, r1(0.4 * H * Math.pow(1 - v, 4)), 0, r2(clamp(v / 0.3, 0, 1)));
|
||||
});
|
||||
} else {
|
||||
// rise captions: fade and lift in as the column centre moves from 0.85 to 0.55 of the width
|
||||
const r = (ex.c + x) / W, end = ex.end;
|
||||
const v = end >= 0.85 ? 1 : clamp((0.85 - r) / (0.85 - end), 0, 1);
|
||||
put(ex.st[0], 0, r1(24 * (1 - v)), 0, r2(v));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// tab focus inside the track: bring the scroll to where that item has arrived and settled
|
||||
S.focus = (target) => {
|
||||
const st = tl.scrollTrigger;
|
||||
const box = target.closest('.settle__card, .settle__cluster, .settle__outro');
|
||||
if (!st || !(travel + run) || !box) return;
|
||||
const left = box.offsetLeft, w = box.offsetWidth;
|
||||
const card = box.matches('.settle__card');
|
||||
const anchor = preset === 'stair' ? left : left + w / 2;
|
||||
const sp = card ? Math.max(o.settle, (anchor - travel) / W) : 0;
|
||||
const p = card && travel ? clamp((anchor - sp * W) / travel, 0, 1) * T : T; // where it has arrived and settled
|
||||
const cur = clamp(st.progress, 0, 1);
|
||||
const xNow = -travel * clamp(clamp(cur / T, 0, 1) / qa, 0, 1);
|
||||
const visible = left + xNow >= 0 && left + w + xNow <= W;
|
||||
const settled = card ? (anchor + xNow) / W <= sp + 0.002 : cur >= T - 0.002;
|
||||
if (visible && settled) return;
|
||||
const y = st.start + (st.end - st.start) * p;
|
||||
if (window.Stage && Stage.lenis) Stage.lenis.scrollTo(y, { immediate: true, force: true });
|
||||
else window.scrollTo(0, y);
|
||||
};
|
||||
|
||||
S.measure();
|
||||
tl.to({}, { duration: 1, ease: 'none' }, 0);
|
||||
tl.eventCallback('onUpdate', () => { if (S.mode === 'desk' && S.map) S.map(tl.progress()); });
|
||||
S.map(0);
|
||||
ScrollTrigger.create({
|
||||
trigger: root, start: 'top bottom', end: 'bottom top',
|
||||
onToggle: (self) => root.classList.toggle('is-moving', self.isActive),
|
||||
});
|
||||
requestAnimationFrame(() => ScrollTrigger.refresh());
|
||||
};
|
||||
|
||||
// ── phone: the same mapping turned 90°, keyed to the line's own screen position ──────────────
|
||||
const buildPhone = () => {
|
||||
reset();
|
||||
S.mode = 'phone';
|
||||
const rec = preset === 'type' && line ? split(line) : null;
|
||||
const glyphs = rec ? rec.chars.map((c) => c.textContent) : null;
|
||||
const sd = rec ? seeds(rec.text, rec.chars.length, glyphs) : null;
|
||||
let H = innerHeight, maxScroll = 0, first = false, top0 = 0, D = 1, clear = 0;
|
||||
const docTop = (el) => { let y = 0; for (let n = el; n; n = n.offsetParent) y += n.offsetTop; return y; };
|
||||
|
||||
S.measure = () => {
|
||||
H = innerHeight;
|
||||
if (rec) {
|
||||
// a first section keeps a whole first screen, so the line has room to assemble on screen
|
||||
root.toggleAttribute('data-first', docTop(root) < 0.25 * H);
|
||||
kern(line, rec.chars);
|
||||
fitLines(line, 2, 34, Math.round(0.2 * innerWidth)); // two lines at most, as large as 20vw, widest word inside
|
||||
}
|
||||
maxScroll = Math.max(0, document.documentElement.scrollHeight - H);
|
||||
const items = [], extras = [];
|
||||
if (rec) {
|
||||
// group letters into visual lines by their layout top
|
||||
const rows = [];
|
||||
for (const el of rec.chars) {
|
||||
const top = el.offsetTop;
|
||||
let row = rows.find((r) => Math.abs(r.top - top) < 4);
|
||||
if (!row) rows.push((row = { top, els: [] }));
|
||||
row.els.push(el);
|
||||
}
|
||||
const h = rec.chars[0] ? rec.chars[0].offsetHeight : 60;
|
||||
const N = rec.chars.length;
|
||||
top0 = docTop(line);
|
||||
// On screen in the upper part at scroll 0 (the first section): the first word is already
|
||||
// level and the first scroll assembles the rest. Otherwise the line assembles as it rises.
|
||||
first = top0 < 0.62 * H;
|
||||
const w1 = first ? rec.chars.filter((c) => c.parentNode === rec.chars[0].parentNode).length : 0;
|
||||
D = first ? clamp(Math.max(0.22 * H, top0 - 0.12 * H), 1, Math.max(1, maxScroll)) : 1;
|
||||
const amp = o.amp / 1.5;
|
||||
let k = 0;
|
||||
rows.forEach((row, ri) => row.els.forEach((el) => {
|
||||
const s = sd[k];
|
||||
// The first row comes from above or below; the rows under it only from below, so a row
|
||||
// never crosses the one above. The first row dips no further than the row under it falls,
|
||||
// and a lower row's overshoot stops short of the row above.
|
||||
const down = ri > 0 || s.a > 0;
|
||||
const m = ri === 0 && down ? 0.6 : s.m;
|
||||
items.push({
|
||||
el,
|
||||
f: k < w1 ? 0 : (k - w1 + 1) / Math.max(1, N - w1), // place in reading order, 0-1
|
||||
A: (down ? 1 : -1) * m * h * amp,
|
||||
R: (down ? 1 : -1) * m * 12 * (o.tilt / 18), // 7-12deg, following the height as on desktop
|
||||
cap: ri ? -0.08 * h : -Infinity,
|
||||
last: ri === rows.length - 1,
|
||||
});
|
||||
k++;
|
||||
}));
|
||||
if (cluster) {
|
||||
const kids = [cluster.querySelector('.settle__support'), cluster.querySelector('.settle__cta')].filter(Boolean);
|
||||
// room between the line's box and the cluster, less a tilted letter's overhang
|
||||
clear = (kids[0] ? kids[0].offsetTop : 0) - (line.offsetTop + line.offsetHeight) - 0.12 * h;
|
||||
kids.forEach((el, i) => extras.push({ el, lag: 0.06 * i, lift: 32, fade: true }));
|
||||
}
|
||||
} else {
|
||||
for (const card of $$('.settle__card')) {
|
||||
const body = card.querySelector('.settle__body') || card.firstElementChild;
|
||||
const top = docTop(card);
|
||||
const pic = preset === 'rise' ? card.querySelector('.settle__picture') : null;
|
||||
const cap = preset === 'rise' ? card.querySelector('.settle__caption') : null;
|
||||
extras.push({ el: pic || body, top, lag: 0, lift: 0.16 * H, from: 1, to: 0.7, fade: false });
|
||||
if (cap) extras.push({ el: cap, top, lag: 0.1, lift: 24, from: 1, to: 0.7, fade: true });
|
||||
}
|
||||
const out = $('.settle__outro');
|
||||
if (out) [...out.children].forEach((el, i) => extras.push({ el, top: docTop(el), lag: 0.06 * i, lift: 32, from: 0.95, to: 0.75, fade: true }));
|
||||
}
|
||||
S.items = items.map((it) => ({ ...it, x: NaN, y: NaN, r: NaN }));
|
||||
S.extras = extras.map((it) => ({ fade: true, ...it, x: NaN, y: NaN, r: NaN, o: NaN }));
|
||||
S.dbg = { H, first, top0, D: Math.round(D), maxScroll };
|
||||
};
|
||||
|
||||
// u for something whose top moves from `from` to `to` of the screen height; if the page ends
|
||||
// before it can get there, it settles at the last scroll position instead.
|
||||
const window01 = (docY, from, to, sy) => {
|
||||
const end = Math.min(Math.max(to * H, docY - maxScroll), from * H - 1);
|
||||
return clamp((from * H - (docY - sy)) / (from * H - end), 0, 1);
|
||||
};
|
||||
// g: the line's progress, 0 (entering at the bottom edge) to 1 (settled at 42% of the screen).
|
||||
// A first section starts at 0.5, where the first word has landed.
|
||||
const lineAt = (sy) => (first ? 0.5 + 0.5 * clamp(sy / D, 0, 1) : window01(top0, 1, 0.42, sy));
|
||||
S.map = () => {
|
||||
const sy = window.scrollY;
|
||||
if (rec) {
|
||||
const g = lineAt(sy);
|
||||
let low = 0; // the last row's deepest letter
|
||||
for (const it of S.items) {
|
||||
const v = clamp((g - 0.5 * it.f) / 0.5, 0, 1); // letters land in reading order
|
||||
if (v === 1 && it.y === 0 && it.r === 0) continue;
|
||||
put(it, 0, r1(Math.max(it.cap, it.A * curveAt(curve.y, v))), r2(it.R * curveAt(curve.r, v)));
|
||||
if (it.last && it.y > low) low = it.y;
|
||||
}
|
||||
for (const ex of S.extras) {
|
||||
if (first) {
|
||||
// a first section shows its button from the start; the last row's falling letters push
|
||||
// the cluster down, and it tucks in under the line as they land
|
||||
put(ex, 0, r1(Math.max(0, low - clear)), 0, 1);
|
||||
} else {
|
||||
// the support line and button rise as the last letters land
|
||||
const v = clamp((g - 0.7 - ex.lag) / (0.3 - ex.lag), 0, 1);
|
||||
put(ex, 0, r1(ex.lift * Math.pow(1 - v, 3)), 0, r2(clamp(v / 0.6, 0, 1)));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const ex of S.extras) {
|
||||
const u = window01(ex.top, ex.from, ex.to, sy);
|
||||
const v = clamp((u - ex.lag) / (1 - ex.lag), 0, 1);
|
||||
put(ex, 0, r1(ex.lift * Math.pow(1 - v, 3)), 0, ex.fade ? r2(clamp(v / 0.6, 0, 1)) : undefined);
|
||||
}
|
||||
};
|
||||
S.focus = null;
|
||||
S.measure();
|
||||
S.map();
|
||||
ScrollTrigger.create({
|
||||
trigger: root, start: 'top bottom', end: 'bottom top',
|
||||
onUpdate: () => S.map(),
|
||||
onToggle: (self) => root.classList.toggle('is-moving', self.isActive),
|
||||
});
|
||||
requestAnimationFrame(() => ScrollTrigger.refresh());
|
||||
};
|
||||
|
||||
// ── the finished composition: reduced motion, a phone set to rest, a failed script ───────────
|
||||
// The line is set as large as it can be in two lines (up to 20vw on the phone, min(14vw, 26vh)
|
||||
// above), so the still composition keeps the scale of the moving one. Without any script the CSS
|
||||
// size, min(11vw, 22vh), holds instead.
|
||||
const toRest = () => {
|
||||
reset();
|
||||
S.mode = 'rest';
|
||||
S.map = null; S.focus = null;
|
||||
unsplit(line);
|
||||
S.measure = () => {
|
||||
if (!line) return;
|
||||
const ceil = Stage.phone ? 0.2 * innerWidth : Math.min(0.14 * innerWidth, 0.26 * innerHeight);
|
||||
fitLines(line, 2, 34, Math.round(ceil));
|
||||
};
|
||||
S.measure();
|
||||
};
|
||||
// Leaving a live layout for the finished one, whoever asks. The stage's matchMedia only runs its
|
||||
// callback when one of its conditions matches, so on a desktop that switches to reduced motion
|
||||
// while the page is open it never calls rest(); and its context revert cannot undo the inline
|
||||
// styles this scene writes. Both cases end here.
|
||||
const leave = () => {
|
||||
if (S.mode === 'rest') return;
|
||||
root.classList.remove('is-live');
|
||||
root.classList.add('is-rest');
|
||||
root.style.removeProperty('--hold');
|
||||
root.style.removeProperty('--p');
|
||||
toRest();
|
||||
};
|
||||
|
||||
// Once per section: re-measure before ScrollTrigger measures (resize, fonts, pictures), map after.
|
||||
if (!root.__settleWired && window.ScrollTrigger) {
|
||||
root.__settleWired = true;
|
||||
ScrollTrigger.addEventListener('refreshInit', () => { if (S.measure && (root.classList.contains('is-live') || S.mode === 'rest')) S.measure(); });
|
||||
ScrollTrigger.addEventListener('refresh', () => { if (S.map && root.classList.contains('is-live')) S.map(S.mode === 'desk' && S.tl ? S.tl.progress() : 0); });
|
||||
root.addEventListener('focusin', (e) => {
|
||||
if (!S.focus || !root.classList.contains('is-live')) return;
|
||||
const t = e.target;
|
||||
requestAnimationFrame(() => S.focus && S.focus(t));
|
||||
});
|
||||
// a late web font (or any change of content) changes the track's size: measure again. The
|
||||
// cluster margin measure() itself writes is not a change.
|
||||
if (window.ResizeObserver && track) {
|
||||
let seen = false, raf = 0;
|
||||
new ResizeObserver(() => {
|
||||
if (!seen) { seen = true; return; }
|
||||
cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(() => {
|
||||
if (!S.measure || S.mode === 'rest') return;
|
||||
if (S.mode === 'desk' && Math.abs(track.offsetWidth - (S.trackW || 0)) < 1) return;
|
||||
ScrollTrigger.refresh();
|
||||
});
|
||||
}).observe(track);
|
||||
}
|
||||
// reduced motion switched on or off while the page is open
|
||||
const rm = window.matchMedia ? matchMedia('(prefers-reduced-motion: reduce)') : null;
|
||||
const onReduce = () => S.onReduce && S.onReduce(rm.matches);
|
||||
if (rm) rm.addEventListener ? rm.addEventListener('change', onReduce) : rm.addListener && rm.addListener(onReduce);
|
||||
// a phone turned into a desktop under reduced motion: the stage's phone context reverts (taking
|
||||
// .is-rest with it) and no context replaces it, so mark the finished composition again
|
||||
const ph = window.matchMedia ? matchMedia('(max-width: 767px)') : null;
|
||||
const onPhone = () => requestAnimationFrame(() => { if (S.mode === 'rest' && !root.classList.contains('is-live')) root.classList.add('is-rest'); });
|
||||
if (ph) ph.addEventListener ? ph.addEventListener('change', onPhone) : ph.addListener && ph.addListener(onPhone);
|
||||
wireFonts();
|
||||
// pictures that move must be decoded first
|
||||
for (const img of $$('img')) if (img.decode) img.decode().catch(() => {});
|
||||
} else if (!root.__settleWired) {
|
||||
// no ScrollTrigger (a failed CDN): the finished composition still refits its line when the web
|
||||
// font arrives and when the window changes size
|
||||
root.__settleWired = true;
|
||||
const again = () => { if (S.mode === 'rest' && S.measure) S.measure(); };
|
||||
if (document.fonts && document.fonts.ready) document.fonts.ready.then(again);
|
||||
let t = 0;
|
||||
addEventListener('resize', () => { clearTimeout(t); t = setTimeout(again, 150); });
|
||||
}
|
||||
|
||||
// when the stage's context is reverted (a media change, or a page tearing the scene down), take
|
||||
// this scene's own writes with it
|
||||
const onRevert = () => {
|
||||
if (window.gsap && gsap.context) gsap.context(() => () => { if (S.mode !== 'rest') { reset(); unsplit(line); S.map = null; S.focus = null; S.measure = null; S.mode = null; } });
|
||||
};
|
||||
|
||||
const plan = {
|
||||
hold: 2,
|
||||
scrub,
|
||||
build(tl) { S.tl = tl; buildDesk(tl); onRevert(); },
|
||||
phone: { hold: 0, build: () => { buildPhone(); onRevert(); } },
|
||||
rest: () => toRest(),
|
||||
};
|
||||
let mm = Stage.scene(root, plan);
|
||||
// Reduced motion from the first load on a desktop: none of the stage's matchMedia conditions match,
|
||||
// so it never calls back and nothing marks the finished composition. Do it here.
|
||||
if (mm && Stage.reduced && !root.classList.contains('is-live')) leave();
|
||||
// Reduced motion on: the finished composition, on every screen. Off again: gsap.matchMedia drops a
|
||||
// context for good once none of its conditions match, so on a desktop the stage never comes back
|
||||
// by itself; start the scene again (the phone's context is still listening and comes back alone).
|
||||
S.onReduce = (reduced) => {
|
||||
if (reduced) { leave(); return; }
|
||||
requestAnimationFrame(() => {
|
||||
if (root.classList.contains('is-live') || S.mode !== 'rest' || Stage.reduced) return;
|
||||
if (mm && mm.revert) mm.revert();
|
||||
const i = Stage.scenes.indexOf(mm); // the stage keeps every scene it made
|
||||
if (i >= 0) Stage.scenes.splice(i, 1);
|
||||
mm = Stage.scene(root, plan);
|
||||
root.__scene = mm || true;
|
||||
});
|
||||
};
|
||||
return mm;
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,127 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Hollin Barn, a recording studio on the moor</title>
|
||||
<meta name="description" content="A residential recording studio in a stone barn at the top of the Scarth valley.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;600&display=swap">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../arrive-and-settle-track.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #ecebe6;
|
||||
--ink: #121211;
|
||||
--muted: #55544f;
|
||||
--accent: #cf2a1d;
|
||||
--accent-ink: #ffffff;
|
||||
--font-display: 'Schibsted Grotesk', system-ui, sans-serif;
|
||||
--font-text: 'Schibsted Grotesk', system-ui, sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--ground); }
|
||||
body { margin: 0; background: var(--ground); color: var(--ink); font: 400 18px/1.5 var(--font-text); -webkit-font-smoothing: antialiased; }
|
||||
a { color: inherit; }
|
||||
img { display: block; max-width: 100%; }
|
||||
|
||||
.bar { display: flex; align-items: center; gap: 32px; padding: 22px 6vw; font-size: 16px; }
|
||||
.bar__mark { font-weight: 600; font-size: 19px; text-decoration: none; margin-right: auto; letter-spacing: -0.01em; }
|
||||
.bar a:not(.bar__mark):not(.bar__book) { text-decoration: none; }
|
||||
.bar__book { display: inline-flex; align-items: center; height: 44px; padding: 0 20px; border-radius: 999px; background: var(--accent); color: var(--accent-ink); font-weight: 600; text-decoration: none; }
|
||||
|
||||
.hero { min-height: calc(100svh - 88px); padding: 4vh 6vw 6vh; display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); grid-template-rows: auto 1fr; column-gap: 2vw; row-gap: 5vh; }
|
||||
.hero h1 { grid-column: 1 / -1; margin: 0; font: 600 clamp(64px, 14.5vw, 232px)/0.86 var(--font-display); letter-spacing: -0.05em; }
|
||||
.hero__text { grid-column: 1 / 5; align-self: end; display: grid; gap: 28px; justify-items: start; }
|
||||
.hero__text p { margin: 0; font-size: clamp(20px, 1.6vw, 24px); line-height: 1.4; max-width: 24ch; }
|
||||
.hero__text a { font-weight: 600; text-underline-offset: 5px; text-decoration-thickness: 2px; }
|
||||
.hero__picture { grid-column: 6 / -1; margin: 0; min-height: 300px; overflow: hidden; border-radius: 4px; }
|
||||
.hero__picture img { width: 100%; height: 100%; object-fit: cover; object-position: 30% 60%; }
|
||||
|
||||
.closing { padding: 16vh 6vw 6vh; display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: 2vw; row-gap: 7vh; }
|
||||
.closing h2 { grid-column: 1 / 6; margin: 0; font: 600 clamp(40px, 5vw, 80px)/1 var(--font-display); letter-spacing: -0.04em; }
|
||||
.closing__text { grid-column: 7 / -1; display: grid; gap: 22px; align-content: start; }
|
||||
.closing__text p { margin: 0; max-width: 40ch; }
|
||||
.closing__text .closing__contact { font-size: clamp(22px, 1.8vw, 28px); line-height: 1.35; font-weight: 600; }
|
||||
.closing__picture { grid-column: 1 / -1; margin: 0; aspect-ratio: 2.4; overflow: hidden; border-radius: 4px; }
|
||||
.closing__picture img { width: 100%; height: 100%; object-fit: cover; object-position: 50% 62%; }
|
||||
.closing__foot { grid-column: 1 / -1; display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px 32px; font-size: 15px; color: var(--muted); }
|
||||
.closing__foot p { margin: 0; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.bar { gap: 18px; padding: 16px; font-size: 15px; }
|
||||
.bar a:not(.bar__mark):not(.bar__book) { display: none; }
|
||||
.bar__book { height: 40px; padding: 0 16px; }
|
||||
.hero { min-height: 0; padding: 6vh 16px 8vh; grid-template-columns: minmax(0, 1fr); row-gap: 28px; }
|
||||
.hero h1, .hero__text, .hero__picture { grid-column: 1; }
|
||||
.hero h1 { font-size: 21vw; }
|
||||
.hero__picture { aspect-ratio: 4 / 3; min-height: 0; }
|
||||
.closing { padding: 12vh 16px 6vh; grid-template-columns: minmax(0, 1fr); row-gap: 32px; }
|
||||
.closing h2, .closing__text, .closing__picture, .closing__foot { grid-column: 1; }
|
||||
.closing__picture { aspect-ratio: 4 / 3; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="bar">
|
||||
<a class="bar__mark" href="#top">Hollin Barn</a>
|
||||
<a href="#book">The rooms</a>
|
||||
<a href="#book">Rates</a>
|
||||
<a href="#book">Getting here</a>
|
||||
<a class="bar__book" href="#book">Book a session</a>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section class="hero" aria-labelledby="hero-title">
|
||||
<h1 id="hero-title">Hollin Barn</h1>
|
||||
<div class="hero__text">
|
||||
<p>A residential recording studio in a stone barn at the top of the Scarth valley, with two live rooms and beds for six.</p>
|
||||
<a href="#book">How a week here works</a>
|
||||
</div>
|
||||
<figure class="hero__picture">
|
||||
<img src="https://picsum.photos/id/304/1400/1000" width="1400" height="1000" alt="A hand moving a fader on the studio's mixing desk">
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section class="scene" data-scene="arrive-and-settle-track" aria-label="Book a session">
|
||||
<div class="scene-stage">
|
||||
<div class="settle">
|
||||
<div class="settle__track">
|
||||
<h2 class="settle__line">Shall we make a record?</h2>
|
||||
<div class="settle__cluster">
|
||||
<p class="settle__support">Weeks from £2,400, with an engineer, six beds and breakfast.</p>
|
||||
<a class="settle__cta" href="#book">Book a session</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="closing" id="book" aria-labelledby="book-title">
|
||||
<h2 id="book-title">Getting here</h2>
|
||||
<div class="closing__text">
|
||||
<p>The barn is a mile up a farm track from Scarthwaite. Trains run to Kettlesby, and we collect you from the station, amps and all.</p>
|
||||
<p>Sessions run Monday to Saturday. Write with your dates and the size of the band, and we will send the week's plan within two days.</p>
|
||||
<p class="closing__contact">bookings@hollinbarn.example<br>01632 960 318</p>
|
||||
</div>
|
||||
<figure class="closing__picture">
|
||||
<img src="https://picsum.photos/id/379/2000/840" width="2000" height="840" alt="The empty road over the moor towards the barn">
|
||||
</figure>
|
||||
<footer class="closing__foot">
|
||||
<p>Hollin Barn, Scarth Head, Scarthwaite</p>
|
||||
<p>Photographs: Drew Patrick Miller, Kamil Lehmann (Unsplash)</p>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../arrive-and-settle-track.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,197 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Varrick Sound Pilots</title>
|
||||
<meta name="description" content="Eleven harbour pilots who take ships through the narrowest sound on the north coast, on every tide, day and night.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../depth-card-flythrough.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #101217;
|
||||
--ink: #eceeea;
|
||||
--dim: #a9adb1;
|
||||
--font-display: "Schibsted Grotesk", system-ui, sans-serif;
|
||||
--pad: clamp(16px, 2.2vw, 32px);
|
||||
/* the weights every scene takes from the page: only ones the page loads (400, 500, 600) */
|
||||
--display-weight: 600;
|
||||
--strong-weight: 500;
|
||||
--text-weight: 400;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--ground); }
|
||||
body { margin: 0; background: var(--ground); color: var(--ink); font: var(--text-weight) 18px/1.5 var(--font-display); -webkit-font-smoothing: antialiased; }
|
||||
a { color: inherit; }
|
||||
|
||||
.bar { position: absolute; top: 0; left: 0; right: 0; z-index: 5; display: flex; justify-content: space-between; align-items: baseline; padding: 22px var(--pad); font-size: 16px; }
|
||||
.bar .mark { font-weight: var(--display-weight); text-decoration: none; letter-spacing: -.01em; }
|
||||
.bar nav { display: flex; gap: 28px; }
|
||||
.bar nav a { text-decoration: none; }
|
||||
.bar nav a:last-child { text-decoration: underline; text-underline-offset: 4px; text-decoration-thickness: 2px; }
|
||||
|
||||
.intro { min-height: min(74svh, 62vw); display: grid; grid-template-columns: repeat(12, 1fr); column-gap: var(--pad); align-content: end; padding: 120px var(--pad) 7vh; }
|
||||
.intro h1 { grid-column: 1 / 9; margin: 0; font-weight: var(--display-weight); font-size: clamp(3.4rem, 10.4vw, 10.5rem); line-height: .9; letter-spacing: -.045em; }
|
||||
.intro .side { grid-column: 9 / 13; align-self: end; margin-bottom: .6rem; }
|
||||
.intro p { margin: 0; font-size: 20px; line-height: 1.4; max-width: 30ch; color: var(--dim); }
|
||||
.intro .act { margin-top: 28px; }
|
||||
|
||||
/* The scene, set to its backdrop: a jetty running out to a lit boathouse at night, and the camera
|
||||
walks the jetty to its door. The picture's lines meet on the horizon at 72% of its height, so the
|
||||
vanishing point sits there; the boathouse stands on the water below it, so the streaming ground
|
||||
starts at its base (78%). The boathouse, eaves included, is 18% of the picture's width, so its
|
||||
half-width with a little air is .1 of the picture: the cards keep outside it. With the cards that
|
||||
low, the cards and the statement can be larger than the scene's defaults. The flat scrim cools the
|
||||
night sky toward blue-black. */
|
||||
.fly {
|
||||
--fly-stage: #14161c;
|
||||
--fly-fog: #1b1e26;
|
||||
--fly-ink: #f3f2ee;
|
||||
--fly-shade: 4 14 22;
|
||||
--fly-scrim: .36;
|
||||
--fly-vp: 72svh;
|
||||
--fly-focus: 72%;
|
||||
--fly-ground: 78svh;
|
||||
--fly-focus-w: .1;
|
||||
--fly-rest-vp: 72svh;
|
||||
--fly-card-w: min(40vw, 62vh);
|
||||
--fly-size: clamp(2.5rem, min(1rem + 4vw, 9.5svh), 6rem);
|
||||
--fly-copy-w: min(50vw, 17ch);
|
||||
}
|
||||
/* held upright (a phone, a tablet): the picture lifted 42.9svh puts its vanishing point at 60svh
|
||||
(-42.9 + 72% of 142.9) and the boathouse's foot at 68.6svh, so the statement, the boathouse and the
|
||||
cards flying low over the jetty share the height with no empty band of sky */
|
||||
@media (min-width: 768px) and (orientation: portrait) {
|
||||
.fly { --fly-lift: 42.9svh; --fly-vp: 60svh; --fly-ground: 68.6svh; --fly-card-w: min(56vw, 36svh); --fly-copy-top: 11svh; --fly-copy-w: min(64vw, 17ch); }
|
||||
}
|
||||
/* the phone's finished opening (80svh) shows the picture unlifted: vanishing point 57.6svh, foot 62.4svh */
|
||||
@media (max-width: 767px) {
|
||||
.fly { --fly-lift: 42.9svh; --fly-vp: 60svh; --fly-ground: 68.6svh; --fly-rest-vp: 57.6svh; --fly-rest-ground: 62.4svh; --fly-card-w: min(78vw, 36svh); --fly-copy-top: 11svh; --fly-size: 2.25rem; --fly-copy-w: calc(100vw - 32px); }
|
||||
}
|
||||
|
||||
.close { display: grid; grid-template-columns: repeat(12, 1fr); column-gap: var(--pad); row-gap: 48px; padding: 200px var(--pad) 160px; }
|
||||
.close h2 { grid-column: 1 / 7; margin: 0; font-weight: var(--display-weight); font-size: clamp(2.6rem, 6vw, 6rem); line-height: .95; letter-spacing: -.04em; }
|
||||
.close .lede { grid-column: 7 / 12; margin: 0; font-size: 20px; line-height: 1.45; max-width: 36ch; }
|
||||
.close dl { grid-column: 7 / 13; display: grid; grid-template-columns: repeat(3, 1fr); gap: 32px var(--pad); margin: 0; }
|
||||
.close dt { font-weight: var(--strong-weight); font-size: 16px; margin-bottom: 6px; }
|
||||
.close dd { margin: 0; font-size: 16px; line-height: 1.45; color: var(--dim); }
|
||||
.act { display: inline-block; padding: 16px 26px; background: var(--ink); color: var(--ground); text-decoration: none; font-weight: var(--strong-weight); font-size: 16px; }
|
||||
.close .act { grid-column: 7 / 13; justify-self: start; }
|
||||
|
||||
.foot { padding: 0 var(--pad) 28px; }
|
||||
.foot .name { margin: 0; font-weight: var(--display-weight); font-size: 10.4vw; line-height: .85; letter-spacing: -.05em; white-space: nowrap; }
|
||||
.foot .credit { margin: 28px 0 0; max-width: 72ch; font-size: 16px; line-height: 1.5; color: var(--dim); }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.bar nav a:first-child { display: none; }
|
||||
.intro { min-height: min(72svh, 150vw); display: block; padding-bottom: 6vh; }
|
||||
.intro h1 { font-size: 15.4vw; }
|
||||
.intro .side { margin-top: 22px; }
|
||||
.close { display: block; padding: 120px var(--pad) 96px; }
|
||||
.close .lede { margin-top: 28px; }
|
||||
.close dl { grid-template-columns: 1fr; gap: 20px; margin-top: 40px; }
|
||||
.close .act { margin-top: 40px; }
|
||||
.foot .name { font-size: 9.4vw; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="mark" href="#top">Varrick Sound Pilots</a>
|
||||
<nav><a href="#approach">The approach</a><a href="#request">Request a pilot</a></nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="intro" id="top">
|
||||
<h1>Varrick Sound Pilots</h1>
|
||||
<div class="side">
|
||||
<p>Eleven harbour pilots who take ships through the narrowest sound on the north coast, on every tide, day and night.</p>
|
||||
<a class="act" href="#request">Request a pilot</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="scene fly" id="approach" data-scene="depth-card-flythrough" data-options='{"inAir":3,"sideX":0.35}' aria-label="The approach to Varrick Sound">
|
||||
<div class="scene-stage fly__stage">
|
||||
<div class="fly__backdrop"><img class="fly__backdrop-img" src="https://picsum.photos/id/594/2600/1625" alt="" width="2600" height="1625" fetchpriority="high"></div>
|
||||
<div class="fly__scrim" aria-hidden="true"></div>
|
||||
<div class="fly__field">
|
||||
<figure class="fly__card" style="--dy:2">
|
||||
<img class="fly__img" src="https://picsum.photos/id/1038/1200/800" alt="A small red launch alone on a wide, dark blue sea" width="1200" height="800" decoding="async">
|
||||
<span class="fly__fog" aria-hidden="true"></span>
|
||||
<figcaption class="fly__cap">Pilot launch Tern</figcaption>
|
||||
</figure>
|
||||
<figure class="fly__card" style="--dy:0">
|
||||
<img class="fly__img" src="https://picsum.photos/id/473/1200/800" alt="A man in dark clothes stands on wet planks, facing mist on the water and dark hills beyond" width="1200" height="800" decoding="async">
|
||||
<span class="fly__fog" aria-hidden="true"></span>
|
||||
<figcaption class="fly__cap">On the quay at five</figcaption>
|
||||
</figure>
|
||||
<figure class="fly__card" style="--dy:4">
|
||||
<img class="fly__img" src="https://picsum.photos/id/483/1200/800" alt="Dark sea cliffs and broken rocks with white surf below, seen from above" width="1200" height="800" decoding="async">
|
||||
<span class="fly__fog" aria-hidden="true"></span>
|
||||
<figcaption class="fly__cap">Below Gull Head</figcaption>
|
||||
</figure>
|
||||
<figure class="fly__card" style="--dy:1">
|
||||
<img class="fly__img" src="https://picsum.photos/id/377/1200/800" alt="Snow-capped fells under a dark blue evening sky" width="1200" height="800" decoding="async">
|
||||
<span class="fly__fog" aria-hidden="true"></span>
|
||||
<figcaption class="fly__cap">Fells above the Neck</figcaption>
|
||||
</figure>
|
||||
<figure class="fly__card" style="--dy:5">
|
||||
<img class="fly__img" src="https://picsum.photos/id/171/1200/800" alt="Rain on a window at night, with red and amber lights blurred beyond it" width="1200" height="800" decoding="async">
|
||||
<span class="fly__fog" aria-hidden="true"></span>
|
||||
<figcaption class="fly__cap">From the wheelhouse</figcaption>
|
||||
</figure>
|
||||
<figure class="fly__card" style="--dy:0">
|
||||
<img class="fly__img" src="https://picsum.photos/id/765/1200/800" alt="A dark sea under a night sky with a last band of orange light along the horizon" width="1200" height="800" decoding="async">
|
||||
<span class="fly__fog" aria-hidden="true"></span>
|
||||
<figcaption class="fly__cap">The roads at dusk</figcaption>
|
||||
</figure>
|
||||
<figure class="fly__card" style="--dy:3">
|
||||
<img class="fly__img" src="https://picsum.photos/id/614/1200/800" alt="White surf washing over dark rocks, seen from above" width="1200" height="800" decoding="async">
|
||||
<span class="fly__fog" aria-hidden="true"></span>
|
||||
<figcaption class="fly__cap">The Skerries</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<div class="fly__copy">
|
||||
<ol class="fly__lines">
|
||||
<li class="fly__line">We bring ships in, day and night.</li>
|
||||
<li class="fly__line">We board in any sea, at any hour.</li>
|
||||
<li class="fly__line">Every rock learnt by heart.</li>
|
||||
<li class="fly__line">Ninety metres wide at the Neck.</li>
|
||||
<li class="fly__line">In fog, we steer by radar alone.</li>
|
||||
<li class="fly__line">Ships wait here for the tide.</li>
|
||||
<li class="fly__line">No keel on rock since 1987.</li>
|
||||
</ol>
|
||||
<p class="fly__count" aria-hidden="true"><span class="fly__count-now">1</span> of <span class="fly__count-all">7</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="close" id="request">
|
||||
<h2>Request a pilot</h2>
|
||||
<p class="lede">Send the vessel's name, draught and arrival time to the station six hours before you reach the outer roads. We answer on channel 14 at any hour, and the launch meets you two miles east of the Skerries.</p>
|
||||
<dl>
|
||||
<div><dt>Station</dt><dd>North Quay, Varrick</dd></div>
|
||||
<div><dt>Boarding</dt><dd>Outer roads, two miles east of the Skerries</dd></div>
|
||||
<div><dt>Notice</dt><dd>Six hours, or two in an emergency</dd></div>
|
||||
</dl>
|
||||
<a class="act" href="mailto:station@varricksound.example">Email the station</a>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="foot">
|
||||
<p class="name">Varrick Sound Pilots</p>
|
||||
<p class="credit">An invented pilot station, made to show a scroll scene. Photographs from Unsplash through Lorem Picsum, by Aurélien Bellanger, Marcelo Quinan, Todd Quackenbush, Paulo Simões Mendes, Martin Staněk, Riley Briggs, Grant McIver and Josefa Holland-Merten.</p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../depth-card-flythrough.js"></script>
|
||||
<script>Stage.smooth({ lerp: 0.1 }); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,139 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Skarven, eleven rooms on the ridge above the sound</title>
|
||||
<meta name="description" content="Skarven is a lodge of eleven rooms at 1,140 metres on the north ridge above the Skarven sound, open from May to October.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,400..800&display=swap">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../descend-through-weather.css">
|
||||
<style>
|
||||
:root {
|
||||
--mist: #e1e6e8;
|
||||
--on-mist: #1c2329;
|
||||
--on-above: #fff;
|
||||
--ground: #131a12;
|
||||
--on-ground: #edeee9;
|
||||
--ink: #edeee9;
|
||||
--muted: #b9bcae;
|
||||
--font-display: "Archivo", system-ui, sans-serif;
|
||||
--font-text: "Archivo", system-ui, sans-serif;
|
||||
--gutter: max(16px, 4vw);
|
||||
--above-pos: 50% 45%;
|
||||
--below-pos: 58% 50%;
|
||||
--tagline-top: 15svh;
|
||||
--wx-letters: 7;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--ground); }
|
||||
body { margin: 0; background: var(--ground); color: var(--ink); font: 400 18px/1.55 var(--font-text); -webkit-font-smoothing: antialiased; }
|
||||
a { color: inherit; }
|
||||
img { display: block; width: 100%; height: auto; }
|
||||
|
||||
.bar { position: absolute; z-index: 20; top: 0; left: 0; right: 0; display: flex; align-items: center; gap: 40px; padding: 24px var(--gutter); color: var(--on-mist); }
|
||||
.bar__name { font: 700 22px/1 var(--font-display); font-stretch: 75%; letter-spacing: 0.01em; text-transform: uppercase; text-decoration: none; margin-right: auto; }
|
||||
.bar nav { display: flex; gap: 28px; font-size: 17px; }
|
||||
.bar nav a { text-decoration: none; }
|
||||
.bar__action { padding: 12px 20px; background: var(--on-mist); color: #edeee9; text-decoration: none; font-weight: 600; font-size: 17px; }
|
||||
|
||||
.stay { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 0 24px; padding: 72px var(--gutter) 96px; background: var(--ground); }
|
||||
.stay h2 { grid-column: 1 / 8; margin: 0; font: 600 clamp(36px, 4.6vw, 72px)/1 var(--font-display); font-stretch: 90%; letter-spacing: -0.02em; }
|
||||
.stay__text { grid-column: 1 / 7; margin: 40px 0 0; max-width: 46ch; font-size: 19px; color: var(--ink); }
|
||||
.stay dl { grid-column: 1 / 7; display: grid; grid-template-columns: max-content 1fr; gap: 14px 32px; margin: 48px 0 0; font-size: 18px; }
|
||||
.stay dt { color: var(--muted); }
|
||||
.stay dd { margin: 0; }
|
||||
.stay__ask { grid-column: 1 / 7; justify-self: start; margin-top: 48px; padding: 16px 26px; background: var(--ink); color: var(--ground); font-weight: 600; text-decoration: none; }
|
||||
.stay figure { grid-column: 8 / 13; grid-row: 1 / 6; align-self: end; margin: 0; }
|
||||
.stay figure img { aspect-ratio: 4 / 5; object-fit: cover; }
|
||||
.stay figure.stay__table { grid-column: 1 / 13; grid-row: auto; margin-top: 120px; }
|
||||
.stay figure.stay__table img { aspect-ratio: 21 / 9; }
|
||||
.stay figcaption { margin-top: 14px; font-size: 16px; line-height: 1.45; color: var(--muted); }
|
||||
|
||||
.foot { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px 32px; padding: 32px var(--gutter) 40px; background: var(--ground); color: var(--muted); font-size: 16px; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.bar { gap: 16px; padding: 18px var(--gutter); }
|
||||
.bar nav { display: none; }
|
||||
.bar__action { padding: 10px 14px; font-size: 16px; }
|
||||
.stay { display: block; padding: 56px var(--gutter) 72px; }
|
||||
.stay__text { margin-top: 28px; }
|
||||
.stay dl { grid-template-columns: 1fr; gap: 4px; margin-top: 36px; }
|
||||
.stay dd { margin-bottom: 14px; }
|
||||
.stay__ask { display: inline-block; margin-top: 28px; }
|
||||
.stay figure { margin-top: 56px; }
|
||||
.stay figure.stay__table img { aspect-ratio: 4 / 3; }
|
||||
.scene { --phone-tagline-top: 14svh; --above-pos: 21% 50%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="bar__name" href="#">Skarven</a>
|
||||
<nav aria-label="Main">
|
||||
<a href="#stay">Rooms</a>
|
||||
<a href="#stay">The ridge</a>
|
||||
<a href="#getting-here">Getting here</a>
|
||||
</nav>
|
||||
<a class="bar__action" href="#ask">Ask for a room</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="scene" data-scene="descend-through-weather" aria-label="Skarven, from the ridge down to the sound"
|
||||
data-options='{"tops": "https://picsum.photos/id/685/2400/1600", "topsCrop": [0.42, 0.37, 1, 0.82], "wisps": "https://picsum.photos/id/932/2000/1250", "wispsCrop": [0, 0, 1, 0.56]}'>
|
||||
<div class="scene-stage">
|
||||
<figure class="wx-above">
|
||||
<img src="https://picsum.photos/id/685/2400/1600" alt="A walker sitting on the rocks of the ridge at dawn, looking out over a sea of cloud that fills the sound" width="2400" height="1600" fetchpriority="high">
|
||||
</figure>
|
||||
<header class="wx-hero">
|
||||
<h1 class="wx-title">Skarven</h1>
|
||||
<p class="wx-tagline">Eleven rooms on a ridge above the Skarven sound, open from May to October.</p>
|
||||
</header>
|
||||
<p class="wx-note">Inside the cloud, 800 metres up</p>
|
||||
<figure class="wx-below">
|
||||
<img src="https://picsum.photos/id/916/2400/1500" alt="A turquoise sound between forested mountains, a road winding through the trees below" width="2400" height="1500" loading="eager">
|
||||
</figure>
|
||||
<h2 class="wx-statement">
|
||||
<span class="wx-line">Below the cloud,</span>
|
||||
<span class="wx-line">the whole sound opens.</span>
|
||||
</h2>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stay" id="stay" aria-labelledby="stay-title">
|
||||
<h2 id="stay-title">Eleven rooms and one long table</h2>
|
||||
<p class="stay__text">Skarven sits at 1,140 metres on the north ridge. Most mornings the cloud fills the sound and burns off by nine; from then on you can count the boats. The last stretch is a stone path, forty minutes on foot, and the goods lift carries your bags.</p>
|
||||
<dl id="getting-here">
|
||||
<dt>Open</dt><dd>Mid May to early October</dd>
|
||||
<dt>Rooms</dt><dd>Eleven, every one facing the water</dd>
|
||||
<dt>From the coast road</dt><dd>Two hours by car, then the path</dd>
|
||||
<dt>Dinner</dt><dd>One sitting at seven, at the long table</dd>
|
||||
</dl>
|
||||
<a class="stay__ask" id="ask" href="mailto:rooms@skarven.example">Ask for a room</a>
|
||||
<figure>
|
||||
<img src="https://picsum.photos/id/208/1000/1250" alt="A multi-pane window in a dark room looking onto green trees" width="1000" height="1250" loading="lazy" decoding="async">
|
||||
<figcaption>Room four, the window over the birches.</figcaption>
|
||||
</figure>
|
||||
<figure class="stay__table">
|
||||
<img src="https://picsum.photos/id/635/2400/1030" alt="Enamel mugs and a teapot on a wooden table in warm evening light" width="2400" height="1030" loading="lazy" decoding="async">
|
||||
<figcaption>The long table, laid for the seven o'clock sitting.</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="foot">
|
||||
<span>Skarven, north ridge above the Skarven sound</span>
|
||||
<span>Open mid May to early October</span>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../descend-through-weather.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,177 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Halden Camera Works</title>
|
||||
<meta name="description" content="The Halden 35, a fixed-lens camera made by hand in a workshop above the harbour in Halden, Norway.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400..700&display=swap">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../fixed-hero-object.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #e4e4e0;
|
||||
--night: #161714;
|
||||
--ink: #151614;
|
||||
--paper: #ecebe6;
|
||||
--accent: #ff5a1f;
|
||||
--font-display: "Schibsted Grotesk", system-ui, sans-serif;
|
||||
--font-text: "Schibsted Grotesk", system-ui, sans-serif;
|
||||
--margin: clamp(20px, 4.4vw, 72px);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--ground); color: var(--ink); }
|
||||
body { margin: 0; font: 400 18px/1.5 var(--font-text); -webkit-font-smoothing: antialiased; }
|
||||
a { color: inherit; }
|
||||
|
||||
.top { min-height: 100svh; display: grid; grid-template-rows: auto 1fr; row-gap: clamp(24px, 4svh, 48px); padding: var(--margin); }
|
||||
.bar { display: flex; align-items: baseline; gap: 32px; }
|
||||
.bar strong { font-weight: 600; letter-spacing: -0.01em; margin-right: auto; }
|
||||
.bar a { text-decoration: none; font-weight: 500; }
|
||||
.bar a:hover, .bar a:focus-visible { text-decoration: underline; text-underline-offset: 4px; }
|
||||
.intro { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 34vw); gap: 32px clamp(24px, 4vw, 72px); }
|
||||
.intro-text { align-self: end; }
|
||||
.intro h1 { margin: 0 0 32px; font-weight: 600; font-size: clamp(56px, 7vw, 120px); line-height: 0.92; letter-spacing: -0.035em; }
|
||||
.intro p { margin: 0 0 32px; font-size: 22px; line-height: 1.45; max-width: 30ch; }
|
||||
.intro figure { margin: 0; min-height: 0; }
|
||||
.intro img { display: block; width: 100%; height: 100%; min-height: 320px; object-fit: cover; }
|
||||
|
||||
.button { display: inline-flex; align-items: center; height: 48px; padding: 0 28px; border-radius: 999px; text-decoration: none; font-weight: 500; font-size: 16px; transition: background-color 180ms ease; }
|
||||
.button--ink { background: var(--ink); color: var(--paper); }
|
||||
.button--ink:hover, .button--ink:focus-visible { background: #33352f; }
|
||||
.button--accent { background: var(--accent); color: #151614; }
|
||||
.button--accent:hover, .button--accent:focus-visible { background: #ff7a48; }
|
||||
|
||||
.workshop { background: var(--ground); padding: clamp(112px, 13vw, 208px) var(--margin) 0; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 32px clamp(24px, 4vw, 72px); align-items: end; }
|
||||
.workshop h2 { margin: 0; font-weight: 600; font-size: clamp(36px, 5vw, 88px); line-height: 0.98; letter-spacing: -0.035em; text-wrap: balance; }
|
||||
.workshop p { margin: 0; max-width: 36ch; font-size: 20px; line-height: 1.5; }
|
||||
.workshop figure { grid-column: 1 / -1; margin: clamp(32px, 5vw, 72px) calc(-1 * var(--margin)) 0; }
|
||||
.workshop img { display: block; width: 100%; height: auto; aspect-ratio: 16 / 7; object-fit: cover; }
|
||||
|
||||
.close { background: var(--night); color: var(--paper); padding: clamp(112px, 13vw, 208px) var(--margin) var(--margin); display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 40px clamp(24px, 4vw, 72px); align-items: end; }
|
||||
.close h2 { margin: 0 0 24px; font-weight: 600; font-size: clamp(36px, 5vw, 88px); line-height: 0.98; letter-spacing: -0.035em; text-wrap: balance; }
|
||||
.close p { margin: 0 0 32px; max-width: 34ch; }
|
||||
.close figure { margin: 0; }
|
||||
.close img { display: block; width: 100%; height: auto; aspect-ratio: 16 / 10; object-fit: cover; }
|
||||
.credits { grid-column: 1 / -1; margin: clamp(80px, 9vw, 140px) 0 0; font-size: 16px; max-width: 70ch; color: color-mix(in srgb, var(--paper) 66%, var(--night)); }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.intro { grid-template-columns: 1fr; }
|
||||
.intro figure { order: 2; }
|
||||
.intro img { height: auto; aspect-ratio: 4 / 3; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.bar a:not(:last-child) { display: none; }
|
||||
.intro h1 { font-size: clamp(40px, 11.4vw, 76px); }
|
||||
.intro p { font-size: 18px; }
|
||||
.workshop, .close { grid-template-columns: 1fr; }
|
||||
.workshop p { font-size: 18px; }
|
||||
.workshop img { aspect-ratio: 4 / 3; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="top">
|
||||
<nav class="bar" aria-label="Main">
|
||||
<strong>Halden Camera Works</strong>
|
||||
<a href="#the-35">The 35</a>
|
||||
<a href="#specs">Specs</a>
|
||||
<a href="#workshop">Workshop</a>
|
||||
<a href="#order">Order</a>
|
||||
</nav>
|
||||
<div class="intro">
|
||||
<div class="intro-text">
|
||||
<h1>Halden Camera Works</h1>
|
||||
<p>We make one camera, the 35, by hand in a workshop above the harbour in Halden, Norway. The first two hundred ship in May.</p>
|
||||
<a class="button button--ink" href="#order">Order the 35</a>
|
||||
</div>
|
||||
<figure>
|
||||
<img src="https://picsum.photos/id/212/1000/1250" width="1000" height="1250" alt="A bicycle with a basket parked on a wet city street">
|
||||
</figure>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="scene" id="the-35" data-scene="fixed-hero-object" aria-label="The Halden 35"
|
||||
data-options='{"frames":"https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/fixed-hero-object/d/{000}.webp","count":72,"phoneFrames":"https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/fixed-hero-object/p/{000}.webp","phoneCount":36}'>
|
||||
<div class="scene-stage">
|
||||
<div class="fho-track">
|
||||
<article class="fho-panel">
|
||||
<h2 class="fho-title"><span class="fho-line">Small enough</span> <span class="fho-line">to always take</span></h2>
|
||||
<p class="fho-caption">A fixed 35 mm lens, a quiet leaf shutter and a body cut from one block of aluminium. Nothing to swap.</p>
|
||||
<figure class="fho-still"><picture><source media="(max-width: 767px)" srcset="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/fixed-hero-object/p/000.webp"><img src="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/fixed-hero-object/d/000.webp" width="1280" height="960" alt="The Halden 35, a moss green camera with a silver top plate, seen from the front left"></picture></figure>
|
||||
</article>
|
||||
|
||||
<article class="fho-panel" id="shutter" data-frame="66">
|
||||
<div class="fho-copy">
|
||||
<h3 class="fho-head"><span class="fho-line">A shutter you</span> <span class="fho-line">barely hear</span></h3>
|
||||
<p>The leaf shutter sits inside the lens, so nothing slaps or swings when you press. At a reading, a market or a sleeping child, the loudest thing in the room is still the room.</p>
|
||||
</div>
|
||||
<figure class="fho-still"><picture><source media="(max-width: 767px)" srcset="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/fixed-hero-object/p/033.webp"><img src="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/fixed-hero-object/d/066.webp" width="1280" height="960" loading="lazy" alt="The Halden 35 from the front left, its lens turned toward the text"></picture></figure>
|
||||
</article>
|
||||
|
||||
<article class="fho-panel is-dark" id="specs" data-frame="18">
|
||||
<div class="fho-copy">
|
||||
<h3 class="fho-head"><span class="fho-line">The camera</span> <span class="fho-line">in five lines</span></h3>
|
||||
<dl class="fho-specs">
|
||||
<div><dt>Lens</dt><dd>35 mm f/2</dd></div>
|
||||
<div><dt>Leaf shutter</dt><dd>1/2000 s</dd></div>
|
||||
<div><dt>Sensor</dt><dd>24.2 MP</dd></div>
|
||||
<div><dt>Weight</dt><dd>412 g</dd></div>
|
||||
<div><dt>Body depth</dt><dd>34 mm</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<figure class="fho-still"><picture><source media="(max-width: 767px)" srcset="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/fixed-hero-object/p/009.webp"><img src="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/fixed-hero-object/d/018.webp" width="1280" height="960" loading="lazy" alt="The Halden 35 from the front right, its lens facing the spec sheet"></picture></figure>
|
||||
</article>
|
||||
|
||||
<article class="fho-panel fho-dock is-dark">
|
||||
<h3 class="fho-head"><span class="fho-line">A full day</span> <span class="fho-line">on one charge</span></h3>
|
||||
<figure class="fho-still"><picture><source media="(max-width: 767px)" srcset="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/fixed-hero-object/p/000.webp"><img src="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/fixed-hero-object/d/000.webp" width="1280" height="960" loading="lazy" alt=""></picture></figure>
|
||||
<svg class="fho-gauge" viewBox="0 0 200 104" style="--fho-v:.72" aria-hidden="true">
|
||||
<path class="fho-arc" pathLength="100" d="M8 100A92 92 0 0 1 192 100"/>
|
||||
<path class="fho-arc fho-arc--fill" pathLength="100" d="M8 100A92 92 0 0 1 192 100"/>
|
||||
</svg>
|
||||
<ul class="fho-readouts">
|
||||
<li><b class="fho-num">312</b> <span class="fho-unit">frames</span> <span class="fho-label">in one day</span></li>
|
||||
<li><b class="fho-num">72</b> <span class="fho-unit">%</span> <span class="fho-label">battery left</span></li>
|
||||
<li><b class="fho-num">0.4</b> <span class="fho-unit">s</span> <span class="fho-label">to first frame</span></li>
|
||||
</ul>
|
||||
<a class="fho-cta" href="#order">Order the 35</a>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workshop" id="workshop" aria-labelledby="workshop-title">
|
||||
<h2 id="workshop-title">One bench above the harbour</h2>
|
||||
<p>Four of us cut, finish and assemble every 35 in Halden. Each body is checked by the person who built it, and their name goes inside the base plate.</p>
|
||||
<figure>
|
||||
<img src="https://picsum.photos/id/491/1600/1000" width="1600" height="1000" loading="lazy" alt="A hammer, pliers and leather gloves laid out on a dark wooden bench">
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section class="close" id="order">
|
||||
<div>
|
||||
<h2>Two hundred, then a pause</h2>
|
||||
<p>We build in batches, so every camera is checked before it leaves. Order now and yours ships from Halden in May with a five-year repair promise.</p>
|
||||
<a class="button button--accent" href="#order">Order the 35</a>
|
||||
</div>
|
||||
<figure>
|
||||
<img src="https://picsum.photos/id/1065/1600/1000" width="1600" height="1000" loading="lazy" alt="A narrow alley between old stone buildings opening onto the sea">
|
||||
</figure>
|
||||
<p class="credits">Photographs by Sylwia Bartyzel, Todd Quackenbush and Bertrand Zuchuat, via Unsplash. The camera on this page is a render made for this demo; Halden Camera Works is an invented maker.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../fixed-hero-object.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,286 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Skarfjell Observatory</title>
|
||||
<meta name="description" content="A public observatory 1,420 metres up the Skarfjell ridge, open to guests on clear winter nights from October to March.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,100..900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../fly-into-the-shape.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #e9e8e4;
|
||||
--ink: #121212;
|
||||
--accent: #1f5bff;
|
||||
--portal-night: #05060b;
|
||||
--font-display: "Archivo", system-ui, sans-serif;
|
||||
--font-text: "Archivo", system-ui, sans-serif;
|
||||
--gutter: clamp(16px, 3vw, 40px);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-text);
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: inherit; }
|
||||
img { display: block; max-width: 100%; }
|
||||
|
||||
/* the bar: wordmark, three links, one action */
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 18px var(--gutter);
|
||||
font-size: 18px;
|
||||
}
|
||||
.brand { display: inline-flex; align-items: center; gap: 12px; font-weight: 700; text-decoration: none; font-size: 18px; }
|
||||
.brand svg { width: 30px; height: 30px; fill: var(--accent); }
|
||||
.bar nav { display: flex; align-items: center; gap: clamp(16px, 2.6vw, 36px); }
|
||||
.bar nav a { text-decoration: none; }
|
||||
.bar nav a.book {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0 20px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* first screen: the name and what it is, against the ridge at dusk */
|
||||
.opening {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.08fr) minmax(0, 1fr);
|
||||
gap: 0 var(--gutter);
|
||||
min-height: calc(100svh - 80px);
|
||||
padding: 0 0 0 var(--gutter);
|
||||
}
|
||||
.opening-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding: 48px 0 clamp(40px, 7svh, 88px);
|
||||
}
|
||||
.opening h1 {
|
||||
margin: 0;
|
||||
font-weight: 800;
|
||||
font-stretch: 87.5%;
|
||||
font-size: clamp(48px, 7.3vw, 124px);
|
||||
line-height: 0.9;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.opening-text p {
|
||||
max-width: 30rem;
|
||||
margin: clamp(24px, 4svh, 40px) 0 0;
|
||||
font-size: 22px;
|
||||
line-height: 1.4;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.opening-pic { margin: 0; min-height: 420px; }
|
||||
.opening-pic img { width: 100%; height: 100%; object-fit: cover; object-position: 72% 50%; }
|
||||
|
||||
/* the scene's title, in the wide cut of the same family */
|
||||
.scene .portal-title { font-stretch: 112%; letter-spacing: -0.02em; }
|
||||
|
||||
/* the chapter inside: the same night ground the world ends on */
|
||||
.night-plan {
|
||||
background: var(--portal-night);
|
||||
color: #e7ebf3;
|
||||
padding: clamp(96px, 16svh, 200px) var(--gutter) clamp(120px, 18svh, 220px);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
|
||||
gap: 48px clamp(32px, 6vw, 112px);
|
||||
align-items: start;
|
||||
}
|
||||
.night-plan h2 {
|
||||
margin: 0 0 clamp(36px, 6svh, 64px);
|
||||
font-weight: 800;
|
||||
font-stretch: 87.5%;
|
||||
font-size: clamp(36px, 4.4vw, 68px);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.02em;
|
||||
color: #f5f7ff;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.times { list-style: none; margin: 0; padding: 0; display: grid; gap: 28px; }
|
||||
.times li { display: grid; grid-template-columns: 6.5rem minmax(0, 1fr); gap: 16px; }
|
||||
.times time { font-weight: 700; font-size: 22px; line-height: 1.3; color: #f5f7ff; font-variant-numeric: tabular-nums; }
|
||||
.times p { margin: 0; max-width: 34rem; color: #c9d2e6; }
|
||||
.night-plan figure { margin: 0; }
|
||||
.night-plan figure img { width: 100%; aspect-ratio: 1; object-fit: cover; object-position: 20% 50%; }
|
||||
.night-plan figcaption { margin-top: 14px; font-size: 16px; color: #8a93a8; }
|
||||
|
||||
/* the way in looks up at the sky: the summit stays low in the frame */
|
||||
#nights { --portal-focus: 50% 0%; }
|
||||
|
||||
/* the close, back on the paper (the scene's own dwell already gives the pause before it) */
|
||||
.close { padding: clamp(40px, 7svh, 80px) var(--gutter) clamp(32px, 5svh, 56px); container-type: inline-size; }
|
||||
.close .cols {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 32rem);
|
||||
gap: 32px 48px;
|
||||
align-items: start;
|
||||
}
|
||||
.close h2 {
|
||||
margin: 0;
|
||||
font-weight: 800;
|
||||
font-stretch: 87.5%;
|
||||
font-size: clamp(36px, 4.4vw, 68px);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.close p { margin: 0 0 1em; text-wrap: pretty; }
|
||||
.close .action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
margin-top: 8px;
|
||||
padding: 0 26px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.close .name {
|
||||
margin: clamp(80px, 14svh, 160px) 0 0;
|
||||
font-weight: 800;
|
||||
font-stretch: 112%;
|
||||
font-size: clamp(48px, 15.4cqi, 260px);
|
||||
line-height: 0.82;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.close .credit { margin-top: 32px; font-size: 16px; }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.night-plan, .close .cols { grid-template-columns: 1fr; }
|
||||
.night-plan figure { max-width: 34rem; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.bar nav a:not(.book) { display: none; }
|
||||
.brand-rest { display: none; }
|
||||
.opening { grid-template-columns: 1fr; min-height: 0; padding: 0; }
|
||||
.opening-text { padding: 40px var(--gutter) 40px; }
|
||||
.opening-pic { min-height: 0; }
|
||||
.opening-pic img { height: auto; aspect-ratio: 4 / 5; }
|
||||
.scene .portal-title { font-stretch: 100%; }
|
||||
.opening-text p { font-size: 20px; }
|
||||
.times li { grid-template-columns: 1fr; gap: 4px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="bar">
|
||||
<a class="brand" href="#top"><svg viewBox="0 0 100 100" aria-hidden="true"><use href="#skarfjell-dome"/></svg><span>Skarfjell<span class="brand-rest"> Observatory</span></span></a>
|
||||
<nav aria-label="Main">
|
||||
<a href="#nights">Nights</a>
|
||||
<a href="#night-plan">Getting up</a>
|
||||
<a href="#close">Prices</a>
|
||||
<a class="book" href="#close">Book a night</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="opening" id="top">
|
||||
<div class="opening-text">
|
||||
<h1>Skarfjell Observatory</h1>
|
||||
<p>A public observatory 1,420 metres up the Skarfjell ridge, an hour above the valley by the last cable car, open to guests on clear winter nights.</p>
|
||||
</div>
|
||||
<figure class="opening-pic">
|
||||
<img src="https://picsum.photos/id/866/1600/1067" alt="A snow ridge at dusk with a sharp peak behind it under pink cloud" width="1600" height="1067">
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<!-- the doorway into the night chapter -->
|
||||
<section class="scene" data-scene="fly-into-the-shape" data-dir="in" id="nights" aria-labelledby="nights-title">
|
||||
<div class="scene-stage">
|
||||
<div class="portal-outside">
|
||||
<p class="portal-aside">Winter nights, October to March</p>
|
||||
<p class="portal-cue" aria-hidden="true">Scroll</p>
|
||||
</div>
|
||||
<h2 class="portal-title portal-title--out" id="nights-title"><span class="portal-line">Lights off,</span> <span class="portal-line">eyes up</span></h2>
|
||||
<div class="portal-world">
|
||||
<picture>
|
||||
<source media="(max-width: 767px)" srcset="https://picsum.photos/id/869/900/1600" width="900" height="1600">
|
||||
<img class="portal-media" src="https://picsum.photos/id/869/2000/1800" alt="The Milky Way over the ridge, with the summit just catching the last of the light below it" width="2000" height="1800">
|
||||
</picture>
|
||||
<div class="portal-scrim" aria-hidden="true"></div>
|
||||
<div class="portal-tint" aria-hidden="true"></div>
|
||||
<svg class="portal-mark" viewBox="0 0 100 100" aria-hidden="true"><use href="#skarfjell-dome"/></svg>
|
||||
<p class="portal-sub">At ten the ridge goes dark, the east dome turns, and the sky you came for opens.</p>
|
||||
</div>
|
||||
<svg class="portal-src" viewBox="0 0 100 100" width="0" height="0" aria-hidden="true">
|
||||
<path id="skarfjell-dome" d="M 7.37 96 L 7.37 84.78 L 14.1 84.78 L 14.1 48.88 L 5.12 48.88 C 5.12 26.48 21.64 7.51 43.83 4.43 L 43.83 26.44 L 56.17 26.44 L 56.17 4.43 C 78.36 7.51 94.88 26.48 94.88 48.88 L 85.9 48.88 L 85.9 84.78 L 92.63 84.78 L 92.63 96 Z" data-anchor="50 61.2" data-rin="34.7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="night-plan" id="night-plan" aria-labelledby="plan-title">
|
||||
<div>
|
||||
<h2 id="plan-title">How a night on the ridge runs</h2>
|
||||
<ol class="times">
|
||||
<li><time>17:40</time><p>The last cable car leaves Hallsdal. Wear boots: the path from the top station is ten minutes on packed snow, lit at ankle height.</p></li>
|
||||
<li><time>19:00</time><p>Soup and rye bread in the hut while the domes cool to the night air, and a short talk on what is up tonight.</p></li>
|
||||
<li><time>22:00</time><p>Every light on the ridge goes out. The east dome opens on whatever stands highest; the west dome is yours to steer.</p></li>
|
||||
<li><time>01:30</time><p>The night car takes you down. If the sky closes in, we text you by four in the afternoon and move you to the next clear night.</p></li>
|
||||
</ol>
|
||||
</div>
|
||||
<figure>
|
||||
<img src="https://picsum.photos/id/232/1200/1200" alt="Two globe lamps glowing in falling snow at night" width="1200" height="1200" loading="lazy">
|
||||
<figcaption>The lamps at the top station stay on until the last guest is on the path.</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<!-- the way back out, at the chapter's end -->
|
||||
<section class="scene" data-scene="fly-into-the-shape" data-dir="out" id="leave" aria-labelledby="leave-title">
|
||||
<div class="scene-stage">
|
||||
<div class="portal-outside">
|
||||
<p class="portal-aside">First car down at half past one</p>
|
||||
</div>
|
||||
<h2 class="portal-title portal-title--out" id="leave-title"><span class="portal-line">See you</span> <span class="portal-line">on the ridge</span></h2>
|
||||
<div class="portal-world">
|
||||
<picture>
|
||||
<source media="(max-width: 767px)" srcset="https://picsum.photos/id/869/900/1600" width="900" height="1600">
|
||||
<img class="portal-media" src="https://picsum.photos/id/869/2000/1250" alt="Stars over a snowfield, the peak behind it still lit by the last of the sun" width="2000" height="1250">
|
||||
</picture>
|
||||
<div class="portal-scrim" aria-hidden="true"></div>
|
||||
<div class="portal-tint" aria-hidden="true"></div>
|
||||
<svg class="portal-mark" viewBox="0 0 100 100" aria-hidden="true"><use href="#skarfjell-dome"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="close" id="close">
|
||||
<div class="cols">
|
||||
<h2>Book a clear night</h2>
|
||||
<div>
|
||||
<p>Nights run from October to March, Thursday to Saturday, for up to 24 guests. A night costs 640 kr with the cable car both ways, soup in the hut and two hours under the domes.</p>
|
||||
<p>We watch the forecast for you. If the sky is closed, your night moves to the next clear one, or you get the money back.</p>
|
||||
<a class="action" href="#top">Book a night</a>
|
||||
</div>
|
||||
</div>
|
||||
<p class="name">Skarfjell</p>
|
||||
<p class="credit">Photographs by Samuel Zeller, Caleb Ralston and Hide Obara, from Unsplash via Lorem Picsum. Skarfjell Observatory is invented for this demo.</p>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../fly-into-the-shape.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,250 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Selkie Sea Baths · hinged sheets</title>
|
||||
<meta name="description" content="Demo of the hinged-sheets scroll scene: three paper tickets rise from a face-down deck, turn over and land in a row while the headline tips back behind them.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wdth,wght@12..96,75..100,300..800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../hinged-sheets.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #fafaf9;
|
||||
--paper: #fafaf9;
|
||||
--ink: #1b1b1b;
|
||||
--accent: #b62118;
|
||||
--font-display: 'Bricolage Grotesque', system-ui, sans-serif;
|
||||
--font-text: 'Bricolage Grotesque', system-ui, sans-serif;
|
||||
/* the weights and the corner every scene takes from the page: one variable font, so any weight is real */
|
||||
--display-weight: 800;
|
||||
--strong-weight: 600;
|
||||
--text-weight: 400;
|
||||
--radius: 0px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--ground); }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font: var(--text-weight) 18px/1.5 var(--font-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: inherit; }
|
||||
img { max-width: 100%; }
|
||||
|
||||
/* first screen */
|
||||
.hero { min-height: 100svh; display: grid; grid-template-rows: auto auto auto 1fr; }
|
||||
.bar { display: flex; align-items: center; gap: 32px; padding: 28px 4vw 0; font-size: 16px; }
|
||||
.bar__mark { margin-right: auto; color: var(--accent); font-weight: var(--display-weight); font-size: 22px; letter-spacing: -0.03em; text-decoration: none; }
|
||||
.bar a:not(.bar__mark):not(.bar__buy) { text-decoration: none; }
|
||||
.bar a:not(.bar__mark):not(.bar__buy):hover { text-decoration: underline; text-underline-offset: 4px; }
|
||||
.bar__buy { padding: 10px 20px; border-radius: var(--radius); background: var(--accent); color: var(--paper); font-weight: var(--strong-weight); text-decoration: none; }
|
||||
.hero h1 {
|
||||
margin: 9svh 0 0;
|
||||
color: var(--accent);
|
||||
padding: 0 4vw;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(56px, 11.6vw, 200px);
|
||||
font-weight: var(--display-weight);
|
||||
line-height: 0.88;
|
||||
letter-spacing: -0.055em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hero__lead {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
gap: 40px;
|
||||
padding: 0 4vw;
|
||||
margin: 36px 0 40px;
|
||||
}
|
||||
.hero__lead p { margin: 0; max-width: 31em; font-size: clamp(18px, 1.5vw, 22px); line-height: 1.4; }
|
||||
.hero__lead p + p { max-width: 16em; text-align: right; font-size: 16px; }
|
||||
.hero__photo { position: relative; margin: 0; min-height: 280px; }
|
||||
.hero__photo img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
|
||||
/* after the scene */
|
||||
.visit {
|
||||
display: grid;
|
||||
grid-template-columns: 4vw minmax(0, 6fr) 4vw minmax(0, 5fr) 4vw;
|
||||
padding: 16svh 0;
|
||||
background: #d5e6f4;
|
||||
}
|
||||
.visit__text { grid-column: 2; align-self: center; }
|
||||
.visit h2 {
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
max-width: 11ch;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(40px, 5vw, 84px);
|
||||
font-weight: var(--display-weight);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
.visit__text > p { margin: 32px 0 0; max-width: 30em; font-size: clamp(18px, 1.5vw, 22px); line-height: 1.5; }
|
||||
.hours { display: grid; grid-template-columns: auto 1fr; gap: 10px 32px; margin: 40px 0 0; font-size: 16px; }
|
||||
.hours dt { font-weight: var(--strong-weight); }
|
||||
.hours dd { margin: 0; font-variant-numeric: tabular-nums; }
|
||||
.visit figure { grid-column: 4; margin: 0; }
|
||||
.visit figure img { display: block; width: 100%; aspect-ratio: 4 / 5; object-fit: cover; border-radius: var(--radius); }
|
||||
.foot { background: var(--accent); color: var(--paper); padding: 12svh 4vw 40px; }
|
||||
.foot__name { margin: 0; font-family: var(--font-display); font-weight: var(--display-weight); font-size: clamp(48px, 11.6vw, 200px); line-height: 0.88; letter-spacing: -0.055em; white-space: nowrap; }
|
||||
.foot__small { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px 40px; margin-top: 64px; font-size: 16px; line-height: 1.5; }
|
||||
.foot__small p { margin: 0; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.bar { padding: 20px 16px 0; gap: 16px; font-size: 16px; }
|
||||
.bar a:not(.bar__mark):not(.bar__buy) { display: none; }
|
||||
.hero h1 { margin-top: 12svh; padding: 0 16px; font-size: 15.4vw; white-space: normal; }
|
||||
.hero__lead { display: block; padding: 0 16px; margin: 24px 0 32px; }
|
||||
.hero__lead p + p { margin-top: 16px; text-align: left; }
|
||||
.visit { grid-template-columns: 16px minmax(0, 1fr) 16px; padding: 96px 0; row-gap: 48px; }
|
||||
.visit figure { grid-column: 2; }
|
||||
.foot { padding: 80px 16px 32px; }
|
||||
.foot__name { font-size: 15.4vw; white-space: normal; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="hero">
|
||||
<nav class="bar" aria-label="Main">
|
||||
<a class="bar__mark" href="#">Selkie</a>
|
||||
<a href="#tickets">Tickets</a>
|
||||
<a href="#hours">Hours</a>
|
||||
<a href="#hours">The pier</a>
|
||||
<a class="bar__buy" href="#tickets">Buy a swim</a>
|
||||
</nav>
|
||||
<h1>Selkie Sea Baths</h1>
|
||||
<div class="hero__lead">
|
||||
<p>A wooden bath house at the end of the north pier in Varvik, with a ladder into the sea and a wood-fired sauna for after.</p>
|
||||
<p>Open every day of the year, from seven in the morning.</p>
|
||||
</div>
|
||||
<figure class="hero__photo">
|
||||
<img src="https://picsum.photos/id/317/1600/760" srcset="https://picsum.photos/id/317/900/540 900w, https://picsum.photos/id/317/1600/760 1600w, https://picsum.photos/id/317/2400/1140 2400w" sizes="100vw" width="1600" height="760" alt="A long wooden pier with railings running out to a pale, still sea at dawn.">
|
||||
</figure>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="scene" data-scene="hinged-sheets" id="tickets" aria-labelledby="hs-title">
|
||||
<div class="scene-stage">
|
||||
<div class="hs-head">
|
||||
<h2 class="hs-title" id="hs-title">The sea is open all winter</h2>
|
||||
<p class="hs-sub">Three tickets for the bath house at the end of the north pier, printed on card and punched at the door.</p>
|
||||
</div>
|
||||
<p class="hs-lead">Prices for October 2026 to April 2027</p>
|
||||
<ol class="hs-row" role="list">
|
||||
<li class="hs-slot">
|
||||
<div class="hs-sheet">
|
||||
<article class="hs-face hs-face--front">
|
||||
<div class="hs-body">
|
||||
<h3 class="hs-name">One swim</h3>
|
||||
<p class="hs-pitch">The sea, the wood-fired sauna and the hot tub on the deck, for one visit on any day of the season.</p>
|
||||
<ul class="hs-points">
|
||||
<li>Towel and locker</li>
|
||||
<li>Sauna until nine</li>
|
||||
<li>Tea on the jetty</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="hs-stub">
|
||||
<p class="hs-price"><span class="hs-amount">€14</span><span class="hs-per">a visit</span></p>
|
||||
<a class="hs-cta" href="#hours">Buy one swim</a>
|
||||
</div>
|
||||
</article>
|
||||
<div class="hs-face hs-face--back" aria-hidden="true">
|
||||
<span class="hs-big">1 swim</span>
|
||||
<span class="hs-brand">Selkie Sea Baths</span>
|
||||
<span class="hs-small">Any day of the season</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="hs-slot">
|
||||
<div class="hs-sheet">
|
||||
<article class="hs-face hs-face--front">
|
||||
<div class="hs-body">
|
||||
<h3 class="hs-name">Ten swims</h3>
|
||||
<p class="hs-pitch">A card we punch at the door. Use it on any ten days within the year, alone or with a friend.</p>
|
||||
<ul class="hs-points">
|
||||
<li>Share it with a friend</li>
|
||||
<li>Towel and locker</li>
|
||||
<li>Twelve months from first punch</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="hs-stub">
|
||||
<p class="hs-price"><span class="hs-amount">€110</span><span class="hs-per">for ten visits</span></p>
|
||||
<a class="hs-cta" href="#hours">Buy ten swims</a>
|
||||
</div>
|
||||
</article>
|
||||
<div class="hs-face hs-face--back" aria-hidden="true">
|
||||
<span class="hs-big">10 swims</span>
|
||||
<span class="hs-brand">Selkie Sea Baths</span>
|
||||
<span class="hs-small">Punched at the door</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="hs-slot">
|
||||
<div class="hs-sheet">
|
||||
<article class="hs-face hs-face--front">
|
||||
<div class="hs-body">
|
||||
<h3 class="hs-name">Winter season</h3>
|
||||
<p class="hs-pitch">Every day from October to April, when the sea is coldest, the sauna hottest and the pier is yours at dawn.</p>
|
||||
<ul class="hs-points">
|
||||
<li>As many swims as you like</li>
|
||||
<li>Full moon night swims</li>
|
||||
<li>Your own hook inside</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="hs-stub">
|
||||
<p class="hs-price"><span class="hs-amount">€290</span><span class="hs-per">October to April</span></p>
|
||||
<a class="hs-cta" href="#hours">Buy the season</a>
|
||||
</div>
|
||||
</article>
|
||||
<div class="hs-face hs-face--back" aria-hidden="true">
|
||||
<span class="hs-big">Winter</span>
|
||||
<span class="hs-brand">Selkie Sea Baths</span>
|
||||
<span class="hs-small">1 October to 30 April</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<p class="hs-note">Children under twelve swim free with an adult. Ten-swim cards can be shared, and run twelve months from the first punch.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="visit" id="hours" aria-labelledby="visit-title">
|
||||
<div class="visit__text">
|
||||
<h2 id="visit-title">At the end of the north pier</h2>
|
||||
<p>Walk past the fish market and keep going until the boards run out. Lockers, towels and tea are inside; the sea is down the ladder, and the sauna is lit an hour before we open.</p>
|
||||
<dl class="hours">
|
||||
<dt>Weekdays</dt><dd>7:00 to 21:00</dd>
|
||||
<dt>Weekends</dt><dd>8:00 to 20:00</dd>
|
||||
<dt>Full moon</dt><dd>Until midnight</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<figure>
|
||||
<img src="https://picsum.photos/id/606/800/1000" srcset="https://picsum.photos/id/606/600/750 600w, https://picsum.photos/id/606/1000/1250 1000w" sizes="(max-width: 767px) 92vw, 40vw" width="800" height="1000" alt="A wooden boardwalk winding through grassy dunes towards the sea." loading="lazy">
|
||||
</figure>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="foot">
|
||||
<p class="foot__name">Selkie Sea Baths</p>
|
||||
<div class="foot__small">
|
||||
<p>North pier, Varvik harbour. Ten minutes on foot from the station.</p>
|
||||
<p>Lifeguard on the ladder whenever the baths are open.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../hinged-sheets.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,127 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Varde Mountain School</title>
|
||||
<meta name="description" content="Two-day guided climbs from the fjord to the summit of the Skarvan ridge, in ropes of four.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,100..900&display=swap">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../instrument-dial-stage.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #e6e8e7;
|
||||
--ink: #101315;
|
||||
--accent: #f04e1d;
|
||||
--font-display: "Archivo", system-ui, sans-serif;
|
||||
--font-text: "Archivo", system-ui, sans-serif;
|
||||
--margin: clamp(20px, 3.4vw, 48px);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--ground); color: var(--ink); }
|
||||
body { margin: 0; font: 400 18px/1.5 var(--font-text); -webkit-font-smoothing: antialiased; }
|
||||
a { color: inherit; }
|
||||
|
||||
/* the first screen: the bar, the ridge the climb follows, and the title under it */
|
||||
.top { height: 100svh; min-height: 560px; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; gap: clamp(20px, 2.4vw, 36px); padding: var(--margin); }
|
||||
.bar { display: flex; justify-content: space-between; align-items: center; gap: 24px; font-size: 18px; }
|
||||
.bar strong { font-weight: 600; letter-spacing: -0.01em; }
|
||||
.lead { margin: 0; min-height: 240px; overflow: hidden; background: #cfd3d2; }
|
||||
.lead img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: 50% 60%; }
|
||||
.hero { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 24rem); gap: 20px clamp(24px, 4vw, 72px); align-items: end; }
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: clamp(44px, 7.2vw, 124px);
|
||||
line-height: 0.92;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
.hero p { margin: 0 0 0.4em; font-size: clamp(18px, 1.45vw, 22px); line-height: 1.45; }
|
||||
|
||||
.close { padding: clamp(112px, 13vw, 208px) var(--margin) var(--margin); display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 26rem); gap: 32px clamp(24px, 4vw, 72px); }
|
||||
.close h2 { margin: 0; font-family: var(--font-display); font-weight: 600; font-size: clamp(36px, 4.6vw, 72px); line-height: 1; letter-spacing: -0.025em; max-width: 14ch; }
|
||||
.close p { margin: 0 0 24px; }
|
||||
.button { display: inline-block; padding: 14px 24px; line-height: 1.2; background: var(--ink); color: var(--ground); text-decoration: none; font-weight: 500; transition: background-color 180ms ease, color 180ms ease; }
|
||||
.button:hover, .button:focus-visible { background: var(--accent); color: var(--ink); }
|
||||
.credits { grid-column: 1 / -1; margin: clamp(80px, 9vw, 140px) 0 0; font-size: 16px; max-width: 60ch; }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.hero, .close { grid-template-columns: 1fr; }
|
||||
.hero p { max-width: 34rem; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.lead { min-height: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="top">
|
||||
<nav class="bar" aria-label="Main">
|
||||
<strong>Varde Mountain School</strong>
|
||||
<a class="button" href="#book">Book a place</a>
|
||||
</nav>
|
||||
<figure class="lead"><img src="https://picsum.photos/id/393/1800/900" alt="A green mountain streaked with snow rising above a dark gravel plain" width="1800" height="900"></figure>
|
||||
<div class="hero">
|
||||
<h1>Fjord to summit in two days</h1>
|
||||
<p>Varde guides ropes of four up the Skarvan ridge every summer weekend, with one night in the hut at the edge of the glacier.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="scene" id="climb" data-scene="instrument-dial-stage" data-options='{}' aria-labelledby="climb-title">
|
||||
<div class="scene-stage">
|
||||
<header class="idl-head">
|
||||
<h2 id="climb-title" class="idl-title">One climb, four heights</h2>
|
||||
</header>
|
||||
<ol class="idl-items">
|
||||
<li class="idl-item" data-at="640" data-tone="#2f3b2a">
|
||||
<h3 class="idl-word">Valley</h3>
|
||||
<figure class="idl-subject"><img src="https://picsum.photos/id/66/840/1050" alt="Open grassland in a wide valley under low grey cloud, a lone peak far behind" width="840" height="1050"></figure>
|
||||
<p class="idl-code">Set off at six</p>
|
||||
<p class="idl-stat"><span class="idl-value">640 m</span> <span class="idl-caption">Car park by the fjord</span></p>
|
||||
</li>
|
||||
<li class="idl-item" data-at="1180" data-tone="#cdd6d0">
|
||||
<h3 class="idl-word">Forest</h3>
|
||||
<figure class="idl-subject"><img src="https://picsum.photos/id/260/840/1050" alt="Dark pines standing in mist, snowy peaks behind them" width="840" height="1050"></figure>
|
||||
<p class="idl-code">Lunch under the pines</p>
|
||||
<p class="idl-stat"><span class="idl-value">1,180 m</span> <span class="idl-caption">Where the trees thin out</span></p>
|
||||
</li>
|
||||
<li class="idl-item" data-at="2350" data-tone="#1d3a4a">
|
||||
<h3 class="idl-word">Glacier</h3>
|
||||
<figure class="idl-subject"><img src="https://picsum.photos/id/29/840/1050" alt="Snow-covered ridges falling into a deep valley under a blue sky" width="840" height="1050"></figure>
|
||||
<p class="idl-code">Roped up at dawn</p>
|
||||
<p class="idl-stat"><span class="idl-value">2,350 m</span> <span class="idl-caption">Hut at the edge of the ice</span></p>
|
||||
</li>
|
||||
<li class="idl-item" data-at="3060" data-tone="#c6dde2">
|
||||
<h3 class="idl-word">Summit</h3>
|
||||
<figure class="idl-subject"><img src="https://picsum.photos/id/327/840/1050" alt="Snow on a summit ridge under a clear teal sky, the moon above" width="840" height="1050"></figure>
|
||||
<p class="idl-code">On top by noon</p>
|
||||
<p class="idl-stat"><span class="idl-value">3,060 m</span> <span class="idl-caption">Cairn on the north top</span></p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="close" id="book" aria-labelledby="book-title">
|
||||
<h2 id="book-title">Ropes leave every Saturday at six</h2>
|
||||
<div>
|
||||
<p>From June to September, four climbers and one guide to a rope. Boots, crampons, the harness and the hut night are ours; bring a warm layer and your own lunch for the first day.</p>
|
||||
<a class="button" href="#book">Ask for a place</a>
|
||||
</div>
|
||||
<p class="credits">Photographs by Alex Talmon, Nicholas Swanson, Sylwia Bartyzel, Go Wild and Ryan Schroeder, through Unsplash and Lorem Picsum. Varde Mountain School and the Skarvan ridge are invented for this demo.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../instrument-dial-stage.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,220 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Studio Halde, selected work</title>
|
||||
<meta name="description" content="Studio Halde photographs coast, stone and the city after dark. Eight commissions from 2019 to 2026, in one scrollable index.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500&display=swap">
|
||||
<link rel="preconnect" href="https://picsum.photos">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../lens-over-thumbnail-stream.css">
|
||||
<style>
|
||||
:root {
|
||||
--ink: #161615;
|
||||
--ground: #efeeea;
|
||||
--font-display: 'Schibsted Grotesk', system-ui, sans-serif;
|
||||
--font-text: 'Schibsted Grotesk', system-ui, sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body { margin: 0; background: var(--ground); color: var(--ink); font: 400 16px/1.5 var(--font-text); }
|
||||
a { color: inherit; text-underline-offset: .2em; }
|
||||
.bar { display: flex; justify-content: space-between; align-items: baseline; gap: 24px; padding: 20px 32px; }
|
||||
.bar b { font-weight: 500; font-size: 21px; letter-spacing: -.01em; }
|
||||
.bar nav { display: flex; gap: 28px; }
|
||||
.bar nav a { text-decoration: none; }
|
||||
.bar nav a:hover { text-decoration: underline; }
|
||||
.intro { min-height: calc(100svh - 72px); display: grid; grid-template-rows: minmax(260px, 1fr) auto; gap: 28px; padding: 0 32px 28px; }
|
||||
.intro figure { position: relative; margin: 0; }
|
||||
.intro img { position: absolute; inset: 0; display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.intro-text { display: grid; gap: 22px; }
|
||||
.intro p { margin: 0; max-width: 40ch; font-size: 21px; line-height: 1.38; }
|
||||
h1, .name { margin: 0 0 0 -.04em; font-weight: 500; font-size: 17.4vw; line-height: .8; letter-spacing: -.055em; white-space: nowrap; }
|
||||
.close { padding: 220px 32px 0; display: grid; grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); column-gap: 32px; row-gap: 56px; align-items: end; }
|
||||
.close h2 { grid-column: 1 / -1; margin: 0; font-weight: 500; font-size: clamp(44px, 7.2vw, 128px); line-height: .9; letter-spacing: -.035em; }
|
||||
.close figure { margin: 0; }
|
||||
.close figure img { display: block; width: 100%; height: auto; }
|
||||
.close p { margin: 0 0 1em; max-width: 34ch; font-size: 21px; line-height: 1.38; }
|
||||
.close .mail { font-size: 21px; text-decoration-thickness: 2px; }
|
||||
footer { padding: 200px 32px 28px; }
|
||||
.name { display: block; }
|
||||
footer .credits { margin: 40px 0 0; max-width: 72ch; }
|
||||
footer .line { display: flex; justify-content: space-between; gap: 24px; margin: 24px 0 0; }
|
||||
@media (max-width: 767px) {
|
||||
.bar { padding: 18px 16px; }
|
||||
.bar b { font-size: 18px; }
|
||||
.bar nav { gap: 18px; }
|
||||
.intro { padding: 0 16px 24px; gap: 22px; min-height: calc(100svh - 64px); }
|
||||
.intro p { font-size: 19px; }
|
||||
.close { grid-template-columns: 1fr; padding: 140px 16px 0; row-gap: 32px; }
|
||||
.close p { font-size: 19px; }
|
||||
footer { padding: 140px 16px 20px; }
|
||||
footer .line { flex-direction: column; gap: 4px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body id="top">
|
||||
<header class="bar">
|
||||
<b>Studio Halde</b>
|
||||
<nav aria-label="Main"><a href="#work">Work</a><a href="#studio">Studio</a><a href="#contact">Contact</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<section class="intro" aria-labelledby="intro-title">
|
||||
<figure><img src="https://picsum.photos/id/1049/1800/900.webp" alt="A rock island reflected on a wet beach under a pale sky" width="1800" height="900" fetchpriority="high"></figure>
|
||||
<div class="intro-text">
|
||||
<p>Two photographers in Bergen. We make pictures of coast, stone and the city after dark for books, hotels and architects.</p>
|
||||
<h1 id="intro-title">Studio Halde</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="scene lens" id="work" data-scene="lens-over-thumbnail-stream" aria-labelledby="lens-title">
|
||||
<div class="scene-stage">
|
||||
<header class="lens-head">
|
||||
<h2 class="lens-title" id="lens-title">Selected work</h2>
|
||||
<p class="lens-range">Commissions from 2019 to 2026</p>
|
||||
<p class="lens-count" data-now="{k} of {n}">8 projects</p>
|
||||
</header>
|
||||
<div class="lens-field">
|
||||
<ol class="lens-track">
|
||||
<li class="lens-cap" aria-hidden="true">Index</li>
|
||||
<li class="lens-group" data-year="2019" data-cat="Editorial series">
|
||||
<h3 class="lens-name"><a class="lens-link" href="#tidewater">Tidewater</a> <span class="lens-year">2019</span></h3>
|
||||
<ul class="lens-thumbs">
|
||||
<li class="lens-thumb" data-phone="hide" style="--r:1.5"><img src="https://picsum.photos/id/626/600/400.webp" alt="" width="600" height="400" decoding="async"></li>
|
||||
<li class="lens-thumb" style="--r:0.8"><img src="https://picsum.photos/id/640/448/560.webp" alt="" width="448" height="560" decoding="async"></li>
|
||||
<li class="lens-thumb" style="--r:1"><img src="https://picsum.photos/id/853/480/480.webp" alt="" width="480" height="480" decoding="async"></li>
|
||||
<li class="lens-thumb" style="--r:1.6"><img src="https://picsum.photos/id/1052/640/400.webp" alt="" width="640" height="400" decoding="async"></li>
|
||||
<li class="lens-thumb lens-thumb--key" style="--r:1.5"><img src="https://picsum.photos/id/77/960/640.webp" alt="Wooden jetty leading to a small pavilion over a calm sea" width="960" height="640" decoding="async"></li>
|
||||
<li class="lens-thumb" style="--r:0.8"><img src="https://picsum.photos/id/606/448/560.webp" alt="" width="448" height="560" decoding="async"></li>
|
||||
<li class="lens-thumb" style="--r:1.5"><img src="https://picsum.photos/id/268/600/400.webp" alt="" width="600" height="400" decoding="async"></li>
|
||||
<li class="lens-thumb" data-phone="hide" style="--r:1"><img src="https://picsum.photos/id/404/480/480.webp" alt="" width="480" height="480" decoding="async"></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="lens-group" data-year="2020" data-cat="Book commission">
|
||||
<h3 class="lens-name"><a class="lens-link" href="#high-passes">High Passes</a> <span class="lens-year">2020</span></h3>
|
||||
<ul class="lens-thumbs">
|
||||
<li class="lens-thumb" data-phone="hide" style="--r:0.8"><img src="https://picsum.photos/id/235/448/560.webp" alt="" width="448" height="560" decoding="async"></li>
|
||||
<li class="lens-thumb" style="--r:1.5"><img src="https://picsum.photos/id/906/600/400.webp" alt="" width="600" height="400" decoding="async"></li>
|
||||
<li class="lens-thumb" style="--r:1"><img src="https://picsum.photos/id/830/480/480.webp" alt="" width="480" height="480" decoding="async"></li>
|
||||
<li class="lens-thumb" style="--r:1.5"><img src="https://picsum.photos/id/368/600/400.webp" alt="" width="600" height="400" decoding="async"></li>
|
||||
<li class="lens-thumb lens-thumb--key" style="--r:1.5"><img src="https://picsum.photos/id/450/960/640.webp" alt="Turquoise lake in a forested valley between snow-dusted mountains" width="960" height="640" decoding="async"></li>
|
||||
<li class="lens-thumb" style="--r:1"><img src="https://picsum.photos/id/591/480/480.webp" alt="" width="480" height="480" decoding="async"></li>
|
||||
<li class="lens-thumb" style="--r:1.6"><img src="https://picsum.photos/id/873/640/400.webp" alt="" width="640" height="400" decoding="async"></li>
|
||||
<li class="lens-thumb" data-phone="hide" style="--r:0.8"><img src="https://picsum.photos/id/1018/448/560.webp" alt="" width="448" height="560" decoding="async"></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="lens-group" data-year="2021" data-cat="Exhibition prints">
|
||||
<h3 class="lens-name"><a class="lens-link" href="#understorey">Understorey</a> <span class="lens-year">2021</span></h3>
|
||||
<ul class="lens-thumbs">
|
||||
<li class="lens-thumb" data-phone="hide" style="--r:1"><img src="https://picsum.photos/id/229/480/480.webp" alt="" width="480" height="480" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:1.6"><img src="https://picsum.photos/id/243/640/400.webp" alt="" width="640" height="400" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:0.8"><img src="https://picsum.photos/id/476/448/560.webp" alt="" width="448" height="560" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:1.5"><img src="https://picsum.photos/id/498/600/400.webp" alt="" width="600" height="400" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb lens-thumb--key" style="--r:1.5"><img src="https://picsum.photos/id/10/960/640.webp" alt="Dense evergreen forest above a blue lake" width="960" height="640" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:1.5"><img src="https://picsum.photos/id/919/600/400.webp" alt="" width="600" height="400" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:0.8"><img src="https://picsum.photos/id/923/448/560.webp" alt="" width="448" height="560" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" data-phone="hide" style="--r:1"><img src="https://picsum.photos/id/944/480/480.webp" alt="" width="480" height="480" decoding="async" loading="lazy"></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="lens-group" data-year="2022" data-cat="Hotel campaign">
|
||||
<h3 class="lens-name"><a class="lens-link" href="#still-water">Still Water</a> <span class="lens-year">2022</span></h3>
|
||||
<ul class="lens-thumbs">
|
||||
<li class="lens-thumb" data-phone="hide" style="--r:1.5"><img src="https://picsum.photos/id/124/600/400.webp" alt="" width="600" height="400" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:1"><img src="https://picsum.photos/id/469/480/480.webp" alt="" width="480" height="480" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:0.8"><img src="https://picsum.photos/id/509/448/560.webp" alt="" width="448" height="560" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:1.6"><img src="https://picsum.photos/id/637/640/400.webp" alt="" width="640" height="400" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb lens-thumb--key" style="--r:1.5"><img src="https://picsum.photos/id/434/960/640.webp" alt="Wooden jetty reaching into a green alpine lake ringed by forest" width="960" height="640" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:0.8"><img src="https://picsum.photos/id/916/448/560.webp" alt="" width="448" height="560" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:1"><img src="https://picsum.photos/id/1039/480/480.webp" alt="" width="480" height="480" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" data-phone="hide" style="--r:1.5"><img src="https://picsum.photos/id/1051/600/400.webp" alt="" width="600" height="400" decoding="async" loading="lazy"></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="lens-group" data-year="2023" data-cat="Architecture practice">
|
||||
<h3 class="lens-name"><a class="lens-link" href="#concrete-year">Concrete Year</a> <span class="lens-year">2023</span></h3>
|
||||
<ul class="lens-thumbs">
|
||||
<li class="lens-thumb" data-phone="hide" style="--r:1.5"><img src="https://picsum.photos/id/855/600/400.webp" alt="" width="600" height="400" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:0.8"><img src="https://picsum.photos/id/887/448/560.webp" alt="" width="448" height="560" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:1"><img src="https://picsum.photos/id/942/480/480.webp" alt="" width="480" height="480" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:1.6"><img src="https://picsum.photos/id/954/640/400.webp" alt="" width="640" height="400" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb lens-thumb--key" style="--r:1.5"><img src="https://picsum.photos/id/1081/960/640.webp" alt="Stepped white building corner against a pale blue sky" width="960" height="640" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:0.8"><img src="https://picsum.photos/id/101/448/560.webp" alt="" width="448" height="560" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:1.5"><img src="https://picsum.photos/id/677/600/400.webp" alt="" width="600" height="400" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" data-phone="hide" style="--r:1"><img src="https://picsum.photos/id/743/480/480.webp" alt="" width="480" height="480" decoding="async" loading="lazy"></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="lens-group" data-year="2024" data-cat="Magazine feature">
|
||||
<h3 class="lens-name"><a class="lens-link" href="#night-shift">Night Shift</a> <span class="lens-year">2024</span></h3>
|
||||
<ul class="lens-thumbs">
|
||||
<li class="lens-thumb" data-phone="hide" style="--r:0.8"><img src="https://picsum.photos/id/122/448/560.webp" alt="" width="448" height="560" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:1.5"><img src="https://picsum.photos/id/171/600/400.webp" alt="" width="600" height="400" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:1"><img src="https://picsum.photos/id/195/480/480.webp" alt="" width="480" height="480" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:1.5"><img src="https://picsum.photos/id/232/600/400.webp" alt="" width="600" height="400" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb lens-thumb--key" style="--r:1.5"><img src="https://picsum.photos/id/496/960/640.webp" alt="City skyline across the water under a deep blue dusk sky" width="960" height="640" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:1"><img src="https://picsum.photos/id/579/480/480.webp" alt="" width="480" height="480" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:1.6"><img src="https://picsum.photos/id/797/640/400.webp" alt="" width="640" height="400" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" data-phone="hide" style="--r:0.8"><img src="https://picsum.photos/id/1078/448/560.webp" alt="" width="448" height="560" decoding="async" loading="lazy"></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="lens-group" data-year="2025" data-cat="Restaurant identity">
|
||||
<h3 class="lens-name"><a class="lens-link" href="#long-table">Long Table</a> <span class="lens-year">2025</span></h3>
|
||||
<ul class="lens-thumbs">
|
||||
<li class="lens-thumb" data-phone="hide" style="--r:1"><img src="https://picsum.photos/id/42/480/480.webp" alt="" width="480" height="480" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:1.6"><img src="https://picsum.photos/id/102/640/400.webp" alt="" width="640" height="400" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:0.8"><img src="https://picsum.photos/id/225/448/560.webp" alt="" width="448" height="560" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:1.5"><img src="https://picsum.photos/id/292/600/400.webp" alt="" width="600" height="400" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb lens-thumb--key" style="--r:1.5"><img src="https://picsum.photos/id/625/960/640.webp" alt="Wooden dining table and white chairs beside tall windows" width="960" height="640" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:1.5"><img src="https://picsum.photos/id/312/600/400.webp" alt="" width="600" height="400" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:0.8"><img src="https://picsum.photos/id/429/448/560.webp" alt="" width="448" height="560" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" data-phone="hide" style="--r:1"><img src="https://picsum.photos/id/488/480/480.webp" alt="" width="480" height="480" decoding="async" loading="lazy"></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="lens-group" data-year="2026" data-cat="Product catalogue">
|
||||
<h3 class="lens-name"><a class="lens-link" href="#small-things">Small Things</a> <span class="lens-year">2026</span></h3>
|
||||
<ul class="lens-thumbs">
|
||||
<li class="lens-thumb" data-phone="hide" style="--r:1.5"><img src="https://picsum.photos/id/39/600/400.webp" alt="" width="600" height="400" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:1"><img src="https://picsum.photos/id/24/480/480.webp" alt="" width="480" height="480" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:0.8"><img src="https://picsum.photos/id/113/448/560.webp" alt="" width="448" height="560" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:1.6"><img src="https://picsum.photos/id/486/640/400.webp" alt="" width="640" height="400" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb lens-thumb--key" style="--r:1.5"><img src="https://picsum.photos/id/63/960/640.webp" alt="A cup of coffee seen from above on a bright red table" width="960" height="640" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:0.8"><img src="https://picsum.photos/id/535/448/560.webp" alt="" width="448" height="560" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" style="--r:1"><img src="https://picsum.photos/id/687/480/480.webp" alt="" width="480" height="480" decoding="async" loading="lazy"></li>
|
||||
<li class="lens-thumb" data-phone="hide" style="--r:1.5"><img src="https://picsum.photos/id/1068/600/400.webp" alt="" width="600" height="400" decoding="async" loading="lazy"></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
<div class="lens-playhead" aria-hidden="true"><i class="lens-line"></i><span class="lens-flag"></span></div>
|
||||
</div>
|
||||
<footer class="lens-foot" aria-hidden="true">
|
||||
<p class="lens-caption"><span class="lens-caption-line"></span><span class="lens-caption-line"></span></p>
|
||||
<p class="lens-meta"><span class="lens-meta-cat"></span><a class="lens-meta-link" href="#work" tabindex="-1">View the project</a></p>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="close" id="contact" aria-labelledby="close-title">
|
||||
<h2 id="close-title">Now booking 2027</h2>
|
||||
<figure><img src="https://picsum.photos/id/87/1200/800.webp" alt="A wooden bench on a timber deck beside rippling water" width="1200" height="800" loading="lazy"></figure>
|
||||
<div id="studio">
|
||||
<p>Write with a place, a season and a deadline. We travel with one camera bag and answer every letter within the week.</p>
|
||||
<a class="mail" href="mailto:studio@halde.example?subject=A%20commission%20for%202027">studio@halde.example</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<footer>
|
||||
<span class="name" aria-hidden="true">Studio Halde</span>
|
||||
<p class="credits">Photographs by May Pamintuan, Nick Diamantidis, Sarah Bürvenich, Stephen Radford, Annie Spratt, Aleksandra Boguslawska, davide ragusa, Cole Patrick, Tanvi Malik, Paul E. Harrer, Andras Toth, Luca Zanon, Tyssul Patel, Billy Onjea, Daniel Roe, Andrew Ridley, Paul Jarvis, Orlova Maria, Mr. Marco, Kim Daniel, Jon Ottosson, Sebastian Unrau, Tim Swaan, Ales Krivec, Anton Sulsky, Matthew Clark, Jeff Sheldon, Griffin Keller, Larry Chen, Andrew Coelho, Julien Moreau, Rodion Kutsaev, Roman Kraft, Carl Nenzen Loven, Todd Quackenbush, Christian Bardenhorst, Anthony Delanoix, Vadim Sherbakov, Riley Briggs, Matthew Skinner, Hide Obara, Israel Sundseth, Gabriel Santiago, Vladimir Kudinov, Breather, Luke Chesser, Ben Moore, Vee O, Webvilla, Sonja Langford, Glen Carrie, Monstruo Estudio, Justin Leibow, Alejandro Escamilla, Zugr, Florian Klauer, Padurariu Alexandru, Barcelona and Rosan Harmens, from <a href="https://unsplash.com">Unsplash</a> through <a href="https://picsum.photos">Lorem Picsum</a>. Studio Halde and its projects are invented for this demo.</p>
|
||||
<p class="line"><span>Photography from Bergen, since 2019</span><a href="#top">Back to the top</a></p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../lens-over-thumbnail-stream.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,152 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Halflicht, lighting for buildings after dark</title>
|
||||
<meta name="description" content="Halflicht is a practice of nine lighting designers in Ghent, lighting churches, museums, stations and squares since 2011.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400..900&display=swap">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../letter-physics.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #0a0d0b;
|
||||
--ink: #f2f3f0;
|
||||
--muted: #a9aea8;
|
||||
--font-display: "Schibsted Grotesk", system-ui, sans-serif;
|
||||
--font-text: "Schibsted Grotesk", system-ui, sans-serif;
|
||||
--gutter: max(16px, 3.5vw);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--ground); }
|
||||
body { margin: 0; background: var(--ground); color: var(--ink); font: 400 18px/1.55 var(--font-text); -webkit-font-smoothing: antialiased; }
|
||||
a { color: inherit; }
|
||||
img { display: block; width: 100%; height: auto; }
|
||||
|
||||
.bar { display: flex; align-items: center; gap: 36px; padding: 24px var(--gutter); }
|
||||
.bar__name { font: 800 24px/1 var(--font-display); letter-spacing: -.03em; text-decoration: none; margin-right: auto; }
|
||||
.bar nav { display: flex; gap: 28px; font-size: 16px; }
|
||||
.bar nav a { text-decoration: none; color: var(--muted); }
|
||||
.bar nav a:hover { color: var(--ink); }
|
||||
.bar__action { padding: 12px 20px; background: var(--ink); color: var(--ground); text-decoration: none; font-weight: 600; font-size: 16px; }
|
||||
|
||||
.hero { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 0 24px; padding: 88px var(--gutter) 0; }
|
||||
.hero h1 { grid-column: 1 / 10; margin: 0; font: 800 clamp(36px, 7.4vw, 120px)/.94 var(--font-display); letter-spacing: -.04em; text-wrap: balance; }
|
||||
.hero p { grid-column: 9 / 13; grid-row: 2; margin: 48px 0 0; max-width: 36ch; color: var(--muted); }
|
||||
.hero figure { grid-column: 1 / 13; grid-row: 3; margin: 72px calc(-1 * var(--gutter)) 0; }
|
||||
.hero img { aspect-ratio: 21 / 9; object-fit: cover; }
|
||||
|
||||
.rooms { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 0 24px; padding: 168px var(--gutter) 120px; }
|
||||
.rooms h2 { grid-column: 1 / 8; margin: 0; font: 800 clamp(34px, 4.4vw, 64px)/1 var(--font-display); letter-spacing: -.035em; }
|
||||
.rooms > p { grid-column: 9 / 13; margin: 6px 0 0; color: var(--muted); }
|
||||
.rooms figure { margin: 0; }
|
||||
.rooms figure:nth-of-type(1) { grid-column: 1 / 8; margin-top: 88px; }
|
||||
.rooms figure:nth-of-type(2) { grid-column: 9 / 13; margin-top: 240px; }
|
||||
.rooms figure:nth-of-type(3) { grid-column: 3 / 11; margin-top: 128px; }
|
||||
.rooms figure:nth-of-type(1) img { aspect-ratio: 4 / 3; object-fit: cover; }
|
||||
.rooms figure:nth-of-type(2) img { aspect-ratio: 3 / 4; object-fit: cover; }
|
||||
.rooms figure:nth-of-type(3) img { aspect-ratio: 16 / 9; object-fit: cover; }
|
||||
.rooms figcaption { margin-top: 14px; font-size: 16px; line-height: 1.45; color: var(--muted); }
|
||||
|
||||
/* the photograph's round window sits where the light will open */
|
||||
.lp .lp-bg-img { object-position: 50% 92%; }
|
||||
|
||||
.close { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 24px; padding: 64px var(--gutter) 56px; background: #151916; }
|
||||
.close h2 { grid-column: 1 / 8; margin: 0; font: 800 clamp(34px, 4.4vw, 64px)/1 var(--font-display); letter-spacing: -.03em; }
|
||||
.close p { grid-column: 1 / 7; margin: 0; max-width: 44ch; color: var(--muted); }
|
||||
.close a.close__action { grid-column: 9 / 13; grid-row: 1 / 3; align-self: end; justify-self: end; padding: 16px 24px; background: var(--ink); color: var(--ground); text-decoration: none; font-weight: 600; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.bar { gap: 16px; padding: 18px var(--gutter); }
|
||||
.bar nav { display: none; }
|
||||
.bar__action { padding: 10px 14px; }
|
||||
.hero { display: block; padding-top: 40px; }
|
||||
.hero p { margin-top: 24px; }
|
||||
.hero figure { margin: 48px calc(-1 * var(--gutter)) 0; }
|
||||
.hero img { aspect-ratio: 4 / 5; }
|
||||
.rooms { display: block; padding: 112px var(--gutter) 88px; }
|
||||
.rooms > p { margin-top: 20px; }
|
||||
.rooms figure:nth-of-type(1) { margin-top: 48px; }
|
||||
.rooms figure:nth-of-type(2) { margin-top: 40px; width: 72%; margin-left: auto; }
|
||||
.rooms figure:nth-of-type(3) { margin-top: 40px; }
|
||||
.close { display: block; padding: 56px var(--gutter) 48px; }
|
||||
.close p { margin-top: 16px; }
|
||||
.close a.close__action { display: inline-block; margin-top: 32px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="bar__name" href="#">Halflicht</a>
|
||||
<nav aria-label="Main"><a href="#work">How we work</a><a href="#rooms">Rooms</a><a href="#walks">Night walks</a></nav>
|
||||
<a class="bar__action" href="#walks">Book a night walk</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="hero" aria-labelledby="hero-title">
|
||||
<h1 id="hero-title">Halflicht lights buildings at night</h1>
|
||||
<p>Nine lighting designers in Ghent, working on churches, museums, stations and squares since 2011.</p>
|
||||
<figure>
|
||||
<picture>
|
||||
<source media="(max-width: 767px)" srcset="https://picsum.photos/id/546/800/1000" width="800" height="1000">
|
||||
<img src="https://picsum.photos/id/546/1680/720" width="1680" height="720" alt="A vaulted stone cloister with low evening light falling through the arches onto the floor">
|
||||
</picture>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section class="scene lp" id="work" data-scene="letter-physics" data-options='{}' aria-labelledby="lp-a">
|
||||
<div class="scene-stage">
|
||||
<div class="lp-bg" aria-hidden="true">
|
||||
<picture>
|
||||
<source media="(max-width: 767px)" srcset="https://picsum.photos/id/679/720/1280" width="720" height="1280">
|
||||
<img class="lp-bg-img" src="https://picsum.photos/id/679/1600/1100" srcset="https://picsum.photos/id/679/1600/1100 1600w, https://picsum.photos/id/679/2400/1650 2400w" sizes="100vw" width="1600" height="1100" alt="">
|
||||
</picture>
|
||||
<div class="lp-scrim"></div>
|
||||
</div>
|
||||
<div class="lp-space">
|
||||
<h2 class="lp-a" id="lp-a">Darkness is a material.</h2>
|
||||
</div>
|
||||
<div class="lp-pool">
|
||||
<div class="lp-halo" aria-hidden="true"></div>
|
||||
<div class="lp-disc" aria-hidden="true"></div>
|
||||
<p class="lp-b">We light a room by first deciding which corners stay dark.</p>
|
||||
</div>
|
||||
<div class="lp-labels"><p class="lp-label">How we work</p><p class="lp-label">Since 2011</p></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rooms" id="rooms" aria-labelledby="rooms-title">
|
||||
<h2 id="rooms-title">Rooms we lit this year</h2>
|
||||
<p>Each project starts with a night on site and every light switched off. We add only what the building asks for, and we come back a year later to look again.</p>
|
||||
<figure>
|
||||
<img src="https://picsum.photos/id/195/1200/900" width="1200" height="900" loading="lazy" alt="A narrow cobbled lane at night under strings of small warm lamps, shopfronts glowing on both sides">
|
||||
<figcaption>A lane in the Patershol, strung with low warm lamps hung under the gutters, out of the residents' windows.</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<img src="https://picsum.photos/id/232/600/800" width="600" height="800" loading="lazy" alt="A twin-globe street lamp glowing in falling snow">
|
||||
<figcaption>Twelve lamps for a square in Leuven, dimmed to a third after midnight.</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<img src="https://picsum.photos/id/942/1600/900" width="1600" height="900" loading="lazy" alt="A white angular concrete church rising against a black sky">
|
||||
<figcaption>A concrete church in Aalst, lit only from the ground, so its tower fades into the sky.</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section class="close" id="walks" aria-labelledby="close-title">
|
||||
<h2 id="close-title">Walk one of our buildings at night</h2>
|
||||
<p>Every second Thursday, from nine, one of us takes twelve people round a finished project and explains what we left dark.</p>
|
||||
<a class="close__action" href="#walks">Book a night walk</a>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../letter-physics.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,285 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Calhau, a fire kitchen above the bay</title>
|
||||
<meta name="description" content="Thirty-two seats at the end of the cliff road above Vale Escuro, open from sundown, with everything cooked over olive wood.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Familjen+Grotesk:wght@400..700&display=swap" rel="stylesheet">
|
||||
<link rel="preconnect" href="https://picsum.photos">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../light-flood.css">
|
||||
<style>
|
||||
/* The page's ground is the scene's day colour and its closing section is the scene's night, so the
|
||||
scene meets both with no seam. Ink 14.9:1 on the ground; the pale text 15.6:1 on the night. One
|
||||
typeface, six sizes: 15 small, 18 body, 20 lead, the closing title, the opening title (the same
|
||||
size as the scene's lead) and the scene's key phrase. */
|
||||
:root {
|
||||
--ground: #e6ebee;
|
||||
--ink: #121820;
|
||||
--night: #101820;
|
||||
--paper: #eef1f3;
|
||||
--font-display: "Familjen Grotesk", system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
--gutter: clamp(16px, 4vw, 56px);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: inherit; }
|
||||
img { display: block; max-width: 100%; }
|
||||
figure { margin: 0; }
|
||||
figcaption { margin-top: 12px; font-size: 15px; }
|
||||
|
||||
/* A plain bar. Fixed when scripts run (the scene turns it light over the night), in the page flow
|
||||
without them. */
|
||||
.bar {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
min-height: 72px;
|
||||
padding: 12px var(--gutter);
|
||||
color: var(--ink);
|
||||
transition: color .35s ease;
|
||||
}
|
||||
html.js .bar { position: fixed; }
|
||||
.bar .mark { text-decoration: none; font-size: 20px; font-weight: 700; letter-spacing: -0.02em; line-height: 1; }
|
||||
.bar nav { display: flex; align-items: center; gap: clamp(16px, 2.6vw, 36px); }
|
||||
.bar nav a { text-decoration: none; }
|
||||
.bar .book {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0 20px;
|
||||
background: var(--ink);
|
||||
color: var(--ground);
|
||||
font-weight: 600;
|
||||
transition: background-color .35s ease, color .35s ease;
|
||||
}
|
||||
/* over the opening the bar is a plain strip of the ground; from the scene on it is see-through (the
|
||||
scene starts on the same ground, so the change cannot be seen) */
|
||||
.bar.is-solid { background: var(--ground); }
|
||||
.bar.is-solid-night { background: var(--night); }
|
||||
.bar.is-dark { color: var(--paper); }
|
||||
.bar.is-dark .book { background: var(--paper); color: var(--night); }
|
||||
@media (max-width: 767px) { .bar nav .hide-s { display: none; } }
|
||||
|
||||
.opening { padding: calc(72px + 5svh) var(--gutter) clamp(40px, 7svh, 72px); }
|
||||
.opening .row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 34ch);
|
||||
gap: 28px 48px;
|
||||
}
|
||||
.opening h1 {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
font-size: clamp(2.25rem, 7.6vw, 8.5rem);
|
||||
font-weight: 500;
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.045em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.opening p { grid-column: 2; margin: 0; font-size: 20px; }
|
||||
.opening figure { margin-top: clamp(32px, 6svh, 64px); }
|
||||
.opening figure img { width: 100%; height: min(60svh, 46vw); object-fit: cover; object-position: 50% 40%; }
|
||||
@media (max-width: 1023px) {
|
||||
.opening .row { grid-template-columns: 1fr; }
|
||||
.opening p { grid-column: 1; max-width: 34ch; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.opening { padding-top: calc(72px + 4svh); }
|
||||
.opening figure img { height: 52svh; }
|
||||
}
|
||||
|
||||
/* The closing opens on a stretch of its own night ground, the same colour the scene ends on, so the
|
||||
pin releases onto more of the same night; the photograph comes after the heading, framed by it. */
|
||||
.closing {
|
||||
background: var(--night);
|
||||
color: var(--paper);
|
||||
padding: 0 var(--gutter) 40px;
|
||||
}
|
||||
.closing .intro {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
|
||||
gap: 28px clamp(32px, 6vw, 96px);
|
||||
align-items: end;
|
||||
padding: clamp(112px, 22svh, 240px) 0 clamp(56px, 9svh, 104px);
|
||||
}
|
||||
.closing h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(2.25rem, 4.6vw, 4.5rem);
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.closing .intro p { margin: 0; }
|
||||
/* the real view the scene abstracts: night over the water with the last light on the horizon */
|
||||
.closing .band { margin: 0 calc(-1 * var(--gutter)); }
|
||||
.closing .band img { width: 100%; height: min(78svh, 56vw); object-fit: cover; object-position: 50% 70%; }
|
||||
.closing .band figcaption { padding: 0 var(--gutter); }
|
||||
.closing .grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
|
||||
gap: clamp(32px, 5vw, 80px) clamp(32px, 6vw, 96px);
|
||||
margin-top: clamp(72px, 12svh, 144px);
|
||||
align-items: start;
|
||||
}
|
||||
.closing .text { grid-column: 1; grid-row: 1; }
|
||||
.closing .tall { grid-column: 2; grid-row: 1 / span 2; }
|
||||
.closing .wide { grid-column: 1; grid-row: 2; }
|
||||
.closing p { max-width: 44ch; margin: 28px 0 0; font-size: 20px; }
|
||||
.closing ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 20px; max-width: 46ch; }
|
||||
.closing li strong { display: block; font-weight: 600; }
|
||||
.closing .book {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 52px;
|
||||
margin-top: 44px;
|
||||
padding: 0 26px;
|
||||
background: var(--paper);
|
||||
color: var(--night);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.closing .tall img { width: 100%; height: auto; aspect-ratio: 4 / 5; object-fit: cover; }
|
||||
.closing .wide img { width: 100%; height: auto; aspect-ratio: 16 / 10; object-fit: cover; }
|
||||
.closing footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 12px 32px;
|
||||
margin-top: clamp(72px, 12svh, 140px);
|
||||
font-size: 15px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.closing .intro { grid-template-columns: 1fr; align-items: start; }
|
||||
.closing .grid { grid-template-columns: 1fr; }
|
||||
.closing .text, .closing .tall, .closing .wide { grid-column: 1; grid-row: auto; }
|
||||
.closing .band img { height: 60svh; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="bar">
|
||||
<a class="mark" href="#top">Calhau</a>
|
||||
<nav aria-label="Main">
|
||||
<a class="hide-s" href="#kitchen">The kitchen</a>
|
||||
<a class="hide-s" href="#find">Find us</a>
|
||||
<a class="book" href="#find">Book a table</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section class="opening" aria-labelledby="title">
|
||||
<div class="row">
|
||||
<h1 id="title">Calhau, a fire kitchen above the bay</h1>
|
||||
<p>Thirty-two seats at the end of the cliff road above Vale Escuro, open from sundown, with everything cooked over olive wood.</p>
|
||||
</div>
|
||||
<figure>
|
||||
<img src="https://picsum.photos/id/323/1600/1000"
|
||||
srcset="https://picsum.photos/id/323/1000/625 1000w, https://picsum.photos/id/323/1600/1000 1600w, https://picsum.photos/id/323/2400/1500 2400w"
|
||||
sizes="(max-width: 767px) 100vw, calc(100vw - 112px)" width="1600" height="1000"
|
||||
alt="Scrub-covered headlands falling to a deep blue bay under a pale afternoon sky">
|
||||
<figcaption>The bay at four in the afternoon, seen from the terrace.</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section class="scene" data-scene="light-flood" data-options='{"header": ".bar"}' aria-labelledby="lf-h">
|
||||
<div class="scene-stage">
|
||||
<div class="lf-ground" aria-hidden="true"></div>
|
||||
<div class="lf-ground lf-ground--core" aria-hidden="true"></div>
|
||||
<div class="lf-ground lf-ground--night" aria-hidden="true"></div>
|
||||
<div class="lf-sun" aria-hidden="true"><div class="lf-halo"></div><div class="lf-core"></div></div>
|
||||
<div class="lf-night" aria-hidden="true"><div class="lf-night__fill"></div></div>
|
||||
<h2 class="lf-line" id="lf-h">
|
||||
<span class="lf-lead">The sun goes down,</span>
|
||||
<span class="lf-key">the fire stays up.</span>
|
||||
</h2>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="closing" id="kitchen" aria-labelledby="closing-title">
|
||||
<div class="intro">
|
||||
<h2 id="closing-title">Tables from sundown, Wednesday to Sunday</h2>
|
||||
<p>The grill is lit an hour before the first table. Fish comes up from the harbour at five; what is not cooked over the coals that night goes back to the boats as bait.</p>
|
||||
</div>
|
||||
<figure class="band">
|
||||
<img src="https://picsum.photos/id/765/1600/1000" loading="lazy"
|
||||
srcset="https://picsum.photos/id/765/1000/625 1000w, https://picsum.photos/id/765/1600/1000 1600w, https://picsum.photos/id/765/2400/1500 2400w"
|
||||
sizes="100vw" width="1600" height="1000"
|
||||
alt="A dark night sky with a thin crescent moon above a flat horizon still glowing orange">
|
||||
<figcaption>Twenty to ten in August: the last of the light, from the end table.</figcaption>
|
||||
</figure>
|
||||
<div class="grid">
|
||||
<div class="text">
|
||||
<ul id="find">
|
||||
<li><strong>Thirty-two seats</strong>On the terrace until the end of October, then around the hearth inside.</li>
|
||||
<li><strong>From sundown until the last table</strong>The kitchen takes its last order at ten.</li>
|
||||
<li><strong>Rua do Vale Escuro 4</strong>At the end of the cliff road. Leave the car at the chapel and walk the last three hundred metres.</li>
|
||||
</ul>
|
||||
<a class="book" href="#find">Book a table</a>
|
||||
</div>
|
||||
<figure class="tall">
|
||||
<img src="https://picsum.photos/id/490/1000/1250" loading="lazy"
|
||||
srcset="https://picsum.photos/id/490/700/875 700w, https://picsum.photos/id/490/1000/1250 1000w"
|
||||
sizes="(max-width: 767px) 100vw, 40vw" width="1000" height="1250"
|
||||
alt="A worn wooden board, a cast-iron pan, cleavers, salt, pepper and rosemary on a dark table">
|
||||
<figcaption>The board, the pan and the salt, before service.</figcaption>
|
||||
</figure>
|
||||
<figure class="wide">
|
||||
<img src="https://picsum.photos/id/999/1200/750" loading="lazy"
|
||||
srcset="https://picsum.photos/id/999/800/500 800w, https://picsum.photos/id/999/1200/750 1200w"
|
||||
sizes="(max-width: 767px) 100vw, 55vw" width="1200" height="750"
|
||||
alt="A cake topped with whole poached pears on a wooden round, on a dark table with bowls of nuts">
|
||||
<figcaption>Pears baked in the embers, the one pudding we make.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<footer>
|
||||
<span>Calhau, Rua do Vale Escuro 4, 8670 Vale Escuro</span>
|
||||
<span>Bookings open at noon for the same evening</span>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../light-flood.js"></script>
|
||||
<script>
|
||||
Stage.smooth(); Stage.init();
|
||||
// the page's own bar: solid ground while the opening is under it, solid night once the closing is
|
||||
// (both match what the scene shows at its two ends, so neither change can be seen)
|
||||
// (either section may be missing, e.g. with the scene as the first section: then that half is skipped)
|
||||
(() => {
|
||||
const bar = document.querySelector('.bar'), opening = document.querySelector('.opening'), closing = document.querySelector('.closing');
|
||||
if (!bar || (!opening && !closing)) return;
|
||||
let raf = 0;
|
||||
const check = () => {
|
||||
raf = 0;
|
||||
const h = bar.offsetHeight;
|
||||
bar.classList.toggle('is-solid', !!opening && opening.getBoundingClientRect().bottom > h);
|
||||
bar.classList.toggle('is-solid-night', !!closing && closing.getBoundingClientRect().top < h);
|
||||
};
|
||||
addEventListener('scroll', () => { if (!raf) raf = requestAnimationFrame(check); }, { passive: true });
|
||||
check();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,128 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Holm, slow coffee from Gothenburg</title>
|
||||
<meta name="description" content="Holm is a roastery of four people and one fifteen-kilo drum in Gothenburg, roasting twice a week in small lots.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Mona+Sans:wdth,wght@75..125,200..900&display=swap">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../object-through-giant-type.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #16130f;
|
||||
--ink: #eae8e5;
|
||||
--muted: #a9a298;
|
||||
--font-display: "Mona Sans", system-ui, sans-serif;
|
||||
--font-text: "Mona Sans", system-ui, sans-serif;
|
||||
--gutter: max(16px, 4vw);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--ground); }
|
||||
body { margin: 0; background: var(--ground); color: var(--ink); font: 400 18px/1.55 var(--font-text); -webkit-font-smoothing: antialiased; }
|
||||
a { color: inherit; }
|
||||
img { display: block; width: 100%; height: auto; }
|
||||
|
||||
.bar { display: flex; align-items: center; gap: 40px; padding: 24px var(--gutter); }
|
||||
.bar__name { font: 700 26px/1 var(--font-display); font-stretch: 75%; letter-spacing: -.01em; text-transform: uppercase; text-decoration: none; margin-right: auto; }
|
||||
.bar nav { display: flex; gap: 28px; }
|
||||
.bar nav a { text-decoration: none; }
|
||||
.bar__action { padding: 12px 20px; background: var(--ink); color: var(--ground); text-decoration: none; font-weight: 600; }
|
||||
|
||||
.hero { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 0 24px; padding: 88px var(--gutter) 0; }
|
||||
.hero h1 { grid-column: 1 / 10; margin: 0; font: 600 clamp(48px, 8.4vw, 136px)/.9 var(--font-display); font-stretch: 75%; letter-spacing: -.02em; }
|
||||
.hero p { grid-column: 1 / 7; margin: 40px 0 0; max-width: 44ch; font-size: 21px; line-height: 1.5; color: var(--muted); }
|
||||
.hero figure { grid-column: 1 / -1; margin: 88px calc(-1 * var(--gutter)) 0; }
|
||||
.hero img { aspect-ratio: 16 / 7; object-fit: cover; }
|
||||
|
||||
.ott { --ott-stretch: 85%; }
|
||||
/* the clock is a photograph, not a cut-out: the page crops it square on the dial and rounds it */
|
||||
.ott-object img { aspect-ratio: 1; object-fit: cover; object-position: 15.8% 50%; border-radius: 50%; }
|
||||
|
||||
.close { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 32px 24px; padding: 160px var(--gutter) 72px; }
|
||||
.close h2 { grid-column: 1 / 7; margin: 0; font: 600 clamp(36px, 4.6vw, 68px)/.95 var(--font-display); font-stretch: 75%; letter-spacing: -.015em; }
|
||||
.close .close__visit { grid-column: 1 / 6; margin: 0; max-width: 40ch; color: var(--muted); }
|
||||
.close figure { margin: 0; }
|
||||
.close .close__tall { grid-column: 7 / 13; grid-row: 1 / 5; }
|
||||
.close .close__tall img { aspect-ratio: 4 / 5; object-fit: cover; }
|
||||
.close .close__wide { grid-column: 1 / 6; grid-row: 4; align-self: end; }
|
||||
.close .close__wide img { aspect-ratio: 3 / 2; object-fit: cover; }
|
||||
.close .close__action { grid-column: 1 / 6; justify-self: start; align-self: start; padding: 14px 22px; background: var(--ink); color: var(--ground); text-decoration: none; font-weight: 600; }
|
||||
.close .close__small { grid-column: 1 / -1; margin: 96px 0 0; font-size: 16px; line-height: 1.5; color: var(--muted); }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.bar { gap: 16px; padding: 18px var(--gutter); }
|
||||
.bar nav { display: none; }
|
||||
.bar__action { padding: 10px 14px; font-size: 16px; }
|
||||
.hero { display: block; padding-top: 48px; }
|
||||
.hero p { margin-top: 24px; font-size: 18px; }
|
||||
.hero figure { margin-top: 48px; }
|
||||
.hero img { aspect-ratio: 4 / 5; }
|
||||
.close { display: block; padding: 120px var(--gutter) 48px; }
|
||||
.close .close__visit { margin-top: 20px; }
|
||||
.close figure { margin: 40px 0 0; }
|
||||
.close .close__action { display: inline-block; margin-top: 32px; }
|
||||
.close .close__small { margin-top: 64px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="bar__name" href="#">Holm</a>
|
||||
<nav aria-label="Main"><a href="#coffee">Coffee</a><a href="#visit">Visit the roastery</a></nav>
|
||||
<a class="bar__action" href="#visit">Order beans</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="hero" id="coffee" aria-labelledby="hero-title">
|
||||
<h1 id="hero-title">Slow coffee from Gothenburg</h1>
|
||||
<p>Holm is four people and one fifteen-kilo drum in a backyard in Majorna. We roast on Tuesdays and Fridays, one farm at a time, and the beans leave within three days.</p>
|
||||
<figure>
|
||||
<picture>
|
||||
<source media="(max-width: 767px)" srcset="https://picsum.photos/id/766/800/1000" width="800" height="1000">
|
||||
<img src="https://picsum.photos/id/766/1600/700" width="1600" height="700" alt="Dark roasted coffee beans filling the whole frame">
|
||||
</picture>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section class="scene ott" data-scene="object-through-giant-type" data-options='{"mode": "flip", "rim": "#8d8a85", "thick": 0.1}' aria-label="No one here is in a rush. Not the drum, not the beans, and not you.">
|
||||
<div class="scene-stage">
|
||||
<blockquote class="ott-quote">
|
||||
<p class="ott-line"><span class="ott-half">No one</span> <span class="ott-half">here is</span></p>
|
||||
<p class="ott-line"><span class="ott-half">in a</span> <span class="ott-half">rush.</span></p>
|
||||
<p class="ott-line"><span class="ott-half">Not the</span> <span class="ott-half">drum,</span></p>
|
||||
<p class="ott-line"><span class="ott-half">not the</span> <span class="ott-half">beans,</span></p>
|
||||
<p class="ott-line"><span class="ott-half">and not</span> <span class="ott-half">you.</span></p>
|
||||
</blockquote>
|
||||
<div class="ott-object" aria-hidden="true">
|
||||
<img class="ott-object__img" src="https://picsum.photos/id/175/1400/940" srcset="https://picsum.photos/id/175/1000/671 1000w, https://picsum.photos/id/175/1400/940 1400w" sizes="(max-width: 767px) 330px, 700px" width="1000" height="1000" alt="">
|
||||
</div>
|
||||
<p class="ott-credit">Ingrid Holm, head roaster</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="close" id="visit" aria-labelledby="visit-title">
|
||||
<h2 id="visit-title">Taste it in the yard on Saturdays</h2>
|
||||
<p class="close__visit">From nine to two, behind the roastery in Majorna. We brew whatever came out of the drum that week, and sell it by the bag, ground or whole.</p>
|
||||
<a class="close__action" href="mailto:hello@holm-roastery.se">Order beans by email</a>
|
||||
<figure class="close__wide">
|
||||
<img src="https://picsum.photos/id/42/900/600" width="900" height="600" loading="lazy" alt="A cup of coffee on a long wooden table in a bright cafe">
|
||||
</figure>
|
||||
<figure class="close__tall">
|
||||
<img src="https://picsum.photos/id/1060/800/1000" width="800" height="1000" loading="lazy" alt="A kettle pouring water over a pour-over coffee dripper on a dark counter">
|
||||
</figure>
|
||||
<p class="close__small">Photographs by Padurariu Alexandru, petradr, Luke Chesser and Karl Fredrickson on Unsplash. Holm is an invented roastery for this demo.</p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../object-through-giant-type.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,141 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Skerrow Sea Swimmers</title>
|
||||
<meta name="description" content="A winter sea-swimming club that has gone into the North Sea off the north pier at seven every morning since 1987.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400..600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../orbit-to-formation.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #0f0e0c;
|
||||
--ink: #ecebe7;
|
||||
--soft: #b4b2ab;
|
||||
--font-display: 'Instrument Sans', system-ui, sans-serif;
|
||||
--font-text: 'Instrument Sans', system-ui, sans-serif;
|
||||
--gutter: clamp(16px, 3.4vw, 56px);
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html { background: var(--ground); }
|
||||
body { margin: 0; background: var(--ground); color: var(--ink); font: 18px/1.5 var(--font-text); -webkit-font-smoothing: antialiased; }
|
||||
a { color: inherit; }
|
||||
img { max-width: 100%; }
|
||||
|
||||
.bar { display: flex; justify-content: space-between; align-items: center; height: 44px; padding: 0 var(--gutter); font-size: 16px; }
|
||||
.bar a { text-decoration: none; }
|
||||
.bar .links { display: flex; gap: 28px; }
|
||||
.bar a { white-space: nowrap; }
|
||||
.bar .links a:hover { text-decoration: underline; text-underline-offset: 4px; }
|
||||
|
||||
.hero { min-height: 100svh; display: grid; grid-template-rows: auto auto 1fr; padding-bottom: var(--gutter); }
|
||||
.hero h1 { margin: clamp(24px, 6svh, 64px) 0 0; padding: 0 var(--gutter); font-family: var(--font-display); font-weight: 500; font-size: clamp(56px, 11.2vw, 196px); line-height: .9; letter-spacing: -.045em; max-width: 9ch; }
|
||||
.hero-row { display: grid; grid-template-columns: minmax(0, 4fr) minmax(0, 8fr); gap: var(--gutter); align-items: end; padding: clamp(24px, 5svh, 56px) var(--gutter) 0; }
|
||||
.hero-row .intro { display: grid; gap: 28px; justify-items: start; }
|
||||
.hero-row p { margin: 0; max-width: 32ch; font-size: 20px; line-height: 1.45; }
|
||||
.button { display: inline-flex; align-items: center; min-height: 52px; padding: 0 26px; background: var(--ink); color: var(--ground); font-size: 16px; font-weight: 500; text-decoration: none; transition: background-color .2s, transform .2s; }
|
||||
.button:hover { background: #ffffff; }
|
||||
.button:active { transform: translateY(1px); }
|
||||
.button:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
|
||||
.hero-row figure { margin: 0; height: 100%; min-height: 220px; }
|
||||
.hero-row img { display: block; width: 100%; height: 100%; max-height: 46svh; object-fit: cover; }
|
||||
|
||||
.join { padding: clamp(120px, 20svh, 240px) var(--gutter) clamp(64px, 10svh, 120px); display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: 48px var(--gutter); }
|
||||
.join h2 { grid-column: 1 / -1; margin: 0; font-family: var(--font-display); font-weight: 500; font-size: clamp(40px, 7vw, 124px); line-height: .96; letter-spacing: -.035em; max-width: 21ch; text-wrap: balance; }
|
||||
.join .lead { margin: 0; max-width: 36ch; font-size: 20px; }
|
||||
.join dl { margin: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 32px; }
|
||||
.join dt { color: var(--soft); font-size: 16px; }
|
||||
.join dd { margin: 6px 0 0; font-size: 20px; line-height: 1.35; }
|
||||
.join .credits { grid-column: 1 / -1; margin: 32px 0 0; max-width: 60ch; color: var(--soft); font-size: 16px; }
|
||||
.close { padding: 0 var(--gutter) var(--gutter); overflow: hidden; }
|
||||
.close p { margin: 0; font-family: var(--font-display); font-weight: 500; font-size: clamp(56px, 11.2vw, 196px); line-height: .9; letter-spacing: -.045em; max-width: 9ch; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.bar .links { gap: 18px; }
|
||||
.hero h1, .close p { font-size: clamp(52px, 16vw, 80px); }
|
||||
.hero-row { grid-template-columns: minmax(0, 1fr); }
|
||||
.hero-row img { max-height: 38svh; }
|
||||
.join { grid-template-columns: minmax(0, 1fr); }
|
||||
.join dl { grid-template-columns: minmax(0, 1fr); gap: 20px; }
|
||||
}
|
||||
/* small phones: the brand shortens to one word (the title below says it in full), every item on one line */
|
||||
@media (max-width: 479px) {
|
||||
.bar .links { gap: 16px; }
|
||||
.bar .more { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="hero">
|
||||
<nav class="bar" aria-label="Main">
|
||||
<a href="#top" aria-label="Skerrow Sea Swimmers">Skerrow<span class="more"> Sea Swimmers</span></a>
|
||||
<span class="links"><a href="#archive">The archive</a><a href="#join">Swim with us</a></span>
|
||||
</nav>
|
||||
<h1 id="top">Skerrow Sea Swimmers</h1>
|
||||
<div class="hero-row">
|
||||
<div class="intro">
|
||||
<p>Since 1987 a few hundred people have met on the north pier at seven each morning and gone into the North Sea, whatever it is doing.</p>
|
||||
<a class="button" href="#join">Book a first swim</a>
|
||||
</div>
|
||||
<figure><img src="https://picsum.photos/id/47/1400/640?grayscale" alt="A long wooden pier running out over misty surf" width="1400" height="640"></figure>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="scene" id="archive" data-scene="orbit-to-formation" data-options='{"hold":5}' style="--n:14" aria-labelledby="archive-title">
|
||||
<div class="scene-stage">
|
||||
<h2 class="orbit-lede" id="archive-title">In the sea at seven, every day of the year.</h2>
|
||||
<div class="orbit-wheel" role="list" aria-label="Mornings from the club's photograph archive">
|
||||
<figure class="orbit-card" role="listitem" style="--i:0"><img class="orbit-img" src="https://picsum.photos/id/338/560/560?grayscale" alt="A swimmer in a hooded coat looking out at a misty sea" width="560" height="560" decoding="async"></figure>
|
||||
<figure class="orbit-card" role="listitem" style="--i:1"><img class="orbit-img" src="https://picsum.photos/id/841/560/560?grayscale" alt="A swimmer mid-stroke in dark water under low hills" width="560" height="560" decoding="async"></figure>
|
||||
<figure class="orbit-card" role="listitem" style="--i:2"><img class="orbit-img" src="https://picsum.photos/id/172/560/560?grayscale" alt="The stone jetty running into still water and fog" width="560" height="560" decoding="async"></figure>
|
||||
<figure class="orbit-card" role="listitem" style="--i:3"><img class="orbit-img" src="https://picsum.photos/id/1014/560/560?grayscale" alt="A woman with a ribbon in her hair facing the sea" width="560" height="560" decoding="async"></figure>
|
||||
<figure class="orbit-card" role="listitem" style="--i:4"><img class="orbit-img" src="https://picsum.photos/id/637/560/560?grayscale" alt="A steel ladder going down from the dock into still water" width="560" height="560" decoding="async"></figure>
|
||||
<figure class="orbit-card" role="listitem" style="--i:5"><img class="orbit-img" src="https://picsum.photos/id/446/560/560?grayscale" alt="A swimmer diving under the surface in a cloud of bubbles" width="560" height="560" decoding="async"></figure>
|
||||
<figure class="orbit-card" role="listitem" style="--i:6"><img class="orbit-img" src="https://picsum.photos/id/144/560/560?grayscale" alt="The shelter at the end of the pier with waves below" width="560" height="560" decoding="async"></figure>
|
||||
<figure class="orbit-card" role="listitem" style="--i:7"><img class="orbit-img" src="https://picsum.photos/id/113/560/560?grayscale" alt="Steam rising from a metal mug after a swim" width="560" height="560" decoding="async"></figure>
|
||||
<figure class="orbit-card" role="listitem" style="--i:8"><img class="orbit-img" src="https://picsum.photos/id/179/560/560?grayscale" alt="Waves breaking round dark rocks in mist" width="560" height="560" decoding="async"></figure>
|
||||
<figure class="orbit-card" role="listitem" style="--i:9"><img class="orbit-img" src="https://picsum.photos/id/1005/560/560?grayscale" alt="A swimmer in a scarf watching the sea at dusk" width="560" height="560" decoding="async"></figure>
|
||||
<figure class="orbit-card" role="listitem" style="--i:10"><img class="orbit-img" src="https://picsum.photos/id/842/560/560?grayscale" alt="Wooden steps going down into fog" width="560" height="560" decoding="async"></figure>
|
||||
<figure class="orbit-card" role="listitem" style="--i:11"><img class="orbit-img" src="https://picsum.photos/id/356/560/560?grayscale" alt="A lone figure walking a misty beach with a board" width="560" height="560" decoding="async"></figure>
|
||||
<figure class="orbit-card" role="listitem" style="--i:12"><img class="orbit-img" src="https://picsum.photos/id/268/560/560?grayscale" alt="The lifeguard chair standing empty in the dune grass" width="560" height="560" decoding="async"></figure>
|
||||
<figure class="orbit-card" role="listitem" style="--i:13"><img class="orbit-img" src="https://picsum.photos/id/68/560/560?grayscale" alt="The curve of the north pier with its lamp post" width="560" height="560" decoding="async"></figure>
|
||||
</div>
|
||||
<div class="orbit-anchor">
|
||||
<p class="orbit-figure">1987 <span class="orbit-figure-line">the first winter</span></p>
|
||||
<p class="orbit-caption">Four swimmers, one flask and no wetsuits.</p>
|
||||
</div>
|
||||
<ul class="orbit-stats">
|
||||
<li class="orbit-stat"><span class="orbit-stat-figure" data-value="365">365</span><span class="orbit-stat-label">Mornings a year</span></li>
|
||||
<li class="orbit-stat"><span class="orbit-stat-figure" data-value="412">412</span><span class="orbit-stat-label">Members this winter</span></li>
|
||||
<li class="orbit-stat"><span class="orbit-stat-figure" data-value="6">6°</span><span class="orbit-stat-label">Coldest sea on record</span></li>
|
||||
<li class="orbit-stat"><span class="orbit-stat-figure" data-value="39">39</span><span class="orbit-stat-label">Winters on the pier</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="join" id="join" aria-labelledby="join-title">
|
||||
<h2 id="join-title">Seven o'clock, at the north pier steps.</h2>
|
||||
<p class="lead">Your first swim is on a Saturday, with a member beside you the whole way. Bring a towel, a warm hat and something hot in a flask. The club brings the kettle and counts heads.</p>
|
||||
<dl>
|
||||
<div><dt>When</dt><dd>Every morning at seven, all year</dd></div>
|
||||
<div><dt>Where</dt><dd>The steps at the end of the north pier</dd></div>
|
||||
<div><dt>Membership</dt><dd>Twenty pounds a year, for the lifebuoy and the kettle</dd></div>
|
||||
</dl>
|
||||
<p class="credits">Photographs from Unsplash, through Picsum: Christopher Sardegna, Patryk Sobczak, Todd Quackenbush, Aleksi Tappura, Oscar Keys, Griffin Keller, Jacob Walti, Mouly Kumar, Zugr, Angelina Odemchuk, Matthew Wiebe, Joe Beck, Daniel Ruswick, davide ragusa and Cristian Moscoso. The club, its pier and its people are invented for this demonstration.</p>
|
||||
</section>
|
||||
|
||||
<footer class="close"><p>Skerrow Sea Swimmers</p></footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../orbit-to-formation.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,232 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Skerry Air Freight · peel, tear and drag</title>
|
||||
<meta name="description" content="Demo of the peel-tear-and-drag scroll scene: cloud closes over the sea and a cargo jet drags the next page in behind it (mode drag); at the end the paper tears and rolls away from the footer (mode tear).">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,100..900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../peel-tear-and-drag.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #f4f4f1;
|
||||
--ink: #121212;
|
||||
--drag-dark: #14204a;
|
||||
--font-display: 'Archivo', system-ui, sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--ground); }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font: 400 18px/1.5 'Archivo', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: inherit; }
|
||||
|
||||
/* first screen: the dark chapter the scene will close */
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 100svh;
|
||||
display: grid;
|
||||
grid-template-columns: 8% minmax(0, 1fr) 4% 36% 8%;
|
||||
grid-template-rows: auto 1fr auto auto 10svh;
|
||||
background: var(--drag-dark);
|
||||
color: #fff;
|
||||
}
|
||||
.bar {
|
||||
grid-column: 2 / 5;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
padding-top: 32px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.bar__mark { font-weight: 800; font-stretch: 112%; font-size: 18px; letter-spacing: -.02em; margin-right: auto; }
|
||||
.bar a { text-decoration: none; }
|
||||
.bar a:hover { text-decoration: underline; text-underline-offset: 4px; }
|
||||
.bar .bar__go { display: inline-flex; align-items: center; min-height: 44px; padding: 0 20px; background: #fff; color: var(--drag-dark); font-weight: 700; }
|
||||
.bar .bar__go:hover { text-decoration: none; background: #dfe3ee; }
|
||||
.hero h1 {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 3;
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(52px, 7.4vw, 128px);
|
||||
font-weight: 800;
|
||||
font-stretch: 112%;
|
||||
line-height: .92;
|
||||
letter-spacing: -.04em;
|
||||
}
|
||||
.hero__lead {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 4;
|
||||
max-width: 26em;
|
||||
margin: 32px 0 0;
|
||||
font-size: clamp(19px, 1.7vw, 27px);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.hero__photo {
|
||||
grid-column: 4;
|
||||
grid-row: 2 / 5;
|
||||
position: relative;
|
||||
margin: 6svh 0 0;
|
||||
}
|
||||
.hero__photo img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
|
||||
/* the chapter after the scene, on the new page's ground */
|
||||
.next {
|
||||
display: grid;
|
||||
grid-template-columns: 8% minmax(0, 7fr) 4% minmax(0, 5fr) 8%;
|
||||
padding: 18svh 0 16svh;
|
||||
}
|
||||
.next h2 {
|
||||
grid-column: 2 / 3;
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(40px, 5.6vw, 96px);
|
||||
font-weight: 800;
|
||||
font-stretch: 112%;
|
||||
line-height: .95;
|
||||
letter-spacing: -.035em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.next__body { grid-column: 4 / 5; align-self: end; }
|
||||
.next__body p { margin: 0 0 32px; max-width: 26em; font-size: clamp(19px, 1.7vw, 27px); line-height: 1.45; }
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 18px 28px;
|
||||
background: var(--drag-dark);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn:hover { background: #0d1636; }
|
||||
.next__times { grid-column: 2 / 5; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4%; margin: 14svh 0 0; padding: 0; list-style: none; }
|
||||
.next__times li { background: #e9e9e4; padding: 28px 28px 32px; }
|
||||
.next__times b { display: block; font-family: var(--font-display); font-weight: 800; font-stretch: 112%; font-size: clamp(19px, 1.7vw, 27px); letter-spacing: -.01em; line-height: 1.2; margin-bottom: 8px; font-variant-numeric: tabular-nums; }
|
||||
/* the end of the page: the paper tears and the dark footer shows below it (the same scene, mode "tear") */
|
||||
.tear-line { position: absolute; left: 8%; top: 12svh; max-width: 8em; margin: 0; font-family: var(--font-display); font-size: clamp(40px, 5.6vw, 96px); font-weight: 800; font-stretch: 112%; line-height: .95; letter-spacing: -.035em; text-wrap: balance; }
|
||||
.foot__inner { position: absolute; left: 8%; right: 8%; bottom: 5svh; }
|
||||
.foot__name { margin: 0; font-family: var(--font-display); font-weight: 800; font-stretch: 112%; font-size: clamp(44px, 9vw, 170px); line-height: .9; letter-spacing: -.045em; white-space: nowrap; }
|
||||
.foot__small { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px 40px; margin-top: 48px; font-size: 16px; line-height: 1.5; }
|
||||
.foot__small p { margin: 0; max-width: 40em; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.hero { grid-template-columns: 16px minmax(0, 1fr) 16px; grid-template-rows: auto 38svh auto auto 6svh; }
|
||||
.bar { grid-column: 2; padding-top: 20px; gap: 20px; }
|
||||
.bar a:not(.bar__go) { display: none; }
|
||||
.hero__photo { grid-column: 2; grid-row: 2; margin: 20px 0 32px; }
|
||||
.hero h1 { grid-column: 2; font-size: clamp(44px, 13vw, 60px); font-stretch: 100%; }
|
||||
.hero__lead { grid-column: 2; margin-top: 20px; font-size: 18px; }
|
||||
.next { grid-template-columns: 16px minmax(0, 1fr) 16px; padding: 12svh 0 12svh; row-gap: 28px; }
|
||||
.next h2, .next__body, .next__times { grid-column: 2; }
|
||||
.next h2 { font-stretch: 100%; }
|
||||
.next__times { grid-template-columns: 1fr; gap: 12px; margin-top: 32px; }
|
||||
.tear-line { left: 20px; right: 20px; top: 10svh; font-size: clamp(32px, 9.4vw, 40px); font-stretch: 100%; }
|
||||
.foot__inner { left: 16px; right: 16px; bottom: 4svh; }
|
||||
.foot__name { white-space: normal; font-size: 13vw; font-stretch: 100%; }
|
||||
.foot__small { margin-top: 24px; gap: 12px; font-size: 15px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="hero">
|
||||
<nav class="bar" aria-label="Main">
|
||||
<span class="bar__mark">Skerry</span>
|
||||
<a href="#islands">Islands</a>
|
||||
<a href="#flights">Timetable</a>
|
||||
<a class="bar__go" href="#book">Book a pallet</a>
|
||||
</nav>
|
||||
<h1>Skerry Air Freight</h1>
|
||||
<p class="hero__lead">Twice a day we carry fish out and post, parts and medicine in, between forty North Atlantic islands and the mainland.</p>
|
||||
<figure class="hero__photo"><img src="https://picsum.photos/id/1038/1000/1500" alt="A small red fishing boat on a dark, rough sea below snowy mountains" width="1000" height="1500"></figure>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="scene" id="islands" data-scene="peel-tear-and-drag" data-options='{"dir":"ltr"}'>
|
||||
<div class="scene-stage">
|
||||
<div class="drag-old">
|
||||
<img class="drag-old__ground" src="https://picsum.photos/id/997/1600/1000" srcset="https://picsum.photos/id/997/1600/1000 1600w, https://picsum.photos/id/997/2880/1800 2880w" sizes="100vw" alt="" width="1600" height="1000">
|
||||
<div class="drag-old__tint"></div>
|
||||
<p class="drag-old__line">We fly when the ferries can't.</p>
|
||||
<div class="drag-veil" aria-hidden="true">
|
||||
<img crossorigin="anonymous" class="drag-veil__piece" src="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/peel-tear-and-drag/cloud-1.webp" alt="" width="1200" height="1000">
|
||||
<img crossorigin="anonymous" class="drag-veil__piece" src="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/peel-tear-and-drag/cloud-5.webp" alt="" width="1000" height="913">
|
||||
<img crossorigin="anonymous" class="drag-veil__piece" src="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/peel-tear-and-drag/cloud-6.webp" alt="" width="1000" height="917">
|
||||
<img crossorigin="anonymous" class="drag-veil__piece" src="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/peel-tear-and-drag/cloud-2.webp" alt="" width="1300" height="780">
|
||||
<img crossorigin="anonymous" class="drag-veil__piece" src="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/peel-tear-and-drag/cloud-7.webp" alt="" width="1150" height="1284">
|
||||
<img crossorigin="anonymous" class="drag-veil__piece" src="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/peel-tear-and-drag/cloud-8.webp" alt="" width="1000" height="789">
|
||||
<img crossorigin="anonymous" class="drag-veil__piece" src="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/peel-tear-and-drag/cloud-3.webp" alt="" width="1200" height="877">
|
||||
</div>
|
||||
</div>
|
||||
<div class="drag-new">
|
||||
<div class="drag-new__inner">
|
||||
<h2 class="drag-new__title">Islands that count on us</h2>
|
||||
<ul class="drag-new__items">
|
||||
<li>
|
||||
<blockquote><p>“The harbour was shut for nine days in February. The insulin still came in on Tuesday, and so did the post.”</p></blockquote>
|
||||
<p class="drag-new__who"><b>Ingrid Hallsdóttir</b>, pharmacist on Ravnsey</p>
|
||||
</li>
|
||||
<li>
|
||||
<blockquote><p>“Our crab leaves the pier at five and is on a plate in Lisbon by dinner. Before Skerry, a third of it went back into the sea.”</p></blockquote>
|
||||
<p class="drag-new__who"><b>Tór Mikkelsen</b>, fish exporter on Fjarholm</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="drag-new__aside">One freighter, one hangar on Fjarholm, and pilots who know every island runway by its wind.</p>
|
||||
<figure class="drag-new__figure"><img src="https://picsum.photos/id/574/2000/900" srcset="https://picsum.photos/id/574/1000/750 1000w, https://picsum.photos/id/574/2000/900 2000w" sizes="(max-width: 767px) 100vw, 106vw" alt="A grassy cliff above a black sand beach, sea stacks offshore" width="2000" height="900"></figure>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drag-rig drag-rig--object" aria-hidden="true">
|
||||
<img crossorigin="anonymous" class="drag-object" src="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/peel-tear-and-drag/plane.webp" srcset="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/peel-tear-and-drag/plane-1200.webp 1200w, https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/peel-tear-and-drag/plane.webp 2400w" sizes="(max-width: 767px) 72vh, 100vw" alt="" width="2400" height="2372">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="next" id="book">
|
||||
<h2>Send it on the 06:10</h2>
|
||||
<div class="next__body">
|
||||
<p>Pallets close at eight the evening before. Tell us what it is, what it weighs and which island it is going to, and we will hold a place on the 06:10.</p>
|
||||
<a class="btn" href="mailto:cargo@skerry-air.example?subject=A%20pallet%20on%20tomorrow's%20first%20flight&body=What%20it%20is%3A%0AWeight%3A%0AIsland%3A%0A">Book a pallet</a>
|
||||
</div>
|
||||
<ul class="next__times" id="flights">
|
||||
<li><b>06:10</b>Mainland to the northern islands, back by eleven with the morning catch.</li>
|
||||
<li><b>13:40</b>Mainland to the southern islands, with the post and the pharmacy run.</li>
|
||||
<li><b>20:00</b>Last pallets in at the Fjarholm hangar for the next morning.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="scene foot" id="contact" data-scene="peel-tear-and-drag" data-options='{"mode":"tear","at":0.48,"phoneAt":0.42}'>
|
||||
<div class="scene-stage">
|
||||
<div class="tear-old">
|
||||
<p class="tear-line">The 06:10 is loading.</p>
|
||||
</div>
|
||||
<div class="tear-new">
|
||||
<div class="foot__inner">
|
||||
<p class="foot__name">Skerry Air Freight</p>
|
||||
<div class="foot__small">
|
||||
<p>An invented carrier for a scroll-scene demo. Photographs from Unsplash via Picsum: Marcelo Quinan, Mickey O'neil, Julien Lavallée. The freighter: <a href="https://commons.wikimedia.org/w/index.php?curid=195299108">“LX-VCE - Boeing 747-8R7F underside”</a> by Steve Knight, <a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>, modified (cut out, turned, toned, markings and fin removed, nose retouched). The cloud: <a href="https://www.flickr.com/photos/31007250@N03/7322479590">“Cumulus cloud from above”</a> by rpieket and <a href="https://www.flickr.com/photos/30682900@N00/9997120">“Clouds from above”</a> by transreal, both <a href="https://creativecommons.org/licenses/by/2.0/">CC BY 2.0</a>, modified (cut out, turned and toned).</p>
|
||||
<p>Fjarholm hangar, open 05:00 to 21:00</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../peel-tear-and-drag.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,171 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Fellside Architects</title>
|
||||
<meta name="description" content="A small practice that builds houses on steep ground. Scene demo: a drawing that draws itself as the page scrolls.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500&display=swap">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../picture-develops.css">
|
||||
<style>
|
||||
:root {
|
||||
--paper: #f3f3f0;
|
||||
--ink: #141413;
|
||||
--accent: #e5401a;
|
||||
--moss: #1d2420;
|
||||
--font-display: 'Hanken Grotesk', system-ui, sans-serif;
|
||||
--gutter: clamp(16px, 4vw, 64px);
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html { background: var(--paper); }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font: 400 17px/1.5 var(--font-display);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
img { display: block; max-width: 100%; }
|
||||
a { color: inherit; }
|
||||
|
||||
.hero {
|
||||
min-height: 100svh;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
.hero-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 28px var(--gutter) 8svh;
|
||||
}
|
||||
.mark { margin: 0; font-size: 17px; font-weight: 500; letter-spacing: -.01em; }
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
max-width: 11ch;
|
||||
font-size: clamp(48px, 6.6vw, 112px);
|
||||
font-weight: 500;
|
||||
line-height: .94;
|
||||
letter-spacing: -.04em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.hero-copy p { margin: 32px 0 0; max-width: 34ch; font-size: clamp(18px, 1.5vw, 22px); line-height: 1.4; }
|
||||
.hero-pic { margin: 0; position: relative; overflow: hidden; background: var(--moss); }
|
||||
.hero-pic img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.next {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
column-gap: clamp(16px, 2vw, 32px);
|
||||
align-items: start;
|
||||
padding: 16svh var(--gutter) 14svh;
|
||||
}
|
||||
.next-copy { grid-column: 1 / span 5; }
|
||||
.next h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(36px, 4vw, 64px);
|
||||
font-weight: 500;
|
||||
line-height: .98;
|
||||
letter-spacing: -.03em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.next-copy p { margin: 28px 0 0; max-width: 38ch; }
|
||||
.write {
|
||||
display: inline-block;
|
||||
margin-top: 40px;
|
||||
padding: 15px 26px;
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
.next-pic { grid-column: 7 / -1; margin: 0; }
|
||||
.next-pic img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; background: var(--moss); }
|
||||
.next-pic figcaption { margin-top: 12px; font-size: 15px; }
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 36px var(--gutter) 48px;
|
||||
background: var(--moss);
|
||||
color: var(--paper);
|
||||
font-size: 15px;
|
||||
}
|
||||
.foot p { margin: 0; max-width: 56ch; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.hero { grid-template-columns: 1fr; grid-template-rows: auto 44svh; }
|
||||
.hero-copy { padding: 22px var(--gutter) 40px; gap: 18svh; }
|
||||
.hero h1 { font-size: clamp(44px, 13vw, 60px); }
|
||||
.hero-copy p { margin-top: 22px; }
|
||||
.next { grid-template-columns: 1fr; row-gap: 44px; padding: 12svh var(--gutter) 10svh; }
|
||||
.next-copy, .next-pic { grid-column: 1; }
|
||||
.foot { flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="hero">
|
||||
<div class="hero-copy">
|
||||
<p class="mark">Fellside Architects</p>
|
||||
<div>
|
||||
<h1>Houses for steep ground</h1>
|
||||
<p>Four architects in a former dairy above the fjord, drawing every house by hand on the slope where it will stand.</p>
|
||||
</div>
|
||||
</div>
|
||||
<figure class="hero-pic">
|
||||
<img src="https://picsum.photos/id/353/1200/1400" alt="Low cloud drifting over dark forested hills" width="1200" height="1400" decoding="async">
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section class="scene" id="ledge-house" data-scene="picture-develops" data-options='{}' aria-labelledby="pd-title">
|
||||
<div class="scene-stage">
|
||||
<header class="pd-head">
|
||||
<h2 class="pd-title" id="pd-title">A house drawn into the hill</h2>
|
||||
<p class="pd-note">Every Fellside house begins on site with a pen. This sheet was drawn above Varde in one afternoon: three concrete floors stepping down the slope, each roof a terrace for the room above.</p>
|
||||
</header>
|
||||
<figure class="pd-frame">
|
||||
<div class="pd-art">
|
||||
<picture>
|
||||
<source media="(max-aspect-ratio: 4/5)" srcset="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='1200'%20height='1320'%20viewBox='0%200%202400%202640'%3E%3Cpath%20fill='%23a3a3a3'%20d='M264,733l4,2,36-15,34-18,34-17,35-17,34-18,34-18,34-20,33-19,34-18,35-17,35-16,34-18,34-18,34-18,35-17,34-17,35-17,32-23,0-3-2-1-35,18-34,16-35,17-35,17-34,18-34,17-35,17-35,17-35,16-34,18-34,20-33,19-34,19-34,18-34,17-34,18-34,19-34,19zM208,607l2,2,78,19,79,16,79,15,79,16,79,15,79,16,79,16,79,16,79,17,79,15,79,16,79,15,79,15,79,15,79,15,79,16,79,14,79,15,79,15,80,14,79,15,79,16,79,15,78,16,79,16,80,12,2-2-1-2-78-19-79-15-79-16-79-15-79-16-79-16-79-15-79-15-79-16-79-16-79-16-79-16-79-15-79-15-79-15-79-15-79-15-79-15-79-16-79-15-80-15-79-15-79-15-79-15-79-16-79-13zM166,1230l3,2,38-13,37-14,37-15,38-15,37-14,37-14,38-14,37-14,37-14,38-14,37-14,37-14,38-14,37-14,38-14,37-14,37-15,37-14,37-15,37-14,38-14,36-17,36-17,1-2-2-2-38,12-38,14-37,14-37,14-38,15-37,14-37,15-37,15-37,15-37,14-38,14-37,14-37,14-37,15-37,14-38,15-37,14-37,14-38,14-37,14-37,15-37,15-36,16zM425,1478l2-3,2-43,1-44,2-43,1-43,1-44,0-43-1-44,0-43,0-43,0-44,0-43,0-43-1-44-1-43-2-44-1-43-1-43,0-43,1-44,1-43,2-43,1-44,0-43,0-44,0-43-4-43-3-3-3,3-3,43,0,43,0,44,0,43,0,43-1,44-1,43-1,44,0,43,1,43,1,44,1,43,1,43,0,44,0,43,0,43-1,44,0,43,1,44,0,43,1,43,0,43-1,44-2,43-1,44,5,43zM145,996l4,1,46-20,47-22,46-21,46-21,46-22,47-21,46-20,47-21,46-20,47-20,47-21,46-21,46-21,47-21,46-21,47-20,46-21,47-21,46-21,46-21,46-22,46-22,46-22,46-22,46-22,45-24,1-3-3-1-48,18-46,21-46,21-47,22-46,21-46,22-46,21-46,21-47,21-46,21-46,21-47,22-46,21-46,22-46,21-46,21-47,21-46,21-47,20-46,21-47,21-46,21-46,22-46,21-47,21-45,24zM153,680l2,3,78,19,79,14,79,15,79,15,79,14,79,14,79,15,79,15,79,16,78,15,79,15,79,15,79,15,79,16,79,15,79,15,79,14,79,13,79,14,79,14,79,15,80,14,79,14,79,13,79,15,79,14,3-2-2-3-78-19-79-15-79-15-79-13-79-15-79-15-79-14-79-15-80-13-79-15-78-15-79-15-79-16-79-15-79-15-79-15-79-15-78-16-79-15-80-14-79-13-79-14-79-15-79-15-79-14-79-12zM638,1812l2-2,2-55,1-54,2-55,0-55,0-55,0-54,0-55,0-55,0-54,0-55-1-55,0-54-1-55,0-54,1-55,1-55,1-54,1-55,1-55,0-55-1-54-1-55-2-55-1-54,0-55-3-54-1-2-2,1-5,55,0,55,0,55,1,54,1,55,0,55,0,54-1,55-1,54-2,55-1,55-1,54,1,55,0,55,1,55,1,54,1,55,1,55,0,54,1,55,0,55,1,54-1,55,0,54-1,55,3,55zM270,1246l5,3,72-23,70-27,71-27,71-27,71-26,71-26,71-27,70-27,71-27,71-26,71-26,71-25,72-25,71-24,72-26,71-26,71-26,70-27,71-26,71-26,72-25,71-26,71-27,70-27,70-28,70-31,1-2-3-1-72,23-71,27-70,26-71,26-72,25-71,25-71,26-71,26-71,27-71,27-70,27-71,26-72,25-71,26-71,25-71,27-71,26-71,27-70,27-71,26-72,25-71,25-71,26-71,26-71,27-69,30zM293,855l3,3,76,14,77,14,76,13,77,13,76,14,77,13,76,14,77,14,76,13,77,14,76,14,77,13,77,13,76,12,77,13,77,12,77,12,76,13,77,13,76,13,77,13,77,13,76,14,77,13,76,13,78,10,1-1-1-2-76-16-77-13-76-13-77-13-76-14-77-13-77-14-76-13-77-14-76-13-77-13-77-13-76-12-77-13-76-14-77-13-77-13-76-14-77-13-76-13-77-12-77-13-76-13-77-13-77-14-77-10zM200,1933l3,3,52-9,53-9,52-9,52-9,52-9,53-9,52-9,52-9,53-8,52-8,53-8,52-9,52-9,52-10,52-11,52-10,53-9,52-9,52-8,52-9,53-9,52-9,52-9,52-10,53-8,52-12,1-3-2-3-53,4-53,9-52,9-52,10-53,9-52,9-52,9-52,9-52,10-53,10-52,10-52,11-52,10-52,10-52,9-52,9-53,8-52,8-53,8-52,8-52,9-53,9-52,9-52,8-53,8-51,14zM999,2383l4-3,4-74,0-74-1-74,0-73-1-74-1-74-1-74,0-73,0-74,0-74,1-74,0-74,0-73,0-74,1-74,1-74,1-73,1-74,0-74,1-74-1-74,0-73-1-74,0-74,0-74-5-74-3-2-2,3-3,73,0,74,0,74,0,74,0,73,0,74,0,74,0,74-1,73,0,74-1,74,0,74,1,74,0,73,0,74-1,74,0,74-1,74,0,73,0,74,1,74,0,74,0,74,1,73,0,74,3,74zM181,1063l2,4,78,28,77,27,77,27,78,28,78,26,78,27,77,26,78,27,77,28,78,26,78,27,78,26,78,25,78,25,78,26,78,26,78,26,79,25,78,26,78,25,78,25,78,27,78,26,77,27,78,26,80,22,2-1-1-2-77-31-78-26-77-27-78-27-78-26-78-26-78-25-78-26-78-25-78-27-78-26-78-25-78-26-78-25-78-26-78-26-78-27-77-27-78-27-78-26-78-27-78-26-77-27-78-27-77-28-80-21zM100,1604l1,5,68,37,70,36,69,36,70,35,70,35,71,34,69,35,70,36,69,36,69,37,69,36,70,35,70,35,69,36,69,37,69,37,69,36,70,35,70,34,70,35,70,36,69,35,70,36,70,34,70,34,72,32,3-1-1-3-69-38-70-34-70-35-69-35-70-37-69-36-70-35-70-34-70-35-69-36-69-37-69-36-70-36-69-35-70-35-70-35-69-36-69-37-70-36-70-35-70-35-70-34-69-36-69-36-70-36-71-33zM488,1165l2,4,65,23,66,22,65,24,66,24,65,23,66,23,66,21,67,21,66,21,66,22,66,22,65,23,66,22,66,22,66,22,66,22,66,23,65,23,66,24,65,23,66,22,67,21,66,20,67,21,66,20,67,20,2-1-1-3-65-26-66-21-66-21-67-21-66-21-66-22-65-23-65-24-66-23-66-23-66-21-66-22-66-21-66-22-66-22-66-22-66-21-67-21-66-21-66-22-66-23-65-24-65-24-65-24-66-24-67-18zM210,1165l2,4,71,31,73,27,74,26,73,25,74,26,73,25,73,27,73,27,73,28,72,28,73,28,73,27,73,26,73,27,73,27,73,27,73,27,73,27,73,27,74,25,74,24,73,25,74,26,73,26,73,27,74,24,2-1-1-2-72-31-73-26-73-27-73-26-74-25-73-26-74-26-72-27-73-27-73-28-73-27-73-27-73-26-73-26-73-27-73-26-73-28-73-27-73-27-74-26-73-26-74-25-73-26-73-27-72-27-74-26zM2114,2026l-3-3-62-1-64,4-2,3,2,3,64,1,62-4zM1855,2030l-2-2-69-5-71,0-74,0-75,2-2,2,2,3,75,4,74,2,71,Line truncated
|
||||
<img src="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='2880'%20height='1200'%20viewBox='0%200%205760%202400'%3E%3Cpath%20fill='%23a3a3a3'%20d='M550,769l2,1,95-15,94-19,95-18,94-18,94-18,95-17,94-18,94-18,94-19,94-19,94-19,94-19,95-18,94-18,94-17,95-17,94-18,94-18,94-19,94-18,95-18,94-17,94-17,95-18,94-17,94-23,0-1-1-1-94,17-94,18-95,17-94,17-95,18-94,17-94,18-95,18-94,18-94,18-95,17-94,17-94,17-95,18-94,19-94,19-94,19-94,19-94,19-94,18-94,18-95,18-94,18-94,19-94,19-94,19zM1891,343l1,3,75,22,76,19,76,20,77,19,76,19,76,20,75,20,76,20,76,19,76,20,76,19,76,19,76,20,76,19,76,20,76,20,76,19,76,20,76,19,76,19,77,19,75,20,76,19,76,20,76,19,77,16,2-1-1-2-75-23-76-20-76-20-76-19-76-20-76-19-76-19-76-19-76-19-76-19-76-20-76-19-76-20-76-19-76-19-77-19-76-19-76-19-75-20-76-20-76-19-77-20-76-19-76-20-75-19-77-18zM603,1221l2,2,101-12,100-14,101-14,100-14,101-13,100-14,100-14,101-13,100-14,101-14,100-14,101-14,100-14,101-13,100-14,100-14,101-14,100-14,101-14,100-15,100-14,101-14,100-14,101-14,100-15,100-16,1-1-1-2-101,13-100,14-101,14-100,14-101,14-100,14-100,14-101,14-100,15-101,13-100,14-101,13-100,14-101,14-100,13-100,14-101,14-100,14-101,13-100,14-101,14-100,14-101,14-100,14-100,14-100,16zM2264,1387l3-2,1-47,2-46,1-47,1-47,0-47,0-46-1-47,0-47,0-46,0-47,0-47,0-46-1-47-1-47-1-47-2-46,0-47,0-47,1-46,1-47,1-47,0-46,1-47,0-47,0-46-4-47-1-1-2,1-3,47,1,46,0,47,0,47-1,46,0,47-1,47-1,46,0,47,1,47,1,47,1,46,1,47,0,47,0,46-1,47,0,47,0,46,0,47,0,47,1,46-1,47-1,47-1,46-1,47,4,47zM689,1098l1,1,129-17,129-19,129-20,128-19,129-19,129-20,128-19,129-18,129-19,129-19,128-19,129-20,129-19,128-20,129-19,129-20,128-19,129-19,129-19,129-20,128-19,129-20,128-20,129-20,129-20,128-21,1-2-1,0-130,16-128,20-129,20-128,20-129,20-129,20-128,19-129,19-129,19-128,19-129,19-129,20-128,19-129,19-129,19-129,19-128,19-129,18-129,19-129,19-129,19-128,19-129,20-129,19-128,19-128,24zM2326,593l1,2,82,23,82,19,82,19,82,20,82,19,82,19,83,19,81,20,82,20,82,21,82,19,82,20,82,20,82,20,82,20,82,20,83,19,82,18,82,19,82,19,82,20,83,18,82,19,82,19,82,20,83,18,1-1-1-2-81-23-82-20-82-19-82-19-83-19-82-20-82-19-82-19-82-19-82-19-82-20-82-20-82-20-82-20-83-19-82-20-81-20-82-20-83-20-82-18-82-19-82-19-82-20-82-19-82-20-83-18zM2992,1856l3-2,1-58,1-58,1-58,0-58,0-58-1-58,0-58,0-58,0-58-1-58-1-58,0-58-1-58,0-58,1-58,1-58,1-58,1-58,1-58,0-58,0-58-1-58-1-58,0-58-1-58-2-58-1-2-2,2-4,58,0,58,1,58,1,58,1,58,1,58,0,58-1,58-1,58-1,58-1,58,0,58,0,58,1,58,0,58,1,58,0,58,0,58,0,58,0,58,1,58,0,58-1,58-1,58-1,58,3,58zM1547,1440l2,2,151-13,151-16,151-15,151-16,152-15,151-15,151-16,151-16,151-16,151-16,151-15,151-15,152-14,151-15,151-15,151-15,151-16,151-16,152-15,151-15,151-14,151-15,151-16,151-16,151-16,151-21,1-1-1-1-152,12-151,16-151,16-151,16-151,15-151,15-151,15-152,16-151,16-151,16-151,16-151,15-151,14-151,15-152,14-151,15-151,16-151,16-151,16-151,15-151,15-152,15-151,15-151,15-151,16-151,19zM3482,1078l1,2,82,19,81,17,82,17,81,17,81,17,82,17,81,17,82,17,81,17,82,17,82,17,81,16,82,17,81,16,82,16,82,16,81,17,82,16,81,17,82,18,81,17,82,17,81,17,82,17,81,17,83,13,1-1-1-2-81-20-81-16-82-17-82-16-81-17-82-17-81-16-82-17-81-17-82-16-82-17-81-16-82-17-81-17-82-17-81-17-82-17-81-18-82-17-81-16-82-17-81-17-82-16-81-17-82-16-82-15zM1432,2058l2,2,128-4,127-4,128-4,127-5,128-5,128-5,127-5,128-4,127-4,128-4,127-4,128-5,128-6,127-6,128-6,127-6,128-6,127-4,128-5,127-5,128-4,128-5,127-6,128-4,127-5,128-7,1-1-1-1-128,0-128,5-127,5-128,6-127,5-128,5-127,5-128,5-128,5-127,5-128,6-127,6-128,6-127,5-128,5-127,4-128,3-128,4-127,4-128,5-127,5-128,5-127,4-128,5-128,4-127,9zM4011,2305l2-2,3-61,0-62,0-62,0-61-1-62-1-61,0-62,0-62,0-61,0-62,0-61,0-62,0-62,0-61,1-62,0-61,1-62,1-61,0-62,1-62,0-61-1-62,0-61,0-62,0-62-5-61-1-1-1,1-3,61,0,62,0,62,0,61,1,62,0,61,0,62,0,61-1,62,0,62-1,61,0,62,0,61,0,62,0,62,0,61-1,62,0,61,0,62,0,62,0,61,1,62,0,61,0,62,0,61,2,62zM397,426l2,3,198,72,198,73,198,72,199,72,198,72,199,71,199,71,198,72,199,71,199,71,199,71,199,71,199,70,199,70,199,70,199,71,199,71,199,70,199,70,199,70,199,71,199,71,199,71,198,71,199,71,201,66,1-1,0-2-198-74-199-71-199-70-199-71-199-70-199-70-199-70-200-70-199-70-199-70-199-71-199-70-199-71-199-70-199-71-198-71-199-72-198-72-199-72-198-72-199-71-198-72-199-72-198-72-199-72-200-67zM101,743l1,3,150,65,151,62,152,63,151,62,152,61,152,61,152,61,151,63,152,63,151,62,152,62,152,61,151,62,152,62,151,63,151,62,152,62,152,62,152,61,152,61,151,62,152,62,151,62,152,61,152,60,153,60,2-1-1-3-151-63-152-60-152-61-152-62-151-62-152-62-152-61-152-61-152-61-151-62-151-63-151-63-152-63-151-62-152-61-151-62-152-63-151-63-151-63-152-62-152-61-151-61-152-62-151-63-152-62-152-60zM1526,826l1,2,155,57,154,57,155,57,155,57,154,57,155,56,156,56,155,54,156,55,155,55,155,55,155,56,156,55,155,55,156,54,155,55,155,56,155,56,155,56,155,56,156,55,155,54,156,54,156,53,156,54,156,53,1,0,0-2-155-57-156-54-156-54-155-53-156-55-155-55-155-56-155-56-155-56-155-56-156-55-155-55-156-55-155-55-155-56-155-55-155-55-156-55-155-55-156-55-155-56-154-57-155-57-154Line truncated
|
||||
</picture>
|
||||
<canvas class="pd-canvas" aria-hidden="true"></canvas>
|
||||
</div>
|
||||
<figcaption class="pd-caption">Ledge House above Varde, site study in ink, May 2025</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="next" aria-labelledby="next-title">
|
||||
<div class="next-copy">
|
||||
<h2 id="next-title">Building starts in April</h2>
|
||||
<p>The foundations go into the rock this winter. The stair, the terraces and the kitchen are being drawn now, and each sheet goes up here when it is finished.</p>
|
||||
<a class="write" href="#ledge-house">Write to the office</a>
|
||||
</div>
|
||||
<figure class="next-pic">
|
||||
<img src="https://picsum.photos/id/255/1200/900" alt="A gravel path climbing a grassy hill to a cluster of bare trees" width="1200" height="900" loading="lazy" decoding="async">
|
||||
<figcaption>The site above Varde in October, where the path will meet the lowest terrace.</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="foot">
|
||||
<p>Fellside Architects, Varde. Houses, cabins and one small school since 2011.</p>
|
||||
<p>Photographs from picsum.photos. The drawing was made for this page.</p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../picture-develops.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,222 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Nesvik Sea Baths</title>
|
||||
<meta name="description" content="A sea-swimming school on the outer coast, with guided swims from seven in the morning until dark.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;600&display=swap">
|
||||
<link rel="preconnect" href="https://picsum.photos">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../pinned-chapter-switcher.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #e9ebe7;
|
||||
--ink: #111513;
|
||||
--font-display: "Schibsted Grotesk", system-ui, sans-serif;
|
||||
--gutter: clamp(20px, 5vw, 80px);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font: 400 18px/1.5 var(--font-display);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: inherit; }
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px var(--gutter);
|
||||
font-size: 16px;
|
||||
}
|
||||
.bar .mark { font-weight: 600; text-decoration: none; letter-spacing: -0.01em; }
|
||||
.bar .go { text-decoration: none; }
|
||||
.bar .go:hover, .bar .go:focus-visible { text-decoration: underline; text-underline-offset: 4px; }
|
||||
|
||||
.intro {
|
||||
min-height: calc(56svh - 76px);
|
||||
padding: clamp(40px, 8vh, 96px) var(--gutter) clamp(48px, 8vh, 88px);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) clamp(280px, 30vw, 420px);
|
||||
column-gap: 6vw;
|
||||
align-items: end;
|
||||
align-content: end;
|
||||
}
|
||||
.intro h1 {
|
||||
margin: 0;
|
||||
max-width: 12ch;
|
||||
font-size: clamp(52px, 8vw, 128px);
|
||||
font-weight: 600;
|
||||
line-height: 0.92;
|
||||
letter-spacing: -0.04em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.intro p { margin: 0 0 28px; font-size: 20px; line-height: 1.45; max-width: 34ch; }
|
||||
|
||||
.after {
|
||||
padding: clamp(120px, 16vw, 220px) var(--gutter) 64px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) min(30vw, 420px);
|
||||
column-gap: 6vw;
|
||||
}
|
||||
.after h2 {
|
||||
margin: 0;
|
||||
max-width: 14ch;
|
||||
font-size: clamp(40px, 5.2vw, 80px);
|
||||
font-weight: 600;
|
||||
line-height: 0.96;
|
||||
letter-spacing: -0.035em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.after p { margin: 0 0 32px; }
|
||||
.after ul { list-style: none; margin: 0 0 40px; padding: 0; display: grid; gap: 14px; }
|
||||
.after li { font-size: 18px; }
|
||||
.after li strong { font-weight: 600; }
|
||||
.book {
|
||||
display: inline-block;
|
||||
padding: 16px 28px;
|
||||
background: var(--ink);
|
||||
color: var(--ground);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1), background-color 0.2s;
|
||||
}
|
||||
.book:hover { background: #2a332f; }
|
||||
.book:active { transform: scale(0.98); }
|
||||
.book:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
|
||||
footer { padding: 48px var(--gutter) 40px; font-size: 16px; color: #3d4441; }
|
||||
footer p { margin: 0; max-width: 62ch; }
|
||||
|
||||
/* below 1024px the headline takes the full measure, so it holds two lines */
|
||||
@media (max-width: 1023px) {
|
||||
.intro, .after { grid-template-columns: minmax(0, 1fr); row-gap: 32px; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.intro { min-height: calc(60svh - 76px); }
|
||||
.intro h1 { font-size: 52px; }
|
||||
.intro p { font-size: 18px; }
|
||||
.after h2 { font-size: 40px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="mark" href="#top" id="top">Nesvik Sea Baths</a>
|
||||
<a class="go" href="mailto:swim@nesvik.example">swim@nesvik.example</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="intro" aria-labelledby="intro-title">
|
||||
<h1 id="intro-title">Swim the open sea at Nesvik</h1>
|
||||
<div>
|
||||
<p>A sea-swimming school on the outer coast, with guided swims from seven in the morning until dark, a wood-fired sauna and a bath house by the water.</p>
|
||||
<a class="book" href="#season">See the season</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="scene" id="day" data-scene="pinned-chapter-switcher" aria-labelledby="day-title" data-options='{"length":1,"wipe":0.5}'>
|
||||
<div class="scene-stage">
|
||||
<header class="chs-head">
|
||||
<h2 class="chs-heading" id="day-title">One day at the baths</h2>
|
||||
</header>
|
||||
<nav class="chs-index" aria-label="The swims of the day">
|
||||
<ol>
|
||||
<li class="chs-tab"><a href="#swim-1">Morning</a></li>
|
||||
<li class="chs-tab"><a href="#swim-2">Noon</a></li>
|
||||
<li class="chs-tab"><a href="#swim-3">Sundown</a></li>
|
||||
<li class="chs-tab"><a href="#swim-4">Last watch</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<article class="chs-ch" id="swim-1" data-horizon=".506" style="--plate-bg:#24434d; --scrim:.52">
|
||||
<figure class="chs-plate">
|
||||
<picture>
|
||||
<source media="(max-width: 767px)" sizes="112vw" srcset="https://picsum.photos/id/16/450/1125.webp 450w, https://picsum.photos/id/16/700/1750.webp 700w">
|
||||
<source media="(orientation: portrait) and (max-width: 1023px)" sizes="112vw" srcset="https://picsum.photos/id/16/720/1200.webp 720w, https://picsum.photos/id/16/1080/1800.webp 1080w">
|
||||
<img class="chs-img" src="https://picsum.photos/id/16/1700/1700.webp" srcset="https://picsum.photos/id/16/1200/1200.webp 1200w, https://picsum.photos/id/16/1700/1700.webp 1700w, https://picsum.photos/id/16/2200/2200.webp 2200w" sizes="112vw" width="1700" height="1700" alt="Flat rocks and a bleached driftwood log at the edge of a clear blue bay, wooded islands on the horizon" fetchpriority="high">
|
||||
</picture>
|
||||
</figure>
|
||||
<div class="chs-text">
|
||||
<h3 class="chs-title">In off the rocks</h3>
|
||||
<p class="chs-body">The first swim leaves at seven from the flat rocks below the bath house. The bay is clear and fourteen degrees, and a guide swims at the edge of every group.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="chs-ch" id="swim-2" data-horizon=".657" style="--plate-bg:#7d95a3; --scrim:.52; --obj-pos:50% 70%">
|
||||
<figure class="chs-plate">
|
||||
<picture>
|
||||
<source media="(max-width: 767px)" sizes="112vw" srcset="https://picsum.photos/id/941/400/1200.webp 400w, https://picsum.photos/id/941/600/1800.webp 600w">
|
||||
<source media="(orientation: portrait) and (max-width: 1023px)" sizes="112vw" srcset="https://picsum.photos/id/941/720/1200.webp 720w, https://picsum.photos/id/941/1080/1800.webp 1080w">
|
||||
<img class="chs-img" src="https://picsum.photos/id/941/1700/1700.webp" srcset="https://picsum.photos/id/941/1200/1200.webp 1200w, https://picsum.photos/id/941/1700/1700.webp 1700w, https://picsum.photos/id/941/2200/2200.webp 2200w" sizes="112vw" width="1700" height="1700" alt="A small wooded island on a pale, glassy sea under a bright white sky">
|
||||
</picture>
|
||||
</figure>
|
||||
<div class="chs-text">
|
||||
<h3 class="chs-title">Across to the island</h3>
|
||||
<p class="chs-body">At noon the strong swimmers cross to the island: 1.2 kilometres of open water, a kayak alongside, a rest on the warm rock, then the long swim back.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="chs-ch" id="swim-3" data-horizon=".29" style="--plate-bg:#6b4630; --scrim:.5">
|
||||
<figure class="chs-plate">
|
||||
<picture>
|
||||
<source media="(max-width: 767px)" sizes="112vw" srcset="https://picsum.photos/id/27/450/1125.webp 450w, https://picsum.photos/id/27/700/1750.webp 700w">
|
||||
<source media="(orientation: portrait) and (max-width: 1023px)" sizes="112vw" srcset="https://picsum.photos/id/27/720/1200.webp 720w, https://picsum.photos/id/27/1080/1800.webp 1080w">
|
||||
<img class="chs-img" src="https://picsum.photos/id/27/1700/1700.webp" srcset="https://picsum.photos/id/27/1200/1200.webp 1200w, https://picsum.photos/id/27/1700/1700.webp 1700w, https://picsum.photos/id/27/2200/2200.webp 2200w" sizes="112vw" width="1700" height="1700" alt="Swimmers waiting beyond a breaking wave in gold evening light, seen from a sandstone ledge">
|
||||
</picture>
|
||||
</figure>
|
||||
<div class="chs-text">
|
||||
<h3 class="chs-title">Out past the break</h3>
|
||||
<p class="chs-body">When the evening swell comes in gold off the point, the strong swimmers go out past the break with a guide. Everyone else watches from the warm sand.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="chs-ch" id="swim-4" data-horizon=".69" data-horizon-at=".64" style="--plate-bg:#0d1a2c; --scrim:.3; --obj-pos:50% 85%; --obj-pos-phone:85% 50%">
|
||||
<figure class="chs-plate">
|
||||
<picture>
|
||||
<source media="(max-width: 767px)" sizes="146vw" srcset="https://picsum.photos/id/505/480/800.webp 480w, https://picsum.photos/id/505/800/1333.webp 800w">
|
||||
<source media="(orientation: portrait) and (max-width: 1023px)" sizes="112vw" srcset="https://picsum.photos/id/505/720/1200.webp 720w, https://picsum.photos/id/505/1080/1800.webp 1080w">
|
||||
<img class="chs-img" src="https://picsum.photos/id/505/1700/1275.webp" srcset="https://picsum.photos/id/505/1200/900.webp 1200w, https://picsum.photos/id/505/1700/1275.webp 1700w, https://picsum.photos/id/505/2200/1650.webp 2200w" sizes="112vw" width="1700" height="1275" alt="A lone figure standing on a dark headland against a deep blue sky, an orange glow along the horizon">
|
||||
</picture>
|
||||
</figure>
|
||||
<div class="chs-text">
|
||||
<h3 class="chs-title">The last watch</h3>
|
||||
<p class="chs-body">When the light goes, a guide stands on the headland until every swimmer is in. Then the sauna, fish soup, a towel by the fire, and the gate locked at ten.</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="after" id="season" aria-labelledby="season-title">
|
||||
<h2 id="season-title">Open June to August</h2>
|
||||
<div>
|
||||
<p>Four swims a day, and every swim has a guide in the water and a kayak alongside. Bring a swimsuit; we lend wetsuits, gloves and the orange caps.</p>
|
||||
<ul>
|
||||
<li><strong>18 euros</strong> a swim, the sauna included</li>
|
||||
<li><strong>1.2 kilometres</strong> across the bay, the longest swim</li>
|
||||
<li><strong>16 degrees</strong> in the sea in a good August</li>
|
||||
</ul>
|
||||
<a class="book" href="mailto:swim@nesvik.example">Book a swim</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer><p>Photographs: Paul Jarvis, Ivan Slade, Yoni Kaplan-Nadel and Lee Scott, through Unsplash. Nesvik and its sea baths are invented for this demonstration.</p></footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/SplitText.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../pinned-chapter-switcher.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,209 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Aldern, films for walls</title>
|
||||
<meta name="description" content="Aldern is a studio of three in Rotterdam that shoots, grades and installs films cut for one wall, one hall or one stairwell.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400..800&display=swap" rel="stylesheet">
|
||||
<link rel="preconnect" href="https://picsum.photos">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../powers-of-ten-pullback.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #e9e9e5;
|
||||
--ink: #131518;
|
||||
--muted: #464a50;
|
||||
--surface: #ffffff;
|
||||
--accent: #23394a;
|
||||
--font-display: "Schibsted Grotesk", system-ui, sans-serif;
|
||||
--font-text: "Schibsted Grotesk", system-ui, sans-serif;
|
||||
--gutter: clamp(20px, 6vw, 120px);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-text);
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: inherit; }
|
||||
img { max-width: 100%; }
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 24px var(--gutter);
|
||||
font-size: 17px;
|
||||
}
|
||||
.bar .mark { font-weight: 800; font-size: 21px; letter-spacing: -.02em; text-decoration: none; }
|
||||
.bar nav { display: flex; align-items: center; gap: clamp(16px, 3vw, 40px); }
|
||||
.bar nav a { text-decoration: none; }
|
||||
|
||||
.opening {
|
||||
min-height: calc(86svh - 80px);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
column-gap: 24px;
|
||||
align-content: end;
|
||||
padding: 10svh var(--gutter) 12svh;
|
||||
}
|
||||
.opening h1 {
|
||||
grid-column: 1 / 12;
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(42px, 8.4vw, 150px);
|
||||
font-weight: 600;
|
||||
line-height: .94;
|
||||
letter-spacing: -.045em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.opening p {
|
||||
grid-column: 7 / 12;
|
||||
margin: 40px 0 0;
|
||||
max-width: 38ch;
|
||||
font-size: clamp(18px, 1.4vw, 21px);
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
padding: 0 22px;
|
||||
background: var(--ink);
|
||||
color: var(--ground);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.bar .button { min-height: 44px; padding: 0 18px; }
|
||||
|
||||
.now {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
grid-template-rows: auto auto 1fr;
|
||||
column-gap: 24px;
|
||||
padding: 16svh var(--gutter) 14svh;
|
||||
}
|
||||
.now figure { grid-column: 1 / 6; grid-row: 1 / span 3; margin: 0; }
|
||||
.now figure img { display: block; width: 100%; height: auto; aspect-ratio: 4 / 5; object-fit: cover; }
|
||||
.now figure figcaption { margin-top: 12px; color: var(--muted); font-size: 15px; }
|
||||
.now h2 {
|
||||
grid-column: 7 / 13;
|
||||
margin: 0 0 48px;
|
||||
font-size: clamp(44px, 5.2vw, 96px);
|
||||
font-weight: 600;
|
||||
line-height: .98;
|
||||
letter-spacing: -.03em;
|
||||
}
|
||||
.now ul { grid-column: 7 / 13; list-style: none; margin: 0; padding: 0; display: grid; gap: 32px; align-content: start; }
|
||||
.now li { display: grid; gap: 4px; }
|
||||
.now li strong { font-size: clamp(24px, 2.2vw, 32px); font-weight: 600; letter-spacing: -.02em; line-height: 1.1; }
|
||||
.now li span { color: var(--muted); }
|
||||
.now .button { grid-column: 7 / 13; justify-self: start; align-self: end; margin-top: 48px; }
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 28px var(--gutter) 36px;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.bar nav a:not(.button) { display: none; }
|
||||
.opening { min-height: 70svh; }
|
||||
.opening h1, .opening p, .now h2, .now ul, .now .button, .now figure { grid-column: 1 / -1; grid-row: auto; }
|
||||
.opening p { margin-top: 24px; }
|
||||
.now h2 { margin-bottom: 32px; }
|
||||
.now figure { margin-top: 48px; order: 5; }
|
||||
footer { flex-direction: column; gap: 6px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="mark" href="#">Aldern</a>
|
||||
<nav><a href="#swim">Work</a><a href="#now">Now showing</a><a class="button" href="#now">Tell us about a wall</a></nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="opening">
|
||||
<h1>Aldern makes films for walls.</h1>
|
||||
<p>A studio of three in Rotterdam. We shoot, grade and install moving pictures cut for one surface and the people who pass it.</p>
|
||||
</section>
|
||||
|
||||
<section class="scene pb" id="swim" data-scene="powers-of-ten-pullback" data-options='{}' aria-labelledby="pb-head">
|
||||
<div class="scene-stage pb-stage">
|
||||
<div class="pb-field" aria-hidden="true"></div>
|
||||
<div class="pb-tilt">
|
||||
<div class="pb-world">
|
||||
<div class="pb-slot">
|
||||
<div class="pb-room" style="--wx:.335;--wy:.245;--ww:.33;--wh:.2784;--wx-sm:.275;--wy-sm:.24;--ww-sm:.45;--wh-sm:.2025">
|
||||
<picture>
|
||||
<source media="(max-width: 767px)" sizes="175vw" width="1600" height="2000"
|
||||
srcset="https://picsum.photos/id/494/1600/2000.webp 1600w, https://picsum.photos/id/494/2666/3333.webp 2666w">
|
||||
<img class="pb-room-img" src="https://picsum.photos/id/494/2400/1600.webp" sizes="120vw" width="3600" height="2400"
|
||||
srcset="https://picsum.photos/id/494/2400/1600.webp 2400w, https://picsum.photos/id/494/3600/2400.webp 3600w"
|
||||
alt="The stone wall under the Varda viaduct at night, a woman and her dog in a shaft of light below the screen">
|
||||
</picture>
|
||||
<div class="pb-inner">
|
||||
<img class="pb-film" src="https://picsum.photos/id/841/2560/1440.webp" width="2560" height="1440"
|
||||
alt="A swimmer mid-stroke in a dark lake under low cloud, a still from Swim">
|
||||
</div>
|
||||
</div>
|
||||
<div class="pb-glass" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="pb-object" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pb-scrim" aria-hidden="true"></div>
|
||||
<header class="pb-title">
|
||||
<p>Swim, a film for one wall</p>
|
||||
<p>Twelve minutes of cold water, filmed at first light on Lake Varda.</p>
|
||||
</header>
|
||||
<p class="pb-label">Swim, 2026. Nightly under the Varda viaduct.</p>
|
||||
<div class="pb-copy">
|
||||
<h2 id="pb-head">One wall at a time.</h2>
|
||||
<p>Every film we make is cut for the surface it plays on: its size, its light, its distance from the pavement. Swim played under the viaduct all summer.</p>
|
||||
<a href="#now">See the walls</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="now" id="now" aria-labelledby="now-title">
|
||||
<figure>
|
||||
<img src="https://picsum.photos/id/954/960/1200.webp" width="960" height="1200" loading="lazy"
|
||||
alt="A narrow opening in a white wall onto a white stair, where Tide Clock plays">
|
||||
<figcaption>The stair at the Hofplein garage, before the screen went in.</figcaption>
|
||||
</figure>
|
||||
<h2 id="now-title">Now showing</h2>
|
||||
<ul>
|
||||
<li><strong>Tide Clock</strong><span>Stairwell of the Hofplein garage, every evening until 30 November.</span></li>
|
||||
<li><strong>Low Sun</strong><span>Lobby of the Kanaalhal, from 12 December, eight in the morning to six.</span></li>
|
||||
<li><strong>Swim</strong><span>Under the Varda viaduct. Ended on 28 September after 96 nights.</span></li>
|
||||
</ul>
|
||||
<a class="button" href="#">Tell us about a wall</a>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span>Aldern, Rotterdam</span>
|
||||
<span>Photographs from Unsplash via Lorem Picsum: Matthew Wiebe, Todd Quackenbush.</span>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../powers-of-ten-pullback.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,275 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Kestrel Night Line</title>
|
||||
<meta name="description" content="A sleeper train from the city to the northern coast. Scene demo: the raster cut between chapters.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;700&display=swap">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../raster-cut.css">
|
||||
<style>
|
||||
:root {
|
||||
--night: #0e100f;
|
||||
--bone: #eaebe6;
|
||||
--ground: #dfe3e3;
|
||||
--ink: #121614;
|
||||
--font-display: 'Schibsted Grotesk', system-ui, sans-serif;
|
||||
--gutter: 6vw;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html { background: var(--night); }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font: 400 18px/1.5 var(--font-display);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
img { display: block; max-width: 100%; }
|
||||
a { color: inherit; }
|
||||
figure { margin: 0; }
|
||||
figcaption { margin-top: 12px; font-size: 15px; }
|
||||
|
||||
/* a plain bar; the scene tells it which ink to use and when a filled bar would not show as a band */
|
||||
.bar {
|
||||
position: fixed;
|
||||
inset: 0 0 auto;
|
||||
z-index: 20;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--gutter);
|
||||
color: var(--rc-nav-ink, var(--bone));
|
||||
font-size: 16px;
|
||||
transition: background-color .35s;
|
||||
}
|
||||
/* once the pin releases it takes the B ground, so text never scrolls under text */
|
||||
.bar[data-rc-fill] { background: var(--rc-nav-ground); }
|
||||
.bar .mark { font-weight: 700; text-decoration: none; letter-spacing: -.01em; }
|
||||
.bar nav { display: flex; gap: 32px; }
|
||||
.bar nav a { text-decoration: none; }
|
||||
.bar nav a:last-child { font-weight: 500; text-decoration: underline; text-underline-offset: 4px; text-decoration-thickness: 2px; }
|
||||
/* without the live scene the bar stays with the first screen */
|
||||
html:not(.js) .bar, body:has(.scene.is-rest) .bar { position: absolute; }
|
||||
@media (prefers-reduced-motion: reduce) { .bar { position: absolute; } }
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 16px 28px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
background: var(--ink);
|
||||
color: var(--ground);
|
||||
transition: background-color .2s, color .2s;
|
||||
}
|
||||
.button:hover { background: #2b332f; }
|
||||
|
||||
.hero {
|
||||
min-height: 100svh;
|
||||
display: grid;
|
||||
grid-template-columns: 1.15fr .85fr;
|
||||
gap: 4vw;
|
||||
padding: 104px var(--gutter) 8svh;
|
||||
background: var(--night);
|
||||
color: var(--bone);
|
||||
}
|
||||
.hero-copy { display: flex; flex-direction: column; justify-content: flex-end; }
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(52px, 8.4vw, 150px);
|
||||
font-weight: 700;
|
||||
line-height: .9;
|
||||
letter-spacing: -.045em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.hero p { margin: 32px 0 0; max-width: 30ch; font-size: clamp(18px, 1.6vw, 24px); line-height: 1.4; }
|
||||
.hero .button { align-self: flex-start; margin-top: 40px; background: var(--bone); color: var(--night); }
|
||||
.hero .button:hover { background: #ffffff; }
|
||||
.hero-pic { position: relative; overflow: hidden; min-height: 320px; }
|
||||
.hero-pic img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: 60% 40%; }
|
||||
|
||||
.arrive {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6vw;
|
||||
align-items: start;
|
||||
/* the scene leaves a flat band of this ground above; 48px or more keeps the heading off the picture's
|
||||
edge even where a short screen leaves the band thin */
|
||||
padding: max(4svh, 48px) var(--gutter) 12svh;
|
||||
background: var(--ground);
|
||||
}
|
||||
.arrive h2, .dining h2 {
|
||||
margin: 0;
|
||||
max-width: 14ch;
|
||||
font-size: clamp(36px, 4.4vw, 72px);
|
||||
font-weight: 700;
|
||||
line-height: .98;
|
||||
letter-spacing: -.035em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.arrive-copy > p { margin: 28px 0 0; max-width: 40ch; }
|
||||
.times { display: grid; grid-template-columns: repeat(3, auto); justify-content: start; gap: 40px; margin: 44px 0 0; padding: 0; list-style: none; }
|
||||
.times strong { display: block; font-size: clamp(28px, 2.6vw, 40px); line-height: 1.1; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
|
||||
.times span { display: block; margin-top: 6px; font-size: 16px; }
|
||||
.arrive .button { margin-top: 48px; }
|
||||
.arrive-pic { overflow: hidden; }
|
||||
.arrive-pic img { width: 100%; aspect-ratio: 4 / 5; object-fit: cover; }
|
||||
|
||||
.dining { padding: 0 var(--gutter) 12svh; background: var(--ground); }
|
||||
.dining-head { display: grid; grid-template-columns: 1fr 1fr; gap: 6vw; align-items: end; margin-bottom: 40px; }
|
||||
.dining-head p { margin: 0; max-width: 40ch; }
|
||||
.dining figure { overflow: hidden; }
|
||||
.dining img { width: 100%; aspect-ratio: 16 / 7; object-fit: cover; }
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 40px var(--gutter) 56px;
|
||||
background: var(--ground);
|
||||
font-size: 15px;
|
||||
}
|
||||
.foot p { margin: 0; max-width: 52ch; }
|
||||
|
||||
/* arrivals: the hero's parts rise on load (settled by 1s), each later section's parts rise as it enters.
|
||||
Only transform and opacity, and only where motion is welcome; otherwise everything is simply there. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
html.js .hero-copy > * { animation: kn-rise .8s cubic-bezier(.2, .75, .15, 1) both; animation-delay: calc(var(--i, 0) * 90ms); }
|
||||
html.js .hero-pic img { animation: kn-settle 1s cubic-bezier(.2, .75, .15, 1) both; }
|
||||
@keyframes kn-rise { from { opacity: 0; transform: translateY(32px); } }
|
||||
@keyframes kn-settle { from { opacity: 0; transform: scale(1.1); } }
|
||||
html.kn-reveal [data-in] {
|
||||
opacity: 0;
|
||||
transform: translateY(36px);
|
||||
transition: opacity .7s cubic-bezier(.2, .75, .15, 1), transform .9s cubic-bezier(.2, .75, .15, 1);
|
||||
transition-delay: calc(var(--i, 0) * 80ms);
|
||||
}
|
||||
html.kn-reveal [data-in].pic { transform: scale(1.08); transition-duration: .9s, 1.4s; }
|
||||
html.kn-reveal .is-in [data-in] { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
:root { --gutter: 24px; }
|
||||
.bar nav a:not(:last-child) { display: none; }
|
||||
.hero { grid-template-columns: 1fr; grid-template-rows: auto 40svh; gap: 32px; padding: 104px var(--gutter) 32px; }
|
||||
.hero h1 { font-size: 15vw; }
|
||||
.hero p { margin-top: 20px; }
|
||||
.hero .button { margin-top: 28px; }
|
||||
.arrive { grid-template-columns: 1fr; gap: 48px; padding: 2svh var(--gutter) 10svh; }
|
||||
.times { grid-template-columns: 1fr 1fr; gap: 24px 32px; }
|
||||
.dining-head { grid-template-columns: 1fr; gap: 20px; }
|
||||
.dining img { aspect-ratio: 4 / 3; }
|
||||
.foot { flex-direction: column; }
|
||||
/* the phone's narrow crop of the sea keeps the marker post out from behind the words */
|
||||
#morning .rc-b .rc-media img { object-position: 28% 50%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="bar">
|
||||
<a class="mark" href="#top">Kestrel Night Line</a>
|
||||
<nav aria-label="Main">
|
||||
<a href="#morning">The route</a>
|
||||
<a href="#cabins">Cabins</a>
|
||||
<a href="#cabins">Book a cabin</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section class="hero">
|
||||
<div class="hero-copy">
|
||||
<h1 style="--i:0">Kestrel Night Line</h1>
|
||||
<p style="--i:1">A sleeper train that leaves the city at 22:40 and wakes you on the northern coast, 640 kilometres later.</p>
|
||||
<a class="button" href="#cabins" style="--i:2">Book a cabin</a>
|
||||
</div>
|
||||
<figure class="hero-pic">
|
||||
<img src="https://picsum.photos/id/232/1000/1300" alt="A twin street lamp in falling snow at night" width="1000" height="1300" decoding="async">
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<!-- the night forest (A) cut into the morning sea (B); --b-scrim washes the pale picture with its own
|
||||
ground so the dark title keeps 7:1 -->
|
||||
<section class="scene" id="morning" data-scene="raster-cut" data-options='{"nav":".bar"}'
|
||||
style="--a-ground:#0e100f; --a-ink:#eaebe6; --b-ground:#dfe3e3; --b-ink:#121614; --a-scrim:.55; --b-scrim:.3; --b-scrim-color:#e6e9e9">
|
||||
<div class="scene-stage">
|
||||
<div class="rc-layer rc-a" aria-hidden="true">
|
||||
<div class="rc-media">
|
||||
<img src="https://picsum.photos/id/227/1600/1000"
|
||||
srcset="https://picsum.photos/id/227/1600/1000 1600w, https://picsum.photos/id/227/2400/1500 2400w"
|
||||
sizes="(orientation: portrait) 160vh, 100vw"
|
||||
alt="" width="2400" height="1500" decoding="async">
|
||||
</div>
|
||||
<div class="rc-copy"><p class="rc-title">Awake by the sea</p></div>
|
||||
</div>
|
||||
<div class="rc-layer rc-b">
|
||||
<div class="rc-media">
|
||||
<img src="https://picsum.photos/id/302/1600/1000"
|
||||
srcset="https://picsum.photos/id/302/1600/1000 1600w, https://picsum.photos/id/302/2400/1500 2400w"
|
||||
sizes="(orientation: portrait) 160vh, 100vw"
|
||||
alt="A marker post standing in a still, pale sea at first light" width="2400" height="1500" decoding="async">
|
||||
</div>
|
||||
<div class="rc-copy">
|
||||
<h2 class="rc-title">Awake by the sea</h2>
|
||||
<p class="rc-deck">The blinds rise at 06:40, when the line meets the shore, and breakfast is served until the last stop at Halvik.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="arrive" id="cabins" data-reveal>
|
||||
<div class="arrive-copy">
|
||||
<h2 data-in style="--i:0">Three nights a week, all year</h2>
|
||||
<p data-in style="--i:1">Kestrel leaves the city on Tuesdays, Fridays and Sundays. Cabins sleep one, two or four, with fresh linen, a washbasin and a window on the sea side for the last two hours of the line.</p>
|
||||
<ul class="times">
|
||||
<li data-in style="--i:2"><strong>22:40</strong><span>leaves the city</span></li>
|
||||
<li data-in style="--i:3"><strong>07:55</strong><span>arrives in Halvik</span></li>
|
||||
<li data-in style="--i:4"><strong>€180</strong><span>a cabin for two</span></li>
|
||||
</ul>
|
||||
<a class="button" href="#cabins" data-in style="--i:5">Book a cabin</a>
|
||||
</div>
|
||||
<figure class="arrive-pic">
|
||||
<img class="pic" src="https://picsum.photos/id/1052/1000/1250" alt="A black sea stack off a dark beach, white surf under a pale sky" width="1000" height="1250" loading="lazy" decoding="async" data-in style="--i:1">
|
||||
<figcaption data-in style="--i:3">The black beach below Halvik station, a few minutes after arrival.</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section class="dining" data-reveal>
|
||||
<div class="dining-head">
|
||||
<h2 data-in style="--i:0">Breakfast in the dining car</h2>
|
||||
<p data-in style="--i:1">Coffee from 06:30, rye bread from the bakery at the halfway stop, and a table by the window for as long as the coast lasts.</p>
|
||||
</div>
|
||||
<figure>
|
||||
<img class="pic" src="https://picsum.photos/id/42/1800/790" alt="A coffee cup on a long wooden table by the window" width="1800" height="790" loading="lazy" decoding="async" data-in style="--i:2">
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<footer class="foot">
|
||||
<p>Kestrel Night Line runs between the city and Halvik. Tickets open ninety days before departure, and dogs travel free in cabins for one.</p>
|
||||
<p>Photographs from picsum.photos.</p>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../raster-cut.js"></script>
|
||||
<script>
|
||||
Stage.smooth(); Stage.init();
|
||||
// each later section's parts rise once as it enters (a class switch; the CSS holds the motion)
|
||||
if ('IntersectionObserver' in window) {
|
||||
document.documentElement.classList.add('kn-reveal');
|
||||
const io = new IntersectionObserver((list) => {
|
||||
for (const e of list) if (e.isIntersecting) { e.target.classList.add('is-in'); io.unobserve(e.target); }
|
||||
}, { rootMargin: '0px 0px -12% 0px' });
|
||||
document.querySelectorAll('[data-reveal]').forEach((s) => io.observe(s));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,301 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Varde Architects</title>
|
||||
<meta name="description" content="Varde Architects builds public stairs, halls, stations and libraries. A walk through eight rooms.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Serif+Display:wght@100..900&display=swap">
|
||||
<link rel="preconnect" href="https://picsum.photos">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../room-walk-track.css">
|
||||
<style>
|
||||
:root {
|
||||
--paper: #efeeea;
|
||||
--ink: #161513;
|
||||
--oat: #e2ddd3;
|
||||
--powder: #c6d4e6;
|
||||
--stone: #9a948b;
|
||||
--clay: #dac7aa;
|
||||
--coral: #e25a4e;
|
||||
--ground: var(--paper);
|
||||
--font-display: "Noto Serif Display", Georgia, serif;
|
||||
--font-text: system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
--gutter: clamp(20px, 5vw, 72px);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
font: 400 17px/1.5 var(--font-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: inherit; }
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 22px var(--gutter);
|
||||
font-size: 16px;
|
||||
}
|
||||
.bar .mark { font-family: var(--font-display); font-size: 26px; letter-spacing: -0.02em; text-decoration: none; }
|
||||
.bar nav { display: flex; gap: clamp(16px, 3vw, 36px); }
|
||||
.bar nav a { text-decoration: none; }
|
||||
.bar nav a:hover, .bar nav a:focus-visible { text-decoration: underline; text-underline-offset: 4px; }
|
||||
|
||||
.intro {
|
||||
min-height: calc(78svh - 76px);
|
||||
padding: clamp(40px, 10svh, 120px) var(--gutter) clamp(44px, 8svh, 96px);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(260px, 30vw);
|
||||
column-gap: 6vw;
|
||||
align-content: end;
|
||||
align-items: end;
|
||||
}
|
||||
.intro h1 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
font-size: clamp(56px, 10.5vw, 188px);
|
||||
line-height: 0.9;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
.intro h1 span { display: block; }
|
||||
.intro p { margin: 0 0 1.2vw; font-size: clamp(18px, 1.3vw, 22px); line-height: 1.45; max-width: 26em; }
|
||||
.intro .go {
|
||||
display: inline-block;
|
||||
margin-top: 18px;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 15px 22px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.intro .go:hover, .intro .go:focus-visible { background: var(--coral); }
|
||||
|
||||
/* the framed pair in the photo room is one room, close and wide: first the reader against the wall,
|
||||
then the room around him and the light falling into it from the high windows */
|
||||
.rw-photo .rw-flip__a img { scale: 2.3; transform-origin: 24% 70%; }
|
||||
|
||||
.after {
|
||||
background: var(--oat);
|
||||
color: var(--ink);
|
||||
padding: clamp(64px, 12svh, 150px) var(--gutter) clamp(56px, 10svh, 120px);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 40px 5vw;
|
||||
align-items: start;
|
||||
}
|
||||
.after h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
font-size: clamp(40px, 4.4vw, 76px);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
.after p { margin: 0 0 14px; max-width: 26em; }
|
||||
.after .book {
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
text-decoration: none;
|
||||
padding: 14px 22px;
|
||||
border-radius: 999px;
|
||||
line-height: 1;
|
||||
}
|
||||
.after .book:hover, .after .book:focus-visible { background: var(--coral); color: var(--ink); }
|
||||
footer {
|
||||
background: var(--oat);
|
||||
color: var(--ink);
|
||||
padding: 0 var(--gutter) 40px;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
footer p { margin: 0; max-width: 66ch; opacity: 0.72; }
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.intro { grid-template-columns: 1fr; row-gap: 28px; min-height: 64svh; }
|
||||
.after { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.bar nav a:not(:last-child) { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="mark" href="#top">Varde</a>
|
||||
<nav aria-label="Main">
|
||||
<a href="#walk">Rooms</a>
|
||||
<a href="#after">Contact</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section class="intro" aria-labelledby="page-title">
|
||||
<h1 id="page-title"><span>Varde</span><span>Architects</span></h1>
|
||||
<p>Public stairs, halls, stations and libraries, drawn in Rotterdam and built slowly since 2009.<br><a class="go" href="#walk">Walk through the rooms</a></p>
|
||||
</section>
|
||||
|
||||
<section class="scene" data-scene="room-walk-track" id="walk" aria-labelledby="rw-title">
|
||||
<div class="scene-stage">
|
||||
<div class="rw-chrome">
|
||||
<a class="rw-chip rw-mark" href="#top">Varde</a>
|
||||
<p class="rw-chip rw-where" aria-hidden="true"><span class="rw-where__name">Eight rooms</span><span class="rw-where__count">1 of 8</span><span class="rw-bar"><span></span></span></p>
|
||||
<a class="rw-chip rw-skip" href="#after">Skip the walk</a>
|
||||
</div>
|
||||
|
||||
<div class="rw-track">
|
||||
|
||||
<article class="rw-room rw-threshold" data-name="Eight rooms">
|
||||
<h2 id="rw-title" class="rw-h1"><span class="rw-line">Rooms that let</span><span class="rw-line">the city slow down</span></h2>
|
||||
<p class="rw-lede">We build stairs, halls, stations and libraries for towns that want somewhere to sit without paying for it. Walk through eight of our rooms, left to right.</p>
|
||||
</article>
|
||||
|
||||
<article class="rw-room rw-photo" data-name="Abbey Library">
|
||||
<figure class="rw-bleed"><img src="https://picsum.photos/id/546/1400/933" srcset="https://picsum.photos/id/546/1400/933 1400w, https://picsum.photos/id/546/2400/1600 2400w" sizes="(min-width: 900px) and (min-aspect-ratio: 5/4) 94vw, (min-width: 900px) 58vw, 100vw" width="1400" height="933" alt="The abbey cloister, its stone vaults lit by low sun through the arches" decoding="async"></figure>
|
||||
<figure class="rw-flip">
|
||||
<div class="rw-flip__b"><img src="https://picsum.photos/id/775/900/600" srcset="https://picsum.photos/id/775/900/600 900w, https://picsum.photos/id/775/1800/1200 1800w" sizes="(min-width: 900px) 30vw, 70vw" width="900" height="600" alt="Morning sun falling through the reading room's high openings onto a reader sitting by the wall" decoding="async"></div>
|
||||
<div class="rw-flip__a"><img src="https://picsum.photos/id/775/1800/1200" srcset="https://picsum.photos/id/775/1800/1200 1800w, https://picsum.photos/id/775/2400/1600 2400w" sizes="(min-width: 900px) 69vw, 161vw" width="1800" height="1200" alt="Close on a reader sitting against the warm stone wall, a glass in his hands" decoding="async"></div>
|
||||
</figure>
|
||||
<div class="rw-copy">
|
||||
<h3><span class="rw-line">Light from</span><span class="rw-line">above</span></h3>
|
||||
<p>The Abbey Library in Ghent keeps its old vaults and adds nothing at eye level. Daylight falls from the high windows, so the books stay out of the sun and the readers sit in it.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="rw-room rw-ink" data-name="On stairs">
|
||||
<figure class="rw-pic rw-pic--a"><img src="https://picsum.photos/id/494/600/800" srcset="https://picsum.photos/id/494/600/800 600w, https://picsum.photos/id/494/1200/1600 1200w" sizes="(min-width: 900px) and (min-aspect-ratio: 5/4) 29vw, (min-width: 900px) 24vw, 50vw" width="600" height="800" alt="A walker and a dog crossing a stone stair landing in a shaft of sun" decoding="async"></figure>
|
||||
<figure class="rw-pic rw-pic--b"><img src="https://picsum.photos/id/402/450/600" srcset="https://picsum.photos/id/402/450/600 450w, https://picsum.photos/id/402/536/715 536w" sizes="(min-width: 900px) 18vw, 50vw" width="450" height="600" alt="A long street of shopfronts running straight into a low evening sun" decoding="async"></figure>
|
||||
<p class="rw-read"><span class="rw-rl">We start every project on the stairs.</span> <span class="rw-rl">A stair is where strangers slow down</span> <span class="rw-rl">together, look up and decide how far</span> <span class="rw-rl">to go. Get the stair right and the rest</span> <span class="rw-rl">of the building tends to follow it.</span></p>
|
||||
</article>
|
||||
|
||||
<div class="rw-band" aria-hidden="true">
|
||||
<div class="rw-band__col">Rules * Rules * Rules * Rules * Rules *</div>
|
||||
</div>
|
||||
|
||||
<article class="rw-room rw-values" data-name="Three rules">
|
||||
<h3 class="rw-sr">Three rules: daylight, weight, patience</h3>
|
||||
<ol class="rw-vals">
|
||||
<li class="rw-val">Daylight</li>
|
||||
<li class="rw-val">Weight</li>
|
||||
<li class="rw-val">Patience</li>
|
||||
</ol>
|
||||
<p class="rw-vals__note">Three rules we bring to every site: let the sky in, build with things heavy enough to last, and give each room the time it takes.</p>
|
||||
</article>
|
||||
|
||||
<div class="rw-relay" data-name="Projects">
|
||||
<article class="rw-card" style="--block: var(--oat)">
|
||||
<figure class="rw-card__photo"><img src="https://picsum.photos/id/153/900/810" srcset="https://picsum.photos/id/153/900/810 900w, https://picsum.photos/id/153/1800/1620 1800w" sizes="(min-width: 900px) and (min-aspect-ratio: 5/4) 60vw, (min-width: 700px) 50vw, 100vw" width="900" height="810" alt="Rows of folded oak seats in the school hall, lit from its tall windows" loading="lazy" decoding="async"></figure>
|
||||
<div class="rw-card__block">
|
||||
<span class="rw-card__year">2018</span><span class="rw-card__place">Antwerp</span>
|
||||
<h3 class="rw-card__name">Kade School</h3>
|
||||
<a class="rw-card__link" href="#after">See the school</a>
|
||||
</div>
|
||||
</article>
|
||||
<article class="rw-card" style="--block: var(--clay)">
|
||||
<figure class="rw-card__photo"><img src="https://picsum.photos/id/508/900/810" srcset="https://picsum.photos/id/508/900/810 900w, https://picsum.photos/id/508/1800/1620 1800w" sizes="(min-width: 900px) and (min-aspect-ratio: 5/4) 60vw, (min-width: 700px) 50vw, 100vw" width="900" height="810" alt="The chapel's tall pale vaults, warm with evening light, over what is now the reading floor" loading="lazy" decoding="async"></figure>
|
||||
<div class="rw-card__block">
|
||||
<span class="rw-card__year">2020</span><span class="rw-card__place">Leeds</span>
|
||||
<h3 class="rw-card__name">Chapel Library</h3>
|
||||
<a class="rw-card__link" href="#after">See the library</a>
|
||||
</div>
|
||||
</article>
|
||||
<article class="rw-card" style="--block: var(--powder)">
|
||||
<figure class="rw-card__photo"><img src="https://picsum.photos/id/322/900/810" srcset="https://picsum.photos/id/322/900/810 900w, https://picsum.photos/id/322/1800/1620 1800w" sizes="(min-width: 900px) and (min-aspect-ratio: 5/4) 60vw, (min-width: 700px) 50vw, 100vw" width="900" height="810" alt="A cobbled lane between old fronts in ochre and rust-red plaster, open to the sky at its far end" loading="lazy" decoding="async"></figure>
|
||||
<div class="rw-card__block">
|
||||
<span class="rw-card__year">2021</span><span class="rw-card__place">Porto</span>
|
||||
<h3 class="rw-card__name">Ochre Lane</h3>
|
||||
<a class="rw-card__link" href="#after">See the lane</a>
|
||||
</div>
|
||||
</article>
|
||||
<article class="rw-card" style="--block: var(--stone)">
|
||||
<figure class="rw-card__photo"><img src="https://picsum.photos/id/396/900/810" srcset="https://picsum.photos/id/396/900/810 900w, https://picsum.photos/id/396/1800/1620 1800w" sizes="(min-width: 900px) and (min-aspect-ratio: 5/4) 60vw, (min-width: 700px) 50vw, 100vw" width="900" height="810" alt="A long moving walkway under a curved, warmly lit ceiling" loading="lazy" decoding="async"></figure>
|
||||
<div class="rw-card__block">
|
||||
<span class="rw-card__year">2023</span><span class="rw-card__place">Malmö</span>
|
||||
<h3 class="rw-card__name">Pier Station</h3>
|
||||
<a class="rw-card__link" href="#after">See the station</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="rw-hold" data-name="How we work">
|
||||
<article class="rw-card rw-card--last" style="--block: var(--oat)">
|
||||
<div class="rw-stack">
|
||||
<figure class="rw-card__photo"><img src="https://picsum.photos/id/78/900/810" srcset="https://picsum.photos/id/78/900/810 900w, https://picsum.photos/id/78/1500/1350 1500w" sizes="(min-width: 900px) and (min-aspect-ratio: 5/4) 60vw, (min-width: 700px) 50vw, 100vw" width="900" height="810" alt="A carved wooden double door in an old stone front" loading="lazy" decoding="async"></figure>
|
||||
<div class="rw-card__block">
|
||||
<span class="rw-card__year">2024</span><span class="rw-card__place">Basel</span>
|
||||
<h3 class="rw-card__name">Gate Hall</h3>
|
||||
<a class="rw-card__link" href="#after">See the hall</a>
|
||||
</div>
|
||||
<figure class="rw-stack__pic"><img src="https://picsum.photos/id/1076/700/390" srcset="https://picsum.photos/id/1076/700/390 700w, https://picsum.photos/id/1076/1400/780 1400w" sizes="(min-width: 900px) and (min-aspect-ratio: 5/4) 43vw, 50vw" width="700" height="390" alt="A building wrapped in bronze fins, seen from below against a pale sky" loading="lazy" decoding="async"></figure>
|
||||
<figure class="rw-stack__pic"><img src="https://picsum.photos/id/975/700/390" srcset="https://picsum.photos/id/975/700/390 700w, https://picsum.photos/id/975/1400/780 1400w" sizes="(min-width: 900px) and (min-aspect-ratio: 5/4) 43vw, 50vw" width="700" height="390" alt="Two tall oak shutters closed in an old brick wall, warm in the late sun" loading="lazy" decoding="async"></figure>
|
||||
<a class="rw-stack__all" href="#after"><span>All 38 projects</span><span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
</article>
|
||||
<section class="rw-question" aria-labelledby="rw-q">
|
||||
<div class="rw-underlay"></div>
|
||||
<h3 class="rw-q" id="rw-q"><span class="rw-line">Who says a public room</span><span class="rw-line">has to be loud?</span></h3>
|
||||
<p class="rw-q__body"><span class="rw-rl">Most of our clients are towns, not companies.</span> <span class="rw-rl">They arrive with a site, a small budget and</span> <span class="rw-rl">a long list of the people who will use it.</span> <span class="rw-rl">We arrive with time: a year of drawings, a</span> <span class="rw-rl">year of models, and a day in every room.</span></p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<article class="rw-room rw-coral" data-name="Hollin Yard">
|
||||
<figure class="rw-window">
|
||||
<div class="rw-window__pan">
|
||||
<div class="rw-flip">
|
||||
<div class="rw-flip__b"><img src="https://picsum.photos/id/953/1000/563" srcset="https://picsum.photos/id/953/1000/563 1000w, https://picsum.photos/id/953/1920/1080 1920w" sizes="(min-width: 900px) and (min-aspect-ratio: 5/4) 64vw, 88vw" width="1000" height="563" alt="Looking straight up the round stair well, its rings of windows lit by the noon sun" loading="lazy" decoding="async"></div>
|
||||
<div class="rw-flip__a"><img src="https://picsum.photos/id/979/1000/563" srcset="https://picsum.photos/id/979/1000/563 1000w, https://picsum.photos/id/979/1920/1080 1920w" sizes="(min-width: 900px) and (min-aspect-ratio: 5/4) 64vw, 88vw" width="1000" height="563" alt="Looking straight up from the yard between two old fronts to a dome and a blue sky" loading="lazy" decoding="async"></div>
|
||||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
<p class="rw-cap"><span>Hollin Yard, looking up at noon</span><span>Inside the round stair well</span></p>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="rw-ground" data-name="Contact">
|
||||
<div class="rw-exit">
|
||||
<h2 class="rw-exit__title"><span class="rw-line">Bring us</span><span class="rw-line">your site</span></h2>
|
||||
<p class="rw-exit__foot">We take on four new buildings a year. Write to studio@varde.example.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="after" id="after" aria-labelledby="after-title">
|
||||
<h2 id="after-title">Visit the studio</h2>
|
||||
<div>
|
||||
<p>Hollin Yard 4, 3011 Rotterdam. The ground floor is open to anyone on weekdays from nine to five; the models are on the first floor.</p>
|
||||
<p>studio@varde.example<br>+31 10 555 0142</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>Send us the site plan, the budget and who the building is for. We answer every letter within two weeks.</p>
|
||||
<a class="book" href="mailto:studio@varde.example">Write to the studio</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>Photographs: Hartmut Tobies, Austin Ban, Matthew Wiebe, Loudge, Charlie Foster, Jeff Sheldon, Afroz Nawaf, Sam X, Paul Evans, Samuel Zeller, Leeroy, Jean-Marie Grange and Alexandre Perotto, through Unsplash. Varde Architects and its buildings are invented for this demonstration.</p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../room-walk-track.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,231 @@
|
||||
<!doctype html>
|
||||
<html lang="en-GB">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Fellhouse, timber cabins delivered whole</title>
|
||||
<meta name="description" content="Fellhouse builds timber cabins whole in a barn at Aldmere and drives each one to its plot overnight.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;600;700;800&display=swap">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../route-journey.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #e9eae6;
|
||||
--ink: #121413;
|
||||
--muted: #4c524e;
|
||||
--accent: #ffc233;
|
||||
--accent-ink: #141210;
|
||||
--font-display: 'Archivo', system-ui, sans-serif;
|
||||
--font-text: 'Archivo', system-ui, sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--ground); }
|
||||
body { margin: 0; background: var(--ground); color: var(--ink); font: 400 18px/1.5 var(--font-text); -webkit-font-smoothing: antialiased; overflow-x: clip; }
|
||||
a { color: inherit; }
|
||||
img { display: block; max-width: 100%; }
|
||||
|
||||
.bar { display: flex; align-items: center; gap: 32px; padding: 22px 48px; font-size: 16px; }
|
||||
.bar__mark { margin-right: auto; font-weight: 800; font-size: 20px; letter-spacing: -0.02em; text-decoration: none; }
|
||||
.bar a:not(.bar__mark):not(.bar__book) { text-decoration: none; }
|
||||
.bar__book { display: inline-flex; align-items: center; height: 44px; padding: 0 20px; border-radius: 999px; background: var(--accent); color: var(--accent-ink); font-weight: 600; text-decoration: none; }
|
||||
|
||||
.hero { min-height: calc(100svh - 88px); padding: 3vh 48px 48px; display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); grid-template-rows: auto 1fr; column-gap: 48px; row-gap: 40px; align-items: end; }
|
||||
.hero h1 { grid-column: 1 / -1; margin: 0; max-width: 11em; font: 800 clamp(56px, 8vw, 132px)/0.92 var(--font-display); letter-spacing: -0.045em; }
|
||||
.hero__text { grid-column: 1 / 6; align-self: start; display: grid; gap: 28px; justify-items: start; }
|
||||
.hero__text p { margin: 0; max-width: 30ch; font-size: clamp(19px, 1.5vw, 23px); line-height: 1.4; }
|
||||
.hero__text a { font-weight: 600; text-underline-offset: 5px; text-decoration-thickness: 2px; }
|
||||
.hero__picture { grid-column: 6 / -1; align-self: stretch; margin: 0; min-height: 340px; max-height: 64svh; overflow: hidden; border-radius: 6px; }
|
||||
.hero__picture img { width: 100%; height: 100%; object-fit: cover; object-position: 50% 55%; }
|
||||
|
||||
.night {
|
||||
--ground: #0f1214;
|
||||
--ink: #eef0ec;
|
||||
--muted: #9ba39f;
|
||||
--route-land: #161b1e;
|
||||
--route-water: #0b2131;
|
||||
--route-forest: #1a3328;
|
||||
--route-field-a: #20272b;
|
||||
--route-field-b: #283035;
|
||||
--route-relief-1: #1f252a;
|
||||
--route-relief-2: #272e33;
|
||||
--route-relief-3: #30383d;
|
||||
--route-road: #4b5459;
|
||||
--route-lane: #384045;
|
||||
--route-house: #4f585d;
|
||||
--route-light: #ffcf73;
|
||||
--route-beam: rgb(255 232 180 / .085);
|
||||
}
|
||||
|
||||
.closing { padding: 200px 48px 48px; display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: 48px; row-gap: 64px; }
|
||||
.closing h2 { grid-column: 1 / 6; margin: 0; font: 800 clamp(40px, 5vw, 80px)/1 var(--font-display); letter-spacing: -0.04em; }
|
||||
.closing__text { grid-column: 7 / -1; display: grid; gap: 22px; align-content: start; }
|
||||
.closing__text p { margin: 0; max-width: 40ch; }
|
||||
.closing__contact { font-size: clamp(22px, 1.8vw, 28px); line-height: 1.35; font-weight: 600; }
|
||||
.closing__picture { grid-column: 1 / -1; margin: 0; aspect-ratio: 2.4; overflow: hidden; border-radius: 6px; }
|
||||
.closing__picture img { width: 100%; height: 100%; object-fit: cover; object-position: 50% 60%; }
|
||||
.closing__foot { grid-column: 1 / -1; display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px 32px; font-size: 15px; color: var(--muted); }
|
||||
.closing__foot p { margin: 0; }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.bar { padding: 18px 24px; }
|
||||
.hero { padding: 3vh 24px 40px; }
|
||||
.closing { padding: 160px 24px 40px; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.bar { gap: 18px; padding: 16px; font-size: 15px; }
|
||||
.bar a:not(.bar__mark):not(.bar__book) { display: none; }
|
||||
.bar__book { height: 40px; padding: 0 16px; }
|
||||
.hero { min-height: 0; padding: 4vh 16px 32px; grid-template-columns: minmax(0, 1fr); row-gap: 28px; }
|
||||
.hero h1, .hero__text, .hero__picture { grid-column: 1; }
|
||||
.hero h1 { font-size: 10.5vw; }
|
||||
.hero__picture { min-height: 0; aspect-ratio: 4 / 3; }
|
||||
.closing { padding: 120px 16px 32px; grid-template-columns: minmax(0, 1fr); row-gap: 32px; }
|
||||
.closing h2, .closing__text, .closing__picture, .closing__foot { grid-column: 1; }
|
||||
.closing__picture { aspect-ratio: 4 / 3; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="bar">
|
||||
<a class="bar__mark" href="#top">Fellhouse</a>
|
||||
<a href="#cabins">Cabins</a>
|
||||
<a href="#delivery">Delivery</a>
|
||||
<a href="#plot">Prices</a>
|
||||
<a class="bar__book" href="#plot">Book a survey</a>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section class="hero" id="cabins" aria-labelledby="hero-title">
|
||||
<h1 id="hero-title">Cabins that arrive finished</h1>
|
||||
<div class="hero__text">
|
||||
<p>Fellhouse builds timber cabins whole in a barn at Aldmere, then drives each one to its plot overnight, ready for the keys by breakfast.</p>
|
||||
<a href="#delivery">How a delivery night goes</a>
|
||||
</div>
|
||||
<figure class="hero__picture">
|
||||
<img src="https://picsum.photos/id/650/1200/1100" width="1200" height="1100" alt="A road curving past dark pine forest into fog">
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section class="scene route night" id="delivery" data-scene="route-journey" aria-labelledby="route-title">
|
||||
<div class="scene-stage route-stage">
|
||||
<header class="route-intro">
|
||||
<h2 id="route-title">Your cabin, driven home</h2>
|
||||
<p class="route-lede">Every cabin leaves our Aldmere workshop finished, wired and glazed, and travels 410 km to your plot as one wide load, by night and under escort.</p>
|
||||
</header>
|
||||
<figure class="route-map">
|
||||
<svg class="route-still" viewBox="200 242 2154 2138" data-map="2560 2600" role="img" aria-label="Map of the drive: from the Aldmere workshop north to Corran harbour, south-east over the Hesketh pass, then north to the plot at Brannock, where the cabin stands on its lit plot.">
|
||||
<defs>
|
||||
<path id="route-geo" class="route-geo" d="M 320 2260 L 320 2060 A 1000 1000 0 0 1 454 1560 L 504 1473.4 A 300 300 0 0 1 661.2 1341.5 A 300 300 0 0 1 866.4 1341.5 L 1467.8 1560.4 A 300 300 0 0 0 1673 1560.4 A 300 300 0 0 0 1830.2 1428.5 L 1940.2 1237.9 A 1000 1000 0 0 0 2074.2 737.9 L 2074.2 617.9"/>
|
||||
<symbol id="route-vehicle" class="route-vehicle" viewBox="0 0 128 240" data-size="64 120">
|
||||
<rect class="rv-outline" x="19" y="0" width="90" height="64" rx="15" fill="#0f1214"/>
|
||||
<rect class="rv-outline" x="4" y="5" width="120" height="22" rx="5" fill="#0f1214"/>
|
||||
<rect class="rv-outline" x="2" y="57" width="124" height="183" rx="8" fill="#0f1214"/>
|
||||
<rect x="5" y="60" width="118" height="178" rx="6" fill="#3d464b"/>
|
||||
<rect x="11" y="66" width="106" height="166" rx="3" fill="#2f363a"/>
|
||||
<rect class="rv-beacon" x="9" y="230" width="14" height="6" rx="1.5" fill="#ffc233"/>
|
||||
<rect class="rv-beacon" x="105" y="230" width="14" height="6" rx="1.5" fill="#ffc233"/>
|
||||
<rect x="44" y="50" width="40" height="18" rx="3" fill="#20262a"/>
|
||||
<rect x="10" y="13" width="108" height="5" rx="2" fill="#20262a"/>
|
||||
<rect x="6" y="8" width="9" height="16" rx="2.5" fill="#20262a"/>
|
||||
<rect x="113" y="8" width="9" height="16" rx="2.5" fill="#20262a"/>
|
||||
<rect x="22" y="2" width="84" height="58" rx="13" fill="#e4e7e3"/>
|
||||
<rect x="29" y="7" width="70" height="16" rx="5" fill="#131a1e"/>
|
||||
<rect x="34" y="28" width="60" height="26" rx="4" fill="#f4f5f2"/>
|
||||
<rect class="rv-beacon" x="38" y="44" width="11" height="7" rx="1.5" fill="#ffc233"/>
|
||||
<rect class="rv-beacon" x="79" y="44" width="11" height="7" rx="1.5" fill="#ffc233"/>
|
||||
<path d="M15 74H64V227H15Z" fill="#c8975f"/>
|
||||
<path d="M64 74H113V227H64Z" fill="#8c633c"/>
|
||||
<path d="M15 87H64M15 99H64M15 111H64M15 123H64M15 135H64M15 147H64M15 159H64M15 171H64M15 183H64M15 195H64M15 207H64M15 219H64" stroke="#a97b49" stroke-width="2"/>
|
||||
<path d="M64 87H113M64 99H113M64 111H113M64 123H113M64 135H113M64 147H113M64 159H113M64 171H113M64 183H113M64 195H113M64 207H113M64 219H113" stroke="#6f4d2d" stroke-width="2"/>
|
||||
<rect x="60" y="74" width="8" height="153" fill="#e6c48f"/>
|
||||
<rect x="84" y="100" width="15" height="15" rx="1.5" fill="#263034"/>
|
||||
<rect x="25" y="166" width="24" height="34" rx="2" fill="#1c2a31"/>
|
||||
<rect x="5" y="104" width="118" height="5" fill="#161b1e"/>
|
||||
<rect x="5" y="194" width="118" height="5" fill="#161b1e"/>
|
||||
</symbol>
|
||||
<symbol id="route-mark" class="route-mark" viewBox="0 0 32 32"><path d="M16 3 29 14V29H3V14Z"/></symbol>
|
||||
<pattern id="route-tiles" x="1914.2" y="361.9" width="66" height="66" patternUnits="userSpaceOnUse"><rect class="route-still__tile" width="56" height="56" rx="6"/><circle class="route-still__dot" cx="28" cy="28" r="7"/></pattern>
|
||||
</defs>
|
||||
<g id="route-scene" class="route-scene">
|
||||
<g class="route-art">
|
||||
<path class="rm-land" d="M4531 1083C4508 1417 4554 1801 4400 2101C4245 2400 3923 2694 3605 2879C3287 3064 2855 3121 2491 3209C2128 3297 1782 3393 1424 3407C1067 3421 724 3338 344 3293C-36 3248 -567 3317 -855 3137C-1144 2957 -1236 2511 -1386 2213C-1535 1915 -1631 1648 -1752 1347C-1874 1046 -2132 715 -2114 409C-2097 103 -1814 -194 -1646 -492C-1478 -790 -1386 -1185 -1106 -1380C-827 -1575 -330 -1535 31 -1664C392 -1793 705 -2136 1059 -2157C1413 -2177 1776 -1880 2154 -1789C2533 -1698 2994 -1761 3330 -1609C3667 -1456 3972 -1157 4173 -872C4374 -588 4477 -229 4536 97C4596 422 4554 749 4531 1083Z"/><g class="rm-relief-1"><path d="M2054 2173C2015 2219 1978 2267 1904 2297C1830 2327 1700 2369 1610 2353C1520 2338 1430 2253 1363 2204C1296 2155 1227 2109 1207 2060C1187 2012 1205 1948 1245 1913C1285 1878 1376 1877 1446 1851C1516 1825 1589 1753 1667 1757C1745 1762 1838 1833 1916 1877C1994 1920 2112 1967 2135 2017C2158 2066 2092 2126 2054 2173Z"/><path d="M2100 1730C2116 1664 2133 1566 2175 1516C2217 1466 2292 1440 2353 1431C2414 1423 2493 1425 2541 1467C2588 1508 2637 1600 2640 1679C2643 1757 2594 1861 2559 1936C2523 2012 2480 2086 2428 2130C2376 2174 2303 2202 2248 2200C2193 2198 2127 2164 2098 2116C2070 2069 2076 1978 2076 1913C2076 1849 2083 1796 2100 1730Z"/><path d="M1757 887C1782 943 1846 1002 1821 1062C1796 1122 1685 1195 1608 1248C1531 1301 1441 1360 1358 1381C1275 1401 1157 1400 1111 1369C1065 1338 1102 1248 1084 1196C1066 1144 991 1111 1003 1058C1016 1005 1095 919 1160 879C1226 838 1312 840 1397 815C1482 789 1611 714 1671 726C1731 738 1732 831 1757 887Z"/><path d="M784 1633C817 1612 863 1593 895 1606C927 1618 971 1662 978 1708C985 1755 948 1833 935 1885C922 1937 919 1984 899 2021C879 2058 849 2079 815 2107C780 2135 719 2203 694 2188C668 2172 679 2065 663 2012C646 1960 590 1919 596 1872C602 1826 666 1774 697 1734C729 1694 751 1654 784 1633Z"/><path d="M1851 362C1880 410 1947 440 1958 482C1969 525 1957 594 1917 616C1877 637 1786 608 1719 613C1652 618 1562 665 1515 643C1469 621 1476 530 1439 481C1402 432 1295 386 1291 347C1288 309 1373 281 1417 249C1461 217 1494 165 1555 156C1617 147 1736 162 1785 196C1834 231 1822 314 1851 362Z"/><path d="M-309 2144C-362 2159 -458 2161 -487 2100C-516 2039 -486 1878 -483 1781C-480 1684 -492 1593 -469 1518C-446 1442 -391 1359 -346 1329C-302 1299 -246 1322 -202 1336C-157 1350 -117 1367 -79 1412C-42 1457 24 1533 24 1605C24 1676 -47 1774 -79 1842C-112 1910 -131 1962 -169 2012C-207 2063 -256 2130 -309 2144Z"/><path d="M681 3005C580 3019 479 3052 394 3049C309 3045 195 3014 171 2983C148 2952 240 2903 251 2863C262 2823 190 2771 236 2745C282 2720 437 2723 528 2711C618 2699 695 2676 780 2676C865 2677 997 2687 1039 2714C1080 2740 1036 2792 1030 2834C1024 2876 1059 2937 1001 2965C943 2994 782 2991 681 3005Z"/><path d="M3010 -76C3095 -52 3158 25 3248 76C3338 127 3494 164 3551 230C3608 295 3617 406 3592 469C3567 533 3485 604 3399 611C3314 617 3174 538 3081 508C2989 478 2936 470 2844 431C2752 391 2577 333 2532 271C2487 209 2538 114 2574 57C2609 1 2670 -44 2743 -67C2816 -89 2926 -100 3010 -76Z"/><path d="M-629 484C-653 562 -687 680 -738 698C-789 715 -860 607 -933 590C-1006 573 -1136 649 -1175 595C-1214 540 -1153 373 -1167 264C-1181 155 -1275 15 -1257 -59C-1238 -133 -1124 -149 -1057 -183C-989 -217 -910 -292 -850 -263C-789 -234 -734 -93 -692 -11C-649 72 -604 152 -594 234C-583 317 -605 407 -629 484Z"/><path d="M651 -908C549 -945 476 -1006 423 -1059C369 -1111 335 -1167 333 -1223C330 -1280 330 -1371 408 -1398C486 -1425 675 -1386 799 -1385C922 -1384 1029 -1413 1151 -1393C1272 -1374 1456 -1325 1527 -1270C1598 -1215 1602 -1123 1577 -1063C1552 -1003 1466 -948 1375 -910C1285 -873 1155 -838 1035 -838C914 -837 753 -872 651 -908Z"/><path d="M3394 2121C3434 2088 3511 2090 3573 2084C3635 2079 3707 2064 3764 2089C3821 2114 3873 2178 3916 2234C3958 2290 4008 2360 4018 2427C4028 2495 4018 2589 3975 2642C3933 2694 3842 2734 3764 2742C3685 2749 3563 2734 3503 2687C3443 2639 3430 2526 3402 2458C3373 2391 3332 2339 3331 2283C3330 2227 3354 2154 3394 2121Z"/><path d="M3297 -1554C3343 -1505 3328 -1402 3284 -1331C3240 -1261 3128 -1179 3034 -1130C2939 -1081 2820 -1060 2718 -1039C2617 -1018 2507 -995 2424 -1004C2341 -1014 2259 -1051 2220 -1096C2180 -1140 2170 -1206 2187 -1272C2203 -1339 2239 -1436 2320 -1493C2402 -1551 2564 -1597 2677 -1619C2791 -1641 2900 -1636 3004 -1625C3107 -1614 3250 -1603 3297 -1554Z"/><path d="M-908 2481C-840 2507 -760 2574 -739 2623C-717 2672 -753 2735 -779 2776C-805 2817 -850 2839 -895 2870C-941 2900 -981 2946 -1051 2960C-1122 2973 -1262 2982 -1318 2949C-1375 2916 -1376 2815 -1390 2762C-1403 2709 -1409 2671 -1398 2629C-1387 2588 -1367 2541 -1324 2514C-1282 2487 -1213 2473 -1144 2467C-1074 2461 -975 2455 -908 2481Z"/></g><g class="rm-relief-2"><path d="M1405 2171C1369 2139 1387 2086 1386 2052C1386 2018 1390 1997 1403 1967C1415 1937 1420 1889 1461 1872C1503 1855 1598 1852 1653 1866C1707 1879 1747 1925 1789 1953C1831 1980 1886 2003 1904 2033C1921 2062 1906 2096 1893 2130C1879 2164 1871 221Line truncated
|
||||
<path class="route-road" d="M 320 2260 L 320 2060 A 1000 1000 0 0 1 454 1560 L 504 1473.4 A 300 300 0 0 1 661.2 1341.5 A 300 300 0 0 1 866.4 1341.5 L 1467.8 1560.4 A 300 300 0 0 0 1673 1560.4 A 300 300 0 0 0 1830.2 1428.5 L 1940.2 1237.9 A 1000 1000 0 0 0 2074.2 737.9 L 2074.2 617.9"/>
|
||||
<text class="route-word" transform="translate(760 1960) scale(1 1.6)">ALDMERE</text>
|
||||
<text class="route-word" transform="translate(1090 1430) rotate(110) scale(1 1.6)">CORRAN</text>
|
||||
<text class="route-word" transform="translate(2010 1290) rotate(30) scale(1 1.6)">HESKETH</text>
|
||||
</g>
|
||||
<use href="#route-geo" class="route-still__trail"/>
|
||||
<circle class="route-stop" data-step="1" cx="320" cy="2260" r="22"/>
|
||||
<circle class="route-stop" data-step="2" cx="661.2" cy="1341.5" r="22"/>
|
||||
<circle class="route-stop" data-step="3" cx="1673" cy="1560.4" r="22"/>
|
||||
<g class="route-still__plot">
|
||||
<rect x="1914.2" y="361.9" width="320" height="320" fill="url(#route-tiles)"/>
|
||||
<rect class="route-still__centre" x="2046.2" y="493.9" width="56" height="56" rx="6"/>
|
||||
<use href="#route-mark" class="route-still__mark" x="2058.2" y="505.9" width="32" height="32"/>
|
||||
</g>
|
||||
<use href="#route-vehicle" x="2042.2" y="557.9" width="64" height="120"/>
|
||||
</g>
|
||||
</svg>
|
||||
<svg class="route-inset" viewBox="1844 312 460 460" aria-hidden="true" focusable="false"><use href="#route-scene"/></svg>
|
||||
</figure>
|
||||
<aside class="route-rail" aria-label="Stops on the route">
|
||||
<p class="route-gauge"><span class="route-gauge__label">Distance driven</span> <span class="route-gauge__value"><b class="route-gauge__num">410</b> <span class="route-gauge__unit">km</span></span></p>
|
||||
<ol class="route-steps">
|
||||
<li class="route-step" data-value="0">
|
||||
<h3 class="route-step__title">Aldmere workshop</h3>
|
||||
<p class="route-step__body">Twelve weeks of joinery end on a Tuesday evening. The crane sets the finished cabin on the low-loader, and we strap it at eight points and photograph every corner.</p>
|
||||
<p class="route-step__value">Start, 0 km</p>
|
||||
</li>
|
||||
<li class="route-step" data-value="128">
|
||||
<h3 class="route-step__title">Corran harbour</h3>
|
||||
<p class="route-step__body">Wide loads may only move between ten at night and six in the morning. We wait out the evening at the harbour, swap drivers and check every strap again.</p>
|
||||
<p class="route-step__value">128 km</p>
|
||||
</li>
|
||||
<li class="route-step" data-value="262">
|
||||
<h3 class="route-step__title">Hesketh pass</h3>
|
||||
<p class="route-step__body">The steepest climb, one in seven at the top. The road is closed behind us for twenty minutes so the cabin can take the bends on the full width.</p>
|
||||
<p class="route-step__value">262 km</p>
|
||||
</li>
|
||||
<li class="route-step" data-value="410">
|
||||
<h3 class="route-step__title">Your plot at Brannock</h3>
|
||||
<p class="route-step__body">We arrive before first light. By breakfast the crane has set the cabin on its twenty-five ground screws, levelled to two millimetres, and the keys are on your table.</p>
|
||||
<p class="route-step__value">410 km</p>
|
||||
</li>
|
||||
</ol>
|
||||
<div class="route-arrive">
|
||||
<p class="route-arrive__line">Set down, levelled and handed over by breakfast.</p>
|
||||
<a class="route-arrive__cta" href="#plot">Plan your delivery</a>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="closing" id="plot" aria-labelledby="plot-title">
|
||||
<h2 id="plot-title">Ask about your plot</h2>
|
||||
<div class="closing__text">
|
||||
<p>Send us a postcode and a few photographs of the ground. Within a week we will tell you whether a low-loader can reach it, which night suits the road, and what the drive will cost.</p>
|
||||
<p>Surveys cost £180 and come off the price of the cabin.</p>
|
||||
<p class="closing__contact">plots@fellhouse.build<br>01768 204 551</p>
|
||||
</div>
|
||||
<figure class="closing__picture">
|
||||
<img src="https://picsum.photos/id/767/2000/840" width="2000" height="840" alt="A straight empty road through tall pine forest" loading="lazy">
|
||||
</figure>
|
||||
<footer class="closing__foot">
|
||||
<p>Fellhouse, The Barn, Aldmere</p>
|
||||
<p>Photographs: Lou Levit, Sébastien Marchand (Unsplash)</p>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../route-journey.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,204 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Thaw, a film by Saltmark</title>
|
||||
<meta name="description" content="Saltmark is a two-person documentary studio. Thaw watched one waterfall every morning for a year.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,400..700&display=swap">
|
||||
<link rel="preconnect" href="https://fastly.picsum.photos" crossorigin>
|
||||
<link rel="preload" as="image" crossorigin="anonymous" media="(min-width: 768px) and (orientation: landscape)" imagesrcset="https://picsum.photos/id/509/1920/1080 1920w, https://picsum.photos/id/509/3200/1800 3200w" imagesizes="100vw">
|
||||
<link rel="preload" as="image" crossorigin="anonymous" media="(max-width: 767px), (orientation: portrait)" imagesrcset="https://picsum.photos/id/509/1000/1250 1000w, https://picsum.photos/id/509/1600/2000 1600w" imagesizes="100vw">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../scatter-and-lock.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #e8e7e2;
|
||||
--ink: #0d100f;
|
||||
--font-display: 'Archivo', system-ui, sans-serif;
|
||||
--font-text: 'Archivo', system-ui, sans-serif;
|
||||
--gutter: clamp(16px, 6vw, 86px);
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html { background: var(--ground); }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font: 400 18px/1.55 var(--font-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: inherit; }
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 24px var(--gutter) 0;
|
||||
}
|
||||
.bar strong { font: 700 22px/1 var(--font-display); letter-spacing: -0.02em; }
|
||||
.bar nav { display: flex; align-items: center; gap: 28px; font-size: 16px; }
|
||||
.bar nav a { text-decoration: none; }
|
||||
.bar nav a:not(.book):hover { text-decoration: underline; text-underline-offset: 4px; }
|
||||
.bar .book { padding: 12px 20px; background: var(--ink); color: var(--ground); font-weight: 600; }
|
||||
.bar .book:hover { background: #2a2d2b; }
|
||||
|
||||
.intro {
|
||||
display: grid;
|
||||
align-content: end;
|
||||
gap: 36px;
|
||||
padding: clamp(120px, 22vh, 220px) var(--gutter) 9vh;
|
||||
}
|
||||
@media (min-width: 1100px) and (min-height: 700px) { .intro { min-height: 76svh; padding-top: 0; } }
|
||||
.intro h1 {
|
||||
margin: 0;
|
||||
max-width: 12.5em;
|
||||
font: 600 clamp(30px, 7.6vw, 118px)/0.96 var(--font-display);
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
.intro p { margin: 0 0 0 auto; max-width: 30em; font-size: clamp(18px, 1.5vw, 22px); }
|
||||
|
||||
/* The word sits on the dark pool and the moss right of the spray at the foot of the falls (every letter
|
||||
at least 3.5:1 against the brightest fifth of the pixels behind it), inside one row band of the grid.
|
||||
At 9.75cqi its letters fit the 7 columns: two cuts, both in the white between letters (T|h, a|w).
|
||||
On the 4:5 art it stays in the middle 60% of the width: that is all a phone keeps of it at the end. */
|
||||
[data-scene="scatter-and-lock"] { --sl-gutter: var(--gutter); --sl-word-size: 9.75cqi; --sl-word-x: 52%; --sl-word-y: 87.5%; }
|
||||
[data-scene="scatter-and-lock"] .sl-word { font-stretch: 125%; font-weight: 700; letter-spacing: -0.01em; }
|
||||
@media (max-width: 767px), (orientation: portrait) {
|
||||
[data-scene="scatter-and-lock"] { --sl-word-size: 13cqi; --sl-word-x: 52%; --sl-word-y: 74%; }
|
||||
}
|
||||
|
||||
/* The screenings rise over the held last frame of the scene (see .sl-still in scatter-and-lock.css). */
|
||||
.after {
|
||||
background: var(--ground);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
|
||||
gap: 48px var(--gutter);
|
||||
padding: 160px var(--gutter) 200px;
|
||||
}
|
||||
.after h2 {
|
||||
margin: 0;
|
||||
font: 600 clamp(34px, 4.6vw, 72px)/1 var(--font-display);
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.after .copy { display: grid; gap: 28px; align-content: start; max-width: 34em; }
|
||||
.after p { margin: 0; }
|
||||
.dates { display: grid; gap: 14px; margin: 0; padding: 0; list-style: none; }
|
||||
.dates li { display: flex; justify-content: space-between; gap: 16px; padding: 16px 20px; background: #d9d9d2; }
|
||||
.dates span:last-child { font-variant-numeric: tabular-nums; }
|
||||
.action {
|
||||
justify-self: start;
|
||||
display: inline-block;
|
||||
padding: 16px 28px;
|
||||
background: var(--ink);
|
||||
color: var(--ground);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.action:hover { background: #2a2d2b; }
|
||||
|
||||
footer {
|
||||
padding: 0 var(--gutter) 40px;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
footer .name {
|
||||
margin: 0;
|
||||
font: 700 22.4vw/0.8 var(--font-display);
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
footer .small { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px 32px; font-size: 15px; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.bar nav { gap: 18px; font-size: 15px; }
|
||||
.bar nav a:nth-child(-n+2) { display: none; }
|
||||
.intro { gap: 24px; padding-top: 18vh; }
|
||||
.intro p { margin: 0; }
|
||||
.after { grid-template-columns: 1fr; padding: 96px var(--gutter) 168px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<strong>Saltmark</strong>
|
||||
<nav aria-label="Main">
|
||||
<a href="#films">Films</a>
|
||||
<a href="#screenings">Screenings</a>
|
||||
<a href="#studio">Studio</a>
|
||||
<a class="book" href="#screenings">Book seats</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="intro">
|
||||
<h1>Saltmark makes slow films about cold water</h1>
|
||||
<p>Two people, one camera and a van. Our new film watched a single waterfall every morning for a whole year.</p>
|
||||
</section>
|
||||
|
||||
<section class="scene" id="films" data-scene="scatter-and-lock" data-options='{"hold":2.5,"cols":7,"rows":4}' aria-labelledby="sl-h">
|
||||
<div class="scene-stage">
|
||||
<header class="sl-title">
|
||||
<h2 class="sl-headline" id="sl-h">A year spent behind one waterfall</h2>
|
||||
</header>
|
||||
<div class="sl-field">
|
||||
<div class="sl-frame">
|
||||
<div class="sl-solid">
|
||||
<div class="sl-art">
|
||||
<picture>
|
||||
<source media="(max-width: 767px), (orientation: portrait)" srcset="https://picsum.photos/id/509/1000/1250 1000w, https://picsum.photos/id/509/1600/2000 1600w" sizes="100vw">
|
||||
<img class="sl-img" src="https://picsum.photos/id/509/1920/1080" srcset="https://picsum.photos/id/509/1920/1080 1920w, https://picsum.photos/id/509/3200/1800 3200w" sizes="100vw" alt="A tall white waterfall pouring into a dark blue pool between banks of bright green moss, seen from the cave behind it" crossorigin="anonymous" fetchpriority="high">
|
||||
</picture>
|
||||
<span class="sl-word">Thaw</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="sl-caption">Filmed from the cave behind the falls, from one thaw to the next, then cut into ninety-four minutes.</p>
|
||||
</div>
|
||||
</section>
|
||||
<div class="sl-still" data-scene="scatter-and-lock">
|
||||
<div class="sl-still-pic" aria-hidden="true">
|
||||
<div class="sl-art">
|
||||
<picture>
|
||||
<source media="(max-width: 767px), (orientation: portrait)" srcset="https://picsum.photos/id/509/1000/1250 1000w, https://picsum.photos/id/509/1600/2000 1600w" sizes="100vw">
|
||||
<img class="sl-img" src="https://picsum.photos/id/509/1920/1080" srcset="https://picsum.photos/id/509/1920/1080 1920w, https://picsum.photos/id/509/3200/1800 3200w" sizes="100vw" alt="" crossorigin="anonymous">
|
||||
</picture>
|
||||
<span class="sl-word">Thaw</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="after" id="screenings" aria-labelledby="after-h">
|
||||
<h2 id="after-h">Thaw opens on 14 November</h2>
|
||||
<div class="copy">
|
||||
<p>Ninety-four minutes, no narration, one waterfall and the light that moves through it. The first screenings are at the Harbour Picture House in Wellmouth, and both of us will stay afterwards to talk about how it was made.</p>
|
||||
<ul class="dates">
|
||||
<li><span>Friday 14 November</span><span>19:30</span></li>
|
||||
<li><span>Saturday 15 November</span><span>14:00 and 19:30</span></li>
|
||||
<li><span>Sunday 16 November</span><span>16:00</span></li>
|
||||
</ul>
|
||||
<a class="action" href="mailto:seats@saltmark.example?subject=Seats%20for%20Thaw&body=Hello%2C%20I%20would%20like%20to%20book%20seats%20for%20Thaw%20on%3A%20">Book seats by email</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer id="studio">
|
||||
<p class="name">Saltmark</p>
|
||||
<div class="small">
|
||||
<span>A documentary studio on the north shore, since 2016.</span>
|
||||
<span>Photograph by Jeff Sheldon, via Unsplash.</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../scatter-and-lock.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,224 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Westerly, a small ferry for the outer islands</title>
|
||||
<meta name="description" content="Westerly sails one slow loop a week from Oban, calling at fifteen islands between Lismore and Lewis.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400..800&display=swap">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../scroll-turned-drum.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #efefed;
|
||||
--ink: #121212;
|
||||
--accent: #ff6a3d;
|
||||
--font-display: "Schibsted Grotesk", system-ui, sans-serif;
|
||||
--margin: clamp(16px, 2.3vw, 32px);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font: 400 18px/1.5 var(--font-display);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
img { display: block; max-width: 100%; }
|
||||
a { color: inherit; }
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 22px var(--margin);
|
||||
font-size: 18px;
|
||||
}
|
||||
.mark { font-weight: 700; font-size: 18px; letter-spacing: -0.02em; text-decoration: none; }
|
||||
.bar nav { display: flex; gap: 28px; }
|
||||
.bar nav a { text-decoration: none; }
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-height: calc(100svh - 72px);
|
||||
padding: 6svh var(--margin) 0;
|
||||
}
|
||||
.hero-text {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(260px, 32ch);
|
||||
gap: 48px;
|
||||
align-items: end;
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-weight: 650;
|
||||
font-size: clamp(38px, 7.4vw, 120px);
|
||||
line-height: 0.94;
|
||||
letter-spacing: -0.04em;
|
||||
max-width: 10em;
|
||||
}
|
||||
.hero p { margin: 0 0 24px; font-size: 18px; }
|
||||
.hero-pic { margin: 40px calc(var(--margin) * -1) 0; min-height: 44svh; position: relative; overflow: clip; }
|
||||
.hero-pic img { position: absolute; left: 0; top: 0; width: 100%; height: 112%; object-fit: cover; }
|
||||
|
||||
.after {
|
||||
display: grid;
|
||||
grid-template-columns: 5fr 7fr;
|
||||
gap: clamp(32px, 6vw, 96px);
|
||||
align-items: end;
|
||||
padding: clamp(120px, 16vw, 220px) var(--margin) clamp(160px, 16vw, 220px);
|
||||
}
|
||||
.after-pic { margin: 0; overflow: clip; aspect-ratio: 4 / 5; }
|
||||
.after-pic img { width: 100%; height: 112%; object-fit: cover; }
|
||||
.after h2 {
|
||||
margin: 0 0 24px;
|
||||
font-weight: 650;
|
||||
font-size: clamp(34px, 4.6vw, 72px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.035em;
|
||||
max-width: 14ch;
|
||||
}
|
||||
.after p { margin: 0 0 36px; max-width: 46ch; font-size: 18px; }
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 16px 26px;
|
||||
background: var(--accent);
|
||||
color: #121212;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.foot { padding: 0 var(--margin) 28px; }
|
||||
.foot-name {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
font-size: 19.5vw;
|
||||
line-height: 0.8;
|
||||
letter-spacing: -0.055em;
|
||||
}
|
||||
.foot p:last-child { margin: 28px 0 0; max-width: 62ch; font-size: 16px; color: color-mix(in srgb, var(--ink) 68%, transparent); }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.hero-text { grid-template-columns: 1fr; gap: 20px; }
|
||||
.hero p { max-width: 40ch; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.bar nav a:not(:last-child) { display: none; }
|
||||
.hero { padding-top: 4svh; }
|
||||
.hero-text { grid-template-columns: 1fr; gap: 20px; }
|
||||
.hero-pic { margin-top: 28px; min-height: 40svh; }
|
||||
.after { grid-template-columns: 1fr; padding: 120px var(--margin); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="mark" href="#top">Westerly</a>
|
||||
<nav aria-label="Main">
|
||||
<a href="#islands">Islands</a>
|
||||
<a href="#book">Fares</a>
|
||||
<a href="#book">Book a berth</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section class="hero" aria-labelledby="hero-title">
|
||||
<div class="hero-text">
|
||||
<h1 id="hero-title" data-rise>A small ferry for the outer islands</h1>
|
||||
<div data-rise>
|
||||
<p>Westerly sails one slow loop a week from Oban, calling at fifteen islands between Lismore and Lewis, April to October.</p>
|
||||
<a class="button" href="#book">Book a berth</a>
|
||||
</div>
|
||||
</div>
|
||||
<figure class="hero-pic">
|
||||
<img data-drift src="https://picsum.photos/id/16/1600/1000" srcset="https://picsum.photos/id/16/800/1000 800w, https://picsum.photos/id/16/1600/1000 1600w" sizes="100vw" width="1600" height="1000" alt="Blue sea and distant islands seen over a rocky shore">
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section class="scene" id="islands" data-scene="scroll-turned-drum" data-options='{}' aria-labelledby="drum-title" style="--ground:#0d1113; --ink:#ebebe7">
|
||||
<div class="scene-stage">
|
||||
<header class="drum-head">
|
||||
<h2 class="drum-title" id="drum-title">Every island on the loop, in the order we call</h2>
|
||||
</header>
|
||||
<div class="drum-view">
|
||||
<ol class="drum">
|
||||
<li class="drum-item"><span class="drum-name">Lismore</span> <span class="drum-meta">Monday, 07:40</span>
|
||||
<img src="https://picsum.photos/id/12/900/600" srcset="https://picsum.photos/id/12/600/400 600w, https://picsum.photos/id/12/900/600 900w, https://picsum.photos/id/12/1200/800 1200w, https://picsum.photos/id/12/1600/1067 1600w" sizes="(max-width: 767px) 100vw, 50vw" width="900" height="600" alt="Dark rocks on a sandy beach, calm sea and low islands beyond"></li>
|
||||
<li class="drum-item"><span class="drum-name">Mull</span> <span class="drum-meta">Monday, 10:15</span>
|
||||
<img src="https://picsum.photos/id/1049/900/600" srcset="https://picsum.photos/id/1049/600/400 600w, https://picsum.photos/id/1049/900/600 900w, https://picsum.photos/id/1049/1200/800 1200w, https://picsum.photos/id/1049/1600/1067 1600w" sizes="(max-width: 767px) 100vw, 50vw" width="900" height="600" alt="A rock island reflected on a wet beach under a pale sky"></li>
|
||||
<li class="drum-item"><span class="drum-name">Coll</span> <span class="drum-meta">Monday, 15:30</span>
|
||||
<img src="https://picsum.photos/id/347/900/600" srcset="https://picsum.photos/id/347/600/400 600w, https://picsum.photos/id/347/900/600 900w, https://picsum.photos/id/347/1200/800 1200w, https://picsum.photos/id/347/1600/1067 1600w" sizes="(max-width: 767px) 100vw, 50vw" width="900" height="600" alt="Dune grass bending in the wind under a wide white sky"></li>
|
||||
<li class="drum-item"><span class="drum-name">Tiree</span> <span class="drum-meta">Monday, 18:05</span>
|
||||
<img src="https://picsum.photos/id/640/900/600" srcset="https://picsum.photos/id/640/600/400 600w, https://picsum.photos/id/640/900/600 900w, https://picsum.photos/id/640/1200/800 1200w, https://picsum.photos/id/640/1600/1067 1600w" sizes="(max-width: 767px) 100vw, 50vw" width="900" height="600" alt="Turquoise surf breaking on golden sand, seen from above"></li>
|
||||
<li class="drum-item"><span class="drum-name">Colonsay</span> <span class="drum-meta">Tuesday, 09:20</span>
|
||||
<img src="https://picsum.photos/id/14/900/600" srcset="https://picsum.photos/id/14/600/400 600w, https://picsum.photos/id/14/900/600 900w, https://picsum.photos/id/14/1200/800 1200w, https://picsum.photos/id/14/1600/1067 1600w" sizes="(max-width: 767px) 100vw, 50vw" width="900" height="600" alt="A rocky shore meeting a calm pale sea under haze"></li>
|
||||
<li class="drum-item"><span class="drum-name">Barra</span> <span class="drum-meta">Tuesday, 16:45</span>
|
||||
<img src="https://picsum.photos/id/162/900/600" srcset="https://picsum.photos/id/162/600/400 600w, https://picsum.photos/id/162/900/600 900w, https://picsum.photos/id/162/1200/800 1200w, https://picsum.photos/id/162/1600/1067 1600w" sizes="(max-width: 767px) 100vw, 50vw" width="900" height="600" alt="A coastline of turquoise sea and patchwork fields, seen from the air"></li>
|
||||
<li class="drum-item"><span class="drum-name">Vatersay</span> <span class="drum-meta">Wednesday, 08:10</span>
|
||||
<img src="https://picsum.photos/id/606/900/600" srcset="https://picsum.photos/id/606/600/400 600w, https://picsum.photos/id/606/900/600 900w, https://picsum.photos/id/606/1200/800 1200w, https://picsum.photos/id/606/1600/1067 1600w" sizes="(max-width: 767px) 100vw, 50vw" width="900" height="600" alt="A wooden boardwalk winding through grassy dunes toward the sea"></li>
|
||||
<li class="drum-item"><span class="drum-name">Eriskay</span> <span class="drum-meta">Wednesday, 11:30</span>
|
||||
<img src="https://picsum.photos/id/853/900/600" srcset="https://picsum.photos/id/853/600/400 600w, https://picsum.photos/id/853/900/600 900w, https://picsum.photos/id/853/1200/800 1200w, https://picsum.photos/id/853/1600/1067 1600w" sizes="(max-width: 767px) 100vw, 50vw" width="900" height="600" alt="A lone rock in a calm blue sea under a pastel evening sky"></li>
|
||||
<li class="drum-item"><span class="drum-name">South Uist</span> <span class="drum-meta">Wednesday, 14:00</span>
|
||||
<img src="https://picsum.photos/id/179/900/600" srcset="https://picsum.photos/id/179/600/400 600w, https://picsum.photos/id/179/900/600 900w, https://picsum.photos/id/179/1200/800 1200w, https://picsum.photos/id/179/1600/1067 1600w" sizes="(max-width: 767px) 100vw, 50vw" width="900" height="600" alt="Waves breaking around dark rocks on a misty shore"></li>
|
||||
<li class="drum-item"><span class="drum-name">Benbecula</span> <span class="drum-meta">Thursday, 09:00</span>
|
||||
<img src="https://picsum.photos/id/172/900/600" srcset="https://picsum.photos/id/172/600/400 600w, https://picsum.photos/id/172/900/600 900w, https://picsum.photos/id/172/1200/800 1200w, https://picsum.photos/id/172/1600/1067 1600w" sizes="(max-width: 767px) 100vw, 50vw" width="900" height="600" alt="A stone jetty running out into still water and grey fog"></li>
|
||||
<li class="drum-item"><span class="drum-name">North Uist</span> <span class="drum-meta">Thursday, 13:20</span>
|
||||
<img src="https://picsum.photos/id/302/900/600" srcset="https://picsum.photos/id/302/600/400 600w, https://picsum.photos/id/302/900/600 900w, https://picsum.photos/id/302/1200/800 1200w, https://picsum.photos/id/302/1600/1067 1600w" sizes="(max-width: 767px) 100vw, 50vw" width="900" height="600" alt="A lone marker post standing in still water wrapped in fog"></li>
|
||||
<li class="drum-item"><span class="drum-name">Berneray</span> <span class="drum-meta">Thursday, 16:10</span>
|
||||
<img src="https://picsum.photos/id/941/900/600" srcset="https://picsum.photos/id/941/600/400 600w, https://picsum.photos/id/941/900/600 900w, https://picsum.photos/id/941/1200/800 1200w, https://picsum.photos/id/941/1600/1067 1600w" sizes="(max-width: 767px) 100vw, 50vw" width="900" height="600" alt="A small island on a pale, glassy sea under soft clouds"></li>
|
||||
<li class="drum-item"><span class="drum-name">Harris</span> <span class="drum-meta">Friday, 10:30</span>
|
||||
<img src="https://picsum.photos/id/323/900/600" srcset="https://picsum.photos/id/323/600/400 600w, https://picsum.photos/id/323/900/600 900w, https://picsum.photos/id/323/1200/800 1200w, https://picsum.photos/id/323/1600/1067 1600w" sizes="(max-width: 767px) 100vw, 50vw" width="900" height="600" alt="Steep green headlands dropping into a deep blue bay"></li>
|
||||
<li class="drum-item"><span class="drum-name">Scalpay</span> <span class="drum-meta">Friday, 13:45</span>
|
||||
<img src="https://picsum.photos/id/626/900/600" srcset="https://picsum.photos/id/626/600/400 600w, https://picsum.photos/id/626/900/600 900w, https://picsum.photos/id/626/1200/800 1200w, https://picsum.photos/id/626/1600/1067 1600w" sizes="(max-width: 767px) 100vw, 50vw" width="900" height="600" alt="A red and white lighthouse in a turquoise sea below a pale cliff"></li>
|
||||
<li class="drum-item"><span class="drum-name">Lewis</span> <span class="drum-meta">Saturday, 09:00</span>
|
||||
<img src="https://picsum.photos/id/574/900/600" srcset="https://picsum.photos/id/574/600/400 600w, https://picsum.photos/id/574/900/600 900w, https://picsum.photos/id/574/1200/800 1200w, https://picsum.photos/id/574/1600/1067 1600w" sizes="(max-width: 767px) 100vw, 50vw" width="900" height="600" alt="A grassy cliff above a black sand beach with sea stacks offshore"></li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="drum-close">
|
||||
<p class="drum-close-line">Board anywhere on the loop, leave anywhere</p>
|
||||
<a class="drum-button" href="#book">See fares for every crossing</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="after" id="book" aria-labelledby="book-title">
|
||||
<figure class="after-pic"><img data-drift src="https://picsum.photos/id/483/960/1200" width="960" height="1200" alt="Dark sea cliffs with white surf breaking around the rocks below"></figure>
|
||||
<div>
|
||||
<h2 id="book-title" data-rise>Berths for April open on 1 February</h2>
|
||||
<p data-rise>There are twelve cabins and one loop a week. Write with your islands and your dates, and we hold a berth for ten days while you decide.</p>
|
||||
<a class="button" data-rise href="mailto:berths@westerly.example?subject=A%20berth%20for%20April&body=Islands%3A%0ADates%3A%0ACabins%3A">Write to the booking office</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="foot">
|
||||
<p class="foot-name" aria-hidden="true">Westerly</p>
|
||||
<p>Photographs by Paul Jarvis, Rosan Harmens, Alex Talmon, Sarah Bürvenich, Dillon McIntosh, Aleksandra Boguslawska, Stephen Radford, Angelina Odemchuk, Aleksi Tappura, Billy Lam, Ivan Slade, Paweł Wojciechowski, Nick Diamantidis, Julien Lavallée and Paulo Simões Mendes, from Unsplash via Picsum. Westerly is an invented company for this demo, and the pictures stand in for the islands.</p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../scroll-turned-drum.js"></script>
|
||||
<script>
|
||||
Stage.smooth(); Stage.init();
|
||||
// the page around the scene: its words rise in, its photographs drift slowly with the scroll
|
||||
if (window.gsap && window.ScrollTrigger) gsap.matchMedia().add('(prefers-reduced-motion: no-preference)', () => {
|
||||
for (const el of document.querySelectorAll('[data-rise]')) {
|
||||
gsap.from(el, { yPercent: 18, autoAlpha: 0, duration: 0.9, ease: 'power3.out', scrollTrigger: { trigger: el, start: 'top 92%', once: true } });
|
||||
}
|
||||
// the photograph is 12% taller than its frame and slides inside it, so it never leaves the frame
|
||||
for (const img of document.querySelectorAll('[data-drift]')) {
|
||||
gsap.fromTo(img, { yPercent: 0 }, { yPercent: -10, ease: 'none', scrollTrigger: { trigger: img.parentElement, start: 'top bottom', end: 'bottom top', scrub: true } });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,151 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Marram Pottery</title>
|
||||
<meta name="description" content="Celadon stoneware thrown, glazed and fired one piece at a time by two potters in a shed above the dunes.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600&display=swap">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../scrubbed-transformation-sequence.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #10100f;
|
||||
--ink: #edefe9;
|
||||
--accent: #b9ccbf;
|
||||
--spruce: #1c332c;
|
||||
--font-display: "Instrument Sans", system-ui, sans-serif;
|
||||
--font-text: "Instrument Sans", system-ui, sans-serif;
|
||||
--margin: clamp(16px, 2.2vw, 32px);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--ground); }
|
||||
body { margin: 0; background: var(--ground); color: var(--ink); font: 400 18px/1.5 var(--font-text); -webkit-font-smoothing: antialiased; }
|
||||
a { color: inherit; }
|
||||
img { display: block; max-width: 100%; height: auto; }
|
||||
|
||||
.bar { display: flex; justify-content: space-between; align-items: baseline; gap: 24px; padding: 24px var(--margin); font-size: 16px; }
|
||||
.bar .mark { font-weight: 600; text-decoration: none; letter-spacing: -0.01em; }
|
||||
.bar nav { display: flex; gap: 28px; }
|
||||
.bar nav a { text-decoration: none; }
|
||||
.bar nav a:hover { text-decoration: underline; text-underline-offset: 4px; }
|
||||
|
||||
.hero { display: grid; grid-template-columns: 6fr 6fr; gap: var(--margin); padding: 0 var(--margin) var(--margin); min-height: calc(100svh - 74px); }
|
||||
.hero-copy { display: flex; flex-direction: column; justify-content: flex-end; padding-bottom: 6px; }
|
||||
.hero h1 { margin: 0 0 28px; font: 500 clamp(56px, 8.4vw, 132px)/0.92 var(--font-display); letter-spacing: -0.05em; }
|
||||
.hero p { margin: 0; max-width: 31ch; font-size: clamp(18px, 1.5vw, 22px); line-height: 1.4; }
|
||||
.hero-pic { margin: 0; min-height: 0; }
|
||||
.hero-pic img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.visit { display: grid; grid-template-columns: 5fr 7fr; gap: var(--margin); padding: clamp(120px, 16svh, 200px) var(--margin) var(--margin); background: var(--spruce); }
|
||||
.visit h2 { margin: 0 0 24px; font: 500 clamp(36px, 4.4vw, 68px)/1 var(--font-display); letter-spacing: -0.035em; }
|
||||
.visit p { margin: 0 0 28px; max-width: 34ch; font-size: clamp(18px, 1.4vw, 21px); line-height: 1.45; }
|
||||
.action { display: inline-block; padding: 15px 22px; background: var(--accent); color: var(--ground); text-decoration: none; font-weight: 500; font-size: 16px; line-height: 1.2; }
|
||||
.action:hover { background: #cfdcd2; }
|
||||
.hero .action { align-self: flex-start; margin-top: 32px; }
|
||||
.visit figure { margin: 0; }
|
||||
.visit img { width: 100%; aspect-ratio: 3 / 2; object-fit: cover; }
|
||||
|
||||
/* the kiln shelf is cut along its slab's lower edge: the vase lands on a slab, not on a table */
|
||||
#vase { --xf-rclip: polygon(0 0, 100% 0, 100% 18.7%, 10% 26.7%, 0 26.7%); }
|
||||
|
||||
footer { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px 32px; padding: 72px var(--margin) 32px; font-size: 16px; background: var(--spruce); }
|
||||
|
||||
@media (max-width: 859px) {
|
||||
.bar nav a:first-child { display: none; }
|
||||
.hero, .visit { grid-template-columns: 1fr; }
|
||||
.hero { min-height: 0; }
|
||||
.hero-copy { padding: 40px 0 28px; }
|
||||
.hero h1 { font-size: clamp(52px, 15vw, 80px); }
|
||||
.hero-pic { order: -1; }
|
||||
.hero-pic img { aspect-ratio: 4 / 3; height: auto; }
|
||||
.visit { padding-top: 96px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="bar">
|
||||
<a class="mark" href="#">Marram Pottery</a>
|
||||
<nav><a href="#vase">How a vase is made</a><a href="#visit">Visit the kiln shed</a></nav>
|
||||
</div>
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-copy">
|
||||
<h1>Marram Pottery</h1>
|
||||
<p>Celadon stoneware thrown, glazed and fired one piece at a time by two potters in a shed above the dunes.</p>
|
||||
<a class="action" href="#visit">Book a firing day</a>
|
||||
</div>
|
||||
<figure class="hero-pic">
|
||||
<img src="https://picsum.photos/id/347/1400/1100.webp" width="1400" height="1100" alt="Dune grass bending in the wind under a wide white sky" fetchpriority="high">
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section class="scene" id="vase" data-scene="scrubbed-transformation-sequence" aria-labelledby="xf-title"
|
||||
data-options='{"frames":"https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/scrubbed-transformation-sequence/d/####.webp","count":120,"phoneFrames":"https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/scrubbed-transformation-sequence/m/####.webp","phoneCount":60}'>
|
||||
<div class="scene-stage">
|
||||
<div class="xf-ground" aria-hidden="true">
|
||||
<div class="xf-ground__after"></div>
|
||||
<div class="xf-ground__before"></div>
|
||||
</div>
|
||||
<div class="xf-prop xf-prop--back" aria-hidden="true">
|
||||
<img data-src="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/scrubbed-transformation-sequence/clay-board.webp" alt="" width="678" height="522">
|
||||
<img class="xf-prop__soft" data-src="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/scrubbed-transformation-sequence/clay-board-soft.webp" alt="" width="678" height="522">
|
||||
</div>
|
||||
<div class="xf-receiver" aria-hidden="true">
|
||||
<img src="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/scrubbed-transformation-sequence/kiln-shelf.webp" alt="" width="960" height="930">
|
||||
<img class="xf-receiver__contact" src="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/scrubbed-transformation-sequence/kiln-shelf-contact.webp" alt="" width="960" height="930">
|
||||
</div>
|
||||
<figure class="xf-object">
|
||||
<img class="xf-still" src="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/scrubbed-transformation-sequence/d/0119.webp"
|
||||
srcset="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/scrubbed-transformation-sequence/m/0059.webp 720w, https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/scrubbed-transformation-sequence/d/0119.webp 1200w"
|
||||
sizes="(max-width: 767px) min(125vw, 58svh), min(92svh, 62vw)"
|
||||
alt="A tall celadon vase with a narrow neck, glaze run down to a bare clay foot" width="1200" height="1200">
|
||||
<div class="xf-before">
|
||||
<img data-src="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/scrubbed-transformation-sequence/m/0000.webp" alt="The same vase as a wire-cut lump of clay" width="720" height="720">
|
||||
<noscript><img src="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/scrubbed-transformation-sequence/m/0000.webp" alt="The same vase as a wire-cut lump of clay" width="720" height="720"></noscript>
|
||||
<p>Where it started</p>
|
||||
</div>
|
||||
<canvas class="xf-canvas" aria-hidden="true"></canvas>
|
||||
<ul class="xf-callouts">
|
||||
<li class="xf-callout" style="--x:64.5%;--y:22%" data-side="right"><span class="xf-mark" aria-hidden="true"></span><span class="xf-label">Thrown in one sitting</span></li>
|
||||
<li class="xf-callout" style="--x:24.5%;--y:38%" data-side="left"><span class="xf-mark" aria-hidden="true"></span><span class="xf-label">Ash celadon from our own kiln</span></li>
|
||||
<li class="xf-callout" style="--x:67%;--y:68%" data-side="right"><span class="xf-mark" aria-hidden="true"></span><span class="xf-label">Poured glaze, left to run</span></li>
|
||||
</ul>
|
||||
</figure>
|
||||
<div class="xf-copy">
|
||||
<h2 class="xf-statement" id="xf-title">It starts as a lump of clay</h2>
|
||||
<p class="xf-closing">Fired once, kept for life.</p>
|
||||
<a class="xf-cta" href="#visit">See the vases</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="visit" id="visit">
|
||||
<div>
|
||||
<h2>Come to the kiln shed</h2>
|
||||
<p>We fire on the first Saturday of every month. Come in the afternoon, when the kiln is opened, and take home a vase while it is still warm.</p>
|
||||
<a class="action" href="#visit">Book a firing day</a>
|
||||
</div>
|
||||
<figure>
|
||||
<img src="https://picsum.photos/id/1060/1400/933.webp" width="1400" height="933" alt="A kettle pouring water over a coffee dripper on a dark counter" loading="lazy">
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<span>Marram Pottery, the kiln shed behind the dunes. First Saturdays, 1 to 5.</span>
|
||||
<span>Photographs by Alex Talmon and Karl Fredrickson on Unsplash. The vase is a render.</span>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/SplitText.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../scrubbed-transformation-sequence.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,277 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Varn High Route</title>
|
||||
<meta name="description" content="Nine days on foot across the Varn glaciers, from Ossen to Valtar, sleeping every night in a staffed hut.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+Display:wdth,wght@62.5..100,300..700&display=swap" rel="stylesheet">
|
||||
<link rel="preconnect" href="https://picsum.photos">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../shaped-edge-ground-change.css">
|
||||
<style>
|
||||
/* Two chapters. The opening is a cool bone paper with deep teal type. The scene's photograph has a deep teal
|
||||
sky; --ground is that sky, the median of a 5% patch in its top third (#015e6f), sampled from the
|
||||
2400 and 3200px files the page loads. Everything after the scene sits on it, in --ink (7.1:1). */
|
||||
:root {
|
||||
--paper: #e8ecea;
|
||||
--deep: #082a30;
|
||||
--ground: #015e6f;
|
||||
--ink: #f6fbfa;
|
||||
--font-display: "Noto Serif Display", "Times New Roman", serif;
|
||||
--font-text: system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
--gutter: 6vw;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-text);
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: inherit; }
|
||||
.display { font-family: var(--font-display); font-weight: 500; font-stretch: 62.5%; text-transform: uppercase; }
|
||||
[data-scene="shaped-edge-ground-change"] .seg-hero__title,
|
||||
[data-scene="shaped-edge-ground-change"] .seg-arc,
|
||||
[data-scene="shaped-edge-ground-change"] .seg-arc-label,
|
||||
[data-scene="shaped-edge-ground-change"] .seg-crest__caption,
|
||||
[data-scene="shaped-edge-ground-change"] .seg-title { font-stretch: 62.5%; }
|
||||
/* a short chapter title (9 characters) can be set larger than the 16-character default allows */
|
||||
/* the same goes for a four-word headline in the sky: set large, it still holds two lines */
|
||||
@media (min-width: 768px) {
|
||||
[data-scene="shaped-edge-ground-change"] { --seg-title-size: clamp(64px, 13vw, 210px); --seg-hero-size: clamp(56px, 7.6vw, 128px); }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
[data-scene="shaped-edge-ground-change"] { --seg-title-size: 19vw; --seg-hero-size: 12.5vw; }
|
||||
}
|
||||
|
||||
/* the paper chapter */
|
||||
.bar, .opening { background: var(--paper); color: var(--deep); }
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
min-height: 76px;
|
||||
padding: 14px var(--gutter);
|
||||
font-size: 16px;
|
||||
}
|
||||
.bar .mark { text-decoration: none; font-size: 26px; line-height: 1; }
|
||||
.bar nav { display: flex; align-items: center; gap: clamp(16px, 2.6vw, 36px); }
|
||||
.bar nav a { text-decoration: none; }
|
||||
.bar nav a.book {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0 20px;
|
||||
background: var(--deep);
|
||||
color: var(--paper);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.opening {
|
||||
min-height: min(calc(100svh - 76px), 62vw);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding: 6svh var(--gutter) clamp(48px, 9svh, 96px);
|
||||
}
|
||||
.opening h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(64px, 16vw, 250px);
|
||||
line-height: 0.84;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
.opening .lead {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 24px 48px;
|
||||
margin-top: clamp(28px, 5svh, 56px);
|
||||
}
|
||||
.opening .lead p { margin: 0; max-width: 36rem; font-size: clamp(19px, 1.6vw, 24px); line-height: 1.4; text-wrap: pretty; }
|
||||
.opening .lead a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 52px;
|
||||
padding: 0 26px;
|
||||
background: var(--deep);
|
||||
color: var(--paper);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* the sky chapter, after the scene */
|
||||
.stages {
|
||||
padding: clamp(24px, 4svh, 48px) var(--gutter) clamp(96px, 16svh, 180px);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
|
||||
gap: 48px clamp(40px, 6vw, 112px);
|
||||
align-items: start;
|
||||
}
|
||||
.stages h2 {
|
||||
margin: 0 0 24px;
|
||||
font-size: clamp(40px, 4.6vw, 72px);
|
||||
line-height: 0.92;
|
||||
letter-spacing: -0.01em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.stages p { margin: 0 0 1em; max-width: 34rem; text-wrap: pretty; }
|
||||
.stages .action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 52px;
|
||||
margin-top: 8px;
|
||||
padding: 0 26px;
|
||||
background: var(--ink);
|
||||
color: var(--ground);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.stages ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 30px 40px;
|
||||
}
|
||||
.stages li { display: grid; gap: 6px; align-content: start; }
|
||||
.stages li strong {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
font-stretch: 62.5%;
|
||||
text-transform: uppercase;
|
||||
font-size: clamp(26px, 2.3vw, 36px);
|
||||
line-height: 0.95;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.stages li span { font-size: 16px; }
|
||||
|
||||
.foot {
|
||||
padding: 0 var(--gutter) 48px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 12px 32px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.foot p { margin: 0; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.stages { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
body { font-size: 17px; }
|
||||
.bar nav a:not(.book) { display: none; }
|
||||
.bar .mark { font-size: 22px; }
|
||||
.opening h1 { font-size: 19vw; }
|
||||
.opening .lead { grid-template-columns: 1fr; }
|
||||
.opening .lead a { justify-self: start; }
|
||||
.stages ul { grid-template-columns: 1fr; gap: 22px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="bar">
|
||||
<a class="mark display" href="#top">Varn High Route</a>
|
||||
<nav aria-label="Main">
|
||||
<a href="#stages">Stages</a>
|
||||
<a href="#stages">Huts</a>
|
||||
<a class="book" href="#stages">Book the route</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section class="opening" aria-labelledby="page-title">
|
||||
<h1 class="display" id="page-title">Varn High Route</h1>
|
||||
<div class="lead">
|
||||
<p>Nine days on foot across the Varn glaciers, from Ossen to Valtar, sleeping every night in a staffed hut. Open from 1 July to 14 September.</p>
|
||||
<a href="#stages">See the stages</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="scene seg" data-scene="shaped-edge-ground-change" data-options='{}' aria-labelledby="seg-hero-title">
|
||||
<div class="scene-stage">
|
||||
<figure class="seg-photo">
|
||||
<picture>
|
||||
<source media="(max-width: 767px)" sizes="150vw"
|
||||
srcset="https://picsum.photos/id/327/1200/1800 1200w, https://picsum.photos/id/327/1600/2400 1600w">
|
||||
<img class="seg-photo__img"
|
||||
src="https://picsum.photos/id/327/2400/1600"
|
||||
srcset="https://picsum.photos/id/327/1600/1067 1600w, https://picsum.photos/id/327/2400/1600 2400w, https://picsum.photos/id/327/3200/2133 3200w"
|
||||
sizes="125vw" width="3200" height="2133" fetchpriority="high"
|
||||
alt="A range of snow-covered peaks and brown ridges under a clear, deep teal sky, with a small daytime moon above them.">
|
||||
</picture>
|
||||
</figure>
|
||||
<div class="seg-hero">
|
||||
<h2 class="seg-hero__title display" id="seg-hero-title">Above the tree line</h2>
|
||||
<p class="seg-hero__lede">Seven cols and two glaciers between Ossen and Valtar, all of it on foot.</p>
|
||||
</div>
|
||||
<div class="seg-ground">
|
||||
<div class="seg-cap">
|
||||
<h3 class="seg-arc-label">Where the sky begins</h3>
|
||||
<svg class="seg-arc" aria-hidden="true" viewBox="0 0 1440 720" preserveAspectRatio="xMidYMax meet">
|
||||
<path id="seg-arc-demo" d="M101 720 A619 619 0 0 1 1339 720" fill="none"/>
|
||||
<text class="seg-arc__static" text-anchor="middle"><textPath href="#seg-arc-demo" startOffset="50%">Where the sky begins</textPath></text>
|
||||
</svg>
|
||||
<div class="seg-crest">
|
||||
<p class="seg-crest__caption">Long days on the snow, warm rooms at night.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="seg-body">
|
||||
<h3 class="seg-title">Nine huts</h3>
|
||||
<figure class="seg-inset">
|
||||
<img class="seg-inset__img"
|
||||
src="https://picsum.photos/id/635/1200/600"
|
||||
srcset="https://picsum.photos/id/635/800/400 800w, https://picsum.photos/id/635/1200/600 1200w, https://picsum.photos/id/635/1600/800 1600w"
|
||||
sizes="(max-width: 767px) 126vw, 36vw" width="1600" height="800"
|
||||
alt="Enamel mugs and a teapot set out on a wooden table in a hut, in warm evening light.">
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stages" id="stages" aria-labelledby="stages-title">
|
||||
<div>
|
||||
<h2 class="display" id="stages-title">One hut a night</h2>
|
||||
<p>Each hut sleeps thirty to sixty in shared rooms and serves dinner, breakfast and a packed lunch. Book the route once and we hold your bed in all nine, on the nights you choose.</p>
|
||||
<p>On the two glacier days a guide walks with groups of eight. The rest of the path is marked, and every hut keeper knows you are coming.</p>
|
||||
<a class="action" href="mailto:huts@varn-route.org?subject=Booking%20the%20Varn%20High%20Route&body=Start%20date%3A%0ANumber%20of%20walkers%3A">Book the route</a>
|
||||
</div>
|
||||
<ul aria-label="The nine stages">
|
||||
<li><strong>Ossen to Kerr Hut</strong><span>11 km, 1,150 m of climbing</span></li>
|
||||
<li><strong>Kerr Hut to Lammer</strong><span>9 km, 640 m of climbing</span></li>
|
||||
<li><strong>Lammer to Ebbet Col</strong><span>13 km, 980 m of climbing</span></li>
|
||||
<li><strong>Ebbet Col to Sander Hut</strong><span>8 km on the glacier, guided</span></li>
|
||||
<li><strong>Sander Hut to Moll</strong><span>12 km, 720 m of climbing</span></li>
|
||||
<li><strong>Moll to Hallin Hut</strong><span>10 km, 860 m of climbing</span></li>
|
||||
<li><strong>Hallin to Tarve Hut</strong><span>7 km on the glacier, guided</span></li>
|
||||
<li><strong>Tarve Hut to Orn</strong><span>14 km, 520 m of climbing</span></li>
|
||||
<li><strong>Orn down to Valtar</strong><span>16 km, mostly downhill</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<footer class="foot">
|
||||
<p>The Varn High Route is kept by the Varn Hut Association.</p>
|
||||
<p>Photographs by Ryan Schroeder and André Freitas, on Unsplash.</p>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../shaped-edge-ground-change.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,229 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sorrel Pass Huts</title>
|
||||
<meta name="description" content="Five timber huts a day's walk apart on the high route over the Sorrel icefield, open from late June to the first snow.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,100..900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../shaped-window-over-fixed-world.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #e5e7e4;
|
||||
--ink: #13171a;
|
||||
--font-display: "Archivo", system-ui, sans-serif;
|
||||
--font-text: "Archivo", system-ui, sans-serif;
|
||||
--gutter: clamp(24px, 4vw, 58px);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-text);
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: inherit; }
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 22px var(--gutter);
|
||||
font-size: 17px;
|
||||
}
|
||||
.bar .mark { font-weight: 700; font-stretch: 87.5%; text-decoration: none; font-size: 19px; }
|
||||
.bar nav { display: flex; align-items: center; gap: clamp(16px, 3vw, 36px); }
|
||||
.bar nav a { text-decoration: none; }
|
||||
.bar nav a.book {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0 20px;
|
||||
background: var(--ink);
|
||||
color: var(--ground);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.opening, .close { container-type: inline-size; }
|
||||
.opening {
|
||||
padding: clamp(56px, 13svh, 150px) var(--gutter) clamp(64px, 14svh, 160px);
|
||||
}
|
||||
/* the name runs gutter to gutter: its advance is 6.69em, so 100cqi / 6.72 */
|
||||
.opening h1 {
|
||||
margin: 0;
|
||||
font-weight: 800;
|
||||
font-stretch: 62%;
|
||||
text-transform: uppercase;
|
||||
font-size: clamp(44px, 14.85cqi, 240px);
|
||||
line-height: 0.84;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.opening .lead {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(0, 34rem);
|
||||
gap: 32px;
|
||||
margin-top: clamp(28px, 5svh, 56px);
|
||||
}
|
||||
.opening .lead p { grid-column: 2; margin: 0; font-size: clamp(19px, 1.6vw, 24px); line-height: 1.4; text-wrap: pretty; }
|
||||
|
||||
.scene-sw .sw-title { font-stretch: 87.5%; }
|
||||
/* the reader is still among the trees at the treeline: a whole fir stands a little way off on the
|
||||
left, and the boughs of a nearer one hang in from the right edge over the low window's corner;
|
||||
the push carries both away */
|
||||
.scene-sw {
|
||||
--sw-prop-l-h: 92%; --sw-prop-l-x: -24lvh; --sw-prop-l-y: -2%;
|
||||
--sw-prop-r-h: 130%; --sw-prop-r-x: -64lvh; --sw-prop-r-y: -5%;
|
||||
}
|
||||
/* narrow screens: the fir fills the empty ground under the high window and only its top crosses that
|
||||
window's corner; the boughs only brush the low window's lower corner */
|
||||
@media (max-width: 767px), (max-aspect-ratio: 11/10) {
|
||||
.scene-sw {
|
||||
--sw-prop-l-h: 52%; --sw-prop-l-x: -15.5lvh; --sw-prop-l-y: -2%;
|
||||
--sw-prop-r-h: 48%; --sw-prop-r-x: -21lvh; --sw-prop-r-y: -5%;
|
||||
}
|
||||
}
|
||||
|
||||
.close {
|
||||
padding: clamp(120px, 20svh, 220px) var(--gutter) clamp(40px, 6svh, 64px);
|
||||
}
|
||||
.close .cols {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 34rem);
|
||||
gap: 32px 48px;
|
||||
align-items: start;
|
||||
}
|
||||
.close h2 {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
font-stretch: 87.5%;
|
||||
font-size: clamp(36px, 4.2vw, 64px);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.02em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.close p { margin: 0 0 1em; text-wrap: pretty; }
|
||||
.close .action {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
padding: 15px 26px;
|
||||
background: var(--ink);
|
||||
color: var(--ground);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.close .credit { font-size: 15px; margin-top: 40px; }
|
||||
.close .name {
|
||||
margin: clamp(80px, 14svh, 160px) 0 0;
|
||||
font-weight: 800;
|
||||
font-stretch: 62%;
|
||||
text-transform: uppercase;
|
||||
font-size: clamp(44px, 14.85cqi, 240px);
|
||||
line-height: 0.84;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.close .cols { grid-template-columns: 1fr; }
|
||||
.close .cols > div { max-width: 34rem; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
body { font-size: 17px; }
|
||||
.bar nav a:not(.book) { display: none; }
|
||||
.opening .lead { grid-template-columns: 1fr; }
|
||||
.opening .lead p { grid-column: 1; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="bar">
|
||||
<a class="mark" href="#top">Sorrel Pass Huts</a>
|
||||
<nav aria-label="Main">
|
||||
<a href="#route">The route</a>
|
||||
<a href="#route">The huts</a>
|
||||
<a class="book" href="#route">Book a bed</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section class="opening" aria-labelledby="page-title">
|
||||
<h1 id="page-title">Sorrel Pass Huts</h1>
|
||||
<div class="lead">
|
||||
<p>Five timber huts a day's walk apart on the high route over the Sorrel icefield, open from late June until the first snow.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- high: left, because the picture's diagonal runs from the peak at upper left down to the ridges at
|
||||
lower right; the high window frames the summit, the low one the icefall and the ridges -->
|
||||
<section class="scene sw scene-sw" data-scene="shaped-window-over-fixed-world" data-options='{"hold": 2.4, "high": "left"}' aria-labelledby="sw-title">
|
||||
<div class="scene-stage sw-stage">
|
||||
<div class="sw-world" data-move>
|
||||
<picture>
|
||||
<source media="(max-width: 767px)" srcset="https://picsum.photos/id/684/1080/1920 1080w, https://picsum.photos/id/684/1440/2560 1440w" sizes="125vw">
|
||||
<img class="sw-world-media" data-move
|
||||
src="https://picsum.photos/id/684/2700/1800"
|
||||
srcset="https://picsum.photos/id/684/1800/1200 1800w, https://picsum.photos/id/684/2700/1800 2700w, https://picsum.photos/id/684/3600/2400 3600w"
|
||||
sizes="125vw" width="3600" height="2400" fetchpriority="high"
|
||||
alt="A jagged snow peak and the icefall below it under a dark blue sky, with ridges of rock and old snow running down to the right.">
|
||||
</picture>
|
||||
</div>
|
||||
<div class="sw-type">
|
||||
<div class="sw-scrim sw-scrim--top" aria-hidden="true"></div>
|
||||
<div class="sw-scrim sw-scrim--bottom" aria-hidden="true"></div>
|
||||
<p class="sw-word" style="--chars:8">Icefield</p>
|
||||
<div class="sw-lede">
|
||||
<p>Day three is the icefield itself: seven hours on old snow, roped to a guide, between two huts that each sleep twelve and keep the stove lit from six.</p>
|
||||
<a class="sw-link" href="#route">See the route</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sw-shutter">
|
||||
<div class="sw-half sw-half--l"></div>
|
||||
<div class="sw-half sw-half--r"></div>
|
||||
<header class="sw-intro"><h2 class="sw-title" id="sw-title">A week above the treeline</h2></header>
|
||||
<p class="sw-caption">Twelve beds in every hut, and a stove that is lit from six.</p>
|
||||
<!-- the foreground: firs at the treeline, photographed against fog and keyed into cut-outs by the
|
||||
scene (data-key), toned into the picture's cold light (data-tone), standing in the bottom
|
||||
corners until the push throws them aside. One photograph, cut two ways so they never read as
|
||||
twins: the whole tree on the left, the right one cropped below its top and mirrored -->
|
||||
<img class="sw-prop sw-prop--l" src="https://picsum.photos/id/925/2400/1600" crossorigin="anonymous"
|
||||
data-key="0.3 0.52" data-crop="0.04 0 0.38 0.56" data-tone="saturate(.45) brightness(.75) hue-rotate(20deg)"
|
||||
alt="" aria-hidden="true" width="816" height="896">
|
||||
<img class="sw-prop sw-prop--r" src="https://picsum.photos/id/925/2400/1600" crossorigin="anonymous"
|
||||
data-key="0.3 0.52" data-crop="0.04 0.06 0.38 0.56" data-tone="saturate(.45) brightness(.75) hue-rotate(20deg)" data-flip
|
||||
alt="" aria-hidden="true" width="816" height="800">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="close" id="route" aria-labelledby="close-title">
|
||||
<div class="cols">
|
||||
<h2 id="close-title">The route opens on 24 June</h2>
|
||||
<div>
|
||||
<p>Beds are booked hut by hut, in order along the route, from 2 March. Write to the warden with your dates and the size of your party, and she will hold the beds for a week while you arrange the rest.</p>
|
||||
<p>Blankets, supper and breakfast are in the price. Bring a sleeping sheet, crampons and a head torch.</p>
|
||||
<a class="action" href="mailto:warden@sorrelpass.example?subject=Beds%20on%20the%20Sorrel%20route&body=Our%20dates%3A%0AParty%20size%3A%0AStarting%20hut%3A">Write to the warden</a>
|
||||
<p class="credit">Photographs of the icefield by Lee Roylland and of the firs by Patrick Fore, on Unsplash.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="name" aria-hidden="true">Sorrel Pass Huts</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/SplitText.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../shaped-window-over-fixed-world.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,151 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Wren Street Joinery</title>
|
||||
<meta name="description" content="Tables, benches and desks in oak and ash, made one at a time in a workshop behind Wren Street.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;600&display=swap">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../stepped-columns-raise-a-stage.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #ffffff;
|
||||
--ink: #d2232a;
|
||||
--accent: #d2232a;
|
||||
--font-display: "Schibsted Grotesk", system-ui, sans-serif;
|
||||
--font-text: "Schibsted Grotesk", system-ui, sans-serif;
|
||||
--margin: clamp(16px, 1.67vw, 24px);
|
||||
/* the weights every scene takes from the page: only ones the page loads (400, 500, 600) */
|
||||
--display-weight: 600;
|
||||
--strong-weight: 500;
|
||||
--text-weight: 400;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--ground); }
|
||||
body { margin: 0; background: var(--ground); color: var(--ink); font: var(--text-weight) 17px/1.5 var(--font-text); -webkit-font-smoothing: antialiased; }
|
||||
a { color: inherit; }
|
||||
img { display: block; max-width: 100%; height: auto; }
|
||||
|
||||
.bar { display: flex; justify-content: space-between; align-items: baseline; gap: 24px; padding: 22px var(--margin); font-size: 16px; }
|
||||
.bar .mark { font-weight: var(--display-weight); text-decoration: none; letter-spacing: -0.01em; }
|
||||
.bar nav { display: flex; gap: 28px; }
|
||||
.bar nav a { text-decoration: none; }
|
||||
.bar nav a:hover { text-decoration: underline; text-underline-offset: 4px; }
|
||||
|
||||
.hero { display: grid; grid-template-columns: 5fr 7fr; gap: var(--margin); padding: 0 var(--margin) var(--margin); min-height: calc(100svh - 70px); }
|
||||
.hero-copy { display: flex; flex-direction: column; justify-content: flex-end; padding-bottom: 8px; }
|
||||
.hero h1 { margin: 0 0 28px; font: var(--display-weight) clamp(48px, 7.2vw, 112px)/0.94 var(--font-display); letter-spacing: -0.045em; }
|
||||
.hero p { margin: 0; max-width: 30ch; font-size: clamp(18px, 1.5vw, 22px); line-height: 1.4; }
|
||||
.hero-pic { margin: 0; min-height: 0; }
|
||||
.hero-pic img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.visit { display: grid; grid-template-columns: 5fr 7fr; gap: var(--margin); padding: clamp(96px, 14svh, 160px) var(--margin) var(--margin); }
|
||||
.visit h2 { margin: 0 0 24px; font: var(--display-weight) clamp(36px, 4.4vw, 68px)/1 var(--font-display); letter-spacing: -0.035em; }
|
||||
.visit p { margin: 0 0 28px; max-width: 34ch; font-size: clamp(18px, 1.4vw, 21px); line-height: 1.45; }
|
||||
.visit .action { display: inline-block; padding: 14px 22px; background: var(--accent); color: var(--ground); text-decoration: none; font-weight: var(--strong-weight); }
|
||||
.visit .action:hover { background: #b51d23; }
|
||||
.visit figure { margin: 0; }
|
||||
.visit img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; }
|
||||
|
||||
footer { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px 32px; padding: 64px var(--margin) 32px; font-size: 15px; }
|
||||
|
||||
@media (max-width: 859px) {
|
||||
.bar nav { gap: 18px; }
|
||||
.bar nav a:first-child { display: none; }
|
||||
.hero, .visit { grid-template-columns: 1fr; }
|
||||
.hero { min-height: 0; }
|
||||
.hero-copy { padding: 40px 0 28px; }
|
||||
.hero h1 { font-size: clamp(48px, 14vw, 72px); }
|
||||
.hero-pic img { aspect-ratio: 4 / 5; height: auto; }
|
||||
.visit { padding-top: 88px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="bar">
|
||||
<a class="mark" href="#">Wren Street Joinery</a>
|
||||
<nav><a href="#process">How a table is made</a><a href="#visit">Visit the workshop</a></nav>
|
||||
</header>
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-copy">
|
||||
<h1>Wren Street Joinery</h1>
|
||||
<p>Tables, benches and desks in oak and ash, made one at a time in a workshop behind Wren Street. About five weeks from the timber yard to your room.</p>
|
||||
</div>
|
||||
<figure class="hero-pic">
|
||||
<img src="https://picsum.photos/id/625/1100/1000" width="1100" height="1000" alt="A long wooden dining table with white chairs beside tall windows" fetchpriority="high">
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section class="scene" id="process" data-scene="stepped-columns-raise-a-stage" style="--n:4" data-options='{"order":"descending"}' aria-labelledby="sr-title">
|
||||
<div class="scene-stage">
|
||||
<header class="sr-head">
|
||||
<h2 class="sr-title" id="sr-title">Every table starts out rough</h2>
|
||||
<div class="sr-rail" aria-hidden="true">
|
||||
<span class="sr-rail-end">Rough</span>
|
||||
<span class="sr-rail-line"><span class="sr-rail-dot"></span></span>
|
||||
<span class="sr-rail-end">Smooth</span>
|
||||
</div>
|
||||
</header>
|
||||
<ol class="sr-cols">
|
||||
<li class="sr-col">
|
||||
<div class="sr-panel">
|
||||
<p class="sr-note">Week one</p>
|
||||
<h3 class="sr-col-title">Rough-sawn oak</h3>
|
||||
<p class="sr-col-text">We pick the boards at the yard ourselves.<span class="sr-more"> Then they settle in the shop for a week before the first cut.</span></p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="sr-col">
|
||||
<div class="sr-panel">
|
||||
<p class="sr-note">Weeks two and three</p>
|
||||
<h3 class="sr-col-title">Cut and fitted</h3>
|
||||
<p class="sr-col-text">Mortise and tenon, cut by hand and dry-fitted twice.<span class="sr-more"> Nothing is glued until every joint closes under the push of a palm.</span></p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="sr-col">
|
||||
<div class="sr-panel">
|
||||
<p class="sr-note">Week four</p>
|
||||
<h3 class="sr-col-title">Five grits of sand</h3>
|
||||
<p class="sr-col-text">From 80 grit to 320, always with the grain.<span class="sr-more"> Each pass takes out the scratches the pass before it left behind.</span></p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="sr-col">
|
||||
<div class="sr-panel">
|
||||
<p class="sr-note">Week five</p>
|
||||
<h3 class="sr-col-title">Oiled by hand</h3>
|
||||
<p class="sr-col-text">Three coats of hard-wax oil, rubbed in and buffed off.<span class="sr-more"> The top ends up feeling like a river stone under your arm.</span></p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="visit" id="visit">
|
||||
<div>
|
||||
<h2>Come and see the boards first</h2>
|
||||
<p>The workshop is open on Saturday mornings. Bring the size of your room and how many sit at the table, and we will show you the boards we would cut it from.</p>
|
||||
<a class="action" href="#visit">Book a Saturday visit</a>
|
||||
</div>
|
||||
<figure>
|
||||
<img src="https://picsum.photos/id/491/1200/900" width="1200" height="900" alt="An axe, a hammer, pliers and leather gloves laid out on dark wood" loading="lazy">
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<span>Wren Street Joinery, workshop 4, Wren Street. Saturdays 9 to 1.</span>
|
||||
<span>Photographs by Breather and Todd Quackenbush on Unsplash.</span>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../stepped-columns-raise-a-stage.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,179 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Orrin Point Baths: night swimming in the sea pool</title>
|
||||
<meta name="description" content="A sea pool cut into the rocks below the harbour wall, open every night from May to October.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600&display=swap">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../thumbnail-becomes-the-stage.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #e8ebe9;
|
||||
--ink: #121517;
|
||||
--paper: #f2f3f1;
|
||||
--night: #0b0d0f;
|
||||
--muted-paper: rgb(242 243 241 / 0.72);
|
||||
--font-display: "Hanken Grotesk", system-ui, sans-serif;
|
||||
--font-text: "Hanken Grotesk", system-ui, sans-serif;
|
||||
--margin: clamp(16px, 2.8vw, 40px);
|
||||
--radius: 0px;
|
||||
--size-name: clamp(60px, 15vw, 240px);
|
||||
--size-head: clamp(40px, 8vw, 132px);
|
||||
--size-lead: clamp(20px, 1.7vw, 24px);
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
|
||||
body { margin: 0; background: var(--ground); color: var(--ink); font: 400 18px/1.5 var(--font-text); -webkit-font-smoothing: antialiased; }
|
||||
img { display: block; max-width: 100%; }
|
||||
a { color: inherit; }
|
||||
h1, h2, h3, p, figure { margin: 0; }
|
||||
|
||||
.bar { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 20px var(--margin); }
|
||||
.bar .mark { font-weight: 600; font-size: 18px; text-decoration: none; letter-spacing: -0.01em; }
|
||||
.bar nav { display: flex; gap: 28px; font-size: 16px; }
|
||||
.bar nav a { text-decoration: none; }
|
||||
.bar nav a:hover { text-decoration: underline; text-underline-offset: 0.2em; }
|
||||
.button { display: inline-flex; align-items: center; min-height: 46px; padding: 0 22px; background: var(--ink); color: var(--ground); font-size: 16px; font-weight: 500; text-decoration: none; border-radius: 999px; white-space: nowrap; transition: transform 140ms cubic-bezier(0.22, 1, 0.36, 1); }
|
||||
.button:active { transform: scale(0.97); }
|
||||
.button--paper { background: var(--paper); color: var(--night); }
|
||||
|
||||
/* first screen: the name, set large, with one sentence and tonight's numbers */
|
||||
.intro { min-height: min(calc(100svh - 86px), 64vw); display: grid; grid-template-rows: 1fr auto; gap: 40px; padding: 6svh var(--margin) 40px; }
|
||||
.intro h1 { align-self: end; font: 500 var(--size-name)/0.86 var(--font-display); letter-spacing: -0.05em; }
|
||||
.intro h1 .ln { display: block; overflow: hidden; padding-bottom: 0.07em; margin-bottom: -0.07em; }
|
||||
.intro h1 .ln > span { display: block; }
|
||||
.intro .foot { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 24px 48px; }
|
||||
.intro .foot p { max-width: 32ch; font-size: var(--size-lead); line-height: 1.3; letter-spacing: -0.01em; }
|
||||
.intro dl { display: grid; grid-template-columns: repeat(3, auto); gap: 2px 48px; margin: 0; font-size: 16px; }
|
||||
.intro dt { color: rgb(18 21 23 / 0.66); }
|
||||
.intro dd { margin: 0; font-size: var(--size-lead); font-weight: 500; grid-row: 2; letter-spacing: -0.01em; }
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.intro h1 .ln > span { animation: rise 1.1s cubic-bezier(0.16, 1, 0.3, 1) both; }
|
||||
.intro h1 .ln + .ln > span { animation-delay: 0.08s; }
|
||||
.intro .foot { animation: settle 0.9s 0.35s cubic-bezier(0.22, 1, 0.36, 1) both; }
|
||||
}
|
||||
@keyframes rise { from { transform: translateY(104%); } }
|
||||
@keyframes settle { from { opacity: 0; transform: translateY(16px); } }
|
||||
|
||||
/* the close starts on the chapter's dark tone, so the scene's release is not a cut to pale */
|
||||
.visit { background: var(--night); color: var(--paper); padding: clamp(96px, 12vw, 180px) var(--margin) 40px; }
|
||||
.visit-grid { display: grid; grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); gap: 48px clamp(24px, 4vw, 64px); align-items: end; }
|
||||
.visit h2 { font: 500 var(--size-head)/0.95 var(--font-display); letter-spacing: -0.035em; }
|
||||
.visit-text { display: grid; gap: 32px; justify-items: start; }
|
||||
.visit-text > p { max-width: 36ch; color: var(--muted-paper); }
|
||||
.visit .facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; width: 100%; margin: 48px 0 0; }
|
||||
.visit .facts div { display: grid; gap: 4px; align-content: start; }
|
||||
.visit .facts dt { color: var(--muted-paper); font-size: 16px; }
|
||||
.visit .facts dd { margin: 0; font-size: var(--size-lead); line-height: 1.25; letter-spacing: -0.01em; }
|
||||
.visit figure img { width: 100%; height: auto; aspect-ratio: 4 / 5; object-fit: cover; background: #1a1d20; }
|
||||
.visit figcaption { margin-top: 12px; color: var(--muted-paper); font-size: 16px; }
|
||||
.name { margin-top: clamp(96px, 12vw, 180px); font: 500 var(--size-name)/0.84 var(--font-display); letter-spacing: -0.05em; white-space: nowrap; }
|
||||
.credits { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: end; gap: 8px 24px; margin-top: 32px; color: var(--muted-paper); font-size: 16px; }
|
||||
.credits p { max-width: 62ch; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.bar { padding-block: 16px; }
|
||||
.bar nav { display: none; }
|
||||
.bar .button { min-height: 40px; padding: 0 16px; }
|
||||
.intro { gap: 28px; padding-top: 10svh; }
|
||||
.intro .foot { grid-template-columns: 1fr; }
|
||||
.intro dl { gap: 2px 24px; }
|
||||
.visit-grid { grid-template-columns: 1fr; }
|
||||
.visit .facts { grid-template-columns: 1fr; margin-top: 16px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="mark" href="#top">Orrin Point Baths</a>
|
||||
<nav aria-label="Main"><a href="#night">Night swims</a><a href="#visit">Visit</a></nav>
|
||||
<a class="button" href="#visit">Book a swim</a>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section class="intro">
|
||||
<h1><span class="ln"><span>Orrin Point</span></span> <span class="ln"><span>Baths</span></span></h1>
|
||||
<div class="foot">
|
||||
<p>A sea pool cut into the rocks below the harbour wall, open every night from May to October.</p>
|
||||
<dl>
|
||||
<dt>Water tonight</dt><dd>14°C</dd>
|
||||
<dt>Lamps on</dt><dd>21:00</dd>
|
||||
<dt>Last swim</dt><dd>23:30</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="scene" id="night" data-scene="thumbnail-becomes-the-stage" data-options='{"hold":2,"phoneHold":1.6,"shade":0.12}'>
|
||||
<div class="scene-stage">
|
||||
<div class="kh-statement-box">
|
||||
<h2 class="kh-statement">
|
||||
<span class="kh-line kh-line--lead"><span class="kh-mask"><span class="kh-in">Swim after dark.</span></span></span>
|
||||
<span class="kh-line"><span class="kh-mask"><span class="kh-in">Let the sea come in.</span></span></span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="kh-row" aria-hidden="true">
|
||||
<div class="kh-sat"><img src="https://picsum.photos/id/614/480/270" width="480" height="270" alt=""></div>
|
||||
<div class="kh-slot"><img class="kh-slot-poster" src="https://picsum.photos/id/536/480/270" width="480" height="270" alt=""></div>
|
||||
<div class="kh-sat"><img src="https://picsum.photos/id/594/480/270" width="480" height="270" alt=""></div>
|
||||
</div>
|
||||
<div class="kh-window">
|
||||
<div class="kh-media">
|
||||
<video muted loop playsinline preload="metadata" poster="https://picsum.photos/id/536/1600/1000" width="1600" height="1000" aria-label="A wave breaking over the sea wall of the lit pool at night">
|
||||
<source src="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/thumbnail-becomes-the-stage/night-pool.mp4" type="video/mp4">
|
||||
</video>
|
||||
</div>
|
||||
<div class="kh-shade" aria-hidden="true"></div>
|
||||
<div class="kh-statement-box kh-statement-box--paper" aria-hidden="true">
|
||||
<div class="kh-statement">
|
||||
<span class="kh-line kh-line--lead"><span class="kh-mask"><span class="kh-in">Swim after dark.</span></span></span>
|
||||
<span class="kh-line"><span class="kh-mask"><span class="kh-in">Let the sea come in.</span></span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kh-chapter">
|
||||
<p class="kh-note">Lamps on from nine until midnight</p>
|
||||
<ul class="kh-features">
|
||||
<li><h3>Lit from below</h3><p>Twelve lamps under the waterline, so you can see your hands on every stroke.</p></li>
|
||||
<li><h3>Fed by the tide</h3><p>The sea refills the pool twice a day. No chlorine, no heating, just the bay.</p></li>
|
||||
<li><h3>Guards on deck</h3><p>Two lifeguards on every night shift, and one in the water when the swell rises.</p></li>
|
||||
<li><h3>Sauna by the steps</h3><p>A wood-fired sauna ten paces from the ladder, kept hot until the last swimmer leaves.</p></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="visit" id="visit">
|
||||
<div class="visit-grid">
|
||||
<div class="visit-text">
|
||||
<h2>Swim tonight</h2>
|
||||
<p>Sessions start every half hour from nine until half past eleven. Bring a towel; hats and lamps are lent at the gate.</p>
|
||||
<a class="button button--paper" href="mailto:gate@orrinpointbaths.test?subject=Night%20swim&body=Date%3A%20%0ATime%3A%20%0ASwimmers%3A%20">Ask for a session</a>
|
||||
<dl class="facts">
|
||||
<div><dt>Where</dt><dd>Below the harbour wall, Orrin Point</dd></div>
|
||||
<div><dt>When</dt><dd>Every night, May to October</dd></div>
|
||||
<div><dt>A swim</dt><dd>£6, or £40 for ten</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<figure>
|
||||
<img src="https://picsum.photos/id/637/800/1000" width="800" height="1000" alt="A steel ladder on the edge of a timber deck above still water at dusk" loading="lazy">
|
||||
<figcaption>The north ladder, an hour before the lamps come on.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<p class="name" aria-hidden="true">Orrin Point</p>
|
||||
<div class="credits"><p>Photographs by David Di Veroli, Josefa Holland-Merten, Aurélien Bellanger and Griffin Keller, via Unsplash. The night loop is a slow camera drift over David Di Veroli's photograph.</p><p>An invented place, made for a scene demo.</p></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../thumbnail-becomes-the-stage.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,179 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Ostra Type Works, typewriter restoration in Bergen</title>
|
||||
<meta name="description" content="Portable typewriters from the 1930s to the 1960s, taken apart, cleaned and put back together by hand in Bergen.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400..700&display=swap">
|
||||
<link rel="preload" as="image" href="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/twin-image-scanner/typewriter.webp" fetchpriority="high">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../twin-image-scanner.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #ece6d8;
|
||||
--ink: #15140f;
|
||||
--accent: #c8f03c;
|
||||
--font-display: "Schibsted Grotesk", system-ui, sans-serif;
|
||||
--font-text: "Schibsted Grotesk", system-ui, sans-serif;
|
||||
--margin: 24px;
|
||||
--gutter: 24px;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html { background: var(--ground); color: var(--ink); -webkit-text-size-adjust: 100%; }
|
||||
body { margin: 0; font-family: var(--font-text); font-size: 17px; line-height: 1.5; background: var(--ground); }
|
||||
a { color: inherit; }
|
||||
img { display: block; }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: var(--gutter); padding-inline: var(--margin); }
|
||||
|
||||
.bar { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 22px var(--margin); }
|
||||
.bar__name { font-weight: 600; font-size: 18px; text-decoration: none; letter-spacing: -0.01em; }
|
||||
.bar nav { display: flex; gap: 28px; font-size: 16px; }
|
||||
.bar nav a { text-decoration: none; }
|
||||
.bar nav a:hover { text-decoration: underline; text-underline-offset: 4px; }
|
||||
|
||||
.intro { min-height: calc(64svh - 72px); align-content: end; padding-bottom: 88px; row-gap: 32px; }
|
||||
.intro h1 {
|
||||
grid-column: 1 / span 8;
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: clamp(56px, 10vw, 156px);
|
||||
line-height: 0.88;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.intro p { grid-column: 9 / -1; align-self: end; margin: 0; font-size: 22px; line-height: 1.35; max-width: 24em; }
|
||||
|
||||
.close { background: var(--ink); color: var(--ground); padding-top: 176px; }
|
||||
.close h2 { grid-column: 1 / span 5; margin: 0; font-family: var(--font-display); font-weight: 600; font-size: clamp(40px, 4.6vw, 76px); line-height: 0.96; letter-spacing: -0.02em; }
|
||||
.close__text { grid-column: 7 / -1; }
|
||||
.close__text > p { margin: 0 0 56px; font-size: 22px; line-height: 1.35; max-width: 30em; }
|
||||
.prices { margin: 0; display: grid; row-gap: 28px; }
|
||||
.prices div { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: var(--gutter); }
|
||||
.prices dt { font-weight: 600; }
|
||||
.prices dd { margin: 0; }
|
||||
.close__mail { display: inline-block; margin-top: 56px; padding: 15px 22px; background: var(--accent); color: var(--ink); text-decoration: none; font-weight: 500; line-height: 1; }
|
||||
.foot { padding-top: 200px; padding-bottom: 32px; row-gap: 24px; }
|
||||
.foot__name { grid-column: 1 / -1; margin: 0; font-family: var(--font-display); font-weight: 600; font-size: clamp(48px, 11.2vw, 200px); line-height: 0.82; letter-spacing: -0.045em; white-space: nowrap; }
|
||||
.foot p:not(.foot__name) { grid-column: span 4; margin: 0; font-size: 15px; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
:root { --margin: 16px; --gutter: 16px; }
|
||||
.bar nav a:not(:last-child) { display: none; }
|
||||
.intro { min-height: calc(82svh - 72px); padding-bottom: 56px; }
|
||||
.intro h1, .intro p { grid-column: 1 / -1; }
|
||||
.intro p { font-size: 19px; }
|
||||
.close { padding-top: 112px; }
|
||||
.close h2, .close__text { grid-column: 1 / -1; }
|
||||
.close h2 { font-size: clamp(32px, 9vw, 40px); margin-bottom: 28px; }
|
||||
.close__text > p { font-size: 19px; margin-bottom: 40px; }
|
||||
.foot { padding-top: 120px; }
|
||||
.foot__name { font-size: 17vw; line-height: 0.88; white-space: normal; }
|
||||
.foot p:not(.foot__name) { grid-column: 1 / -1; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="bar__name" href="#top">Ostra Type Works</a>
|
||||
<nav aria-label="Main">
|
||||
<a href="#restore">How we restore</a>
|
||||
<a href="#prices">Prices</a>
|
||||
<a href="#send">Send a machine</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section class="intro grid" aria-labelledby="intro-title">
|
||||
<h1 id="intro-title">Ostra Type Works</h1>
|
||||
<p>Portable typewriters from the 1930s to the 1960s, taken apart, cleaned and put back together by hand in a small workshop in Bergen.</p>
|
||||
</section>
|
||||
|
||||
<section id="restore" class="scene" data-scene="twin-image-scanner" data-options='{"hold":2.5,"park":90}' aria-labelledby="ts-title"
|
||||
style="--ts-zoom:2; --ts-fx:48%; --ts-fy:56%; --ts-phone-zoom:1.6; --ts-edge:34%; --ts-phone-edge:30%">
|
||||
<div class="scene-stage">
|
||||
<div class="ts-layer ts-layer--a">
|
||||
<div class="ts-copy">
|
||||
<h2 class="ts-title" id="ts-title">Every lever, every spring</h2>
|
||||
<div class="ts-outro">
|
||||
<p>Each machine comes back cleaned, adjusted and fitted with a new platen and ribbon, in about three weeks.</p>
|
||||
<a class="ts-cta" href="#send">Book a restoration</a>
|
||||
</div>
|
||||
</div>
|
||||
<figure class="ts-frame">
|
||||
<div class="ts-subject">
|
||||
<img class="ts-img" src="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/twin-image-scanner/typewriter.webp" alt="A black Favorit portable typewriter from the 1930s, seen from above with a sheet of paper in it" width="1600" height="2000" decoding="async">
|
||||
</div>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
<div class="ts-layer ts-layer--b" aria-hidden="true" inert>
|
||||
<div class="ts-inner">
|
||||
<div class="ts-copy">
|
||||
<h2 class="ts-title">Every lever, every spring</h2>
|
||||
<div class="ts-outro">
|
||||
<p>Each machine comes back cleaned, adjusted and fitted with a new platen and ribbon, in about three weeks.</p>
|
||||
<span class="ts-cta">Book a restoration</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ts-frame">
|
||||
<div class="ts-subject">
|
||||
<img class="ts-img" src="https://cdn.jsdelivr.net/gh/Leonxlnx/taste-skill@scene-media/twin-image-scanner/typewriter-structure.svg" alt="" width="1600" height="2000" decoding="async">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ts-head" aria-hidden="true">
|
||||
<i class="ts-line"></i>
|
||||
<span class="ts-tag ts-tag--a">Black enamel</span>
|
||||
<span class="ts-tag ts-tag--b">Mechanism</span>
|
||||
</div>
|
||||
|
||||
<div class="ts-marks">
|
||||
<div class="ts-frame">
|
||||
<ul class="ts-subject" aria-label="Parts of the typewriter">
|
||||
<li class="ts-callout is-above" style="--x:62.75%;--y:39.5%"><i class="ts-callout__dot"></i><i class="ts-callout__leader"></i><span class="ts-callout__label">Escapement</span></li>
|
||||
<li class="ts-callout" style="--x:37.4%;--y:46.4%"><i class="ts-callout__dot"></i><i class="ts-callout__leader"></i><span class="ts-callout__label">Ribbon spool</span></li>
|
||||
<li class="ts-callout is-focus" style="--x:51.9%;--y:46.6%"><i class="ts-callout__dot"></i><i class="ts-callout__leader"></i><span class="ts-callout__label">Type bars</span></li>
|
||||
<li class="ts-callout" style="--x:39.75%;--y:63.1%"><i class="ts-callout__dot"></i><i class="ts-callout__leader"></i><span class="ts-callout__label">Key levers</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="send" class="close" aria-labelledby="send-title">
|
||||
<div class="grid">
|
||||
<h2 id="send-title">Send us your machine</h2>
|
||||
<div class="close__text" id="prices">
|
||||
<p>Post it in its case or bring it to the workshop on Strandgaten. Within two days we write back with what it needs and what it will cost.</p>
|
||||
<dl class="prices">
|
||||
<div><dt>Full restoration</dt><dd>From 3,400 kr, about three weeks</dd></div>
|
||||
<div><dt>Clean and adjust</dt><dd>From 1,200 kr, one week</dd></div>
|
||||
<div><dt>New platen rubber</dt><dd>650 kr, done while you wait</dd></div>
|
||||
</dl>
|
||||
<a class="close__mail" href="mailto:?subject=Typewriter%20restoration&body=Make%20and%20model%3A%20%0AWhat%20it%20does%20wrong%3A%20">Write to the workshop</a>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="foot grid">
|
||||
<p class="foot__name">Ostra Type Works</p>
|
||||
<p>Strandgaten 18, 5013 Bergen</p>
|
||||
<p>Open Tuesday to Saturday, 10 to 17</p>
|
||||
<p>Photograph of the typewriter: Florian Klauer, Unsplash. Drawing traced from it.</p>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../twin-image-scanner.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,146 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Hollis Press, letterpress printing in Ghent</title>
|
||||
<meta name="description" content="Hollis Press is a letterpress shop of four in a former tram depot in Ghent, printing books, labels and posters from wood and metal type set by hand.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400..900&display=swap">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../type-flip-relayout.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #ecebe7;
|
||||
--ink: #161616;
|
||||
--font-display: "Schibsted Grotesk", system-ui, sans-serif;
|
||||
--font-text: "Schibsted Grotesk", system-ui, sans-serif;
|
||||
--gutter: 40px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--ground); }
|
||||
body { margin: 0; background: var(--ground); color: var(--ink); font: 400 18px/1.55 var(--font-text); -webkit-font-smoothing: antialiased; }
|
||||
a { color: inherit; }
|
||||
img { display: block; width: 100%; height: auto; }
|
||||
figure { margin: 0; }
|
||||
|
||||
.bar { display: flex; align-items: center; gap: 40px; padding: 24px var(--gutter); }
|
||||
.bar__name { font: 800 24px/1 var(--font-display); letter-spacing: -.02em; text-decoration: none; margin-right: auto; }
|
||||
.bar nav { display: flex; gap: 28px; }
|
||||
.bar nav a { text-decoration: none; }
|
||||
.bar__action { padding: 12px 20px; background: var(--ink); color: var(--ground); text-decoration: none; font-weight: 600; }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 0 24px; padding-inline: var(--gutter); }
|
||||
|
||||
.hero { padding-top: 120px; }
|
||||
.hero h1 { grid-column: 1 / 11; margin: 0; font: 800 clamp(44px, 7.2vw, 120px)/.94 var(--font-display); letter-spacing: -.04em; }
|
||||
.hero p { grid-column: 1 / 6; margin: 56px 0 0; font-size: 21px; line-height: 1.5; }
|
||||
.hero dl { grid-column: 8 / 13; margin: 56px 0 0; display: grid; grid-template-columns: auto 1fr; gap: 6px 24px; align-self: end; font-size: 18px; }
|
||||
.hero dt { font-weight: 700; }
|
||||
.hero dd { margin: 0; }
|
||||
.hero__book { grid-column: 1 / 9; margin-top: 96px; }
|
||||
.hero__book img { aspect-ratio: 4 / 3; object-fit: cover; height: 100%; }
|
||||
.hero__prints { grid-column: 9 / 13; margin-top: 96px; display: flex; flex-direction: column; }
|
||||
.hero__prints img { aspect-ratio: 3 / 4; object-fit: cover; flex: 1; }
|
||||
.hero figcaption { margin-top: 14px; font-size: 16px; line-height: 1.4; }
|
||||
|
||||
/* the scene takes the page's tokens; here it reverses them for one dark, picture-free stage */
|
||||
.scene.press { --ground: #161616; --ink: #ecebe7; }
|
||||
|
||||
.close { gap: 32px 24px; padding-top: 160px; padding-bottom: 48px; }
|
||||
.close h2 { grid-column: 1 / 8; margin: 0; font: 800 clamp(40px, 5vw, 76px)/.98 var(--font-display); letter-spacing: -.035em; }
|
||||
.close .close__visit { grid-column: 1 / 6; margin: 0; }
|
||||
.close .close__hours { grid-column: 8 / 13; grid-row: 2; margin: 0; }
|
||||
.close__street { grid-column: 1 / 9; margin-top: 48px; }
|
||||
.close__street img { aspect-ratio: 16 / 10; object-fit: cover; }
|
||||
.close__door { grid-column: 9 / 13; margin-top: 48px; }
|
||||
.close__door img { aspect-ratio: 4 / 5; object-fit: cover; height: 100%; }
|
||||
.close .close__mail { grid-column: 1 / 13; margin: 48px 0 0; font: 700 clamp(28px, 3vw, 44px)/1.1 var(--font-display); letter-spacing: -.02em; }
|
||||
.close .close__mail a { text-decoration-thickness: 3px; text-underline-offset: 8px; }
|
||||
.close footer { grid-column: 1 / 13; margin-top: 96px; display: flex; justify-content: space-between; gap: 24px; font-size: 16px; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
:root { --gutter: 16px; }
|
||||
.bar { gap: 16px; padding: 18px var(--gutter); }
|
||||
.bar nav { display: none; }
|
||||
.bar__action { padding: 10px 14px; font-size: 16px; }
|
||||
.grid { display: block; }
|
||||
.hero { padding-top: 56px; }
|
||||
.hero p { margin-top: 28px; font-size: 18px; }
|
||||
.hero dl { margin-top: 28px; font-size: 16px; }
|
||||
.hero__book { margin: 48px calc(-1 * var(--gutter)) 0; }
|
||||
.hero__prints { margin-top: 16px; width: 62%; margin-left: auto; }
|
||||
.hero__prints figcaption { padding-right: 0; }
|
||||
.close { padding-top: 120px; }
|
||||
.close .close__visit, .close .close__hours { margin-top: 24px; }
|
||||
.close__street { margin: 40px calc(-1 * var(--gutter)) 0; }
|
||||
.close__door { margin-top: 16px; width: 62%; }
|
||||
.close .close__mail { margin-top: 40px; }
|
||||
.close footer { margin-top: 64px; flex-direction: column; gap: 4px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="bar__name" href="#">Hollis Press</a>
|
||||
<nav aria-label="Main"><a href="#work">Work</a><a href="#visit">Visit</a></nav>
|
||||
<a class="bar__action" href="#visit">Ask for a quote</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="hero grid" aria-labelledby="hero-title">
|
||||
<h1 id="hero-title">Letterpress, set by hand in Ghent</h1>
|
||||
<p>Hollis Press is a print shop of four in a former tram depot, printing from wood and lead type that came out of closed shops across Flanders.</p>
|
||||
<dl>
|
||||
<dt>Presses</dt><dd>Two Heidelberg platens and a 1962 Vandercook</dd>
|
||||
<dt>Runs</dt><dd>From forty invitations to six thousand labels</dd>
|
||||
<dt>Since</dt><dd>2011</dd>
|
||||
</dl>
|
||||
<figure class="hero__book">
|
||||
<img src="https://picsum.photos/id/24/1200/900" width="1200" height="900" alt="An open book lying on a wooden table in soft window light">
|
||||
</figure>
|
||||
<figure class="hero__prints">
|
||||
<img src="https://picsum.photos/id/834/600/800" width="600" height="800" alt="Three framed prints on a pale wall above a dark tufted bench">
|
||||
<figcaption>Three of last winter's posters, framed for a café on the Kouter.</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section class="scene press" id="work" data-scene="type-flip-relayout" data-options='{"hold": 3}' aria-labelledby="work-title">
|
||||
<div class="scene-stage">
|
||||
<h2 class="rl-label" id="work-title">Work</h2>
|
||||
<ul class="rl-block">
|
||||
<li class="rl-line">Books and zines</li>
|
||||
<li class="rl-line">Labels for beer</li>
|
||||
<li class="rl-line">Menus and maps</li>
|
||||
<li class="rl-line">Wedding cards</li>
|
||||
<li class="rl-line">Posters</li>
|
||||
</ul>
|
||||
<p class="rl-body">Each job starts at the type cases, four hundred drawers of wood and lead. We set it by hand and print it deep enough to feel.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="close grid" id="visit" aria-labelledby="visit-title">
|
||||
<h2 id="visit-title">Come and see the presses run</h2>
|
||||
<p class="close__visit">Tramstraat 14, 9000 Ghent. Bring the paper you want to print on and we will tell you what it can take and what a run of it costs.</p>
|
||||
<p class="close__hours">Open Thursday to Saturday, ten to five. Printing days are Monday to Wednesday, when the shop is closed and the presses are loud.</p>
|
||||
<figure class="close__street">
|
||||
<img src="https://picsum.photos/id/826/1200/750" width="1200" height="750" loading="lazy" alt="A boat on a canal between tall brick warehouses in hazy morning light">
|
||||
</figure>
|
||||
<figure class="close__door">
|
||||
<img src="https://picsum.photos/id/859/600/750" width="600" height="750" loading="lazy" alt="A red brick house front with a cream door, red shutters and a parked bicycle">
|
||||
</figure>
|
||||
<p class="close__mail"><a href="mailto:hello@hollispress.example">hello@hollispress.example</a></p>
|
||||
<footer><span>Hollis Press, letterpress and bindery, Ghent.</span><span>Tramstraat 14, ten minutes from Gent-Sint-Pieters.</span></footer>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../type-flip-relayout.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,157 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Solvra, fusion power from seawater</title>
|
||||
<meta name="description" content="Solvra is a forty-person lab on Albert Quay in Aberdeen, building a compact fusion reactor that runs on deuterium drawn from the North Sea.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,400..900&display=swap">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../warp-jump.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #060708;
|
||||
--ground-dark: #060708;
|
||||
--ink: #eeeeea;
|
||||
--ink-light: #eeeeea;
|
||||
--muted: #b4b4b0;
|
||||
--accent: #ff5b24;
|
||||
--font-display: "Archivo", system-ui, sans-serif;
|
||||
--font-text: "Archivo", system-ui, sans-serif;
|
||||
--gutter: max(16px, 3.5vw);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--ground); }
|
||||
body { margin: 0; background: var(--ground); color: var(--ink); font: 400 19px/1.55 var(--font-text); -webkit-font-smoothing: antialiased; }
|
||||
a { color: inherit; }
|
||||
img { display: block; max-width: 100%; height: auto; }
|
||||
|
||||
.bar { position: absolute; z-index: 20; top: 0; left: 0; right: 0; display: flex; align-items: center; gap: 36px; padding: 22px var(--gutter); font-size: 16px; }
|
||||
.bar__name { margin-right: auto; font: 800 19px/1 var(--font-display); font-stretch: 112%; letter-spacing: -0.01em; text-transform: uppercase; text-decoration: none; }
|
||||
.bar nav { display: flex; gap: 28px; }
|
||||
.bar nav a { text-decoration: none; }
|
||||
.bar__action { padding: 12px 20px; border-radius: 2px; background: var(--ink); color: var(--ground); font-weight: 600; text-decoration: none; }
|
||||
|
||||
.hero { position: relative; height: 100svh; min-height: 560px; overflow: hidden; background: var(--ground); }
|
||||
.hero__photo { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: 50% 100%; }
|
||||
.hero__text { position: absolute; left: var(--gutter); right: var(--gutter); top: 30svh; }
|
||||
.hero h1 { margin: 0; max-width: 11ch; font: 700 max(48px, min(7.2vw, 12.5svh))/0.95 var(--font-display); letter-spacing: -0.03em; text-wrap: balance; }
|
||||
.hero p { max-width: 40ch; margin: 28px 0 0; }
|
||||
|
||||
.next { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 0 24px; padding: clamp(72px, 12vh, 140px) var(--gutter) clamp(96px, 14vh, 160px); background: var(--accent); color: #000; }
|
||||
.next h2 { grid-column: 1 / 7; margin: 0; font: 800 clamp(36px, 4.4vw, 68px)/0.98 var(--font-display); font-stretch: 112%; letter-spacing: -0.02em; text-wrap: balance; }
|
||||
.next__text { grid-column: 1 / 6; margin: 36px 0 0; max-width: 46ch; }
|
||||
.next dl { grid-column: 1 / 6; display: grid; grid-template-columns: max-content 1fr; gap: 12px 28px; margin: 40px 0 0; }
|
||||
.next dt { font-weight: 600; }
|
||||
.next dd { margin: 0; }
|
||||
.next__action { grid-column: 1 / 6; justify-self: start; margin-top: 44px; padding: 16px 26px; border-radius: 2px; background: #000; color: var(--accent); font-weight: 600; text-decoration: none; }
|
||||
.next figure { margin: 0; }
|
||||
.next figure img { width: 100%; aspect-ratio: 8 / 5; object-fit: cover; }
|
||||
.next figcaption { margin-top: 14px; font-size: 16px; line-height: 1.45; }
|
||||
.next__hall { grid-column: 7 / 13; grid-row: 1 / 6; align-self: start; }
|
||||
.next__sea { grid-column: 1 / 7; grid-row: 6; margin-top: clamp(72px, 10vh, 120px) !important; }
|
||||
.next__quay { grid-column: 7 / 13; grid-row: 6; margin-top: clamp(72px, 10vh, 120px) !important; }
|
||||
|
||||
.foot { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px 32px; padding: 32px var(--gutter) 40px; background: var(--ground); color: var(--muted); font-size: 16px; }
|
||||
.foot strong { color: var(--ink); font-weight: 600; }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.next h2 { grid-column: 1 / 13; }
|
||||
.next__text, .next dl, .next__action { grid-column: 1 / 7; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.bar { gap: 16px; padding: 16px var(--gutter); }
|
||||
.bar nav { display: none; }
|
||||
.bar__action { padding: 10px 14px; }
|
||||
.hero__photo { object-position: 30% 100%; }
|
||||
.hero__text { top: 24svh; }
|
||||
.hero p { margin-top: 20px; }
|
||||
.next { display: block; }
|
||||
.next__text { margin-top: 28px; }
|
||||
.next dl { grid-template-columns: 1fr; gap: 2px; }
|
||||
.next dd { margin-bottom: 12px; }
|
||||
.next__action { display: inline-block; margin-top: 28px; }
|
||||
.next figure { margin-top: 48px !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="bar__name" href="#">Solvra</a>
|
||||
<nav aria-label="Main">
|
||||
<a href="#reactor">The reactor</a>
|
||||
<a href="#principles">How we work</a>
|
||||
<a href="#visit">Careers</a>
|
||||
</nav>
|
||||
<a class="bar__action" href="#visit">Visit the hall</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="hero" aria-labelledby="hero-title">
|
||||
<img class="hero__photo" src="https://picsum.photos/id/765/2000/1250" alt="Dawn breaking in a thin orange band along a flat dark horizon, a crescent moon high above" width="2000" height="1250" fetchpriority="high">
|
||||
<div class="hero__text">
|
||||
<h1 id="hero-title">Fusion power from seawater</h1>
|
||||
<p>Solvra is a forty-person lab on Albert Quay in Aberdeen, building a compact reactor that runs on deuterium drawn from the North Sea.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="scene" id="principles" data-scene="warp-jump" aria-label="What Solvra holds to" data-options='{}'>
|
||||
<div class="scene-stage">
|
||||
<div class="warp-copy">
|
||||
<div class="warp-statement">
|
||||
<h2 class="warp-anchor">A star</h2>
|
||||
<p class="warp-tail">in a steel box</p>
|
||||
<p class="warp-tail">on the grid</p>
|
||||
</div>
|
||||
<ol class="warp-points">
|
||||
<li class="warp-point">Measure twice. Fire once.</li>
|
||||
<li class="warp-point">Every shot logged. Every log public.</li>
|
||||
<li class="warp-point">Forty people. One machine.</li>
|
||||
<li class="warp-point">Safety sets the pace. Every day.</li>
|
||||
</ol>
|
||||
<p class="warp-final">See you at first light</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="next" id="reactor" aria-labelledby="reactor-title">
|
||||
<h2 id="reactor-title">Solvra One, on the quay</h2>
|
||||
<p class="next__text">A tokamak four metres across, wound from high-temperature superconducting tape and fuelled with deuterium taken from seawater pumped in at the quay wall. The magnets arrive this winter. First plasma is planned for spring 2031, and first power to the Aberdeen grid for 2034.</p>
|
||||
<dl>
|
||||
<dt>Size</dt><dd>Four metres across, eleven tall</dd>
|
||||
<dt>Fuel</dt><dd>Deuterium from the North Sea, tritium bred on site</dd>
|
||||
<dt>Output</dt><dd>Fifty megawatts at full power</dd>
|
||||
<dt>First plasma</dt><dd>Spring 2031</dd>
|
||||
</dl>
|
||||
<a class="next__action" id="visit" href="mailto:visits@solvra.example?subject=A%20visit%20to%20the%20hall%20on%20Albert%20Quay">Book a visit to the hall</a>
|
||||
<figure class="next__hall">
|
||||
<img src="https://picsum.photos/id/331/1400/875" alt="A man standing alone at the window of a hall under a steel truss roof, an aircraft parked outside in the rain" width="1400" height="875" decoding="async">
|
||||
<figcaption>The assembly hall on Albert Quay, the week before the magnet crates came in.</figcaption>
|
||||
</figure>
|
||||
<figure class="next__sea">
|
||||
<img src="https://picsum.photos/id/1038/1400/875" alt="A small red boat on a wide, dark, rippling sea" width="1400" height="875" decoding="async">
|
||||
<figcaption>The fuel: one litre of seawater holds about 33 milligrams of deuterium.</figcaption>
|
||||
</figure>
|
||||
<figure class="next__quay">
|
||||
<img src="https://picsum.photos/id/637/1400/875" alt="A metal ladder at the edge of a wooden dock over still water at dusk" width="1400" height="875" decoding="async">
|
||||
<figcaption>The intake on the quay wall, where the seawater comes in.</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="foot">
|
||||
<span><strong>Solvra</strong>, Albert Quay, Aberdeen</span>
|
||||
<span>Open days on the first Saturday of every month</span>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../warp-jump.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,153 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Flint, stone stairs cut by hand</title>
|
||||
<meta name="description" content="Flint is a workshop of six masons in Porto building staircases, steps and thresholds from Portuguese limestone and granite.">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,400..800&display=swap">
|
||||
<link rel="stylesheet" href="../stage.css">
|
||||
<link rel="stylesheet" href="../wordmark-under-strain.css">
|
||||
<style>
|
||||
:root {
|
||||
--ground: #eeeeeb;
|
||||
--ink: #1f1f1f;
|
||||
--font-display: "Archivo", system-ui, sans-serif;
|
||||
--font-text: "Archivo", system-ui, sans-serif;
|
||||
--gutter: max(16px, 4vw);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--ground); }
|
||||
body { margin: 0; background: var(--ground); color: var(--ink); font: 400 18px/1.55 var(--font-text); -webkit-font-smoothing: antialiased; }
|
||||
a { color: inherit; }
|
||||
img { display: block; width: 100%; height: auto; }
|
||||
|
||||
.bar { display: flex; align-items: center; gap: 40px; padding: 24px var(--gutter); }
|
||||
.bar__name { font: 800 24px/1 var(--font-display); letter-spacing: -.02em; text-decoration: none; margin-right: auto; }
|
||||
.bar nav { display: flex; gap: 28px; }
|
||||
.bar nav a { text-decoration: none; }
|
||||
.bar__action { padding: 12px 20px; background: var(--ink); color: var(--ground); text-decoration: none; font-weight: 600; }
|
||||
|
||||
.hero { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 0 24px; padding: 96px var(--gutter) 0; }
|
||||
.hero h1 { grid-column: 1 / 9; margin: 0; font: 800 clamp(44px, 7.6vw, 124px)/.94 var(--font-display); letter-spacing: -.035em; }
|
||||
.hero p { grid-column: 9 / 13; align-self: end; margin: 0; max-width: 34ch; font-size: 21px; line-height: 1.5; }
|
||||
.hero figure { grid-column: 1 / 13; margin: 88px 0 0; }
|
||||
.hero img { aspect-ratio: 16 / 9; object-fit: cover; }
|
||||
|
||||
.work { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 0 24px; padding: 200px var(--gutter) 200px; }
|
||||
.work h2 { grid-column: 1 / 8; margin: 0; font: 800 clamp(34px, 4.4vw, 64px)/1 var(--font-display); letter-spacing: -.03em; }
|
||||
.work > p { grid-column: 9 / 13; margin: 8px 0 0; }
|
||||
.work figure { margin: 0; }
|
||||
.work figure:nth-of-type(1) { grid-column: 1 / 8; margin-top: 96px; }
|
||||
.work figure:nth-of-type(2) { grid-column: 9 / 13; margin-top: 280px; }
|
||||
.work figure:nth-of-type(1) img { aspect-ratio: 4 / 3; object-fit: cover; }
|
||||
.work figure:nth-of-type(2) img { aspect-ratio: 3 / 4; object-fit: cover; }
|
||||
.work figure:nth-of-type(3) { grid-column: 1 / 13; margin-top: 160px; }
|
||||
.work figure:nth-of-type(3) img { aspect-ratio: 21 / 9; object-fit: cover; }
|
||||
.work figcaption { margin-top: 14px; font-size: 16px; line-height: 1.45; }
|
||||
|
||||
.close { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 32px 24px; padding: 120px var(--gutter) 64px; background: var(--ground); }
|
||||
.close h2 { grid-column: 1 / 7; margin: 0; font: 800 36px/1.05 var(--font-display); letter-spacing: -.02em; }
|
||||
.close .close__visit { grid-column: 1 / 7; margin: 0; max-width: 40ch; }
|
||||
.close .close__small { grid-column: 9 / 13; grid-row: 1 / 3; margin: 0; font-size: 16px; line-height: 1.5; align-self: end; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.bar { gap: 16px; padding: 18px var(--gutter); }
|
||||
.bar nav { display: none; }
|
||||
.bar__action { padding: 10px 14px; font-size: 16px; }
|
||||
.hero { display: block; padding-top: 48px; }
|
||||
.hero p { margin-top: 24px; font-size: 18px; }
|
||||
.hero figure { margin: 48px calc(-1 * var(--gutter)) 0; }
|
||||
.hero img { aspect-ratio: 4 / 5; }
|
||||
.work { display: block; padding: 120px var(--gutter); }
|
||||
.work > p { margin-top: 20px; }
|
||||
.work figure:nth-of-type(1) { margin-top: 48px; }
|
||||
.work figure:nth-of-type(2) { margin-top: 40px; width: 72%; margin-left: auto; }
|
||||
.work figure:nth-of-type(3) { margin-top: 40px; }
|
||||
.work figure:nth-of-type(3) img { aspect-ratio: 4 / 3; }
|
||||
.close { display: block; padding: 96px var(--gutter) 48px; }
|
||||
.close .close__visit { margin-top: 16px; }
|
||||
.close .close__small { margin-top: 48px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="bar__name" href="#">Flint</a>
|
||||
<nav aria-label="Main"><a href="#work">Work</a><a href="#yard">Visit the yard</a></nav>
|
||||
<a class="bar__action" href="#contact">Start a project</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="hero" aria-labelledby="hero-title">
|
||||
<h1 id="hero-title">Stone stairs, cut by hand</h1>
|
||||
<p>Flint is a workshop of six masons in Porto building staircases, steps and thresholds from Portuguese limestone and granite, eight commissions a year.</p>
|
||||
<figure>
|
||||
<picture>
|
||||
<source media="(max-width: 767px)" srcset="https://picsum.photos/id/276/800/1000" width="800" height="1000">
|
||||
<img src="https://picsum.photos/id/276/1600/900" width="1600" height="900" alt="A wide stone stairway rising into thick fog">
|
||||
</picture>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section class="work" id="work" aria-labelledby="work-title">
|
||||
<h2 id="work-title">Every stair is drawn on site and cut in our yard</h2>
|
||||
<p>We measure the room, draw each tread at full size and cut it from a single block. A staircase takes six to fourteen weeks from the first visit to the last stone, and it is fitted by the people who cut it.</p>
|
||||
<figure>
|
||||
<img src="https://picsum.photos/id/240/1200/900" width="1200" height="900" loading="lazy" alt="Wide pale stone steps beside a white wall, a figure sitting at the top">
|
||||
<figcaption>Garden steps for a house in Foz do Douro, granite, 2025.</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<img src="https://picsum.photos/id/954/600/800" width="600" height="800" loading="lazy" alt="A narrow opening in a white wall showing a pale staircase">
|
||||
<figcaption>A narrow limestone stair for a flat in Bonfim, 2024.</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<img src="https://picsum.photos/id/78/1600/900" width="1600" height="900" loading="lazy" alt="A carved wooden double door set in an old stone facade">
|
||||
<figcaption>A new granite threshold under an old door in Miragaia, 2023.</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section class="scene ws" id="contact" data-scene="wordmark-under-strain" data-options='{}' aria-labelledby="ws-title">
|
||||
<div class="scene-stage">
|
||||
<div class="ws-top">
|
||||
<p>Commissions for spring 2027 are open. Tell us about the room and we will come and measure it.</p>
|
||||
<p>Porto, since 2009</p>
|
||||
</div>
|
||||
<h2 class="ws-word" id="ws-title">
|
||||
<span class="ws-sr">FLINT</span>
|
||||
<span class="ws-stack" aria-hidden="true">
|
||||
<span class="ws-copy" style="--i:0">FL<span class="ws-l">I</span>NT</span>
|
||||
<span class="ws-copy" style="--i:1">FL<span class="ws-l">I</span>NT</span>
|
||||
<span class="ws-copy" style="--i:2">FL<span class="ws-l">I</span>NT</span>
|
||||
<span class="ws-copy" style="--i:3">FL<span class="ws-l">I</span>NT</span>
|
||||
<span class="ws-copy" style="--i:4">FL<span class="ws-l">I</span>NT</span>
|
||||
<span class="ws-copy ws-copy--front" style="--i:5">FL<span class="ws-l ws-slot">I</span>NT</span>
|
||||
</span>
|
||||
</h2>
|
||||
<a class="ws-drop" href="mailto:hello@flint-stone.pt?subject=A%20new%20staircase&body=Where%20the%20stair%20goes%3A%0AFloor%20to%20floor%20height%3A%0AStone%20we%20like%3A"><span class="ws-drop__label">Start a project</span></a>
|
||||
<div class="ws-meta">
|
||||
<a href="mailto:hello@flint-stone.pt">hello@flint-stone.pt</a>
|
||||
<span>Rua do Bonjardim 980, Porto</span>
|
||||
<a href="#yard">Visit the yard</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="close" id="yard" aria-labelledby="yard-title">
|
||||
<h2 id="yard-title">Visit the yard</h2>
|
||||
<p class="close__visit">Thursday to Saturday, 9:00 to 17:00. Bring a drawing, a photograph, or only the measurements of the room, and walk the stone before you choose it.</p>
|
||||
<p class="close__small">Photographs by davide ragusa, Elisabetta Foco, Todd Quackenbush and Paul Evans on Unsplash. Flint Stonework is an invented studio for this demo.</p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
|
||||
<script src="../stage.js"></script>
|
||||
<script src="../wordmark-under-strain.js"></script>
|
||||
<script>Stage.smooth(); Stage.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,310 @@
|
||||
/* tasteskill / scenes / depth-card-flythrough.css — a fly-through of captioned photo cards.
|
||||
Default (no script, reduced motion, a phone set to 'rest'): the finished composition. The backdrop
|
||||
opens full-bleed with the first statement on its calm band and the first card in the air, then the
|
||||
other cards and their statements zig-zag down a depth wall, each statement beside its own card, the
|
||||
smaller cards fogged as if farther away.
|
||||
Under .is-live: the held stage, where the camera runs over the ground and the cards fly out of the
|
||||
vanishing point (script).
|
||||
Tokens sit in :where() so a page overrides any of them with a plain `.fly { --fly-stage: … }`.
|
||||
Weights come from the page: --display-weight (statements, captions) and --text-weight (the count); set
|
||||
them to weights the page loads, or the browser draws the nearest weight it has (fake bold is off).
|
||||
Where the vanishing point sits decides how wide the statement box may be: at the default 56svh keep
|
||||
--fly-copy-w within 32vw (16vw each side of the axis, which no card reaches); with the vanishing point
|
||||
at 68svh or lower the cards only rise to the box when they are far out at the sides, so it may go to
|
||||
56vw for a larger statement. */
|
||||
|
||||
[data-scene="depth-card-flythrough"] { font-synthesis-weight: none; }
|
||||
:where(.scene[data-scene="depth-card-flythrough"]) {
|
||||
--fly-stage: #8a8e91; /* the backdrop's horizon colour: ground of the finished wall */
|
||||
--fly-fog: var(--fly-stage); /* haze over far cards */
|
||||
--fly-ink: var(--ink, #111); /* statement colour on the photo (4.5:1 or better) */
|
||||
--fly-shade: 16 18 20; /* scrim colour, as r g b */
|
||||
--fly-scrim: 0; /* 0-0.5: flat fill over the whole backdrop, for white text */
|
||||
--fly-chip: #f2f1ec; /* caption chip */
|
||||
--fly-chip-ink: #121212;
|
||||
--fly-font: var(--font-display, inherit);
|
||||
--fly-vp: 56svh; /* vanishing point, from the top of the stage */
|
||||
--fly-focus: 56%; /* the picture's own vanishing point height (object-position) */
|
||||
--fly-lift: 0svh; /* live backdrop extends this far above the stage, raising the picture */
|
||||
--fly-ground: var(--fly-vp); /* where the streaming ground starts: the base of a focal object that
|
||||
stands below the vanishing point (a house at a jetty's end) */
|
||||
--fly-focus-w: .06; /* the focal object's half-width at dolly 1: far cards are held outside
|
||||
it until they have grown clear. A plain number is a share of the
|
||||
picture's width (follows the crop); a length is taken as it is */
|
||||
--fly-rest-vp: 57.5svh; /* where the vanishing point sits in the finished opening (no lift) */
|
||||
--fly-rest-ground: calc(var(--fly-rest-vp) + 4svh); /* the focal object's foot in the finished opening */
|
||||
--fly-card-w: min(34vw, 54vh); /* card width at scale 1; the 54vh cap keeps cards out of the copy */
|
||||
--fly-size: clamp(1.75rem, .9rem + 2.2vw, 3.5rem); /* statement: 46px at 1440, 56px from 1940 */
|
||||
--fly-copy-top: 12svh; /* top of the statement box */
|
||||
--fly-copy-w: min(32vw, 21ch); /* statement box: 16vw each side of the axis stays clear of cards */
|
||||
}
|
||||
/* a tall screen (a tablet held upright): the cards fly the low path (see the script), so they can be
|
||||
larger; the box widens so the statement keeps to two lines */
|
||||
@media (min-width: 768px) and (orientation: portrait) {
|
||||
:where(.scene[data-scene="depth-card-flythrough"]) {
|
||||
--fly-card-w: min(56vw, 36svh);
|
||||
--fly-copy-top: 14svh;
|
||||
--fly-copy-w: min(56vw, 18ch);
|
||||
}
|
||||
}
|
||||
/* a phone: the statement high, the vanishing point a little below the middle, and cards that reach about
|
||||
72vw at reading size on the low path (see the script) */
|
||||
@media (max-width: 767px) {
|
||||
:where(.scene[data-scene="depth-card-flythrough"]) {
|
||||
--fly-vp: 56svh;
|
||||
--fly-rest-vp: 44.8svh; /* the same picture in the 80svh finished opening */
|
||||
--fly-card-w: min(78vw, 36svh);
|
||||
--fly-size: 1.75rem;
|
||||
--fly-copy-top: 14svh;
|
||||
}
|
||||
}
|
||||
|
||||
.scene[data-scene="depth-card-flythrough"] { background: var(--fly-stage); color: var(--fly-ink); font-family: var(--fly-font); }
|
||||
.scene[data-scene="depth-card-flythrough"] .fly__backdrop { overflow: hidden; }
|
||||
.scene[data-scene="depth-card-flythrough"] :is(.fly__backdrop-img, .fly__backdrop-video, .fly__ground-img) {
|
||||
display: block; width: 100%; height: 100%; object-fit: cover; object-position: 50% var(--fly-focus);
|
||||
}
|
||||
/* the script's copy of the ground and its scrim spans: live only */
|
||||
.scene[data-scene="depth-card-flythrough"] :is(.fly__ground, .fly__shade) { display: none; }
|
||||
.scene[data-scene="depth-card-flythrough"] .fly__scrim {
|
||||
background: rgb(var(--fly-shade) / var(--fly-scrim)); pointer-events: none;
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"] .fly__card { margin: 0; aspect-ratio: 3 / 2; }
|
||||
.scene[data-scene="depth-card-flythrough"] .fly__card.is-portrait { aspect-ratio: 4 / 5; }
|
||||
.scene[data-scene="depth-card-flythrough"] .fly__img {
|
||||
display: block; width: 100%; height: 100%; object-fit: cover; background: var(--fly-fog);
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"] .fly__fog {
|
||||
position: absolute; inset: 0; background: var(--fly-fog); pointer-events: none;
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"] .fly__cap {
|
||||
position: absolute; left: 0; bottom: 100%;
|
||||
padding: .32em .6em .36em;
|
||||
font: var(--display-weight, 500) 16px/1.1 var(--fly-font);
|
||||
letter-spacing: -.005em;
|
||||
background: var(--fly-chip); color: var(--fly-chip-ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"] .fly__lines { margin: 0; padding: 0; list-style: none; }
|
||||
.scene[data-scene="depth-card-flythrough"] .fly__line {
|
||||
margin: 0; font-weight: var(--display-weight, 500); letter-spacing: -.022em; text-wrap: balance; color: var(--fly-ink);
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"] .fly__count {
|
||||
margin: 0; font: var(--text-weight, 400) 16px/1.2 var(--fly-font); font-variant-numeric: tabular-nums; color: var(--fly-ink);
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"].fly--no-count .fly__count { display: none; }
|
||||
.scene[data-scene="depth-card-flythrough"] .fly__sr { display: none; } /* the script's copy of the statements for screen readers */
|
||||
|
||||
/* ---------- the finished composition (default) ---------- */
|
||||
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) > .fly__stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
column-gap: 2vw; row-gap: 11vh;
|
||||
align-items: center;
|
||||
padding: calc(100svh + 12vh) 6vw 14vh;
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) :is(.fly__field, .fly__copy, .fly__lines) { display: contents; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__count { display: none; }
|
||||
|
||||
/* the opening: one frame of the flight, held */
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__backdrop,
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__scrim {
|
||||
position: absolute; top: 0; left: 0; right: 0; height: 100svh;
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__line:first-child {
|
||||
position: absolute; top: var(--fly-copy-top); left: 0; right: 0; z-index: 2;
|
||||
grid-area: auto; margin: 0 auto; width: var(--fly-copy-w); max-width: none;
|
||||
font-size: var(--fly-size); line-height: 1.06; text-align: center;
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card:first-child {
|
||||
position: absolute; z-index: 1;
|
||||
grid-area: auto; margin: 0;
|
||||
width: calc(var(--fly-card-w) * .78);
|
||||
left: calc(50% - 27vw - var(--fly-card-w) * .39);
|
||||
top: calc(var(--fly-rest-vp) - var(--fly-card-w) * .26);
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card.is-portrait:first-child {
|
||||
width: calc(var(--fly-card-w) * .44);
|
||||
left: calc(50% - 27vw - var(--fly-card-w) * .22);
|
||||
top: calc(var(--fly-rest-vp) - var(--fly-card-w) * .275);
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card:first-child .fly__fog { opacity: 0; }
|
||||
|
||||
/* the depth wall: card k and statement k share row k - 1, the statement beside its own card and centred
|
||||
on it, hugging the gutter between them; sides alternate as in the flight, and card sizes cycle near,
|
||||
middle, far with the far ones fogged */
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card {
|
||||
position: relative; grid-column: 1 / 7; justify-self: end;
|
||||
width: calc(100% * var(--fly-pct, 1));
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card.is-portrait { width: calc(100% * var(--fly-pct, 1) * .56); }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card:nth-child(even) { grid-column: 7 / 13; justify-self: start; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__line {
|
||||
grid-column: 7 / 12; justify-self: start; text-align: left;
|
||||
max-width: 17ch; margin: 0;
|
||||
font-size: calc(var(--fly-size) * .8); line-height: 1.1;
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__line:nth-child(even) { grid-column: 2 / 7; justify-self: end; text-align: right; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) :is(.fly__card, .fly__line):nth-child(3n + 2) { --fly-pct: 1; --fly-haze: 0; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) :is(.fly__card, .fly__line):nth-child(3n) { --fly-pct: .74; --fly-haze: .2; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) :is(.fly__card, .fly__line):nth-child(3n + 4) { --fly-pct: .56; --fly-haze: .38; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__fog { opacity: var(--fly-haze, 0); }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) :is(.fly__card, .fly__line):nth-child(2) { grid-row: 1; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) :is(.fly__card, .fly__line):nth-child(3) { grid-row: 2; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) :is(.fly__card, .fly__line):nth-child(4) { grid-row: 3; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) :is(.fly__card, .fly__line):nth-child(5) { grid-row: 4; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) :is(.fly__card, .fly__line):nth-child(6) { grid-row: 5; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) :is(.fly__card, .fly__line):nth-child(7) { grid-row: 6; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) :is(.fly__card, .fly__line):nth-child(8) { grid-row: 7; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) :is(.fly__card, .fly__line):nth-child(9) { grid-row: 8; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) :is(.fly__card, .fly__line):nth-child(10) { grid-row: 9; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) :is(.fly__card, .fly__line):nth-child(11) { grid-row: 10; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) :is(.fly__card, .fly__line):nth-child(12) { grid-row: 11; }
|
||||
|
||||
/* ---------- the live stage ---------- */
|
||||
|
||||
.scene[data-scene="depth-card-flythrough"].is-live > .fly__stage { background: var(--fly-stage); }
|
||||
.scene[data-scene="depth-card-flythrough"].is-live .fly__backdrop {
|
||||
position: absolute; left: 0; right: 0; bottom: 0; top: calc(-1 * var(--fly-lift));
|
||||
transform-origin: 50% calc(var(--fly-vp) + var(--fly-lift)); /* the dolly aims at the vanishing point */
|
||||
will-change: transform; /* one layer, drawn once: the dolly never re-rasters the picture */
|
||||
background: var(--fly-stage); /* an opaque layer, so the stage colour under it is never drawn */
|
||||
}
|
||||
/* the ground below the vanishing point, which the script maps as the camera runs over it */
|
||||
.scene[data-scene="depth-card-flythrough"].is-live .fly__ground {
|
||||
display: block; position: absolute; inset: 0;
|
||||
clip-path: inset(calc(var(--fly-ground) + var(--fly-lift)) 0 0 0);
|
||||
transform-origin: 50% calc(var(--fly-ground) + var(--fly-lift));
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"].is-live .fly__scrim { position: absolute; inset: 0; }
|
||||
/* over a still the scrim lives inside the picture's layers (the script's .fly__shade spans), so the
|
||||
stage never blends a full-screen translucent layer per frame */
|
||||
.scene[data-scene="depth-card-flythrough"].is-live .fly__shade {
|
||||
display: block; position: absolute; inset: 0; pointer-events: none;
|
||||
background: rgb(var(--fly-shade) / var(--fly-scrim));
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"].is-live.fly--shaded .fly__scrim { display: none; }
|
||||
.scene[data-scene="depth-card-flythrough"].is-live .fly__field {
|
||||
position: absolute; inset: 0;
|
||||
perspective: 300px; perspective-origin: 50% var(--fly-vp); /* never preserve-3d: plain z-index order */
|
||||
contain: layout paint; pointer-events: none;
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"].is-live .fly__card {
|
||||
position: absolute; left: 50%; top: var(--fly-vp);
|
||||
width: var(--fly-card-w);
|
||||
visibility: hidden; opacity: 0;
|
||||
backface-visibility: hidden;
|
||||
transform: translate(-50%, -50%) translateZ(-600px);
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"].is-live .fly__card.is-portrait { width: calc(var(--fly-card-w) * .735); }
|
||||
/* the haze is its own flat layer, so lifting it never repaints the card's picture */
|
||||
.scene[data-scene="depth-card-flythrough"].is-live .fly__fog { opacity: .7; will-change: opacity; }
|
||||
/* in flight the caption is drawn by the tag layer; the figcaption stays for screen readers */
|
||||
.scene[data-scene="depth-card-flythrough"].is-live .fly__cap { opacity: 0; }
|
||||
/* the caption tags: one flat layer above the cards and below the statement; the script puts each tag on
|
||||
its card's inner top corner, so it stays on screen as the card passes */
|
||||
.scene[data-scene="depth-card-flythrough"] .fly__tags { display: none; }
|
||||
.scene[data-scene="depth-card-flythrough"].is-live .fly__tags {
|
||||
display: block; position: absolute; inset: 0; z-index: 10;
|
||||
overflow: clip; contain: layout paint; pointer-events: none;
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"].is-live .fly__tag {
|
||||
position: absolute; left: 0; top: 0;
|
||||
padding: .32em .6em .36em;
|
||||
font: var(--display-weight, 500) clamp(16px, 1.15vw, 20px)/1.1 var(--fly-font);
|
||||
letter-spacing: -.005em;
|
||||
background: var(--fly-chip); color: var(--fly-chip-ink);
|
||||
white-space: nowrap; opacity: 0;
|
||||
will-change: transform, opacity; /* seven small layers that ride their cards without re-rastering */
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"].is-live .fly__copy {
|
||||
position: absolute; top: var(--fly-copy-top); left: 0; right: 0; z-index: 20;
|
||||
margin-inline: auto; width: var(--fly-copy-w);
|
||||
font-size: var(--fly-size); text-align: center;
|
||||
transform-origin: 50% 0; /* the title scale grows down from the box's top edge */
|
||||
}
|
||||
/* every line in one cell, so the box never changes height; lines sit on the cell's foot, so the count
|
||||
always hangs right under the line on screen, whichever line is tallest */
|
||||
.scene[data-scene="depth-card-flythrough"].is-live .fly__lines { display: grid; align-items: end; }
|
||||
.scene[data-scene="depth-card-flythrough"].is-live .fly__line {
|
||||
grid-area: 1 / 1;
|
||||
font-size: inherit; line-height: 1.06;
|
||||
opacity: 0; visibility: hidden;
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"].is-live .fly__line:first-child { opacity: 1; visibility: visible; }
|
||||
.scene[data-scene="depth-card-flythrough"].is-live .fly__sr {
|
||||
display: block; position: absolute; inline-size: 2px; block-size: 2px; margin: 0; padding: 0;
|
||||
overflow: hidden; clip-path: inset(50%); white-space: nowrap; /* read aloud, never drawn */
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"].is-live .fly__count { margin-top: .85rem; }
|
||||
|
||||
/* ---------- phone ---------- */
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) > .fly__stage {
|
||||
grid-template-columns: minmax(0, 1fr); column-gap: 0; row-gap: 0; align-items: start;
|
||||
padding: calc(80svh + 8vh) 16px 10vh;
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__backdrop,
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__scrim { height: 80svh; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__line:first-child {
|
||||
top: var(--fly-copy-top); left: 16px; right: 16px; width: auto; line-height: 1.1;
|
||||
}
|
||||
/* the opening's first card stands small at the left edge below the focal object's foot, as a card
|
||||
that has just come out of the picture, so the picture keeps its subject */
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card:first-child,
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card.is-portrait:first-child {
|
||||
left: 16px; width: 38vw; top: calc(var(--fly-rest-ground) + 4.6svh);
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card.is-portrait:first-child { width: 24vw; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) :is(.fly__card, .fly__line):nth-child(3n + 2) { --fly-pct: 1; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) :is(.fly__card, .fly__line):nth-child(3n) { --fly-pct: .84; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) :is(.fly__card, .fly__line):nth-child(3n + 4) { --fly-pct: .68; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card,
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card:nth-child(even),
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card.is-portrait {
|
||||
grid-column: 1; margin-top: 0; justify-self: start; width: calc(100% * var(--fly-pct, 1)); aspect-ratio: 3 / 2;
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card:nth-child(even) { justify-self: end; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card:first-child { aspect-ratio: 3 / 2; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card.is-portrait:first-child { aspect-ratio: 4 / 5; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__line,
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__line:nth-child(even) {
|
||||
grid-column: 1; justify-self: start; text-align: left;
|
||||
width: calc(100% * var(--fly-pct, 1)); max-width: none; margin: .9rem 0 3.2rem;
|
||||
font-size: 1.375rem; line-height: 1.2;
|
||||
}
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__line:nth-child(even) { justify-self: end; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__line:first-child { margin: 0; width: auto; font-size: var(--fly-size); text-align: center; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card:nth-child(2) { grid-row: 1; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__line:nth-child(2) { grid-row: 2; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card:nth-child(3) { grid-row: 3; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__line:nth-child(3) { grid-row: 4; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card:nth-child(4) { grid-row: 5; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__line:nth-child(4) { grid-row: 6; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card:nth-child(5) { grid-row: 7; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__line:nth-child(5) { grid-row: 8; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card:nth-child(6) { grid-row: 9; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__line:nth-child(6) { grid-row: 10; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card:nth-child(7) { grid-row: 11; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__line:nth-child(7) { grid-row: 12; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card:nth-child(8) { grid-row: 13; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__line:nth-child(8) { grid-row: 14; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card:nth-child(9) { grid-row: 15; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__line:nth-child(9) { grid-row: 16; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card:nth-child(10) { grid-row: 17; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__line:nth-child(10) { grid-row: 18; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card:nth-child(11) { grid-row: 19; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__line:nth-child(11) { grid-row: 20; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__card:nth-child(12) { grid-row: 21; }
|
||||
.scene[data-scene="depth-card-flythrough"]:not(.is-live) .fly__line:nth-child(12) { grid-row: 22; }
|
||||
|
||||
.scene[data-scene="depth-card-flythrough"].is-live .fly__card.is-portrait { width: calc(var(--fly-card-w) * .52); }
|
||||
.scene[data-scene="depth-card-flythrough"].is-live .fly__tag { font-size: 15px; }
|
||||
.scene[data-scene="depth-card-flythrough"].is-live .fly__copy { width: calc(100vw - 32px); }
|
||||
.scene[data-scene="depth-card-flythrough"].is-live .fly__line { line-height: 1.1; }
|
||||
.scene[data-scene="depth-card-flythrough"].is-live .fly__count { margin-top: .6rem; font-size: 15px; }
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
// tasteskill / scenes / depth-card-flythrough.js — a fly-through of captioned photo cards.
|
||||
//
|
||||
// The camera runs forward into a one-point-perspective picture (a jetty, a road, a rail line running to
|
||||
// the horizon). The ground below the horizon streams toward the reader while the far scene closes in,
|
||||
// and photo cards start as faint chips at the vanishing point, grow to reading size on alternating sides
|
||||
// and fly out past the screen edges, the near ones as huge passing ghosts. One short statement at a time
|
||||
// changes on the top-centre axis, paired with the card that is at reading size; the first statement
|
||||
// opens larger, as the scene's title, and settles as the first card arrives. A change is out, then in:
|
||||
// the old statement has gone before the new one starts, so two statements are never on screen at once,
|
||||
// and each card's caption leaves and arrives with its own statement.
|
||||
// Use it for 5-12 proof points, manifesto lines or a product world; skip it for dense copy or photos
|
||||
// that cannot be made to look like one place. Load after stage.js.
|
||||
//
|
||||
// DOM:
|
||||
// <section class="scene fly" data-scene="depth-card-flythrough" data-options='{"inAir":3}' aria-label="…">
|
||||
// <div class="scene-stage fly__stage">
|
||||
// <div class="fly__backdrop">
|
||||
// <img class="fly__backdrop-img" src="…" alt="" width="2600" height="1625" fetchpriority="high">
|
||||
// (or, for a moving backdrop: <video class="fly__backdrop-video" src="…" poster="…" muted playsinline preload="auto"></video>)
|
||||
// </div>
|
||||
// <div class="fly__scrim" aria-hidden="true"></div> (optional, see --fly-scrim; over a still the
|
||||
// script draws it inside the picture while live)
|
||||
// <div class="fly__field">
|
||||
// <figure class="fly__card" style="--dy:2"> (x N, 3-12; 7 reads best)
|
||||
// <img class="fly__img" src="…" alt="What the photo shows" width="1200" height="800">
|
||||
// <span class="fly__fog" aria-hidden="true"></span>
|
||||
// <figcaption class="fly__cap">One to four words</figcaption>
|
||||
// </figure>
|
||||
// </div>
|
||||
// <div class="fly__copy">
|
||||
// <ol class="fly__lines">
|
||||
// <li class="fly__line">Statement one, the scene title.</li> (x N, statement k pairs with card k)
|
||||
// </ol>
|
||||
// <p class="fly__count" aria-hidden="true"><span class="fly__count-now">1</span> of <span class="fly__count-all">7</span></p>
|
||||
// </div>
|
||||
// </div>
|
||||
// </section>
|
||||
// Cards fly on the left, right, left… (data-side="-1" or "1" on a card overrides). --dy is the card's
|
||||
// drop below the vanishing point in vh, 0-8; leave it out and the pattern 2 0 4 1 5 0 3 repeats.
|
||||
// Add class is-portrait to a card holding a 4:5 photo (two at most, never two in a row).
|
||||
// Statements: 4-7 words, about 34 characters, so they hold two lines at the live size. Captions: 1-4
|
||||
// words, 22 characters at most. In flight the script shows each caption as a flat tag of constant size
|
||||
// (div.fly__tags, aria-hidden) riding its card's inner top corner. Statement k takes over as card k
|
||||
// reaches reading size, or as card k-1 leaves if that is sooner, so a statement never outlives its
|
||||
// picture; card k's tag comes in with statement k and leaves with it (or earlier, as the card grows
|
||||
// past reading size or nears the screen edge), so a passing ghost is picture only, no label is ever
|
||||
// louder than the statement, and the caption on screen always belongs to the statement on screen. The
|
||||
// figcaption stays in the card for screen readers and the finished composition.
|
||||
// The backdrop needs its vanishing point on the vertical centre line at --fly-vp (56% of the stage by
|
||||
// default; set it to where the picture's own lines meet) and a calm, low-detail band above it for the
|
||||
// statement. Its lines must end at something that stands at the vanishing point (a boathouse, a door, a
|
||||
// gate, a lit window): a road or rail that runs off to nothing looks the same at any zoom about that
|
||||
// point, so the first and last frames match and the camera seems not to move, however far it dollies.
|
||||
// Tell the scene how wide its focal object is (--fly-focus-w, the half-width at dolly 1: a plain
|
||||
// number is a share of the picture's width, a length is taken as it is) and where it stands (--fly-ground):
|
||||
// on a wide stage the cards fly beside it on alternating sides, and a far card is held just outside it
|
||||
// until it has grown clear, so no card crosses the boathouse, mast or doorway the picture is about. On a
|
||||
// tall stage (a phone, a tablet held upright) the focal object fills the width, so the cards fly the low
|
||||
// path instead: each comes out at its foot, grows to about 72vw whole and on screen just below
|
||||
// --fly-ground, and leaves through the bottom as a big pass. A card comes in solid under a haze that lifts
|
||||
// as it nears, and leaves solid: it fades only once less than 15% of it is left on screen, and if its own
|
||||
// course would not take it off the screen it veers out over the last part of its flight. With a still picture the camera dollies toward the focal object, and the script lays a copy of
|
||||
// the picture over the ground (everything below the vanishing point, or below --fly-ground when the focal
|
||||
// object stands on the ground below it) and runs the camera over it: the ground streams toward the reader
|
||||
// along the lines that meet at the vanishing point, while the far scene holds. That suits a jetty, road,
|
||||
// runway, rail line or floor; set run 0 for a picture whose lower half is not ground.
|
||||
// A <video> backdrop is scrubbed instead (currentTime = progress x duration): a 6-10 s muted forward
|
||||
// move, encoded with a keyframe on every frame so it seeks cleanly
|
||||
// (ffmpeg -i in.mp4 -an -vf scale=1920:-2 -c:v libx264 -g 1 -crf 24 -movflags +faststart out.mp4),
|
||||
// and give it a poster for the finished composition.
|
||||
// Bake one grade into every card photo (same white balance, muted colour); no CSS filters.
|
||||
//
|
||||
// Options (data-options), defaults first:
|
||||
// inAir cards in the air at once. 3 (2-4)
|
||||
// phoneInAir cards in the air at once on a phone: two, so the passing card and the next (2-3)
|
||||
// one sit on opposite sides and never overlap. 2
|
||||
// hold screens the stage is held for. Default 0.36 / slot: 3.3 for 7 cards, 1.8 for 3,
|
||||
// 5.1 for 12 (slot = 1 / (cards + 0.88 inAir - 0.55)); a phone works it out from
|
||||
// phoneInAir (3.0 for 7 cards) (1.5-6)
|
||||
// sideX how far out the cards fly, as a share of the stage width. 0.32 (0.28-0.38)
|
||||
// endScale how large a card is when it leaves. 4 = the bold ghost pass at the edge, (2.5-5)
|
||||
// 2.5 = a calm drift. The phone keeps 2.5 at most.
|
||||
// yaw inward turn in degrees: the cards stand as the walls of a corridor. 8; (0-10)
|
||||
// 0 = flat cards facing the camera. A turned card straightens as it passes, so
|
||||
// its outer edge never comes nearer than 0.8 x perspective; a ghost turned further
|
||||
// than 10 degrees would cover so much of the frame that it could not pass solid.
|
||||
// perspective camera lens in px; lower is wider and faster at the edges. 300 (240-600)
|
||||
// title how much larger statement one stands at the start, as the scene's title. It (1-1.6)
|
||||
// settles to statement size as the first card reaches reading size, and never
|
||||
// grows wider than the stage. 1.3; 1 = no change
|
||||
// dolly backdrop scale as the camera moves in: how far it travels toward the focal (1.6-2)
|
||||
// object. 1.8 for a still; never under 1.6: a road, rail or jetty looks the same
|
||||
// at any zoom about its own vanishing point, so a smaller move leaves the first
|
||||
// and last frames alike and the camera seems not to move. A video: 1.08 (1-1.3),
|
||||
// as the footage moves itself. A tall stage uses 70% of the move. Serve a still at
|
||||
// the dolly x the stage width (2600 px for 1440) so the end of the move stays sharp
|
||||
// run how far the camera runs over the ground of a still backdrop: the share of (0-0.7)
|
||||
// the way to the ground at the bottom edge. 0.45; 0 = the dolly alone
|
||||
// arrive in the last 10% the camera eases off and lifts a little, a short arrival (true)
|
||||
// beat before the stage lets go (it keeps 95% of the dolly). true
|
||||
// haze fog left on a card at its nearest, to pull the photos into one grade. 0 (0-0.3)
|
||||
// count show "3 of 7" under the statement. true
|
||||
// phone 'same' (the fly-through with phone numbers) | 'rest' (the finished composition). 'same'
|
||||
// phoneMax more cards than this and a phone gets the finished composition. 9 (3-12)
|
||||
// phoneSideX the farthest out the cards fly on the low path (a phone, a tablet held (0.1-0.45)
|
||||
// upright). 0.33; within it each card flies just as far out as keeps it whole on
|
||||
// screen with its outer edge on the gutter at reading size
|
||||
// phoneDy drop below the vanishing point on a phone, in vh. Left out, each card drops (0-40)
|
||||
// so its top edge sits on --fly-ground at reading size and reaches the bottom of
|
||||
// the screen as its flight fades, so a passing card goes out solid
|
||||
// phoneYaw inward turn on a phone. 3 (0-10)
|
||||
// The script adds a visually hidden copy of the statements (ol.fly__sr) so a screen reader hears all of
|
||||
// them while only one is on screen; the visible list is aria-hidden only while the flight runs.
|
||||
// Page tokens it takes: --ink (statement), --font-display, --display-weight (statements and caption tags,
|
||||
// 500) and --text-weight (the count, 400): set them to weights the page loads, or the browser draws the
|
||||
// nearest weight it has (fake bold is off). Set per backdrop on the section (plain
|
||||
// `.fly { … }` wins; the scene's defaults sit in :where()):
|
||||
// --fly-stage (the backdrop's horizon colour; the ground of the finished wall), --fly-fog (haze
|
||||
// colour, defaults to --fly-stage), --fly-ink (statement colour on the photo; must reach 4.5:1 on the
|
||||
// band behind it), --fly-shade / --fly-scrim (a flat fill over the whole backdrop when white text needs
|
||||
// it), --fly-vp / --fly-focus / --fly-lift (where the vanishing point sits; see the CSS), --fly-ground and
|
||||
// --fly-focus-w (where the focal object stands and how wide it is), --fly-rest-vp / --fly-rest-ground (the
|
||||
// same two in the finished opening),
|
||||
// --fly-card-w, --fly-size (statement size), --fly-copy-top and --fly-copy-w (the statement box; see
|
||||
// the CSS for how wide it may go).
|
||||
(() => {
|
||||
const ID = 'depth-card-flythrough';
|
||||
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
|
||||
const num = (v, d, a, b) => (v === undefined || v === null || v === '' || Number.isNaN(+v) ? d : clamp(+v, a, b));
|
||||
const DY = [2, 0, 4, 1, 5, 0, 3];
|
||||
const DEG = 180 / Math.PI;
|
||||
const BACK = 0.05; // the arrival gives back this share of the dolly
|
||||
// a statement change, in seconds: the old line (and its card's caption) leaves in OUT_S, and only then
|
||||
// does the new one come in, over IN_S, so two statements never share the screen. Each moves only SLIDE
|
||||
// (% of its own height) as it fades, so it never slides over the count under it.
|
||||
const OUT_S = 0.2, IN_S = 0.45, SLIDE = 8;
|
||||
const r3 = (v) => Math.round(v * 1000) / 1000;
|
||||
if (window.ScrollTrigger) ScrollTrigger.config({ ignoreMobileResize: true });
|
||||
|
||||
window.Scenes = window.Scenes || {};
|
||||
window.Scenes[ID] = (root, opts = {}) => {
|
||||
if (!window.Stage) return null;
|
||||
const cards = [...root.querySelectorAll('.fly__card')];
|
||||
const lines = [...root.querySelectorAll('.fly__line')];
|
||||
const N = cards.length;
|
||||
const backdrop = root.querySelector('.fly__backdrop');
|
||||
const still = backdrop && backdrop.querySelector('.fly__backdrop-img');
|
||||
const video = backdrop && backdrop.querySelector('video');
|
||||
const field = root.querySelector('.fly__field');
|
||||
const copy = root.querySelector('.fly__copy');
|
||||
const countNow = root.querySelector('.fly__count-now');
|
||||
const countAll = root.querySelector('.fly__count-all');
|
||||
if (countAll) countAll.textContent = String(Math.max(N, lines.length));
|
||||
if (opts.count === false) root.classList.add('fly--no-count');
|
||||
// While the flight runs only one statement is visible; a visually hidden copy of the list keeps all
|
||||
// of them, in order, for screen readers (the CSS shows it only under .is-live).
|
||||
const list = root.querySelector('.fly__lines');
|
||||
if (list && !root.querySelector('.fly__sr')) {
|
||||
const sr = document.createElement('ol');
|
||||
sr.className = 'fly__sr';
|
||||
for (const l of lines) { const li = document.createElement('li'); li.textContent = l.textContent; sr.appendChild(li); }
|
||||
list.after(sr);
|
||||
}
|
||||
|
||||
// Captions in flight: a constant-size tag per card on a flat layer above the field, riding the
|
||||
// card's inner top corner. Text inside a scaling card is either too small to read or louder than
|
||||
// the statement; a tag is always 15-20px, sits on its card and never passes the screen edge. The
|
||||
// figcaption stays in the card (transparent while the flight runs) for screen readers and the
|
||||
// finished composition.
|
||||
let tagLayer = root.querySelector('.fly__tags');
|
||||
if (!tagLayer && field) {
|
||||
tagLayer = document.createElement('div');
|
||||
tagLayer.className = 'fly__tags';
|
||||
tagLayer.setAttribute('aria-hidden', 'true');
|
||||
for (const card of cards) {
|
||||
const cap = card.querySelector('.fly__cap');
|
||||
const tag = document.createElement('span');
|
||||
tag.className = 'fly__tag';
|
||||
tag.textContent = cap ? cap.textContent.trim() : '';
|
||||
tagLayer.appendChild(tag);
|
||||
}
|
||||
field.after(tagLayer);
|
||||
}
|
||||
const tags = tagLayer ? [...tagLayer.children] : [];
|
||||
// one caption gate per card (0-1), opened and closed with the card's statement
|
||||
const gates = cards.map(() => ({ v: 0 }));
|
||||
|
||||
// the clock: slot S between card starts, flight F = c slots, so c cards are in the air at once.
|
||||
// A phone runs its own clock (fewer cards in the air), so it gets its own hold.
|
||||
const clockOf = (phone) => {
|
||||
const c = phone ? num(opts.phoneInAir, 2, 2, 3) : num(opts.inAir, 3, 2, 4);
|
||||
const S = 1 / (N + 0.88 * c - 0.55);
|
||||
return { c, S, F: c * S, hold: num(opts.hold, clamp(0.36 / S, 1.5, 6), 1.5, 6) };
|
||||
};
|
||||
const desk = clockOf(false), mob = clockOf(true);
|
||||
const runOpt = video || !still ? 0 : num(opts.run, 0.45, 0, 0.7);
|
||||
|
||||
// The ground the camera runs over: a copy of the still backdrop, clipped to below the vanishing
|
||||
// point. A forward move maps the ground's picture by (x, y) / (1 - k·y) about the vanishing point
|
||||
// (one matrix3d), so the planks stream outward along their own lines while the horizon holds.
|
||||
// The scrim over a still is drawn into the picture's own layers (a span in the backdrop, painted under
|
||||
// the ground, and one in the ground), so while the flight runs no separate full-screen translucent
|
||||
// layer is blended over the stage on every frame; the page's .fly__scrim stays for the finished
|
||||
// composition. A video keeps the page's scrim.
|
||||
const shade = () => { const s = document.createElement('span'); s.className = 'fly__shade'; s.setAttribute('aria-hidden', 'true'); return s; };
|
||||
if (still && !video && !backdrop.querySelector(':scope > .fly__shade')) still.after(shade());
|
||||
if (still && !video) root.classList.add('fly--shaded');
|
||||
let ground = null;
|
||||
if (runOpt > 0) {
|
||||
ground = backdrop.querySelector('.fly__ground');
|
||||
if (!ground) {
|
||||
ground = document.createElement('div');
|
||||
ground.className = 'fly__ground';
|
||||
ground.setAttribute('aria-hidden', 'true');
|
||||
const pic = still.cloneNode(false);
|
||||
pic.className = 'fly__ground-img';
|
||||
pic.alt = '';
|
||||
pic.removeAttribute('fetchpriority');
|
||||
pic.removeAttribute('id');
|
||||
ground.append(pic, shade());
|
||||
backdrop.append(ground);
|
||||
}
|
||||
}
|
||||
|
||||
// a video backdrop is scrubbed: one seek at a time, the latest wanted time queued behind it
|
||||
const vid = { want: -1, busy: false, at: 0 };
|
||||
const seek = (p) => {
|
||||
if (!video || !(video.duration > 0)) return;
|
||||
const t = clamp(p, 0, 1) * Math.max(0, video.duration - 0.05);
|
||||
if (vid.busy && performance.now() - vid.at < 500) { vid.want = t; return; } // a lost 'seeked' frees it after 0.5 s
|
||||
if (Math.abs(video.currentTime - t) < 0.015) return;
|
||||
vid.busy = true; vid.want = -1; vid.at = performance.now();
|
||||
try { video.currentTime = t; } catch { vid.busy = false; }
|
||||
};
|
||||
let live = { p: 0 }; // the running build's clock
|
||||
if (video) {
|
||||
video.muted = true; video.playsInline = true;
|
||||
video.addEventListener('seeked', () => {
|
||||
vid.busy = false;
|
||||
if (vid.want >= 0) { const t = vid.want; vid.want = -1; if (Math.abs(video.currentTime - t) >= 0.015) { vid.busy = true; vid.at = performance.now(); video.currentTime = t; } }
|
||||
});
|
||||
video.addEventListener('loadedmetadata', () => { if (root.classList.contains('is-live')) seek(live.p); });
|
||||
}
|
||||
|
||||
// everything the script writes, cleared when the scene falls back to its finished composition
|
||||
const clean = () => {
|
||||
if (window.gsap) { gsap.killTweensOf(lines); gsap.killTweensOf(gates); }
|
||||
for (const g of gates) g.v = 0;
|
||||
for (const card of cards) {
|
||||
card.style.removeProperty('transform'); card.style.removeProperty('opacity'); card.style.removeProperty('visibility');
|
||||
card.style.removeProperty('will-change'); card.style.removeProperty('z-index');
|
||||
for (const el of card.querySelectorAll('.fly__fog, .fly__cap')) el.style.removeProperty('opacity');
|
||||
}
|
||||
for (const l of lines) for (const p of ['opacity', 'visibility', 'transform', 'translate', 'will-change']) l.style.removeProperty(p);
|
||||
for (const t of tags) { t.style.removeProperty('transform'); t.style.removeProperty('opacity'); }
|
||||
if (copy) { copy.style.removeProperty('transform'); copy.style.removeProperty('will-change'); }
|
||||
if (ground) ground.style.removeProperty('transform');
|
||||
if (list) list.removeAttribute('aria-hidden');
|
||||
if (field) field.style.removeProperty('perspective');
|
||||
};
|
||||
// Stage only calls rest() when one of its media conditions matches, so a desktop that switches to
|
||||
// reduced motion never gets it: watch for .is-live going away and clear the flight's inline styles.
|
||||
new MutationObserver(() => { if (!root.classList.contains('is-live')) clean(); })
|
||||
.observe(root, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
const build = (tl, ctx) => {
|
||||
const phone = ctx.phone;
|
||||
const stage = ctx.stage;
|
||||
const { S, F } = phone ? mob : desk;
|
||||
const P = num(opts.perspective, 300, 240, 600);
|
||||
const sEnd = Math.min(phone ? 2.5 : 5, num(opts.endScale, 4, 2.5, 5));
|
||||
const zStart = -2 * P; // scale 1/3: a faint chip at the vanishing point
|
||||
const zEnd = P * (1 - 1 / sEnd); // 0.8P at most: a card's centre never reaches the camera
|
||||
const zSafe = 0.8 * P; // nor does any point of it, the turned outer edge included
|
||||
const sideX = phone ? num(opts.phoneSideX, 0.33, 0.1, 0.45) : num(opts.sideX, 0.32, 0.28, 0.38);
|
||||
// at most 10 degrees: a ghost turned further covers so much of the frame that it would have to pass
|
||||
// half-transparent, and a see-through card over another reads as a double exposure
|
||||
const yaw = phone ? num(opts.phoneYaw, 3, 0, 10) : num(opts.yaw, 8, 0, 10);
|
||||
const sinYaw = Math.sin(yaw / DEG);
|
||||
const haze = num(opts.haze, 0, 0, 0.3);
|
||||
// a still needs a move the eye sees between the first frame and the last: 1.6 at least
|
||||
const D = video ? num(opts.dolly, 1.08, 1, 1.3) : num(opts.dolly, 1.8, 1.6, 2);
|
||||
// a tall stage (a phone, a tablet held upright) shows the focal object large already: 70% of the
|
||||
// move, so it does not grow up into the statement
|
||||
const tall = phone || stage.clientHeight > 1.15 * stage.clientWidth;
|
||||
const dolly = tall ? 1 + (D - 1) * 0.7 : D;
|
||||
const run = runOpt * (phone ? 0.85 : 1);
|
||||
const arrive = opts.arrive !== false;
|
||||
const titleOpt = num(opts.title, 1.3, 1, 1.6);
|
||||
const settle = gsap.parseEase('power2.inOut');
|
||||
// the backdrop's scale at progress p (the dolly below, worked out here so the cards can keep clear
|
||||
// of the focal object as it grows)
|
||||
const backAt = (p) => (!arrive ? 1 + (dolly - 1) * p : p < 0.9 ? 1 + ((dolly - 1) * p) / 0.9 : dolly - (dolly - 1) * BACK * settle((p - 0.9) / 0.1));
|
||||
// within one flight t (0-1): a far card comes in solid, under a haze that lifts as it nears (fog, not
|
||||
// transparency: a half-transparent card reads as a double exposure); the caption tag shows with the
|
||||
// card's statement, from the moment the card reaches reading size (S_READ, in card scale), and leaves
|
||||
// with it, or as the card grows past reading size (CAP_OUT), so a ghost passes as picture only. The
|
||||
// card stays solid while it sweeps past the edge and fades only as its last strip leaves the screen.
|
||||
const IN = 0.02, FOG = 0.45, FOG0 = 0.82, CAP_LEN = phone ? 0.06 : 0.1, OUT = 0.96;
|
||||
const S_READ = phone ? 0.56 : 0.57;
|
||||
const CAP_OUT = phone ? [1.3, 1.55] : [1.15, 1.35];
|
||||
const S_LOW = 0.92; // the low path lays a card out whole at this scale
|
||||
const tAt = (s) => (P * (1 - 1 / s) - zStart) / (zEnd - zStart); // flight time at which a card reaches scale s
|
||||
// a turned card's outer edge is nearer than its centre: straighten it as it passes, so no point of
|
||||
// the card comes closer than 0.8P (behind the camera a card flips or vanishes)
|
||||
const turnAt = (z, half) => (yaw && half && zSafe - z < half * sinYaw ? Math.asin(clamp((zSafe - z) / half, 0, 1)) * DEG : yaw);
|
||||
// statement k comes in as card k reaches reading size, or as card k-1 leaves if that is sooner, so
|
||||
// a statement never outlives its picture; it holds until statement k+1 takes over the same way
|
||||
const CAP = clamp(tAt(S_READ), 0.3, 0.7);
|
||||
const swOff = Math.min(OUT * F - S, CAP * F);
|
||||
let title = 1, titleEnd = 0.12;
|
||||
|
||||
clean();
|
||||
if (field) field.style.perspective = P + 'px';
|
||||
if (list) list.setAttribute('aria-hidden', 'true'); // the visually hidden copy speaks for it
|
||||
|
||||
const geo = cards.map((card, i) => {
|
||||
const ds = parseFloat(card.dataset.side);
|
||||
const side = ds === 1 || ds === -1 ? ds : (i % 2 ? 1 : -1);
|
||||
card.dataset.side = String(side);
|
||||
const raw = parseFloat(getComputedStyle(card).getPropertyValue('--dy'));
|
||||
// a phone's cards fly low and leave through the bottom edge (measure() works out the drop unless
|
||||
// phoneDy sets it), so a passing card never hangs half-transparent over the picture
|
||||
const dy = phone && opts.phoneDy !== undefined ? num(opts.phoneDy, 20, 0, 40) : clamp(Number.isFinite(raw) ? raw : DY[i % DY.length], 0, 8);
|
||||
card.style.zIndex = String(N - i); // the earlier card is always the nearer one
|
||||
const tag = tags[i] && tags[i].textContent ? tags[i] : null;
|
||||
// paired: the card has a statement of its own, and its tag shows only while that statement does
|
||||
return { card, fog: card.querySelector('.fly__fog'), tag, gate: gates[i], paired: i < lines.length, side, dy, a: S * i - 0.12 * F, half: 0, halfH: 0, ox: 0, x1: 0, y0: 0, y1: 0, tL: 1, tagW: 0, tagH: 0, vis: null, o: -1, f: -1, k: -1, kg: 0 };
|
||||
});
|
||||
// a tag's opacity: where its card is (on screen, at reading size) times its statement's gate
|
||||
const paint = (g) => {
|
||||
if (!g.tag) return;
|
||||
const k = r3(g.kg * (g.paired ? g.gate.v : 1));
|
||||
if (k !== g.k) { g.tag.style.opacity = String(k); g.k = k; }
|
||||
};
|
||||
|
||||
// Two paths. On a wide stage the cards fly beside the focal object (the boathouse, the mast) on
|
||||
// alternating sides, and a far card is held just outside it (--fly-focus-w) until it has grown clear,
|
||||
// so no card ever crosses the thing the picture is about. On a tall stage (a phone, a tablet held
|
||||
// upright) the focal object fills the width, so the cards fly low instead: each comes out at its foot,
|
||||
// grows to nearly the stage width just below the ground line (--fly-ground), whole and on screen, and
|
||||
// leaves through the bottom as a big pass.
|
||||
let W = 0, H = 0, VP = 0, GH = 1, GY = 0, FW = 0, low = false, gk = -1, ts = -1;
|
||||
const measure = () => {
|
||||
W = stage.clientWidth || window.innerWidth; H = stage.clientHeight || window.innerHeight;
|
||||
VP = field ? parseFloat(getComputedStyle(field).perspectiveOrigin.split(' ')[1]) : NaN;
|
||||
if (!Number.isFinite(VP)) VP = H * 0.56;
|
||||
low = phone || H > 1.15 * W;
|
||||
// --fly-focus-w: the focal object's half-width at dolly 1, as a length, or as a plain number for a
|
||||
// share of the picture's width (it then follows the picture's crop at any screen size).
|
||||
// --fly-ground: where the focal object stands, from the top of the stage.
|
||||
const cs = getComputedStyle(root);
|
||||
const fwRaw = cs.getPropertyValue('--fly-focus-w').trim();
|
||||
const probe = document.createElement('div');
|
||||
probe.style.cssText = 'position:absolute;left:0;top:0;visibility:hidden;pointer-events:none;height:var(--fly-ground,0px);width:' + (fwRaw && !/^[\d.]+$/.test(fwRaw) ? fwRaw : '0px');
|
||||
stage.appendChild(probe);
|
||||
const lift = backdrop ? Math.max(0, backdrop.offsetHeight - H) : 0;
|
||||
GY = probe.offsetHeight || VP;
|
||||
FW = probe.offsetWidth;
|
||||
probe.remove();
|
||||
if (/^[\d.]+$/.test(fwRaw) && backdrop) {
|
||||
const media = still || video;
|
||||
const nw = (media && (media.naturalWidth || media.videoWidth)) || +(media && media.getAttribute('width')) || 16;
|
||||
const nh = (media && (media.naturalHeight || media.videoHeight)) || +(media && media.getAttribute('height')) || 10;
|
||||
FW = clamp(+fwRaw, 0, 0.4) * nw * Math.max(backdrop.offsetWidth / nw, (H + lift) / nh);
|
||||
}
|
||||
// the streaming ground runs from its own line (--fly-ground, the vanishing point unless the focal
|
||||
// object stands below it) to the bottom edge
|
||||
if (ground) {
|
||||
const o = parseFloat(getComputedStyle(ground).transformOrigin.split(' ')[1]);
|
||||
GH = Math.max(1, (ground.offsetHeight || H) - (Number.isFinite(o) ? o : VP));
|
||||
}
|
||||
// Every card is off the screen by the time its flight fades (OUT), so no card ever fades in view
|
||||
// over another: if its own course would not take it out, it veers out over the last part of the
|
||||
// flight, through the side on the wide path and through the bottom on the low path.
|
||||
const gutter = W < 768 ? 14 : 0.03 * W, zOut = zStart + (zEnd - zStart) * OUT, sOut = P / (P - zOut);
|
||||
for (const g of geo) {
|
||||
g.half = g.card.offsetWidth / 2; g.halfH = g.card.offsetHeight / 2;
|
||||
if (!low) {
|
||||
g.ox = sideX * W; g.y0 = g.y1 = (g.dy * H) / 100;
|
||||
// the inner edge as projected at OUT, turn included (a turned card's inner edge stands back)
|
||||
const yo = turnAt(zOut, g.half) / DEG, spIn = P / (P - (zOut - g.half * Math.sin(yo)));
|
||||
g.x1 = Math.max(g.ox, (W / 2 + 4) / spIn + g.half * Math.cos(yo));
|
||||
g.tL = clamp(tAt(1), 0, OUT - 0.05);
|
||||
} else {
|
||||
// the low path: whole and on screen at S_LOW, its outer edge on the gutter and its top edge on
|
||||
// the ground line (which the dolly lowers as it grows); after that it drops a little faster, so
|
||||
// its top edge reaches the bottom of the screen as the flight fades
|
||||
g.ox = g.x1 = clamp((W / 2 - gutter) / S_LOW - g.half, 0, sideX * W);
|
||||
g.tL = clamp(tAt(S_LOW), 0, OUT - 0.05);
|
||||
const BL = backAt(clamp(g.a + g.tL * F, 0, 1));
|
||||
g.y0 = phone && opts.phoneDy !== undefined ? (g.dy * H) / 100 : g.halfH + (Math.max(0, (GY - VP) * BL) + 0.01 * H) / S_LOW;
|
||||
g.y1 = Math.max(g.y0, g.halfH + (H - VP + 4) / sOut);
|
||||
}
|
||||
if (g.tag) { g.tagW = g.tag.offsetWidth; g.tagH = g.tag.offsetHeight; }
|
||||
}
|
||||
// the title: statement one larger at the start, never wider than the stage less its gutters
|
||||
title = 1;
|
||||
if (titleOpt > 1 && lines[0]) {
|
||||
if (copy) copy.style.transform = ''; // measured at statement size; render() puts the scale back
|
||||
const rg = document.createRange(); rg.selectNodeContents(lines[0]);
|
||||
const tw = rg.getBoundingClientRect().width;
|
||||
title = tw > 0 ? clamp((W - 48) / tw, 1, titleOpt) : 1;
|
||||
}
|
||||
titleEnd = Math.max(0.04, geo[0] ? geo[0].a + CAP * F : 0.12);
|
||||
gk = -1; ts = -1;
|
||||
};
|
||||
// the camera's run over the ground: linear (constant speed) to 0.9, then it eases back a little
|
||||
// as the camera settles and lifts for the arrival
|
||||
const runAt = (p) => (!arrive ? run * p : p < 0.9 ? (run * p) / 0.9 : run * (1 - 0.2 * settle((p - 0.9) / 0.1)));
|
||||
|
||||
const render = (p) => {
|
||||
const fwB = FW * backAt(p), soft = 0.02 * W;
|
||||
for (const g of geo) {
|
||||
const t = (p - g.a) / F;
|
||||
if (t <= 0 || t >= 1) {
|
||||
if (g.vis !== false) { g.card.style.visibility = 'hidden'; g.card.style.opacity = '0'; g.card.style.willChange = 'auto'; g.vis = false; g.o = -1; }
|
||||
g.kg = 0; paint(g);
|
||||
continue;
|
||||
}
|
||||
if (g.vis !== true) { g.card.style.visibility = 'visible'; g.card.style.willChange = 'transform, opacity'; g.vis = true; }
|
||||
const z = zStart + (zEnd - zStart) * t; // linear z = constant camera speed; perspective does the rest
|
||||
const s = P / (P - z);
|
||||
const e = t > g.tL ? Math.min(1, (t - g.tL) / (OUT - g.tL)) ** 2 : 0;
|
||||
let x = g.side * (g.ox + (g.x1 - g.ox) * e);
|
||||
const y = g.y0 + (g.y1 - g.y0) * e;
|
||||
const yw = turnAt(z, g.half);
|
||||
// the card's inner edge on screen, projected as the card is (turn included)
|
||||
const a = (-g.side * yw) / DEG, cx = -g.side * g.half;
|
||||
let X = x + cx * Math.cos(a);
|
||||
const Z = z - cx * Math.sin(a), sp = P / (P - Z);
|
||||
// beside the focal object: hold the inner edge outside it (its half-width grows with the dolly)
|
||||
// until the card has grown clear of it; a soft max, so the card eases off its edge with no kink
|
||||
if (!low && FW > 0) {
|
||||
const d = fwB - g.side * X * sp;
|
||||
const push = (d + Math.sqrt(d * d + soft * soft)) / 2 / sp;
|
||||
x += g.side * push; X += g.side * push;
|
||||
}
|
||||
const sx = W / 2 + X * sp;
|
||||
g.card.style.transform = `translate(-50%, -50%) translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, ${z.toFixed(2)}px)` + (yw ? ` rotateY(${(-g.side * yw).toFixed(2)}deg)` : '');
|
||||
// on its way out the card stays solid while it sweeps past the side (or, on the low path, the
|
||||
// bottom) and fades only over its last sliver on screen (15% of it, 3% of the screen at most), so
|
||||
// a near card never hangs at the edge as a see-through slab; the time fade at the very end of the
|
||||
// flight is the backstop
|
||||
const top = VP + (y - g.halfH) * s;
|
||||
const fadeW = Math.min(0.3 * g.half * s, 0.03 * W), fadeH = Math.min(0.3 * g.halfH * s, 0.03 * H);
|
||||
const edge = s > 1 ? Math.min(clamp((g.side < 0 ? sx : W - sx) / fadeW, 0, 1), clamp((H - top) / fadeH, 0, 1)) : 1;
|
||||
const u = (t > OUT ? 1 - (t - OUT) / (1 - OUT) : 1) * edge;
|
||||
const o = r3(Math.min(1, t / IN) * u);
|
||||
if (o !== g.o) { g.card.style.opacity = String(o); g.o = o; }
|
||||
const f = r3(haze + (FOG0 - haze) * Math.max(0, 1 - t / FOG));
|
||||
if (g.fog && f !== g.f) { g.fog.style.opacity = String(f); g.f = f; }
|
||||
// the caption tag: on with its statement (the gate, a short tween in time), off with it or as the
|
||||
// card grows past reading size. A card with no statement of its own shows its tag from reading
|
||||
// size. It sits on the card's inner top corner, projected as the card is (turn included), and runs
|
||||
// outward along the top edge; over its last 20px before the screen edge it fades rather than be
|
||||
// read as a cut word.
|
||||
if (g.tag) {
|
||||
// in by reading size (a paired tag waits for its statement too), out past it
|
||||
let k = clamp((t - CAP) / CAP_LEN + (g.paired ? 1 : 0), 0, 1) * clamp((CAP_OUT[1] - s) / (CAP_OUT[1] - CAP_OUT[0]), 0, 1);
|
||||
let tx = 0, ty = 0;
|
||||
if (k > 0) {
|
||||
const sy = VP + (y - g.halfH) * sp;
|
||||
tx = g.side < 0 ? sx - g.tagW : sx; ty = sy - g.tagH;
|
||||
// near the screen edge a long tag slides up to 16px in along the card's top edge, then fades
|
||||
const room = g.side < 0 ? tx - 8 : W - 8 - tx - g.tagW;
|
||||
if (room < 0) tx += g.side * Math.max(room, -16);
|
||||
k *= clamp((room + 36) / 20, 0, 1);
|
||||
}
|
||||
g.kg = k;
|
||||
if (k > 0) g.tag.style.transform = `translate3d(${Math.round(tx)}px, ${Math.round(ty)}px, 0)`; // whole pixels keep the type sharp
|
||||
paint(g);
|
||||
}
|
||||
}
|
||||
if (copy) {
|
||||
const v = title > 1 ? r3(1 + (title - 1) * (1 - clamp(p / titleEnd, 0, 1))) : 1;
|
||||
// its own layer only while the title settles (no repaint per frame), then drawn sharp again
|
||||
if (v !== ts) { copy.style.transform = v === 1 ? '' : `scale(${v})`; copy.style.willChange = v === 1 ? '' : 'transform'; ts = v; }
|
||||
}
|
||||
if (ground) {
|
||||
const k = Math.round((runAt(p) / GH) * 1e8) / 1e8;
|
||||
if (k !== gk) { ground.style.transform = `matrix3d(1,0,0,0,0,1,0,${-k},0,0,1,0,0,0,0,1)`; gk = k; }
|
||||
}
|
||||
if (video) seek(p);
|
||||
};
|
||||
|
||||
// statements: a state change, never a scrubbed crossfade. Statement k takes over at a_k + swOff:
|
||||
// as card k's caption is fully on, or as card k-1 leaves if that comes first.
|
||||
let cur = -1, lastP = 0, countCall = null;
|
||||
const last = Math.max(0, lines.length - 1);
|
||||
const lineAt = (p) => clamp(Math.floor((p + 0.12 * F - swOff) / S + 1e-6), 0, last);
|
||||
const setCount = (k) => { if (countNow) countNow.textContent = String(k + 1); };
|
||||
// each card's caption gate follows its statement: open while statement i is the one on screen
|
||||
const gateTo = (k, animate) => geo.forEach((g, i) => {
|
||||
const v = i === k ? 1 : 0;
|
||||
if (!animate) { gsap.killTweensOf(g.gate); g.gate.v = v; paint(g); return; }
|
||||
if (g.gate.v === v && !gsap.isTweening(g.gate)) return;
|
||||
gsap.to(g.gate, v
|
||||
? { v, duration: IN_S, ease: 'power2.out', delay: OUT_S, overwrite: true, onUpdate: () => paint(g) }
|
||||
: { v, duration: OUT_S, ease: 'power2.in', overwrite: true, onUpdate: () => paint(g) });
|
||||
});
|
||||
const force = (k) => { // set the line with no animation (a load, a refresh, a jump)
|
||||
if (!lines.length) return;
|
||||
cur = k;
|
||||
lines.forEach((l, i) => { gsap.killTweensOf(l); gsap.set(l, { autoAlpha: i === k ? 1 : 0, yPercent: 0 }); l.style.willChange = ''; });
|
||||
gateTo(k, false);
|
||||
if (countCall) countCall.kill();
|
||||
setCount(k);
|
||||
};
|
||||
// a line is its own layer only while it moves, so a swap is drawn once rather than every frame
|
||||
const lift = (l) => { l.style.willChange = 'transform, opacity'; return () => { l.style.willChange = ''; }; };
|
||||
// out, then in: the old line (and every other) is gone after OUT_S, and only then does the new one
|
||||
// start, so two statements never share the screen; the captions change with them
|
||||
const show = (k, dir) => {
|
||||
const prev = cur; cur = k;
|
||||
lines.forEach((l, i) => { if (i !== k && i !== prev && +gsap.getProperty(l, 'opacity') > 0) gsap.to(l, { autoAlpha: 0, duration: OUT_S, overwrite: true }); });
|
||||
gsap.to(lines[prev], { yPercent: -SLIDE * dir, autoAlpha: 0, duration: OUT_S, ease: 'power2.in', overwrite: true, onComplete: lift(lines[prev]) });
|
||||
gsap.fromTo(lines[k], { yPercent: SLIDE * dir, autoAlpha: 0 }, { yPercent: 0, autoAlpha: 1, duration: IN_S, ease: 'power3.out', delay: OUT_S, overwrite: true, onComplete: lift(lines[k]) });
|
||||
gateTo(k, true);
|
||||
if (countCall) countCall.kill();
|
||||
countCall = gsap.delayedCall(OUT_S, () => setCount(k));
|
||||
};
|
||||
const sync = (p) => {
|
||||
const k = lineAt(p);
|
||||
const jump = Math.abs(p - lastP) > 2 * S;
|
||||
const dir = p >= lastP ? 1 : -1;
|
||||
lastP = p;
|
||||
if (k === cur || !lines.length) return;
|
||||
if (jump || cur < 0) force(k); else show(k, dir);
|
||||
};
|
||||
|
||||
// the camera: the backdrop dollies in toward the vanishing point, then pulls back and lifts
|
||||
if (backdrop) {
|
||||
if (arrive) {
|
||||
tl.fromTo(backdrop, { scale: 1, yPercent: 0 }, { scale: dolly, duration: 0.9, ease: 'none' }, 0);
|
||||
// the arrival: the camera eases back a little and lifts, a settle rather than a retreat
|
||||
tl.fromTo(backdrop, { scale: dolly, yPercent: 0 }, { scale: dolly - (dolly - 1) * BACK, yPercent: -2, duration: 0.1, ease: 'power2.inOut', immediateRender: false }, 0.9);
|
||||
} else {
|
||||
tl.fromTo(backdrop, { scale: 1, yPercent: 0 }, { scale: dolly, duration: 1, ease: 'none' }, 0);
|
||||
}
|
||||
}
|
||||
// one clock drives every card, the title and the ground from the scene's progress, so scrolling
|
||||
// back replays it exactly
|
||||
const clock = { p: 0 };
|
||||
live = clock;
|
||||
tl.fromTo(clock, { p: 0 }, { p: 1, duration: 1, ease: 'none', immediateRender: false, onUpdate: () => { render(clock.p); sync(clock.p); } }, 0);
|
||||
|
||||
// Pictures decode before they move, and every card layer is drawn once, invisibly, at reading size
|
||||
// before its first flight, so no card meets the screen as a first-time decode and upload. It starts
|
||||
// when the section is within two screens, one card per frame.
|
||||
let warm = false;
|
||||
const prewarm = () => {
|
||||
let i = 0;
|
||||
const step = () => {
|
||||
if (!root.classList.contains('is-live') || live !== clock) return;
|
||||
if (i >= geo.length) return;
|
||||
const g = geo[i++];
|
||||
if (g.vis === true) { requestAnimationFrame(step); return; } // already flying
|
||||
g.card.style.willChange = 'transform, opacity';
|
||||
g.card.style.transform = `translate(-50%, -50%) translate3d(${(g.side * g.ox).toFixed(1)}px, ${g.y0.toFixed(1)}px, 0px)`;
|
||||
g.card.style.opacity = '0.01'; g.card.style.visibility = 'visible'; g.vis = 'warm';
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (g.vis === 'warm') { g.card.style.visibility = 'hidden'; g.card.style.opacity = '0'; g.card.style.willChange = 'auto'; g.vis = false; g.o = -1; }
|
||||
step();
|
||||
}));
|
||||
};
|
||||
step();
|
||||
};
|
||||
const warmUp = () => {
|
||||
if (warm) return; warm = true;
|
||||
const imgs = [...root.querySelectorAll('img')];
|
||||
for (const img of imgs) img.loading = 'eager';
|
||||
const cardImgs = imgs.filter((img) => img.closest('.fly__card'));
|
||||
Promise.all(cardImgs.map((img) => (img.decode ? img.decode().catch(() => {}) : null))).then(() => requestAnimationFrame(prewarm));
|
||||
if (still && still.decode) still.decode().catch(() => {});
|
||||
if (video) {
|
||||
video.preload = 'auto';
|
||||
// iOS paints no frame of a video until it has played once
|
||||
const go = video.play && video.play();
|
||||
if (go && go.then) go.then(() => { video.pause(); seek(clock.p); }).catch(() => {});
|
||||
}
|
||||
};
|
||||
ScrollTrigger.create({
|
||||
trigger: root, start: 'top bottom+=200%', end: 'bottom top',
|
||||
onToggle: (self) => { if (self.isActive) warmUp(); },
|
||||
// a resize or a rebuild: measure again and put cards, title, ground and statement where the clock is
|
||||
onRefresh: () => { measure(); render(clock.p); lastP = clock.p; if (root.classList.contains('is-live')) force(lineAt(clock.p)); },
|
||||
});
|
||||
|
||||
measure();
|
||||
render(0);
|
||||
// statement state is set outside the matchMedia context: a ScrollTrigger refresh reverts what the
|
||||
// context recorded, which would undo an in-context gsap.set on the lines
|
||||
Promise.resolve().then(() => {
|
||||
if (!root.classList.contains('is-live')) return;
|
||||
lastP = clock.p;
|
||||
force(lineAt(clock.p));
|
||||
});
|
||||
};
|
||||
|
||||
const tooMany = N > num(opts.phoneMax, 9, 3, 12);
|
||||
return Stage.scene(root, {
|
||||
hold: desk.hold,
|
||||
scrub: Stage.lenis ? true : 0.5,
|
||||
build: N ? build : null,
|
||||
phone: opts.phone === 'rest' || tooMany ? 'rest' : { hold: mob.hold, build: N ? build : null },
|
||||
rest: clean,
|
||||
});
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,369 @@
|
||||
/* tasteskill / scenes / descend-through-weather.css — the hero sinks through a cloud deck onto the place below.
|
||||
By default (no script, reduced motion, a failed CDN, a phone set to 'rest') the section reads top to
|
||||
bottom as a finished composition: the view above the cloud with the title and tagline, a seam of mist
|
||||
carrying the note, then the place below with the statement over its dark ground. With the script the
|
||||
seam's edges are keyed cloud (the deck, parked in the seam); without it, a soft flat band. Under
|
||||
.is-live the stage is held and the script raises the cloud layers and the deck (see the .js file for
|
||||
the DOM and the options).
|
||||
Tokens: --mist (cloud and deck), --on-mist (the note on the white), --on-above (the title on the first
|
||||
photograph, #fff), --on-sky (the tagline, --on-mist by default), --ground (the ground the scene lands
|
||||
on; the next section uses it), --on-ground (statement), --font-display, --font-text, --above-pos and
|
||||
--below-pos (object-position of the two photographs), --title-stretch (font-stretch of the title),
|
||||
--wx-letters (letters in the title, 10 by default: sizes the title when there is no script),
|
||||
--tagline-top / --phone-tagline-top (46svh / 54svh: put the tagline on the calmest part of the sky).
|
||||
No gradients: every soft edge is a keyed photograph of cloud or a blurred flat fill.
|
||||
Weights follow the page: --display-weight (the title, the note and the statement; 700 and 600 here
|
||||
when the page sets none). A weight the page has not loaded is never faked: the nearest loaded one
|
||||
shows. */
|
||||
|
||||
[data-scene="descend-through-weather"] { font-synthesis-weight: none; }
|
||||
|
||||
[data-scene="descend-through-weather"] {
|
||||
--wx-mist: var(--mist, #eef2f4);
|
||||
--wx-on-mist: var(--on-mist, #1c2329);
|
||||
--wx-on-above: var(--on-above, #fff);
|
||||
--wx-on-sky: var(--on-sky, var(--wx-on-mist));
|
||||
--wx-ground: var(--ground, #12201e);
|
||||
--wx-on-ground: var(--on-ground, #f4f1ea);
|
||||
--wx-display: var(--font-display, inherit);
|
||||
--wx-text: var(--font-text, inherit);
|
||||
--wx-feather: 60svh;
|
||||
--wx-blur: 14px;
|
||||
--wx-seam: 36svh; /* half the seam of the finished composition */
|
||||
position: relative;
|
||||
background: var(--wx-ground);
|
||||
color: var(--wx-on-mist);
|
||||
}
|
||||
|
||||
/* ---------- the finished composition ---------- */
|
||||
|
||||
[data-scene="descend-through-weather"] > .scene-stage {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
/* hero screen, the seam's two halves, landing screen: each photograph runs on under half of the seam,
|
||||
so the band of mist always has a picture under its soft edges */
|
||||
grid-template-rows: 100svh var(--wx-seam) var(--wx-seam) 100svh;
|
||||
overflow: clip;
|
||||
background: var(--wx-ground);
|
||||
}
|
||||
[data-scene="descend-through-weather"] .wx-above,
|
||||
[data-scene="descend-through-weather"] .wx-below {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
[data-scene="descend-through-weather"] .wx-above { grid-area: 1 / 1 / 3 / 2; background: var(--wx-mist); }
|
||||
[data-scene="descend-through-weather"] .wx-below { grid-area: 3 / 1 / 5 / 2; background: var(--wx-ground); }
|
||||
[data-scene="descend-through-weather"] .wx-above picture,
|
||||
[data-scene="descend-through-weather"] .wx-below picture { display: contents; }
|
||||
[data-scene="descend-through-weather"] .wx-above img,
|
||||
[data-scene="descend-through-weather"] .wx-below img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
[data-scene="descend-through-weather"] .wx-above img { object-position: var(--above-pos, 50% 50%); }
|
||||
[data-scene="descend-through-weather"] .wx-below img { object-position: var(--below-pos, 50% 50%); }
|
||||
|
||||
/* the hero: title along the bottom, tagline in the clear sky above it */
|
||||
[data-scene="descend-through-weather"] .wx-hero {
|
||||
grid-area: 1 / 1;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
}
|
||||
[data-scene="descend-through-weather"] .wx-title {
|
||||
position: absolute;
|
||||
left: 3vw;
|
||||
right: 3vw;
|
||||
bottom: 5svh;
|
||||
margin: 0;
|
||||
font-family: var(--wx-display);
|
||||
font-weight: var(--display-weight, 700);
|
||||
font-stretch: var(--title-stretch, 75%);
|
||||
/* the script fits the word to the width (--wx-title-size); without it the size is worked out from
|
||||
--wx-letters (the word's length, 10 by default, so any 4-10 letter word fits inside 94vw) */
|
||||
font-size: var(--wx-title-size, min(300px, calc(94vw / (var(--wx-letters, 10) * 0.72))));
|
||||
line-height: 0.82;
|
||||
letter-spacing: -0.01em;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
color: var(--wx-on-above);
|
||||
}
|
||||
[data-scene="descend-through-weather"] .wx-title__word { display: inline-block; }
|
||||
[data-scene="descend-through-weather"] .wx-title__ghost { display: none; }
|
||||
[data-scene="descend-through-weather"] .wx-tagline {
|
||||
position: absolute;
|
||||
left: 6vw;
|
||||
top: var(--tagline-top, 46svh); /* clear of the title as it rises, until the tagline has faded */
|
||||
max-width: 25ch;
|
||||
margin: 0;
|
||||
font-family: var(--wx-text);
|
||||
font-size: clamp(18px, 1.35vw, 21px);
|
||||
line-height: 1.45;
|
||||
text-wrap: pretty;
|
||||
color: var(--wx-on-sky);
|
||||
}
|
||||
|
||||
/* the seam of mist between the two pictures, and the one line it carries, in the display face */
|
||||
[data-scene="descend-through-weather"] .wx-note {
|
||||
grid-area: 2 / 1 / 4 / 2;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0;
|
||||
padding: 0 6vw;
|
||||
font-family: var(--wx-display);
|
||||
font-weight: var(--display-weight, 600);
|
||||
font-stretch: 90%;
|
||||
font-size: clamp(34px, 4.6vw, 84px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
text-align: center;
|
||||
text-wrap: balance;
|
||||
color: var(--wx-on-mist);
|
||||
}
|
||||
/* without the script: a blurred flat fill, never a gradient */
|
||||
[data-scene="descend-through-weather"] .wx-note::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
left: -60%;
|
||||
right: -60%;
|
||||
top: calc(var(--wx-seam) * 0.36);
|
||||
bottom: calc(var(--wx-seam) * 0.36);
|
||||
background: var(--wx-mist);
|
||||
filter: blur(2.4svh);
|
||||
}
|
||||
/* with the script: the deck parks in the seam, keyed cloud tops above, the cloud turned over below */
|
||||
[data-scene="descend-through-weather"]:not(.is-live) .wx-deck {
|
||||
--wx-feather: calc(var(--wx-seam) * 0.8);
|
||||
--wx-body: calc(var(--wx-seam) * 0.4);
|
||||
display: block;
|
||||
visibility: hidden;
|
||||
grid-area: 2 / 1 / 4 / 2;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* the parked deck does not move, so its cloud needs no spare width: less stretch on the art */
|
||||
[data-scene="descend-through-weather"]:not(.is-live) :is(.wx-deck__top, .wx-deck__bottom) { left: -10vw; width: 120vw; }
|
||||
[data-scene="descend-through-weather"].wx-art:not(.is-live) .wx-deck { visibility: visible; }
|
||||
[data-scene="descend-through-weather"].wx-art:not(.is-live) .wx-note::before { display: none; }
|
||||
[data-scene="descend-through-weather"]:not(.is-live) .wx-deck__soft { display: none; }
|
||||
|
||||
/* the place below: the ground band darkens its foot so the statement reads and the next section joins */
|
||||
[data-scene="descend-through-weather"] .wx-below::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -60%;
|
||||
right: -60%;
|
||||
top: 76%;
|
||||
bottom: -40%;
|
||||
background: var(--wx-ground);
|
||||
filter: blur(9svh);
|
||||
}
|
||||
[data-scene="descend-through-weather"] .wx-statement {
|
||||
grid-area: 4 / 1;
|
||||
align-self: end;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
margin: 0 6vw 9svh;
|
||||
max-width: 88vw;
|
||||
font-family: var(--wx-display);
|
||||
font-weight: var(--display-weight, 600);
|
||||
font-stretch: 90%;
|
||||
font-size: clamp(40px, 4.6vw, 76px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--wx-on-ground);
|
||||
}
|
||||
[data-scene="descend-through-weather"] .wx-line {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
padding-bottom: 0.1em;
|
||||
margin-bottom: -0.1em;
|
||||
}
|
||||
[data-scene="descend-through-weather"] .wx-line__in { display: block; }
|
||||
|
||||
/* the script's layers exist only while the scene runs */
|
||||
[data-scene="descend-through-weather"] .wx-layer { display: none; }
|
||||
|
||||
/* ---------- live: the held stage ---------- */
|
||||
|
||||
[data-scene="descend-through-weather"].is-live > .scene-stage {
|
||||
display: block;
|
||||
background: var(--wx-mist);
|
||||
}
|
||||
[data-scene="descend-through-weather"].is-live .wx-above,
|
||||
[data-scene="descend-through-weather"].is-live .wx-below {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -1px;
|
||||
width: 100%;
|
||||
height: calc(100% + 2px);
|
||||
}
|
||||
/* photo A zooms from a quarter down its height: its foot then grows faster than it drifts up */
|
||||
[data-scene="descend-through-weather"].is-live .wx-above img { transform-origin: 50% 25%; }
|
||||
[data-scene="descend-through-weather"].is-live .wx-below { z-index: 1; }
|
||||
[data-scene="descend-through-weather"].is-live .wx-haze { z-index: 2; }
|
||||
[data-scene="descend-through-weather"].is-live .wx-ground { z-index: 3; }
|
||||
[data-scene="descend-through-weather"].is-live .wx-above { z-index: 4; }
|
||||
[data-scene="descend-through-weather"].is-live .wx-hero { position: absolute; inset: 0; z-index: 5; }
|
||||
[data-scene="descend-through-weather"].is-live .wx-cloud--far { z-index: 6; }
|
||||
[data-scene="descend-through-weather"].is-live .wx-cloud--near { z-index: 7; }
|
||||
[data-scene="descend-through-weather"].is-live .wx-cloud--under { z-index: 8; }
|
||||
[data-scene="descend-through-weather"].is-live .wx-deck { z-index: 9; }
|
||||
[data-scene="descend-through-weather"].is-live .wx-note { z-index: 10; }
|
||||
[data-scene="descend-through-weather"].is-live .wx-statement { z-index: 11; }
|
||||
|
||||
[data-scene="descend-through-weather"].is-live .wx-below::after,
|
||||
[data-scene="descend-through-weather"].is-live .wx-note::before { display: none; }
|
||||
[data-scene="descend-through-weather"].is-live .wx-layer {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* the title: the soft and lost copies sit exactly over the word, fixed blur, opacity only. They are
|
||||
generated content on aria-hidden spans, so the heading's text stays the one word. The blur sits on
|
||||
the generated text inside each copy, not on the copy whose opacity changes: it is drawn once into the
|
||||
copy's own layer, and the compositor only fades that layer (a filter on the fading layer itself would
|
||||
be blurred again on every frame). */
|
||||
[data-scene="descend-through-weather"].is-live .wx-title__ghost {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-scene="descend-through-weather"].is-live .wx-title__ghost::before { content: attr(data-word); display: block; }
|
||||
[data-scene="descend-through-weather"].is-live .wx-title__ghost.is-soft::before { filter: blur(calc(var(--wx-blur) * 0.36)); }
|
||||
[data-scene="descend-through-weather"].is-live .wx-title__ghost.is-lost::before { filter: blur(var(--wx-blur)); }
|
||||
|
||||
/* soft flat fills: haze over the top of the place below, ground band over its foot. Each runs about
|
||||
three blur radii past the sides of the screen, no further. As with the title copies, the blur sits on
|
||||
the fill inside the layer that fades, so it is drawn once, not on every frame. */
|
||||
[data-scene="descend-through-weather"].is-live .wx-haze,
|
||||
[data-scene="descend-through-weather"].is-live .wx-ground {
|
||||
left: -30svh;
|
||||
width: calc(100% + 60svh);
|
||||
}
|
||||
[data-scene="descend-through-weather"].is-live :is(.wx-haze, .wx-ground)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
filter: blur(9svh);
|
||||
}
|
||||
[data-scene="descend-through-weather"].is-live .wx-haze { top: -20svh; height: 62svh; }
|
||||
[data-scene="descend-through-weather"].is-live .wx-haze::before { background: var(--wx-mist); }
|
||||
[data-scene="descend-through-weather"].is-live .wx-ground { top: 75svh; height: 70svh; opacity: 0; }
|
||||
[data-scene="descend-through-weather"].is-live .wx-ground::before { background: var(--wx-ground); }
|
||||
|
||||
/* cloud layers: canvases of keyed cloud, wide enough that their feathered ends never come on screen */
|
||||
[data-scene="descend-through-weather"] .wx-cloud { display: none; }
|
||||
[data-scene="descend-through-weather"].is-live .wx-cloud--far { left: -40vw; width: 180vw; height: 80svh; opacity: 0.9; }
|
||||
[data-scene="descend-through-weather"].is-live .wx-cloud--near { left: -65vw; width: 230vw; height: 140svh; transform-origin: 50% 0; }
|
||||
[data-scene="descend-through-weather"].is-live .wx-cloud--under { left: -50vw; width: 200vw; height: 64svh; visibility: hidden; }
|
||||
|
||||
/* the deck: cloud edge, a screen of flat mist, cloud underside */
|
||||
[data-scene="descend-through-weather"].is-live .wx-deck {
|
||||
width: 100%;
|
||||
height: calc(var(--wx-feather) * 2 + 100svh);
|
||||
}
|
||||
[data-scene="descend-through-weather"] .wx-deck__top,
|
||||
[data-scene="descend-through-weather"] .wx-deck__bottom {
|
||||
position: absolute;
|
||||
left: -35vw;
|
||||
width: 170vw;
|
||||
height: var(--wx-feather);
|
||||
display: block;
|
||||
transform: scaleX(-1); /* the same art as the far cloud and the ceiling, mirrored so no pattern repeats */
|
||||
}
|
||||
[data-scene="descend-through-weather"] .wx-deck__top { top: 0; }
|
||||
[data-scene="descend-through-weather"] .wx-deck__bottom { bottom: 0; }
|
||||
[data-scene="descend-through-weather"] .wx-deck__body {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
top: calc(var(--wx-feather) - 2px);
|
||||
height: calc(var(--wx-body, 100svh) + 4px);
|
||||
background: var(--wx-mist);
|
||||
}
|
||||
/* until the cloud art is cut (or if it cannot be), the deck's edges are soft flat mist */
|
||||
[data-scene="descend-through-weather"] .wx-deck__soft {
|
||||
position: absolute;
|
||||
left: calc(var(--wx-feather) * -0.3);
|
||||
width: calc(100% + var(--wx-feather) * 0.6);
|
||||
height: calc(var(--wx-feather) * 0.85);
|
||||
background: var(--wx-mist);
|
||||
filter: blur(calc(var(--wx-feather) * 0.1));
|
||||
}
|
||||
[data-scene="descend-through-weather"] .wx-deck__soft--top { top: calc(var(--wx-feather) * 0.55); }
|
||||
[data-scene="descend-through-weather"] .wx-deck__soft--bottom { bottom: calc(var(--wx-feather) * 0.55); }
|
||||
[data-scene="descend-through-weather"].wx-art .wx-deck__soft { display: none; }
|
||||
|
||||
[data-scene="descend-through-weather"].is-live .wx-note {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
display: block;
|
||||
visibility: hidden;
|
||||
}
|
||||
[data-scene="descend-through-weather"].is-live .wx-statement {
|
||||
position: absolute;
|
||||
top: auto;
|
||||
left: 6vw;
|
||||
bottom: 9svh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* composited only while the scene is near the screen */
|
||||
[data-scene="descend-through-weather"].is-live.wx-near :is(.wx-above img, .wx-below img, .wx-title, .wx-tagline, .wx-cloud, .wx-deck, .wx-note, .wx-line__in) { will-change: transform; }
|
||||
[data-scene="descend-through-weather"].is-live.wx-near :is(.wx-title__word, .wx-title__ghost, .wx-haze, .wx-ground, .wx-above) { will-change: opacity; }
|
||||
|
||||
/* ---------- the phone ---------- */
|
||||
|
||||
@media (max-width: 767px) {
|
||||
[data-scene="descend-through-weather"] { --wx-seam: 26svh; }
|
||||
[data-scene="descend-through-weather"] .wx-title {
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: 12svh;
|
||||
font-size: var(--wx-title-size, calc((100vw - 32px) / (var(--wx-letters, 10) * 0.72)));
|
||||
}
|
||||
[data-scene="descend-through-weather"] .wx-tagline {
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
top: var(--phone-tagline-top, 54svh);
|
||||
max-width: 22ch;
|
||||
font-size: 18px;
|
||||
}
|
||||
[data-scene="descend-through-weather"] .wx-note { padding: 0 16px; font-size: clamp(30px, 8.6vw, 40px); line-height: 1.05; }
|
||||
[data-scene="descend-through-weather"] .wx-below::after { top: 70%; }
|
||||
[data-scene="descend-through-weather"] .wx-statement {
|
||||
margin: 0 16px 10svh;
|
||||
max-width: none;
|
||||
font-size: clamp(30px, 8.6vw, 34px);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
/* a narrower far layer: the cloud sea still crosses the word, at a third of the desktop's pixels */
|
||||
[data-scene="descend-through-weather"].is-live .wx-cloud--far { left: -60vw; width: 220vw; height: 64svh; }
|
||||
[data-scene="descend-through-weather"].is-live .wx-cloud--near { left: -110vw; width: 320vw; height: 120svh; }
|
||||
[data-scene="descend-through-weather"].is-live .wx-cloud--under { left: -100vw; width: 300vw; height: 50svh; }
|
||||
[data-scene="descend-through-weather"] .wx-deck__top,
|
||||
[data-scene="descend-through-weather"] .wx-deck__bottom { left: -50vw; width: 200vw; }
|
||||
[data-scene="descend-through-weather"].is-live .wx-ground { top: 66svh; }
|
||||
[data-scene="descend-through-weather"].is-live .wx-statement { left: 16px; right: 16px; bottom: 10svh; }
|
||||
}
|
||||
@@ -0,0 +1,944 @@
|
||||
// tasteskill / scenes / descend-through-weather.js — the hero sinks through a cloud deck onto the place below.
|
||||
//
|
||||
// A held full-bleed photograph is a camera going down through the weather. The giant place name creeps
|
||||
// up and loses focus while two cloud layers rise past it at different speeds; a soft wall of cloud then
|
||||
// fills the frame with mist while one line in the display face rises slowly through it; the frame clears
|
||||
// from the bottom, under a ceiling of cloud, onto a second photograph where a two-line statement rises
|
||||
// out of its masks. A signature hero exit for travel, hospitality, outdoor, architecture or wellness pages
|
||||
// with two strong pictures.
|
||||
//
|
||||
// The cloud. By default the scene grows its own: warped fractal noise gathered into rounded billows,
|
||||
// laid on a plane in perspective, lit from above, solid toward the deck and graded to --mist exactly so
|
||||
// it meets the flat mist of the deck without a seam. It needs no files and never picks anything up from
|
||||
// the pictures. For a real cloud, give it a photograph of a cloud sea seen from above (`tops`, with a
|
||||
// `topsCrop` on clean cloud: no rock, people or horizon in the crop) and, if you have one, fog drifting
|
||||
// over darker ground (`wisps`); they are keyed to transparency (bright cloud stays, darker sky or ground
|
||||
// drops out). Their pixels are read, so they must be same-origin or served with CORS
|
||||
// (Access-Control-Allow-Origin). The cutting runs in a Worker (OffscreenCanvas), so the page never stalls
|
||||
// while it is scrolled; the finished art is handed to the layers one per frame, and the deck's art is
|
||||
// swapped in only while the scene is still or the deck is off screen. Without Worker support it runs on
|
||||
// the main thread in small slices. If a cloud photograph cannot be read, the scene falls back to its own
|
||||
// cloud; if nothing can be drawn, the deck still rises as a soft flat wall of mist and nothing breaks.
|
||||
// No gradients anywhere: soft edges are cloud art or blurred flat fills.
|
||||
//
|
||||
// DOM. The script builds the title copies, cloud layers, deck, haze and ground band itself.
|
||||
//
|
||||
// <section class="scene" data-scene="descend-through-weather" aria-label="Skarven, from the ridge to the sound"
|
||||
// data-options='{"tops": "cloud-sea.jpg", "topsCrop": [0, 0.3, 1, 0.9]}'> (optional)
|
||||
// <div class="scene-stage">
|
||||
// <figure class="wx-above"><img src="above.jpg" alt="…" width="2400" height="1600" fetchpriority="high"></figure>
|
||||
// <header class="wx-hero">
|
||||
// <h1 class="wx-title">Skarven</h1> one word, 4-10 letters (h2 mid-page)
|
||||
// <p class="wx-tagline">One sentence of 8-14 words.</p>
|
||||
// </header>
|
||||
// <p class="wx-note">Inside the cloud, 800 metres up</p> 3-7 words, the line on the white
|
||||
// <figure class="wx-below"><img src="below.jpg" alt="…" width="2400" height="1500" loading="eager"></figure>
|
||||
// <h2 class="wx-statement"> two lines (house rule: headlines two lines)
|
||||
// <span class="wx-line">Below the cloud,</span>
|
||||
// <span class="wx-line">the whole sound opens.</span>
|
||||
// </h2>
|
||||
// </div>
|
||||
// </section>
|
||||
//
|
||||
// Pictures. Above: the view on top of the weather, landscape: a peak, ridge or headland over a cloud sea
|
||||
// under a blue or dusk sky. Give it contrast for the weather to swallow (dark rock, a saturated sky): a
|
||||
// photograph that is already white makes the descent read as a fade to white. Its lower third holds the
|
||||
// title (white by default, so keep it mid to dark there, or set --on-above dark for a pale one). Below:
|
||||
// an aerial or high view of the place, its top quarter the palest part (hazy sky), its bottom third dark
|
||||
// and calm (it fades into --ground), warmer and more saturated than the first so the descent reads as
|
||||
// arriving. Both at least 2400px wide. --above-pos / --below-pos set their object-position (the phone
|
||||
// crops them to portrait).
|
||||
//
|
||||
// Tokens (set on :root or the section): --mist (#eef2f4, the cloud and the deck; match it to the
|
||||
// brightest cloud in the first photograph), --on-mist (#1c2329: the note on the white), --on-above (#fff:
|
||||
// the title on the first photograph), --on-sky (--on-mist: the tagline, set in the calm sky), --ground
|
||||
// (#12201e, sample it from the dark bottom of the second photograph; the next section uses it),
|
||||
// --on-ground (#f4f1ea, the statement), --font-display, --font-text. A dusk or smoke variant: a dark
|
||||
// --mist (#15120f) with --on-mist white; the cloud, grown or keyed, takes the dark tone.
|
||||
//
|
||||
// Options (data-options JSON). Default, then range:
|
||||
// hold 3 screens the stage is held on desktop (2.4-4)
|
||||
// phoneHold 2.2 screens held under 768px (1.8-3)
|
||||
// phone 'same' 'same' runs the phone plan (smaller numbers, a narrower far cloud); 'rest'
|
||||
// shows the finished composition on phones
|
||||
// scrub auto true when Lenis smooths the page, else 0.8 (seconds of catch-up)
|
||||
// tops none URL of a cloud-sea photograph seen from above (keyed into the far cloud and
|
||||
// the deck's edges; the ceiling below is always grown, unless art is 'alpha').
|
||||
// Without it the scene grows its own cloud (see above); never point it at a
|
||||
// picture with rock, people or buildings in the crop: whatever is bright there is
|
||||
// keyed into the cloud
|
||||
// topsCrop [0,0,1,1] part of it to use, as fractions [left, top, right, bottom]: aim it at
|
||||
// clean cloud
|
||||
// wisps tops URL of fog over darker ground (keyed into the near veil); without either,
|
||||
// a grown veil
|
||||
// wispsCrop [0,0,1,1]
|
||||
// art 'key' 'key' keys ordinary photographs; 'alpha' takes ready-made transparent cloud art
|
||||
// as it is (still read through CORS)
|
||||
// seed 7 the grown cloud's shapes (any whole number)
|
||||
// fill 0.86 share of the width the title is fitted to (0.6-0.94); held under 36% of the
|
||||
// height, so very wide screens keep the word inside the bottom band. Phones fit
|
||||
// it 0.1 wider (up to 0.98).
|
||||
// blur 14 px of blur on the lost title copy (8-16); the soft copy carries a third of it
|
||||
// feather 60 svh of soft cloud edge on each side of the deck (40-80; softer is calmer)
|
||||
// phoneFeather 40 the same under 768px (30-60)
|
||||
// settle 1.2 scale photo B settles from (1.08-1.25); phone 1.12
|
||||
//
|
||||
// The root also gets data-sky="above" | "in" | "below" as the scene plays (in = 0.44-0.60), so a fixed
|
||||
// nav can change colour: body:has([data-sky="in"]) .nav { color: … }. It is removed whenever the scene
|
||||
// shows its finished composition.
|
||||
//
|
||||
// Progress map (desktop, hold 3): 0 photo A still, the white title sharp at about 66-95svh over the dark
|
||||
// foot of the picture, tagline read in the sky, a 4svh cloud lip below the title. 0-50% photo A zooms 1
|
||||
// to 1.12 and drifts up 8svh; the title rises 42svh (0.28x). 10-20% a 5px copy of the title fades in over
|
||||
// it and 17-25% the sharp copy leaves (soft, still read to about 24%); 28-40% the 14px copy takes over
|
||||
// and 40-48% fades to a ghost and out. 14-22% the tagline leaves. Far cloud 96 to 14svh (0.55x), near
|
||||
// veil 118 to -30svh (1.07x, scale 1.25). 24-48% the deck rises 100 to -60svh (power1.in) and the
|
||||
// frame is mist from 48%; 42-58% the note rises 14 to -14svh through the mist (read 45-55%). 50% photo
|
||||
// A, title and clouds are switched off under the deck; photo B settles 1.2 to 1 (to 100%, power2.out).
|
||||
// 41-48% photo A pales to 0.4 into the mist. 52-78% the deck leaves upward (power1.out) and the frame
|
||||
// clears from the bottom through thinning, pale cloud; 54-86% a cloud ceiling with grey-blue undersides
|
||||
// behind it (a 64svh layer, 6 to -64svh, power2.in): it lingers, its dense part in the top quarter of
|
||||
// the frame to about 72%, then lifts away; 58-84% the haze clears; 66-80% the ground band darkens the
|
||||
// bottom; 74-85% the statement rises line by line; 85-100% held.
|
||||
// Phone (hold 2.2): the far cloud on a narrower layer (114 to 10svh) and the near veil 122 to -30svh
|
||||
// (scale 1.15, no sideways drift), both starting lower so the word is still read at 24%; the title fitted
|
||||
// to 0.96 of the width, rising 30svh with a 10px lost copy, each blur step 0.03 later; deck feathers of
|
||||
// 40svh (180svh deck); ceiling 4 to -50svh; photo B settles from 1.12. Same fractions otherwise.
|
||||
//
|
||||
// Finished composition (no script, reduced motion, phone 'rest'): photo A with the title, a seam of
|
||||
// mist carrying the note, photo B with the statement. With the script present the seam's edges are the
|
||||
// cloud art (cloud tops above, the turned-over cloud below); without it, a soft flat band. Without the
|
||||
// script the title is sized from --wx-letters (the word's length, 10 by default) so it never clips.
|
||||
(() => {
|
||||
const ID = 'descend-through-weather';
|
||||
const num = (v, d, a, b) => (typeof v === 'number' && isFinite(v) ? clamp(v, a, b) : d);
|
||||
const box = (v, d) => (Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === 'number') ? v.map((n) => clamp(n, 0, 1)) : d);
|
||||
|
||||
const settings = (opts) => {
|
||||
const o = opts || {};
|
||||
return {
|
||||
hold: num(o.hold, 3, 2.4, 4),
|
||||
phoneHold: num(o.phoneHold, 2.2, 1.8, 3),
|
||||
phone: o.phone === 'rest' ? 'rest' : 'same',
|
||||
scrub: typeof o.scrub === 'boolean' ? o.scrub : typeof o.scrub === 'number' ? clamp(o.scrub, 0, 2) : null,
|
||||
tops: typeof o.tops === 'string' && o.tops ? o.tops : '',
|
||||
wisps: typeof o.wisps === 'string' && o.wisps ? o.wisps : '',
|
||||
topsCrop: box(o.topsCrop, null),
|
||||
wispsCrop: box(o.wispsCrop, null),
|
||||
art: o.art === 'alpha' ? 'alpha' : 'key',
|
||||
seed: num(o.seed, 7, 1, 1e6) | 0,
|
||||
fill: num(o.fill, 0.86, 0.6, 0.94),
|
||||
blur: num(o.blur, 14, 8, 16),
|
||||
feather: num(o.feather, 60, 40, 80),
|
||||
phoneFeather: num(o.phoneFeather, 40, 30, 60),
|
||||
settle: num(o.settle, 1.2, 1.08, 1.25),
|
||||
};
|
||||
};
|
||||
|
||||
// ---------- pure helpers (also sent to the worker as source, so they stay plain functions) ----------
|
||||
|
||||
function clamp(v, a, b) { return Math.min(b, Math.max(a, v)); }
|
||||
function ss(a, b, x) { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); }
|
||||
|
||||
// The part of the picture to draw into a w x h canvas: trimmed at the sides when it is wider than
|
||||
// needed, a band out of its height when it is much taller (clouds may stretch sideways up to `max`,
|
||||
// 1.8x by default). `zoom` below 1 uses only that share of the crop's width, around its middle, so a
|
||||
// small canvas keeps the cloud's shapes large instead of squeezing the whole crop into it.
|
||||
function cropRect(iw, ih, crop, w, h, max, zoom) {
|
||||
let sx = crop[0] * iw, sy = crop[1] * ih, sw = Math.max(1, (crop[2] - crop[0]) * iw), sh = Math.max(1, (crop[3] - crop[1]) * ih);
|
||||
if (zoom && zoom < 1) { const nw = sw * zoom; sx += (sw - nw) / 2; sw = nw; }
|
||||
const A = w / h, C = sw / sh, MAX = max || 1.8;
|
||||
if (C > A) { const nw = sh * A; sx += (sw - nw) / 2; sw = nw; }
|
||||
else if (C < A / MAX) { const nh = sw / (A / MAX); sy += (sh - nh) / 2; sh = nh; }
|
||||
return [Math.round(sx), Math.round(sy), Math.max(1, Math.round(sw)), Math.max(1, Math.round(sh))];
|
||||
}
|
||||
|
||||
// Keying. edge: 'top' (soft edge at the top, solid toward the bottom), 'bottom', or 'all' (a veil, no
|
||||
// solid part). Along the soft edge only the brightest crests survive; deeper in, the threshold slides
|
||||
// down until the darkest troughs fill too, so the cloud turns solid along its own shapes instead of along
|
||||
// a straight line. Split in passes over rows so the main-thread fallback can give frames back between.
|
||||
function keyLum(p, lum, hist, j0, j1) {
|
||||
for (let j = j0, i = j0 * 4; j < j1; j++, i += 4) {
|
||||
const L = (0.2126 * p[i] + 0.7152 * p[i + 1] + 0.0722 * p[i + 2]) / 255;
|
||||
lum[j] = L;
|
||||
hist[(L * 255) | 0]++;
|
||||
}
|
||||
}
|
||||
function keyPlan(w, h, k, hist) {
|
||||
const n = w * h;
|
||||
const pct = (q) => { let s = 0; const t = q * n; for (let b = 0; b < 256; b++) { s += hist[b]; if (s >= t) return b / 255; } return 1; };
|
||||
const lo = pct(k.qLo), hi = Math.max(lo + 0.14, pct(k.qHi));
|
||||
const rowShift = new Float32Array(h), rowFade = new Float32Array(h), rowSolid = new Float32Array(h), rowMist = new Float32Array(h);
|
||||
for (let y = 0; y < h; y++) {
|
||||
const v = y / (h - 1);
|
||||
if (k.edge === 'all') { rowFade[y] = ss(0, k.vside, v) * ss(0, k.vside, 1 - v); continue; }
|
||||
const e = k.edge === 'top' ? v : 1 - v; // distance from the soft edge
|
||||
rowShift[y] = k.crest * (1 - ss(0, k.soft, e)) - ss(k.fill[0], k.fill[1], e);
|
||||
rowFade[y] = ss(0, k.fade, e);
|
||||
rowSolid[y] = ss(0.95, 1, e); // the far border is solid for certain
|
||||
rowMist[y] = ss(k.fill[0], Math.min(1, k.fill[1] + 0.05), e);
|
||||
}
|
||||
const colZ = new Float32Array(w);
|
||||
for (let x = 0; x < w; x++) { const u = x / (w - 1); colZ[x] = ss(0, k.side, u) * ss(0, k.side, 1 - u); }
|
||||
return { lo, hi, rowShift, rowFade, rowSolid, rowMist, colZ };
|
||||
}
|
||||
function keyRows(p, lum, w, k, P, y0, y1) {
|
||||
const [sr, sg, sb] = k.shade, [lr, lg, lb] = k.light, [mr, mg, mb] = k.mist;
|
||||
const { lo, hi, rowShift, rowFade, rowSolid, rowMist, colZ } = P;
|
||||
for (let y = y0, j = y0 * w; y < y1; y++) {
|
||||
const sh = rowShift[y], fade = rowFade[y] * k.gain, solid = rowSolid[y], mix = rowMist[y];
|
||||
const a0 = lo + sh, a1 = hi + sh * 0.6;
|
||||
for (let x = 0; x < w; x++, j++) {
|
||||
const L = lum[j];
|
||||
let a = ss(a0, a1, L) * fade;
|
||||
if (a < solid) a = solid;
|
||||
a *= colZ[x];
|
||||
const t = ss(lo, hi, L);
|
||||
let r = sr + (lr - sr) * t, gg = sg + (lg - sg) * t, b = sb + (lb - sb) * t;
|
||||
r += (mr - r) * mix; gg += (mg - gg) * mix; b += (mb - b) * mix;
|
||||
const i = j * 4;
|
||||
p[i] = r; p[i + 1] = gg; p[i + 2] = b; p[i + 3] = a * 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
function keyAll(p, w, h, k) {
|
||||
const lum = new Float32Array(w * h), hist = new Uint32Array(256);
|
||||
keyLum(p, lum, hist, 0, w * h);
|
||||
keyRows(p, lum, w, k, keyPlan(w, h, k, hist), 0, h);
|
||||
}
|
||||
|
||||
// The grown cloud. Warped fractal noise, partly billowed (rounded puffs meeting in creases), on a plane
|
||||
// in perspective: smaller and flatter toward the soft edge, larger toward the solid border. Near the
|
||||
// soft edge only the densest crests show; deeper in the threshold falls until the cloud is solid, so it
|
||||
// closes along its own shapes. Lit by the density difference toward the light (denser toward the light
|
||||
// means in shadow), graded from shade to --mist. Two passes over rows (density, then light), so the
|
||||
// main-thread fallback can give frames back between slices. g.edge: 'top', 'bottom' or 'all' (a veil).
|
||||
function genTables(seed) {
|
||||
let s = (seed * 2654435761) >>> 0 || 1;
|
||||
const rnd = () => { s = (Math.imul(s ^ (s >>> 15), 2246822519) + 0x9e3779b9) >>> 0; return s / 4294967296; };
|
||||
const perm = new Uint8Array(512), gx = new Float32Array(256), gy = new Float32Array(256);
|
||||
for (let i = 0; i < 256; i++) perm[i] = i;
|
||||
for (let i = 255; i > 0; i--) { const j = (rnd() * (i + 1)) | 0; const t = perm[i]; perm[i] = perm[j]; perm[j] = t; }
|
||||
for (let i = 0; i < 256; i++) { perm[i + 256] = perm[i]; const a = rnd() * 6.2831853; gx[i] = Math.cos(a); gy[i] = Math.sin(a); }
|
||||
return { perm, gx, gy };
|
||||
}
|
||||
function genDensity(g, w, h, T, D, y0, y1) {
|
||||
const { perm, gx, gy } = T;
|
||||
const noise = (x, y) => {
|
||||
const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi;
|
||||
const X = xi & 255, Y = yi & 255;
|
||||
const a = perm[X + perm[Y]], b = perm[X + 1 + perm[Y]], c = perm[X + perm[Y + 1]], d = perm[X + 1 + perm[Y + 1]];
|
||||
const u = xf * xf * xf * (xf * (xf * 6 - 15) + 10), v = yf * yf * yf * (yf * (yf * 6 - 15) + 10);
|
||||
const n00 = gx[a] * xf + gy[a] * yf, n10 = gx[b] * (xf - 1) + gy[b] * yf;
|
||||
const n01 = gx[c] * xf + gy[c] * (yf - 1), n11 = gx[d] * (xf - 1) + gy[d] * (yf - 1);
|
||||
const p = n00 + u * (n10 - n00), q = n01 + u * (n11 - n01);
|
||||
return (p + v * (q - p)) * 1.4;
|
||||
};
|
||||
// octaves turned about 37 degrees each, so no grid shows; `bil` mixes in the billowed form
|
||||
const fbm = (x, y, oct, bil) => {
|
||||
let f = 0, a = 0.5, n = 0;
|
||||
for (let o = 0; o < oct; o++) {
|
||||
const s = noise(x, y);
|
||||
f += a * (bil ? (1 - bil) * s + bil * (Math.abs(s) * 2 - 0.6) : s);
|
||||
n += a;
|
||||
const nx = x * 1.6 - y * 1.2 + 5.3;
|
||||
y = x * 1.2 + y * 1.6 + 1.7; x = nx; a *= 0.5;
|
||||
}
|
||||
return f / n;
|
||||
};
|
||||
// (settings read into locals once: in a worker `g` arrives as a cloned object, and reading its
|
||||
// properties inside the pixel loop is several times slower)
|
||||
const A = w / h, all = g.edge === 'all', top = g.edge === 'top';
|
||||
const scale = +g.scale, squash = +g.squash, z0 = +g.z0, warp = +g.warp, oct = g.oct | 0, billow = +g.billow;
|
||||
for (let y = y0; y < y1; y++) {
|
||||
const v = y / (h - 1);
|
||||
const e = top ? v : 1 - v; // distance from the soft edge
|
||||
const s = all ? 1 : 1 / (z0 + (1 - z0) * e); // perspective: smaller toward the soft edge
|
||||
const Y0 = all ? v * scale * squash : scale * squash * s;
|
||||
const kx = A * scale * s;
|
||||
for (let x = 0, j = y * w; x < w; x++, j++) {
|
||||
const X0 = (x / (w - 1) - 0.5) * kx;
|
||||
const X = X0 + fbm(X0 * 0.6 + 7.1, Y0 * 0.6 + 2.9, 3, 0) * warp;
|
||||
const Y = Y0 + fbm(X0 * 0.6 + 1.3, Y0 * 0.6 + 8.2, 3, 0) * warp;
|
||||
D[j] = fbm(X, Y, oct, billow);
|
||||
}
|
||||
}
|
||||
}
|
||||
function genShade(g, w, h, D, p, y0, y1) {
|
||||
const all = g.edge === 'all', top = g.edge === 'top';
|
||||
const [sr, sg, sb] = g.shade, [lr, lg, lb] = g.light, [mr, mg, mb] = g.mist;
|
||||
const width = +g.width, contrast = +g.contrast, thick = +g.thick, side = +g.side;
|
||||
const dy = Math.max(1, Math.round((g.lightLen * h) / 260));
|
||||
const colZ = new Float32Array(w);
|
||||
for (let x = 0; x < w; x++) { const u = x / (w - 1); colZ[x] = ss(0, side, u) * ss(0, side, 1 - u); }
|
||||
for (let y = y0; y < y1; y++) {
|
||||
const v = y / (h - 1);
|
||||
const e = top ? v : 1 - v;
|
||||
const thr = all ? g.thr : g.thr - g.fill * ss(0, g.soft, e);
|
||||
const fade = (all ? ss(0, g.vside, v) * ss(0, g.vside, 1 - v) : ss(0, g.fade, e)) * g.alpha;
|
||||
const solid = all ? 0 : ss(0.93, 1, e);
|
||||
const mix = all ? 0 : ss(g.mistAt[0], g.mistAt[1], e);
|
||||
const lo = thr - width, hi = thr + width;
|
||||
// the light comes from above, for the tops and for the undersides of a ceiling alike
|
||||
const ly = Math.max(0, y - dy);
|
||||
for (let x = 0, j = y * w; x < w; x++, j++) {
|
||||
const d = D[j], dl = D[ly * w + x];
|
||||
let a = ss(lo, hi, d) * fade;
|
||||
if (a < solid) a = solid;
|
||||
a *= colZ[x];
|
||||
const lit = clamp(0.62 + (d - dl) * contrast + (d - thr) * 0.9 - thick * clamp((d - thr) * 4, 0, 1), 0, 1);
|
||||
let r = sr + (lr - sr) * lit, gg = sg + (lg - sg) * lit, b = sb + (lb - sb) * lit;
|
||||
r += (mr - r) * mix; gg += (mg - gg) * mix; b += (mb - b) * mix;
|
||||
const i = j * 4;
|
||||
p[i] = r; p[i + 1] = gg; p[i + 2] = b; p[i + 3] = a * 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The worker: fetches and decodes the pictures, cuts every piece of art and sends it back as
|
||||
// ImageBitmaps (one per layer that shows it), so the main thread only hands them to the canvases.
|
||||
function workerMain() {
|
||||
const pics = new Map();
|
||||
const pic = (src) => {
|
||||
if (!pics.has(src)) {
|
||||
pics.set(src, fetch(src, { mode: 'cors', credentials: 'same-origin' }).then((res) => {
|
||||
if (!res.ok) throw new Error('cloud picture failed to load: ' + src + ' (' + res.status + ')');
|
||||
return res.blob();
|
||||
}).then((b) => createImageBitmap(b)));
|
||||
}
|
||||
return pics.get(src);
|
||||
};
|
||||
// grown cloud: drawn small, then scaled up softly to the layer's size
|
||||
function grow(job) {
|
||||
const { w, h } = job;
|
||||
const gw = Math.max(32, Math.round(w * job.res)), gh = Math.max(16, Math.round(h * job.res));
|
||||
const D = new Float32Array(gw * gh), img = new ImageData(gw, gh);
|
||||
genDensity(job.gen, gw, gh, genTables(job.seed), D, 0, gh);
|
||||
genShade(job.gen, gw, gh, D, img.data, 0, gh);
|
||||
const small = new OffscreenCanvas(gw, gh);
|
||||
small.getContext('2d').putImageData(img, 0, 0);
|
||||
const c = new OffscreenCanvas(w, h);
|
||||
const g = c.getContext('2d');
|
||||
g.imageSmoothingQuality = 'high';
|
||||
if (job.blur && 'filter' in g) g.filter = 'blur(' + job.blur.toFixed(2) + 'px)';
|
||||
g.drawImage(small, 0, 0, w, h);
|
||||
return c;
|
||||
}
|
||||
async function cut(job) {
|
||||
if (job.gen) {
|
||||
const c = grow(job);
|
||||
const out = [];
|
||||
for (let i = 1; i < job.copies; i++) out.push(await createImageBitmap(c));
|
||||
out.push(c.transferToImageBitmap());
|
||||
return out;
|
||||
}
|
||||
const img = await pic(job.src);
|
||||
const { w, h } = job;
|
||||
const c = new OffscreenCanvas(w, h);
|
||||
const g = c.getContext('2d', { willReadFrequently: !!job.k });
|
||||
const r = cropRect(img.width, img.height, job.crop, w, h, job.max, job.zoom);
|
||||
let part = null;
|
||||
try { part = await createImageBitmap(img, r[0], r[1], r[2], r[3], { resizeWidth: w, resizeHeight: h, resizeQuality: 'high' }); } catch (e) { part = null; }
|
||||
if (job.blur && 'filter' in g) g.filter = 'blur(' + job.blur.toFixed(2) + 'px)';
|
||||
if (job.flip) { g.translate(0, h); g.scale(1, -1); }
|
||||
if (part) { g.drawImage(part, 0, 0, w, h); part.close(); } else g.drawImage(img, r[0], r[1], r[2], r[3], 0, 0, w, h);
|
||||
g.setTransform(1, 0, 0, 1, 0, 0);
|
||||
if ('filter' in g) g.filter = 'none';
|
||||
if (job.k) {
|
||||
const d = g.getImageData(0, 0, w, h);
|
||||
keyAll(d.data, w, h, job.k);
|
||||
g.putImageData(d, 0, 0);
|
||||
} else if (job.shade) {
|
||||
g.globalCompositeOperation = 'source-atop';
|
||||
g.globalAlpha = 0.28;
|
||||
g.fillStyle = 'rgb(' + job.shade.map((n) => Math.round(n)).join(' ') + ')';
|
||||
g.fillRect(0, 0, w, h);
|
||||
}
|
||||
// the keyed canvas is CPU-backed (its pixels were read); copy it once into an ordinary canvas, which
|
||||
// the browser keeps on the GPU, so the bitmaps handed to the page need no upload when they show
|
||||
let src = c;
|
||||
if (job.k) {
|
||||
const gpu = new OffscreenCanvas(w, h);
|
||||
const gg = gpu.getContext('2d');
|
||||
if (gg) { gg.drawImage(c, 0, 0); src = gpu; }
|
||||
}
|
||||
const out = [];
|
||||
for (let i = 1; i < job.copies; i++) out.push(await createImageBitmap(src));
|
||||
out.push(src.transferToImageBitmap());
|
||||
return out;
|
||||
}
|
||||
self.onmessage = async (e) => {
|
||||
const { id, jobs } = e.data;
|
||||
try {
|
||||
for (let n = 0; n < jobs.length; n++) {
|
||||
const bitmaps = await cut(jobs[n]);
|
||||
self.postMessage({ id, n, bitmaps }, bitmaps);
|
||||
}
|
||||
self.postMessage({ id, done: true });
|
||||
} catch (err) {
|
||||
self.postMessage({ id, error: String((err && err.message) || err) });
|
||||
} finally {
|
||||
for (const p of pics.values()) p.then((b) => b.close(), () => {});
|
||||
pics.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let worker = null, workerOff = false, seq = 0;
|
||||
const pending = new Map();
|
||||
const workerFailed = (why) => {
|
||||
workerOff = true;
|
||||
try { if (worker) worker.terminate(); } catch {}
|
||||
worker = null;
|
||||
for (const j of pending.values()) j.fail(why);
|
||||
pending.clear();
|
||||
};
|
||||
function getWorker() {
|
||||
if (worker || workerOff) return worker;
|
||||
if (!window.Worker || !window.OffscreenCanvas || !window.createImageBitmap || !window.Blob || !window.URL || !URL.createObjectURL) { workerOff = true; return null; }
|
||||
try {
|
||||
const src = [clamp, ss, cropRect, keyLum, keyPlan, keyRows, keyAll, genTables, genDensity, genShade, workerMain].map(String).join('\n') + '\nworkerMain();';
|
||||
const url = URL.createObjectURL(new Blob([src], { type: 'text/javascript' }));
|
||||
worker = new Worker(url);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 30000);
|
||||
worker.onmessage = (e) => { const j = pending.get(e.data.id); if (j) j.on(e.data); };
|
||||
worker.onerror = (e) => { if (e && e.preventDefault) e.preventDefault(); workerFailed('worker'); };
|
||||
} catch { workerOff = true; worker = null; }
|
||||
return worker;
|
||||
}
|
||||
// resolves with one array of ImageBitmaps per job; rejects with 'worker' when the worker itself cannot
|
||||
// run (the caller then cuts on the main thread), or with the job's own error (a picture that failed)
|
||||
function cutInWorker(jobs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const w = getWorker();
|
||||
if (!w) { reject(new Error('worker')); return; }
|
||||
const id = ++seq;
|
||||
const out = new Array(jobs.length);
|
||||
pending.set(id, {
|
||||
on: (d) => {
|
||||
if (d.error) { pending.delete(id); out.forEach((b) => b && b.forEach((x) => x.close())); reject(new Error(d.error)); }
|
||||
else if (d.done) { pending.delete(id); resolve(out); }
|
||||
else out[d.n] = d.bitmaps;
|
||||
},
|
||||
fail: (why) => { out.forEach((b) => b && b.forEach((x) => x.close())); reject(new Error(why)); },
|
||||
});
|
||||
try { w.postMessage({ id, jobs }); } catch { pending.delete(id); workerFailed('worker'); reject(new Error('worker')); }
|
||||
});
|
||||
}
|
||||
|
||||
// The main-thread fallback: the same cut, in slices that give the browser a frame back every few ms.
|
||||
const breathe = () => new Promise((res) => setTimeout(res, 0));
|
||||
const images = new Map();
|
||||
const viaImg = (src) => new Promise((res, rej) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.decoding = 'async';
|
||||
img.onload = () => (img.decode ? img.decode().catch(() => {}) : Promise.resolve()).then(() => res(img));
|
||||
img.onerror = () => rej(new Error('cloud picture failed to load: ' + src));
|
||||
img.src = src;
|
||||
});
|
||||
const load = (src) => {
|
||||
if (!images.has(src)) {
|
||||
images.set(src, (async () => {
|
||||
if (!window.createImageBitmap || !window.fetch) return viaImg(src);
|
||||
const res = await fetch(src, { mode: 'cors', credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error('cloud picture failed to load: ' + src + ' (' + res.status + ')');
|
||||
return createImageBitmap(await res.blob());
|
||||
})());
|
||||
}
|
||||
return images.get(src);
|
||||
};
|
||||
async function cutOnMain(jobs, alive) {
|
||||
const out = [];
|
||||
const copiesOf = async (c, n) => { const a = []; for (let i = 0; i < n; i++) a.push(window.createImageBitmap ? await createImageBitmap(c) : c); return a; };
|
||||
for (const job of jobs) {
|
||||
if (!alive()) return out;
|
||||
const { w, h } = job;
|
||||
if (job.gen) {
|
||||
const gw = Math.max(32, Math.round(w * job.res)), gh = Math.max(16, Math.round(h * job.res));
|
||||
const D = new Float32Array(gw * gh), img = new ImageData(gw, gh), T = genTables(job.seed);
|
||||
const rows = Math.max(4, Math.floor(24000 / gw));
|
||||
for (let y = 0; y < gh; y += rows) { genDensity(job.gen, gw, gh, T, D, y, Math.min(gh, y + rows)); await breathe(); }
|
||||
for (let y = 0; y < gh; y += rows) { genShade(job.gen, gw, gh, D, img.data, y, Math.min(gh, y + rows)); await breathe(); }
|
||||
const small = document.createElement('canvas');
|
||||
small.width = gw; small.height = gh;
|
||||
small.getContext('2d').putImageData(img, 0, 0);
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w; c.height = h;
|
||||
const g = c.getContext('2d');
|
||||
g.imageSmoothingQuality = 'high';
|
||||
if (job.blur && 'filter' in g) g.filter = 'blur(' + job.blur.toFixed(2) + 'px)';
|
||||
g.drawImage(small, 0, 0, w, h);
|
||||
out.push(await copiesOf(c, job.copies));
|
||||
await breathe();
|
||||
continue;
|
||||
}
|
||||
const img = await load(job.src);
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w; c.height = h;
|
||||
const g = c.getContext('2d', { willReadFrequently: !!job.k });
|
||||
const iw = img.naturalWidth || img.width, ih = img.naturalHeight || img.height;
|
||||
const r = cropRect(iw, ih, job.crop, w, h, job.max, job.zoom);
|
||||
let part = null;
|
||||
try { if (window.createImageBitmap) part = await createImageBitmap(img, r[0], r[1], r[2], r[3], { resizeWidth: w, resizeHeight: h, resizeQuality: 'high' }); } catch { part = null; }
|
||||
if (job.blur && 'filter' in g) g.filter = 'blur(' + job.blur.toFixed(2) + 'px)';
|
||||
if (job.flip) { g.translate(0, h); g.scale(1, -1); }
|
||||
if (part) { g.drawImage(part, 0, 0, w, h); part.close(); } else g.drawImage(img, r[0], r[1], r[2], r[3], 0, 0, w, h);
|
||||
g.setTransform(1, 0, 0, 1, 0, 0);
|
||||
if ('filter' in g) g.filter = 'none';
|
||||
await breathe();
|
||||
if (job.k) {
|
||||
const d = g.getImageData(0, 0, w, h);
|
||||
const p = d.data, lum = new Float32Array(w * h), hist = new Uint32Array(256);
|
||||
const rows = Math.max(8, Math.floor(60000 / w));
|
||||
for (let y = 0; y < h; y += rows) { keyLum(p, lum, hist, y * w, Math.min(h, y + rows) * w); await breathe(); }
|
||||
const P = keyPlan(w, h, job.k, hist);
|
||||
for (let y = 0; y < h; y += rows) { keyRows(p, lum, w, job.k, P, y, Math.min(h, y + rows)); await breathe(); }
|
||||
g.putImageData(d, 0, 0);
|
||||
} else if (job.shade) {
|
||||
g.globalCompositeOperation = 'source-atop';
|
||||
g.globalAlpha = 0.28;
|
||||
g.fillStyle = 'rgb(' + job.shade.map((n) => Math.round(n)).join(' ') + ')';
|
||||
g.fillRect(0, 0, w, h);
|
||||
}
|
||||
const copies = [];
|
||||
for (let i = 0; i < job.copies; i++) copies.push(window.createImageBitmap ? await createImageBitmap(c) : c);
|
||||
out.push(copies);
|
||||
await breathe();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Hand one piece of art to a layer: an ImageBitmap goes in without a copy (bitmaprenderer).
|
||||
function show(canvas, art) {
|
||||
canvas.__art = true;
|
||||
const bitmap = typeof ImageBitmap !== 'undefined' && art instanceof ImageBitmap;
|
||||
const br = window.ImageBitmapRenderingContext && (bitmap || window.createImageBitmap) ? canvas.getContext('bitmaprenderer') : null;
|
||||
if (br && !bitmap && window.createImageBitmap) { createImageBitmap(art).then((b) => show(canvas, b)).catch(() => {}); return; }
|
||||
canvas.width = art.width; canvas.height = art.height;
|
||||
if (br && bitmap) { br.transferFromImageBitmap(art); return; }
|
||||
const g = canvas.getContext('2d');
|
||||
if (g) { g.clearRect(0, 0, art.width, art.height); g.drawImage(art, 0, 0); }
|
||||
if (bitmap) art.close();
|
||||
}
|
||||
|
||||
const rgbOf = (value, fallback) => {
|
||||
const c = document.createElement('canvas').getContext('2d');
|
||||
c.fillStyle = fallback;
|
||||
if (value) c.fillStyle = value.trim();
|
||||
const s = c.fillStyle;
|
||||
if (s[0] === '#') return [1, 3, 5].map((i) => parseInt(s.slice(i, i + 2), 16));
|
||||
const m = s.match(/[\d.]+/g);
|
||||
return m ? m.slice(0, 3).map(Number) : [238, 242, 244];
|
||||
};
|
||||
const sizeOf = (el, fallbackW, fallbackH, cap) => {
|
||||
const w = el.offsetWidth || fallbackW, h = el.offsetHeight || fallbackH;
|
||||
const k = Math.min(1, cap / w);
|
||||
return [Math.max(64, Math.round(w * k)), Math.max(32, Math.round(h * k))];
|
||||
};
|
||||
const abs = (src) => { try { return new URL(src, document.baseURI).href; } catch { return src; } };
|
||||
|
||||
// ---------- markup the script owns ----------
|
||||
|
||||
const make = (tag, cls, parent) => {
|
||||
const el = document.createElement(tag);
|
||||
el.className = cls;
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
parent.append(el);
|
||||
return el;
|
||||
};
|
||||
|
||||
function setup(root) {
|
||||
if (root.__wx) return root.__wx;
|
||||
const $ = (s) => root.querySelector(s);
|
||||
const stage = root.querySelector(':scope > .scene-stage') || root.firstElementChild;
|
||||
const r = {
|
||||
root, stage,
|
||||
above: $('.wx-above'), aImg: $('.wx-above img'),
|
||||
below: $('.wx-below'), bImg: $('.wx-below img'),
|
||||
hero: $('.wx-hero'), title: $('.wx-title'), tagline: $('.wx-tagline'),
|
||||
note: $('.wx-note'), statement: $('.wx-statement'),
|
||||
};
|
||||
|
||||
// the title: the word itself for readers, two blurred copies on top whose opacity alone changes. The
|
||||
// copies are drawn as generated content, so the heading's text stays the one word.
|
||||
if (r.title && !r.title.querySelector('.wx-title__word')) {
|
||||
const word = r.title.textContent.trim().replace(/\s+/g, ' ');
|
||||
r.title.textContent = '';
|
||||
const w = document.createElement('span');
|
||||
w.className = 'wx-title__word';
|
||||
w.textContent = word;
|
||||
r.title.append(w);
|
||||
for (const k of ['soft', 'lost']) {
|
||||
const copy = make('span', 'wx-title__ghost is-' + k, r.title);
|
||||
copy.dataset.word = word;
|
||||
}
|
||||
}
|
||||
if (r.title) {
|
||||
r.word = r.title.querySelector('.wx-title__word');
|
||||
r.soft = r.title.querySelector('.wx-title__ghost.is-soft');
|
||||
r.lost = r.title.querySelector('.wx-title__ghost.is-lost');
|
||||
}
|
||||
|
||||
// the statement: each line rises out of its own mask
|
||||
if (r.statement) {
|
||||
if (!r.statement.querySelector('.wx-line')) {
|
||||
const line = document.createElement('span');
|
||||
line.className = 'wx-line';
|
||||
while (r.statement.firstChild) line.append(r.statement.firstChild);
|
||||
r.statement.append(line);
|
||||
}
|
||||
for (const line of r.statement.querySelectorAll('.wx-line')) {
|
||||
if (line.querySelector(':scope > .wx-line__in')) continue;
|
||||
const inner = document.createElement('span');
|
||||
inner.className = 'wx-line__in';
|
||||
while (line.firstChild) inner.append(line.firstChild);
|
||||
line.append(inner);
|
||||
}
|
||||
r.lines = [...r.statement.querySelectorAll('.wx-line__in')];
|
||||
} else r.lines = [];
|
||||
|
||||
// layers: shown only while the scene runs (the deck also edges the seam of the finished composition)
|
||||
r.haze = make('div', 'wx-layer wx-haze', stage);
|
||||
r.ground = make('div', 'wx-layer wx-ground', stage);
|
||||
r.far = make('canvas', 'wx-layer wx-cloud wx-cloud--far', stage);
|
||||
r.near = make('canvas', 'wx-layer wx-cloud wx-cloud--near', stage);
|
||||
r.under = make('canvas', 'wx-layer wx-cloud wx-cloud--under', stage);
|
||||
r.deck = make('div', 'wx-layer wx-deck', stage);
|
||||
r.deckTop = make('canvas', 'wx-deck__top', r.deck);
|
||||
r.deckBody = make('div', 'wx-deck__body', r.deck);
|
||||
r.deckBottom = make('canvas', 'wx-deck__bottom', r.deck);
|
||||
make('div', 'wx-deck__soft wx-deck__soft--top', r.deck);
|
||||
make('div', 'wx-deck__soft wx-deck__soft--bottom', r.deck);
|
||||
|
||||
// the layers take their art as bitmaps; giving them that context (and a 1px bitmap) now means they
|
||||
// are already composited when the art arrives, so the handover is a swap, not a new layer
|
||||
if (window.ImageBitmapRenderingContext && window.createImageBitmap && window.ImageData) {
|
||||
for (const c of [r.far, r.near, r.under, r.deckTop, r.deckBottom]) {
|
||||
const br = c.getContext('bitmaprenderer');
|
||||
if (br) createImageBitmap(new ImageData(1, 1)).then((b) => { if (!c.__art) { c.width = 1; c.height = 1; br.transferFromImageBitmap(b); } else b.close(); }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
root.__wx = r;
|
||||
return r;
|
||||
}
|
||||
|
||||
// ---------- the title is fitted to the width, held under a share of the height ----------
|
||||
|
||||
function fit(r, o, phone) {
|
||||
if (!r.title || !r.word) return;
|
||||
const t = r.title;
|
||||
t.style.removeProperty('--wx-title-size');
|
||||
const fs = parseFloat(getComputedStyle(t).fontSize) || 100;
|
||||
const natural = r.word.offsetWidth;
|
||||
if (!natural) return;
|
||||
const H = r.stage.offsetHeight > 0 && r.root.classList.contains('is-live') ? r.stage.offsetHeight : window.innerHeight;
|
||||
const share = phone ? Math.min(0.98, o.fill + 0.1) : o.fill;
|
||||
const size = Math.min((fs * t.clientWidth * share) / natural, H * (phone ? 0.2 : 0.36), 460);
|
||||
t.style.setProperty('--wx-title-size', size.toFixed(1) + 'px');
|
||||
}
|
||||
|
||||
// ---------- cloud art ----------
|
||||
|
||||
let warned = false;
|
||||
// mode: 'desktop' | 'phone' (the running scene) or 'rest' (the seam of the finished composition)
|
||||
// grown: true draws every layer from the scene's own cloud (no `tops`, or the photograph failed)
|
||||
async function paint(r, o, mode, token, grown) {
|
||||
const alive = () => token === r.token;
|
||||
const topsSrc = !grown && o.tops ? abs(o.tops) : '';
|
||||
const wispsSrc = !grown && (o.wisps || o.tops) ? abs(o.wisps || o.tops) : '';
|
||||
const topsCrop = o.topsCrop || [0, 0, 1, 1];
|
||||
const wispsCrop = o.wispsCrop || (o.wisps ? [0, 0, 1, 1] : topsCrop);
|
||||
const mist = rgbOf(getComputedStyle(r.root).getPropertyValue('--wx-mist'), '#eef2f4');
|
||||
const tone = (m) => mist.map((c, i) => c * m[i]);
|
||||
const phone = mode === 'phone' || (mode === 'rest' && window.matchMedia('(max-width: 767px)').matches);
|
||||
const cap = phone ? 1400 : 1800;
|
||||
const W = window.innerWidth, H = window.innerHeight;
|
||||
const rest = mode === 'rest';
|
||||
const topsSize = rest ? sizeOf(r.deckTop, W * 1.7, H * 0.26, cap) : sizeOf(r.far, W * 1.8, H * 0.8, cap);
|
||||
const lowSize = sizeOf(r.deckBottom, W * 1.7, H * (rest ? 0.26 : 0.6), cap);
|
||||
const key = o.art === 'key';
|
||||
// the grown cloud's settings for each layer (see genDensity / genShade)
|
||||
const G = (edge, more) => ({ edge, scale: 0.7, squash: 3, z0: 0.5, warp: 0.25, oct: 6, billow: 0.7, thr: 0.3, fill: 0.9, soft: 0.5, width: 0.1, fade: 0.25, vside: 0.2, side: 0.08, mistAt: [0.5, 0.95], lightLen: 8, contrast: 6, thick: 0, alpha: 1, shade: tone([0.74, 0.79, 0.85]), light: mist, mist, ...more });
|
||||
// (no blur on the grown art: it is soft already, and a blur would thin its solid border into a line)
|
||||
const grow = (size, g, n, copies, res) => ({ gen: g, seed: o.seed * 4 + n, w: size[0], h: size[1], res: res || 0.5, blur: 0, copies });
|
||||
// in the still seam of a phone the cloud keeps its shapes large (half the crop's width, no stretch)
|
||||
// and is shaded lighter, so it reads as crests and not as a band of squeezed grey streaks
|
||||
const seamZoom = rest && phone ? 0.5 : 0, seamMax = rest && phone ? 1 : 0;
|
||||
// tops: sunlit crests, solid toward the bottom (the far cloud and the deck's top edge). low: the same
|
||||
// sea turned over for the deck's lower edge, the part that lies over the land as the frame clears, so
|
||||
// it stays pale (shade close to the mist), is blurred further and fades over most of its height: it
|
||||
// thins out instead of drawing grey sheets with hard lower edges. under: the ceiling behind the deck,
|
||||
// held in the top of the frame, so it carries the crisper grey-blue undersides of real cloud.
|
||||
// wisps: a veil, shaded deeper so it reads on pale ground too.
|
||||
const jobs = [
|
||||
topsSrc
|
||||
? { src: topsSrc, crop: topsCrop, w: topsSize[0], h: topsSize[1], flip: false, blur: topsSize[0] / 1400, copies: rest ? 1 : 2, zoom: seamZoom, max: seamMax,
|
||||
// (in the still seam the cloud closes sooner and is shaded lighter: nothing there moves to explain
|
||||
// the darker troughs, so they would read as smudges)
|
||||
k: key ? { edge: 'top', soft: rest ? 0.4 : 0.5, crest: rest ? (phone ? 0.04 : 0.1) : 0.18, fill: rest ? [0.35, 0.85] : [0.45, 0.9], fade: 0.3, gain: 1, side: 0.08, qLo: 0.1, qHi: 0.8, shade: tone(rest ? (phone ? [0.9, 0.915, 0.935] : [0.87, 0.89, 0.915]) : [0.8, 0.835, 0.875]), light: mist, mist } : null }
|
||||
// (in the still seam the shapes are larger and softer: the band is short, and a crisp crest at
|
||||
// that size reads as a torn edge)
|
||||
: grow(topsSize, G('top', rest ? { scale: 0.42, width: 0.17, fade: 0.45, contrast: 4, shade: tone([0.84, 0.865, 0.9]) } : {}), 0, rest ? 1 : 2),
|
||||
topsSrc
|
||||
? { src: topsSrc, crop: topsCrop, w: lowSize[0], h: lowSize[1], flip: true, blur: lowSize[0] / 340, copies: 1, zoom: seamZoom, max: seamMax,
|
||||
k: key ? { edge: 'bottom', soft: 0.72, crest: 0.32, fill: [0.5, 0.94], fade: 0.66, gain: 1, side: 0.08, qLo: 0.12, qHi: 0.82, shade: tone([0.93, 0.945, 0.96]), light: mist, mist } : null,
|
||||
shade: key ? null : tone([0.94, 0.95, 0.96]) }
|
||||
: grow(lowSize, G('bottom', { scale: rest ? 0.42 : 0.7, soft: 0.6, fade: 0.6, width: 0.2, contrast: 3, shade: tone([0.9, 0.92, 0.94]) }), 1, 1),
|
||||
];
|
||||
if (!rest) {
|
||||
const underSize = sizeOf(r.under, W * 2, H * 0.64, cap);
|
||||
const wispSize = sizeOf(r.near, W * 2.3, H * 1.4, cap);
|
||||
// (the ceiling is always grown unless ready-made alpha art is given: a photograph of cloud tops
|
||||
// turned over reads as haze, not as the undersides of a cloud deck)
|
||||
jobs.push(
|
||||
topsSrc && !key
|
||||
? { src: topsSrc, crop: topsCrop, w: underSize[0], h: underSize[1], flip: true, blur: underSize[0] / 1000, copies: 1, k: null, shade: tone([0.86, 0.88, 0.91]) }
|
||||
: grow(underSize, G('bottom', { squash: 3.5, scale: 0.9, warp: 0.15, billow: 0.8, soft: 0.4, fade: 0.4, width: 0.07, contrast: 6, thick: 0.45, shade: tone([0.8, 0.84, 0.89]) }), 2, 1, 0.7),
|
||||
wispsSrc
|
||||
? { src: wispsSrc, crop: wispsCrop, w: wispSize[0], h: wispSize[1], flip: false, blur: wispSize[0] / 1400, copies: 1,
|
||||
k: key ? { edge: 'all', gain: 0.74, side: 0.14, vside: 0.2, qLo: 0.35, qHi: 0.97, shade: tone([0.78, 0.81, 0.85]), light: mist, mist } : null }
|
||||
: grow(wispSize, G('all', { scale: 1.4, squash: 3.6, warp: 0.6, oct: 5, billow: 0, thr: 0.12, width: 0.22, vside: 0.22, side: 0.14, alpha: 0.72, contrast: 3, shade: tone([0.78, 0.81, 0.85]) }), 3, 1));
|
||||
}
|
||||
let arts;
|
||||
try { arts = await cutInWorker(jobs); }
|
||||
catch (err) {
|
||||
if (err.message !== 'worker') throw err;
|
||||
arts = await cutOnMain(jobs, alive);
|
||||
}
|
||||
if (!alive()) { arts.forEach((a) => a && a.forEach((x) => x.close && x.close())); return; }
|
||||
const [tops, low, under, wisps] = arts;
|
||||
const items = rest
|
||||
? [{ c: r.deckTop, a: tops[0], deck: true }, { c: r.deckBottom, a: low[0], deck: true }]
|
||||
: [{ c: r.far, a: tops[0] }, { c: r.near, a: wisps[0] }, { c: r.under, a: under[0] },
|
||||
{ c: r.deckTop, a: tops[1], deck: true }, { c: r.deckBottom, a: low[0], deck: true }];
|
||||
await commit(r, items, token);
|
||||
}
|
||||
|
||||
// One canvas per frame, and only while the page is still: a layer never changes under a moving scroll.
|
||||
// The deck's art (and the class that drops its flat stand-in edges) waits until the scene is still or
|
||||
// the deck is off screen; the other layers go in after a short wait even if the reader keeps scrolling.
|
||||
function commit(r, items, token) {
|
||||
return new Promise((resolve) => {
|
||||
const t0 = performance.now();
|
||||
const step = () => {
|
||||
if (token !== r.token) { items.forEach((x) => x.a.close && x.a.close()); resolve(false); return; }
|
||||
const now = performance.now();
|
||||
const still = now - (r.moved || 0) > 140;
|
||||
const deckFree = still || !r.live || r.p < 0.2 || r.p > 0.82;
|
||||
const i = items.findIndex((x) => (x.deck ? deckFree : still || now - t0 > 1200));
|
||||
if (i >= 0) {
|
||||
const x = items.splice(i, 1)[0];
|
||||
show(x.c, x.a);
|
||||
if (x.deck && !items.some((y) => y.deck)) r.root.classList.add('wx-art');
|
||||
}
|
||||
if (!items.length) { resolve(true); return; }
|
||||
requestAnimationFrame(step);
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
});
|
||||
}
|
||||
|
||||
function cut(r, o, mode) {
|
||||
const token = ++r.token;
|
||||
r.root.classList.remove('wx-noart');
|
||||
const fail = (err, what) => {
|
||||
if (token !== r.token) return;
|
||||
if (!warned) { warned = true; console.warn('[' + ID + '] ' + what + ': ' + err.message); }
|
||||
};
|
||||
// a cloud photograph that cannot be read (CORS, 404) falls back to the grown cloud; if even that
|
||||
// cannot be drawn, the deck rises as the soft flat wall of mist
|
||||
paint(r, o, mode, token, !o.tops && !o.wisps).catch((err) => {
|
||||
if (token !== r.token) return;
|
||||
if (o.tops || o.wisps) {
|
||||
fail(err, 'cloud photograph unavailable, growing the cloud instead');
|
||||
return paint(r, o, mode, token, true);
|
||||
}
|
||||
throw err;
|
||||
}).catch((err) => {
|
||||
if (token !== r.token) return;
|
||||
r.root.classList.add('wx-noart');
|
||||
fail(err, 'cloud art unavailable, using the soft mist wall');
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- the scene ----------
|
||||
|
||||
window.Scenes = window.Scenes || {};
|
||||
window.Scenes[ID] = (root, opts = {}) => {
|
||||
const o = settings(opts);
|
||||
const r = setup(root);
|
||||
r.token = 0;
|
||||
r.p = 0;
|
||||
r.moved = 0;
|
||||
r.live = false;
|
||||
|
||||
// keep the title fitted in every state (rest included): after fonts, on resize
|
||||
let raf = 0;
|
||||
const refit = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(() => fit(r, o, window.matchMedia('(max-width: 767px)').matches)); };
|
||||
root.__wxRefit = refit;
|
||||
if (!root.__wxFit) {
|
||||
root.__wxFit = true;
|
||||
const call = () => root.__wxRefit();
|
||||
window.addEventListener('resize', call);
|
||||
if (document.fonts) { document.fonts.ready.then(call); document.fonts.addEventListener('loadingdone', call); }
|
||||
}
|
||||
refit();
|
||||
|
||||
// state the timeline leaves on the root: cleared whenever the scene is rebuilt or shows its rest
|
||||
const clean = () => {
|
||||
delete root.dataset.sky;
|
||||
root.classList.remove('wx-near');
|
||||
};
|
||||
|
||||
function build(tl, ctx) {
|
||||
const P = ctx.phone;
|
||||
const stage = r.stage;
|
||||
const H = () => stage.offsetHeight || window.innerHeight;
|
||||
const Wd = () => stage.offsetWidth || window.innerWidth;
|
||||
const sv = (n) => () => (n / 100) * H();
|
||||
const vw = (n) => () => (n / 100) * Wd();
|
||||
const F = P ? o.phoneFeather : o.feather;
|
||||
clean();
|
||||
r.live = true;
|
||||
r.p = 0;
|
||||
root.style.setProperty('--wx-feather', F + 'svh');
|
||||
root.style.setProperty('--wx-blur', (P ? Math.min(o.blur, 10) : o.blur) + 'px');
|
||||
fit(r, o, P);
|
||||
|
||||
// photo B must be decoded before it is revealed; the cloud art is cut while the hero is read
|
||||
if (r.bImg && r.bImg.decode) r.bImg.decode().catch(() => {});
|
||||
cut(r, o, P ? 'phone' : 'desktop');
|
||||
|
||||
// will-change only while the scene is near the screen
|
||||
ScrollTrigger.create({
|
||||
trigger: root, start: 'top bottom', end: 'bottom top',
|
||||
onToggle: (self) => root.classList.toggle('wx-near', self.isActive),
|
||||
});
|
||||
|
||||
let sky = '';
|
||||
tl.eventCallback('onUpdate', () => {
|
||||
const p = tl.progress();
|
||||
r.p = p;
|
||||
r.moved = performance.now();
|
||||
const s = p < 0.44 ? 'above' : p < 0.6 ? 'in' : 'below';
|
||||
if (s !== sky) { sky = s; root.dataset.sky = s; }
|
||||
});
|
||||
|
||||
// photo A and the title (the picture scales from a quarter down its height, so the zoom always
|
||||
// covers the drift and its foot never lifts off the frame); in the last of the deck's rise the
|
||||
// picture pales into the mist, so nothing sharp-edged still shows through the thinning cloud
|
||||
if (r.aImg) {
|
||||
tl.fromTo(r.aImg, { scale: 1, y: 0 }, { scale: P ? 1.08 : 1.12, y: sv(P ? -5 : -8), duration: 0.5 }, 0)
|
||||
.fromTo(r.aImg, { opacity: 1 }, { opacity: 0.4, duration: 0.07 }, 0.41);
|
||||
}
|
||||
// (photo A is held at 0.999, not 1: an opaque layer hides what lies under it from the browser, which
|
||||
// then leaves photo B, the haze and the ground band unpainted until the moment they show; behind a
|
||||
// not-quite-opaque photo they are painted while the hero is still read, before anything moves)
|
||||
if (r.above) tl.fromTo(r.above, { autoAlpha: 0.999 }, { autoAlpha: 0, duration: 0.01 }, 0.49);
|
||||
if (r.title) {
|
||||
tl.fromTo(r.title, { y: 0 }, { y: sv(P ? -30 : -42), duration: 0.5 }, 0);
|
||||
// each blurrier copy fades in on top before the one under it leaves, so the word never dims or
|
||||
// doubles; autoAlpha hides what has faded out, so nothing invisible still counts as text on screen
|
||||
// (on the phone the word is smaller against the cloud, so each step comes 0.03 later)
|
||||
const lag = P ? 0.03 : 0;
|
||||
// The lost copy carries the word's fade to a ghost (to 0.55 by 0.44, gone by 0.48) on its own
|
||||
// opacity: fading the whole heading would make the compositor draw the copies into a group
|
||||
// first on every frame.
|
||||
tl.fromTo(r.soft, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.1 }, 0.1 + lag)
|
||||
.fromTo(r.word, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.08 }, 0.17 + lag)
|
||||
.fromTo(r.lost, { autoAlpha: 0 }, { autoAlpha: 0.72, duration: 0.12 - lag }, 0.28 + lag)
|
||||
.fromTo(r.soft, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.08 - lag, immediateRender: false }, 0.36 + lag)
|
||||
.fromTo(r.lost, { autoAlpha: 0.72 }, { autoAlpha: 0.55, duration: 0.04, immediateRender: false }, 0.4)
|
||||
.fromTo(r.lost, { autoAlpha: 0.55 }, { autoAlpha: 0, duration: 0.04, immediateRender: false }, 0.44);
|
||||
}
|
||||
if (r.tagline) tl.fromTo(r.tagline, { autoAlpha: 1, y: 0 }, { autoAlpha: 0, y: sv(-3), duration: 0.08, ease: 'power1.in' }, P ? 0.16 : 0.14);
|
||||
|
||||
// clouds above the deck: the far sea of cloud tops and the near veil cross the word (on the phone
|
||||
// both start lower, so the cloud reaches the word's lower half only as the read window closes)
|
||||
tl.fromTo(r.far, { y: sv(P ? 114 : 96), x: 0 }, { y: sv(P ? 10 : 14), x: vw(P ? -10 : -6), duration: 0.5 }, 0)
|
||||
.fromTo(r.far, { autoAlpha: 0.9 }, { autoAlpha: 0, duration: 0.01 }, 0.5);
|
||||
tl.fromTo(r.near, { y: sv(P ? 122 : 118), x: 0, scale: 1 }, { y: sv(-30), x: P ? 0 : vw(8), scale: P ? 1.15 : 1.25, duration: 0.46 }, 0.04)
|
||||
.fromTo(r.near, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.01 }, 0.5);
|
||||
|
||||
// the deck: in with power1.in (it accelerates into the cloud), out with power1.out
|
||||
tl.fromTo(r.deck, { y: sv(100) }, { y: sv(-F), duration: 0.24, ease: 'power1.in' }, 0.24)
|
||||
.fromTo(r.deck, { y: sv(-F) }, { y: sv(-(2 * F + 100)), duration: 0.26, ease: 'power1.out', immediateRender: false }, 0.52);
|
||||
|
||||
// the one line on the white: it rises slowly through the mist on its own, held by the deck's solid
|
||||
// body the whole time it is visible (the body covers it from 0.44 to past 0.57)
|
||||
if (r.note) {
|
||||
tl.fromTo(r.note, { yPercent: -50, y: sv(14) }, { y: sv(-14), duration: 0.16 }, 0.42)
|
||||
.fromTo(r.note, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.03, ease: 'power1.out' }, 0.44)
|
||||
.fromTo(r.note, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.025, immediateRender: false }, 0.545);
|
||||
}
|
||||
|
||||
// the cloud ceiling behind the deck: farther off than the deck's own edge, so it lingers. It drifts
|
||||
// up slowly while the deck clears (its dense part held in the top quarter of the frame to about
|
||||
// 0.72, so arriving under the cloud reads as an image), then lifts away faster, gone by 0.86
|
||||
tl.fromTo(r.under, { autoAlpha: 0 }, { autoAlpha: 0.92, duration: 0.01 }, 0.5)
|
||||
.fromTo(r.under, { y: sv(P ? 4 : 6), x: 0 }, { y: sv(P ? -50 : -64), x: P ? 0 : vw(-5), duration: 0.32, ease: 'power2.in' }, 0.54);
|
||||
|
||||
// photo B and the landing
|
||||
if (r.bImg) tl.fromTo(r.bImg, { scale: P ? Math.min(o.settle, 1.12) : o.settle, y: sv(P ? 4 : 6) }, { scale: 1, y: 0, duration: 0.5, ease: 'power2.out' }, 0.5);
|
||||
// the haze and the ground band are large blurred fills: they wait at a trace of opacity (0.002,
|
||||
// invisible, under photo A) rather than hidden, so their blur is painted once while the hero is read
|
||||
// and never on the frame they appear
|
||||
tl.fromTo(r.haze, { opacity: 0.002 }, { opacity: 1, duration: 0.01 }, 0.49)
|
||||
.fromTo(r.haze, { opacity: 1 }, { opacity: 0, duration: 0.26, immediateRender: false }, 0.58)
|
||||
.fromTo(r.ground, { opacity: 0.002 }, { opacity: 1, duration: 0.14 }, 0.66);
|
||||
// one tween per line (a staggered fromTo only sets its first target before it starts); each line is
|
||||
// hidden while it waits in its mask and shown the moment it starts to rise
|
||||
r.lines.forEach((line, i) => {
|
||||
const at = 0.74 + i * 0.03;
|
||||
tl.fromTo(line, { yPercent: 110 }, { yPercent: 0, duration: 0.08, ease: 'power3.out' }, at)
|
||||
.fromTo(line, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.002 }, at);
|
||||
});
|
||||
}
|
||||
|
||||
// the finished composition: the seam of mist gets the keyed cloud on its edges (cut once per size)
|
||||
const W = () => window.innerWidth + 'x' + window.innerHeight;
|
||||
const rest = (again) => {
|
||||
clean();
|
||||
r.live = false;
|
||||
refit();
|
||||
if (!again && r.mode === 'rest' && r.restSize === W()) return;
|
||||
r.mode = 'rest';
|
||||
r.restSize = W();
|
||||
cut(r, o, 'rest');
|
||||
};
|
||||
const live = build;
|
||||
const mm = Stage.scene(root, {
|
||||
hold: o.hold,
|
||||
scrub: o.scrub ?? (Stage.lenis ? true : 0.8),
|
||||
build: (tl, ctx) => { r.mode = 'live'; live(tl, ctx); },
|
||||
phone: o.phone === 'rest' ? 'rest' : { hold: o.phoneHold, build: (tl, ctx) => { r.mode = 'live'; live(tl, ctx); } },
|
||||
rest: () => rest(false),
|
||||
});
|
||||
|
||||
// Stage.scene calls rest() only when one of its media conditions matches; a desktop with reduced
|
||||
// motion matches neither, so the scene also watches reduced motion itself. It checks a frame later,
|
||||
// after GSAP has reverted the running scene, and then also clears what the timeline left on the root.
|
||||
// The listeners belong to the root, not to this call: started again, the scene swaps in its new
|
||||
// handlers instead of piling up another pair.
|
||||
const settle = () => requestAnimationFrame(() => { if (!root.classList.contains('is-live')) rest(false); });
|
||||
if (!root.classList.contains('is-live')) settle();
|
||||
let rt = 0;
|
||||
root.__wxOn = {
|
||||
motion: () => settle(),
|
||||
resize: () => {
|
||||
clearTimeout(rt);
|
||||
rt = setTimeout(() => { if (r.mode === 'rest' && !root.classList.contains('is-live') && r.restSize !== W()) rest(true); }, 300);
|
||||
},
|
||||
};
|
||||
if (!root.__wxWatch) {
|
||||
root.__wxWatch = true;
|
||||
const rm = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const onMotion = () => root.__wxOn.motion();
|
||||
if (rm.addEventListener) rm.addEventListener('change', onMotion); else if (rm.addListener) rm.addListener(onMotion);
|
||||
window.addEventListener('resize', () => root.__wxOn.resize());
|
||||
}
|
||||
return mm;
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,246 @@
|
||||
/* tasteskill / scenes / fixed-hero-object.css — one product stays on its own layer and turns while the
|
||||
page's chapters slide up beneath it (fixed-hero-object.js has the DOM and the options).
|
||||
|
||||
By default this is the finished composition: the panels stacked, each on its own ground, each with
|
||||
its still, every number final. That is what no script, a failed CDN, reduced motion and the phone
|
||||
set to 'rest' show. Under .is-live the panels become a track inside the held stage and the object
|
||||
flies above them. Every rule hangs on [data-scene="fixed-hero-object"], so the section needs no other
|
||||
class. Tokens: --ground, --night, --ink, --paper, --accent, --font-display, --font-text, --margin.
|
||||
Six sizes: the title, the heading, the numbers, a lead (values and units), the body and a small size
|
||||
(captions, labels, the count, the action).
|
||||
Weights follow the page: --display-weight (the heading and the figures; 600 here when the page sets
|
||||
none) and --strong-weight (the spec values, the units, the button and the counter; 500 here when the
|
||||
page sets none). A weight the page has not loaded is never faked: the nearest loaded one shows. */
|
||||
|
||||
[data-scene="fixed-hero-object"] { font-synthesis-weight: none; }
|
||||
|
||||
[data-scene="fixed-hero-object"] {
|
||||
--fho-m: var(--margin, clamp(20px, 4.4vw, 72px));
|
||||
--fho-paper: var(--ground, #e5e4e0);
|
||||
--fho-paper-2: #f0f0ee;
|
||||
--fho-paper-2: color-mix(in srgb, var(--fho-paper) 55%, #fff);
|
||||
--fho-night: var(--night, #161714);
|
||||
--fho-night-2: #0f100e;
|
||||
--fho-night-2: color-mix(in srgb, var(--fho-night) 70%, #000);
|
||||
--fho-ink: var(--ink, #151614);
|
||||
--fho-on-dark: var(--paper, #ecebe6);
|
||||
--fho-accent: var(--accent, #ff5a1f);
|
||||
--fho-display: var(--font-display, inherit);
|
||||
--fho-text: var(--font-text, inherit);
|
||||
--fho-obj: min(80svh, 54vw);
|
||||
--fho-title: clamp(56px, 7vw, 120px);
|
||||
--fho-head: clamp(40px, 5.6vw, 104px);
|
||||
--fho-num: clamp(44px, 5.8vw, 108px);
|
||||
--fho-lead: clamp(20px, 1.5vw, 26px);
|
||||
--fho-body: clamp(17px, 1.4vw, 24px);
|
||||
--fho-small: clamp(15px, 1.1vw, 18px);
|
||||
font-family: var(--fho-text);
|
||||
}
|
||||
|
||||
/* grounds: light panels step lighter, dark panels step darker, in turn */
|
||||
[data-scene="fixed-hero-object"] .fho-panel { --fho-g: var(--fho-paper); --fho-c: var(--fho-ink); position: relative; box-sizing: border-box; background: var(--fho-g); color: var(--fho-c); }
|
||||
[data-scene="fixed-hero-object"] .fho-panel:nth-child(even) { --fho-g: var(--fho-paper-2); }
|
||||
[data-scene="fixed-hero-object"] .fho-panel.is-dark { --fho-g: var(--fho-night); --fho-c: var(--fho-on-dark); }
|
||||
[data-scene="fixed-hero-object"] .fho-panel.is-dark:nth-child(even) { --fho-g: var(--fho-night-2); }
|
||||
[data-scene="fixed-hero-object"] .fho-panel { --fho-muted: color-mix(in srgb, var(--fho-c) 64%, var(--fho-g)); }
|
||||
|
||||
/* type */
|
||||
[data-scene="fixed-hero-object"] .fho-title,
|
||||
[data-scene="fixed-hero-object"] .fho-head { margin: 0; font-family: var(--fho-display); font-weight: var(--display-weight, 600); text-wrap: balance; }
|
||||
[data-scene="fixed-hero-object"] .fho-title { font-size: calc(var(--fho-title) * var(--fho-fit, 1)); line-height: 0.92; letter-spacing: -0.035em; }
|
||||
[data-scene="fixed-hero-object"] .fho-head { font-size: calc(var(--fho-head) * var(--fho-fit, 1)); line-height: 0.98; letter-spacing: -0.035em; }
|
||||
/* live, each .fho-line is one line: the script sets a line too long for its column smaller (--fho-fit) */
|
||||
[data-scene="fixed-hero-object"].is-live .fho-title .fho-in,
|
||||
[data-scene="fixed-hero-object"].is-live .fho-head .fho-in { white-space: nowrap; }
|
||||
[data-scene="fixed-hero-object"].is-live .fho-block > .fho-in,
|
||||
[data-scene="fixed-hero-object"].is-live .is-wrapped .fho-in { white-space: normal; }
|
||||
[data-scene="fixed-hero-object"] .fho-line { display: block; overflow: clip; padding: 0.06em 0 0.1em; margin: -0.06em 0 -0.1em; }
|
||||
[data-scene="fixed-hero-object"] .fho-in { display: block; }
|
||||
[data-scene="fixed-hero-object"] .fho-caption { margin: 0; font-size: var(--fho-small); line-height: 1.45; color: var(--fho-muted); max-width: 19rem; text-wrap: pretty; }
|
||||
[data-scene="fixed-hero-object"] .fho-copy > p { margin: 0; font-size: var(--fho-body); line-height: 1.5; max-width: 32ch; text-wrap: pretty; }
|
||||
[data-scene="fixed-hero-object"] .fho-copy > .fho-head + * { margin-top: clamp(20px, 3.6svh, 40px); }
|
||||
[data-scene="fixed-hero-object"] .fho-copy > p + p { margin-top: 1em; }
|
||||
|
||||
/* the spec sheet: rows on a step of tone, no rules */
|
||||
[data-scene="fixed-hero-object"] .fho-specs { margin: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 4px; }
|
||||
[data-scene="fixed-hero-object"] .fho-specs > div {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
||||
min-height: clamp(40px, 6.4svh, 60px); padding: 0 18px;
|
||||
background: color-mix(in srgb, var(--fho-c) 7%, transparent);
|
||||
}
|
||||
[data-scene="fixed-hero-object"] .fho-specs dt { font-size: var(--fho-small); color: var(--fho-muted); }
|
||||
[data-scene="fixed-hero-object"] .fho-specs dd { margin: 0; font-size: var(--fho-lead); font-weight: var(--strong-weight, 500); letter-spacing: -0.01em; white-space: nowrap; }
|
||||
|
||||
/* the dock: gauge, numbers, the action */
|
||||
[data-scene="fixed-hero-object"] .fho-gauge { display: block; overflow: visible; color: var(--fho-c); }
|
||||
[data-scene="fixed-hero-object"] .fho-arc { fill: none; stroke: color-mix(in srgb, currentColor 16%, transparent); stroke-width: 2.4; stroke-dasharray: 100 120; stroke-dashoffset: 0; }
|
||||
[data-scene="fixed-hero-object"] .fho-arc--fill { stroke: var(--fho-accent); stroke-dashoffset: calc(100 - var(--fho-v, 0.72) * 100); }
|
||||
[data-scene="fixed-hero-object"] .fho-readouts { list-style: none; margin: 0; padding: 0; display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 16px; text-align: center; }
|
||||
[data-scene="fixed-hero-object"] .fho-readouts li { margin: 0; white-space: nowrap; }
|
||||
/* four readouts start smaller; live, the script fits them to their columns */
|
||||
[data-scene="fixed-hero-object"] .fho-readouts:has(> li:nth-child(4)) { --fho-nfit: 0.7; }
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-readouts li { white-space: normal; }
|
||||
[data-scene="fixed-hero-object"] .fho-num { display: inline-block; font: var(--display-weight, 600) calc(var(--fho-num) * var(--fho-nfit, 1)) / 1 var(--fho-display); letter-spacing: -0.03em; text-align: right; margin-right: 0.1em; }
|
||||
/* the unit gives way a little when the numbers are set smaller, never below the small size */
|
||||
[data-scene="fixed-hero-object"] .fho-unit { font-size: max(var(--fho-small), calc(var(--fho-lead) * (0.5 + 0.5 * var(--fho-nfit, 1)))); font-weight: var(--strong-weight, 500); letter-spacing: -0.01em; }
|
||||
/* readings too many or too long to sit beside their units: each unit goes under its number (set by the script) */
|
||||
[data-scene="fixed-hero-object"] .fho-readouts.is-stacked .fho-unit { display: block; margin-top: 6px; line-height: 1.2; }
|
||||
/* stacked, nothing sits beside the number: it counts centred over its unit */
|
||||
[data-scene="fixed-hero-object"] .fho-readouts.is-stacked .fho-num { text-align: center; margin-right: 0; }
|
||||
[data-scene="fixed-hero-object"] .fho-readouts.is-stacked .fho-label { margin-top: 2px; }
|
||||
[data-scene="fixed-hero-object"] .fho-label { display: block; margin-top: 8px; font-size: var(--fho-small); line-height: 1.3; color: var(--fho-muted); white-space: normal; }
|
||||
[data-scene="fixed-hero-object"] .fho-cta {
|
||||
display: inline-flex; align-items: center; justify-content: center; height: 48px; padding: 0 28px;
|
||||
border-radius: 999px; background: var(--fho-accent); color: #151614; text-decoration: none;
|
||||
font: var(--strong-weight, 500) var(--fho-small) / 1 var(--fho-text); transition: background-color 180ms ease;
|
||||
}
|
||||
[data-scene="fixed-hero-object"] .fho-cta:hover,
|
||||
[data-scene="fixed-hero-object"] .fho-cta:focus-visible { background: color-mix(in srgb, var(--fho-accent) 82%, #fff); }
|
||||
[data-scene="fixed-hero-object"] .fho-cta:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
|
||||
|
||||
[data-scene="fixed-hero-object"] .fho-still { margin: 0; }
|
||||
[data-scene="fixed-hero-object"] .fho-still img { display: block; width: 100%; height: auto; }
|
||||
|
||||
/* ---------------------------------------------------------------- the finished composition */
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-object,
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-counter { display: none; }
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel { min-height: 80svh; padding: 12svh var(--fho-m); overflow: clip; }
|
||||
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel:first-child { display: grid; align-content: start; row-gap: 3svh; }
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel:first-child .fho-still { justify-self: center; width: var(--fho-obj); grid-row: 2; }
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel:first-child .fho-caption { grid-row: 3; }
|
||||
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel:not(:first-child):not(.fho-dock) {
|
||||
display: grid; align-items: center; column-gap: 4vw;
|
||||
grid-template-columns: minmax(0, 36vw) minmax(0, 1fr);
|
||||
}
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel:not(:first-child):not(.fho-dock) > .fho-copy { grid-row: 1; grid-column: 1; }
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel:not(:first-child):not(.fho-dock) > .fho-still {
|
||||
grid-row: 1; grid-column: 2; justify-self: start; width: calc(var(--fho-obj) * 1.3); max-width: none;
|
||||
}
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel:nth-child(odd):not(:first-child):not(.fho-dock):not([data-lane="right"]),
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel[data-lane="left"]:not(:first-child):not(.fho-dock) { grid-template-columns: minmax(0, 1fr) minmax(0, 38vw); }
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel:nth-child(odd):not(:first-child):not(.fho-dock):not([data-lane="right"]) > .fho-copy,
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel[data-lane="left"]:not(:first-child):not(.fho-dock) > .fho-copy { grid-column: 2; }
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel:nth-child(odd):not(:first-child):not(.fho-dock):not([data-lane="right"]) > .fho-still,
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel[data-lane="left"]:not(:first-child):not(.fho-dock) > .fho-still { grid-column: 1; justify-self: end; width: min(50vw, var(--fho-obj) * 1.1); }
|
||||
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-dock { display: grid; justify-items: center; row-gap: 3svh; }
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-dock > .fho-head { justify-self: start; grid-row: 1; }
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-dock > .fho-still { grid-row: 2; width: calc(var(--fho-obj) * 0.62); }
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-dock > .fho-gauge { grid-row: 3; grid-column: 1; width: min(100svh, 66vw); }
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-dock > .fho-readouts { grid-row: 3; grid-column: 1; align-self: end; width: min(84svh, 54vw); margin-bottom: 4%; }
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-dock > .fho-cta { grid-row: 4; }
|
||||
|
||||
/* ---------------------------------------------------------------- live: the held stage */
|
||||
/* the stage writes --p on the section on every scroll update; inherited, it would restyle every element
|
||||
of the scene each frame, and nothing here reads it, so it stops at the stage (read it on the section) */
|
||||
[data-scene="fixed-hero-object"].is-live > .scene-stage { --p: initial; }
|
||||
[data-scene="fixed-hero-object"].is-live .fho-track { position: absolute; inset: 0 0 auto; height: calc(var(--fho-n, 4) * 100%); z-index: 1; will-change: transform; }
|
||||
[data-scene="fixed-hero-object"].is-live .fho-panel { height: calc(100% / var(--fho-n, 4)); overflow: clip; }
|
||||
[data-scene="fixed-hero-object"].is-live .fho-still { display: none; }
|
||||
|
||||
[data-scene="fixed-hero-object"].is-live .fho-object {
|
||||
position: absolute; left: 50%; top: 65%; z-index: 3;
|
||||
width: var(--fho-obj); aspect-ratio: 4 / 3;
|
||||
pointer-events: none; will-change: transform;
|
||||
}
|
||||
[data-scene="fixed-hero-object"] .fho-tilt,
|
||||
[data-scene="fixed-hero-object"] .fho-poster,
|
||||
[data-scene="fixed-hero-object"] .fho-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
|
||||
[data-scene="fixed-hero-object"] .fho-tilt { will-change: transform; }
|
||||
|
||||
/* the count, in two inks: each is clipped to the ground beneath it, so a passing edge cuts the words */
|
||||
[data-scene="fixed-hero-object"].is-live .fho-counter {
|
||||
position: absolute; top: 5svh; right: var(--fho-m); z-index: 4;
|
||||
font: var(--strong-weight, 500) var(--fho-small) / 1.2 var(--fho-text); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
[data-scene="fixed-hero-object"] .fho-count { display: flex; align-items: flex-start; gap: 0.3em; color: var(--fho-ink); }
|
||||
[data-scene="fixed-hero-object"] .fho-count + .fho-count { position: absolute; inset: 0; color: var(--fho-on-dark); }
|
||||
[data-scene="fixed-hero-object"] .fho-digits { display: block; height: 1.2em; overflow: clip; }
|
||||
[data-scene="fixed-hero-object"] .fho-roll { display: block; }
|
||||
[data-scene="fixed-hero-object"] .fho-roll > span { display: block; height: 1.2em; text-align: right; }
|
||||
|
||||
/* the hero: title top left, caption bottom left, the object in the middle */
|
||||
[data-scene="fixed-hero-object"].is-live .fho-panel:first-child .fho-title { position: absolute; left: var(--fho-m); top: 11svh; max-width: min(62vw, 15ch); }
|
||||
[data-scene="fixed-hero-object"].is-live .fho-caption { position: absolute; left: var(--fho-m); bottom: 6svh; width: clamp(13.5rem, 18vw, 19rem); }
|
||||
|
||||
/* chapters: the copy in the lane the object is not in (placed by the panel, so no transform of its own) */
|
||||
[data-scene="fixed-hero-object"].is-live .fho-panel:not(:first-child):not(.fho-dock) { display: flex; align-items: center; }
|
||||
[data-scene="fixed-hero-object"].is-live .is-lane-right .fho-copy { margin-left: var(--fho-m); width: 40vw; min-width: 0; }
|
||||
[data-scene="fixed-hero-object"].is-live .is-lane-left .fho-copy { margin-left: 55vw; width: min(40vw, calc(45vw - var(--fho-m))); min-width: 0; }
|
||||
[data-scene="fixed-hero-object"].is-live .is-lane-left .fho-copy > p { max-width: none; }
|
||||
[data-scene="fixed-hero-object"].is-live .fho-specs > div { min-height: clamp(30px, calc(58svh / var(--fho-rows, 5) - 4px), 60px); }
|
||||
|
||||
/* the dock: heading top left, gauge and numbers under the docked object, the action bottom right */
|
||||
/* the heading keeps left of the docked object, 6vw clear of it (--fho-room, measured by the script) */
|
||||
[data-scene="fixed-hero-object"].is-live .fho-dock > .fho-head { position: absolute; left: var(--fho-m); top: 11svh; width: max-content; max-width: min(var(--fho-room, 100vw), calc(34vw - var(--fho-m)), 7.4em); }
|
||||
[data-scene="fixed-hero-object"].is-live .fho-gauge { position: absolute; left: 50%; bottom: 4svh; width: min(100svh, 62vw); translate: -50% 0; }
|
||||
[data-scene="fixed-hero-object"].is-live .fho-readouts { position: absolute; left: 50%; bottom: 9svh; width: min(84svh, 52vw); translate: -50% 0; }
|
||||
[data-scene="fixed-hero-object"].is-live .fho-dock > .fho-cta { position: absolute; right: var(--fho-m); bottom: 6svh; }
|
||||
|
||||
/* ---------------------------------------------------------------- vertical: phones and tall stages */
|
||||
@media (max-width: 767px), (max-aspect-ratio: 1/1) {
|
||||
[data-scene="fixed-hero-object"] {
|
||||
--fho-obj: min(88vw, 52svh);
|
||||
--fho-title: clamp(40px, 11.4vw, 76px);
|
||||
--fho-head: clamp(30px, 8.6vw, 48px);
|
||||
--fho-num: clamp(28px, 8.6vw, 48px);
|
||||
--fho-lead: 18px;
|
||||
--fho-body: 16px;
|
||||
--fho-small: 15px;
|
||||
}
|
||||
[data-scene="fixed-hero-object"] .fho-label { margin-top: 6px; }
|
||||
[data-scene="fixed-hero-object"] .fho-unit { font-size: var(--fho-small); }
|
||||
[data-scene="fixed-hero-object"] .fho-readouts { gap: 8px; }
|
||||
[data-scene="fixed-hero-object"] .fho-arc { stroke-width: 3.6; }
|
||||
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel { padding: 10svh var(--fho-m); }
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel:not(:first-child):not(.fho-dock),
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel[data-lane]:not(:first-child):not(.fho-dock),
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel:nth-child(odd):not(:first-child):not(.fho-dock):not([data-lane="right"]) { grid-template-columns: minmax(0, 1fr); row-gap: 4svh; }
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel:not(:first-child):not(.fho-dock) > .fho-still,
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel[data-lane]:not(:first-child):not(.fho-dock) > .fho-still,
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel:nth-child(odd):not(:first-child):not(.fho-dock):not([data-lane="right"]) > .fho-still { grid-row: 1; grid-column: 1; justify-self: center; width: var(--fho-obj); }
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel:not(:first-child):not(.fho-dock) > .fho-copy,
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel[data-lane]:not(:first-child):not(.fho-dock) > .fho-copy,
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-panel:nth-child(odd):not(:first-child):not(.fho-dock):not([data-lane="right"]) > .fho-copy { grid-row: 2; grid-column: 1; }
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-dock > .fho-gauge { width: min(84vw, 26rem); }
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-dock > .fho-readouts { grid-row: 4; width: 100%; margin: 0; }
|
||||
[data-scene="fixed-hero-object"]:not(.is-live) .fho-dock > .fho-cta { grid-row: 5; justify-self: stretch; }
|
||||
|
||||
[data-scene="fixed-hero-object"].is-live .fho-object { top: 52%; }
|
||||
[data-scene="fixed-hero-object"].is-live .fho-counter { top: 6svh; }
|
||||
[data-scene="fixed-hero-object"].is-live .fho-panel:first-child .fho-title { top: 11svh; right: var(--fho-m); max-width: none; }
|
||||
[data-scene="fixed-hero-object"].is-live .fho-caption { right: var(--fho-m); bottom: 6svh; width: auto; max-width: 30rem; }
|
||||
/* the copy sits at the foot of the lower lane, so a short chapter leaves the object more room */
|
||||
[data-scene="fixed-hero-object"].is-live .fho-panel:not(:first-child):not(.fho-dock) { align-items: flex-end; padding: 44svh 0 7svh; }
|
||||
[data-scene="fixed-hero-object"].is-live .is-lane-right .fho-copy,
|
||||
[data-scene="fixed-hero-object"].is-live .is-lane-left .fho-copy { flex: 1 1 auto; width: auto; min-width: 0; max-width: 34rem; margin: 0 var(--fho-m); }
|
||||
[data-scene="fixed-hero-object"].is-live .fho-specs > div { min-height: clamp(28px, calc(34svh / var(--fho-rows, 5) - 4px), 46px); padding: 0 14px; }
|
||||
/* a long sheet goes into two columns, label above value */
|
||||
[data-scene="fixed-hero-object"].is-live .fho-specs:has(> div:nth-child(7)) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
[data-scene="fixed-hero-object"].is-live .fho-specs:has(> div:nth-child(7)) > div { min-height: 0; flex-direction: column; align-items: flex-start; justify-content: center; gap: 0; padding: 6px 12px; }
|
||||
[data-scene="fixed-hero-object"].is-live .fho-specs:has(> div:nth-child(7)) dd { font-size: var(--fho-body); }
|
||||
[data-scene="fixed-hero-object"].is-live .fho-dock > .fho-head { top: 6svh; width: auto; max-width: none; right: 28vw; }
|
||||
[data-scene="fixed-hero-object"].is-live .fho-gauge { top: 70svh; bottom: auto; width: min(84vw, 43svh); translate: -50% -96.2%; }
|
||||
[data-scene="fixed-hero-object"].is-live .fho-readouts { top: 73svh; bottom: auto; left: var(--fho-m); right: var(--fho-m); width: auto; max-width: 34rem; margin: 0 auto; translate: none; }
|
||||
[data-scene="fixed-hero-object"].is-live .fho-dock > .fho-cta { left: var(--fho-m); right: var(--fho-m); bottom: 6svh; max-width: 34rem; margin: 0 auto; }
|
||||
}
|
||||
|
||||
/* tall stages wider than a phone (tablets held upright): the same vertical route with larger type */
|
||||
@media (min-width: 768px) and (max-aspect-ratio: 1/1) {
|
||||
[data-scene="fixed-hero-object"] {
|
||||
--fho-head: clamp(44px, 6.6vw, 80px);
|
||||
--fho-num: clamp(36px, 5vw, 60px);
|
||||
--fho-lead: 22px;
|
||||
--fho-body: 20px;
|
||||
--fho-small: 16px;
|
||||
}
|
||||
[data-scene="fixed-hero-object"] .fho-copy > p { max-width: 36ch; }
|
||||
[data-scene="fixed-hero-object"].is-live .is-lane-right .fho-copy,
|
||||
[data-scene="fixed-hero-object"].is-live .is-lane-left .fho-copy { max-width: 44rem; }
|
||||
[data-scene="fixed-hero-object"].is-live .fho-specs > div { min-height: clamp(32px, calc(36svh / var(--fho-rows, 5) - 4px), 56px); padding: 0 18px; }
|
||||
[data-scene="fixed-hero-object"].is-live .fho-readouts,
|
||||
[data-scene="fixed-hero-object"].is-live .fho-dock > .fho-cta { max-width: 44rem; }
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/* tasteskill / scenes / fly-into-the-shape.css — the brand's mark turns to face you, then you fly into it.
|
||||
|
||||
Without .is-live (no script, reduced motion, no clip-path: path(), a phone set to 'rest') this is the
|
||||
finished composition of the same markup. "in": the chapter's world fills the stage (its picture under
|
||||
a flat scrim), the mark sits above the title in the accent, the sub-line below. "out": the paper the
|
||||
reader returns to, the closing title in ink and the solid mark under it.
|
||||
With .is-live the stage is held while the script writes the mark's outline into the world's clip-path
|
||||
every frame. Layers, bottom to top: the paper, the ink title, the extruded side (dark accent, clipped
|
||||
to the mark swept along the side), the world (clipped to the mark's face), and a light copy of the
|
||||
title clipped to the whole mark, face and side (both made by the script).
|
||||
Weights follow the page: --display-weight (the title; 800 here when the page sets none). A weight the
|
||||
page has not loaded is never faked: the nearest loaded one shows. */
|
||||
|
||||
[data-scene="fly-into-the-shape"] { font-synthesis-weight: none; }
|
||||
|
||||
[data-scene="fly-into-the-shape"] {
|
||||
--_paper: var(--ground, #ECE9E2);
|
||||
--_ink: var(--ink, #121212);
|
||||
--_accent: var(--accent, #1F5BFF);
|
||||
--_side: var(--portal-side, color-mix(in srgb, var(--_accent) 55%, #000));
|
||||
--_night: var(--portal-night, #05060B);
|
||||
--_light: var(--portal-light, #F5F7FF);
|
||||
--_soft: var(--portal-soft, #C9D2E6);
|
||||
--_muted: var(--portal-muted, #8A93A8);
|
||||
--_size: var(--portal-size, clamp(44px, 9vw, 152px));
|
||||
--_inset: 24px;
|
||||
--_mark: 72px;
|
||||
}
|
||||
|
||||
/* ---------- the finished composition ---------- */
|
||||
|
||||
[data-scene="fly-into-the-shape"] > .scene-stage {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
min-height: max(560px, 100svh);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: 1fr auto auto auto 1fr;
|
||||
justify-items: center;
|
||||
padding: 88px 16px;
|
||||
overflow: clip;
|
||||
isolation: isolate;
|
||||
background: var(--_night);
|
||||
color: var(--_light);
|
||||
}
|
||||
|
||||
/* the layers dissolve into the stage's grid, so the mark, the title and the sub-line stack in flow */
|
||||
[data-scene="fly-into-the-shape"] .portal-outside,
|
||||
[data-scene="fly-into-the-shape"] .portal-world,
|
||||
[data-scene="fly-into-the-shape"] .portal-world > picture { display: contents; }
|
||||
|
||||
[data-scene="fly-into-the-shape"] .portal-media {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: var(--portal-focus, 50% 50%);
|
||||
}
|
||||
[data-scene="fly-into-the-shape"] .portal-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: rgb(5 6 11 / 0.34);
|
||||
}
|
||||
[data-scene="fly-into-the-shape"] .portal-tint,
|
||||
[data-scene="fly-into-the-shape"] .portal-cue,
|
||||
[data-scene="fly-into-the-shape"] .portal-depth,
|
||||
[data-scene="fly-into-the-shape"] .portal-title--in { display: none; }
|
||||
|
||||
/* rendered (not display:none) so its path stays measurable; never seen */
|
||||
[data-scene="fly-into-the-shape"] .portal-src {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* every copy of the title shares these rules, so the copies stay pixel-identical */
|
||||
[data-scene="fly-into-the-shape"] .portal-title {
|
||||
box-sizing: border-box;
|
||||
width: min(14ch, 86vw);
|
||||
margin: 0;
|
||||
font-family: var(--font-display, inherit);
|
||||
font-weight: var(--display-weight, 800);
|
||||
font-size: var(--_size);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.01em;
|
||||
text-align: center;
|
||||
}
|
||||
[data-scene="fly-into-the-shape"] .portal-line { display: block; }
|
||||
|
||||
[data-scene="fly-into-the-shape"] .portal-mark {
|
||||
grid-row: 2;
|
||||
grid-column: 1;
|
||||
display: block;
|
||||
width: var(--_mark);
|
||||
height: var(--_mark);
|
||||
margin: 0 0 32px;
|
||||
fill: var(--_accent);
|
||||
overflow: visible;
|
||||
}
|
||||
[data-scene="fly-into-the-shape"] .portal-title--out {
|
||||
grid-row: 3;
|
||||
grid-column: 1;
|
||||
position: relative;
|
||||
color: var(--_light);
|
||||
}
|
||||
[data-scene="fly-into-the-shape"] .portal-sub {
|
||||
grid-row: 4;
|
||||
grid-column: 1;
|
||||
box-sizing: border-box;
|
||||
max-width: 38ch;
|
||||
margin: 28px 0 0;
|
||||
font-family: var(--font-text, inherit);
|
||||
font-size: clamp(18px, 1.4vw, 21px);
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
text-wrap: pretty;
|
||||
color: var(--_soft);
|
||||
}
|
||||
[data-scene="fly-into-the-shape"] .portal-aside {
|
||||
position: absolute;
|
||||
top: var(--_inset);
|
||||
left: var(--_inset);
|
||||
max-width: calc(100% - 2 * var(--_inset));
|
||||
margin: 0;
|
||||
font-family: var(--font-text, inherit);
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
color: var(--_muted);
|
||||
}
|
||||
|
||||
/* "out" ends on the paper: the closing title in ink, the solid mark under it */
|
||||
[data-scene="fly-into-the-shape"][data-dir="out"] > .scene-stage {
|
||||
background: var(--_paper);
|
||||
color: var(--_ink);
|
||||
}
|
||||
[data-scene="fly-into-the-shape"][data-dir="out"] .portal-media,
|
||||
[data-scene="fly-into-the-shape"][data-dir="out"] .portal-scrim { display: none; }
|
||||
[data-scene="fly-into-the-shape"][data-dir="out"] .portal-title--out { grid-row: 2; color: var(--_ink); }
|
||||
[data-scene="fly-into-the-shape"][data-dir="out"] .portal-sub { grid-row: 3; color: var(--_ink); }
|
||||
[data-scene="fly-into-the-shape"][data-dir="out"] .portal-mark { grid-row: 4; width: 96px; height: 96px; margin: 40px 0 0; }
|
||||
[data-scene="fly-into-the-shape"][data-dir="out"] .portal-aside { color: var(--_ink); }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
[data-scene="fly-into-the-shape"] { --_size: var(--portal-size, clamp(44px, 13.5vw, 64px)); --_inset: 16px; --_mark: 56px; }
|
||||
[data-scene="fly-into-the-shape"] .portal-title { width: min(14ch, calc(100vw - 32px)); }
|
||||
[data-scene="fly-into-the-shape"] .portal-sub { font-size: 16px; max-width: calc(100% - 16px); }
|
||||
[data-scene="fly-into-the-shape"][data-dir="out"] .portal-mark { width: 72px; height: 72px; }
|
||||
}
|
||||
|
||||
/* ---------- live: the held stage ---------- */
|
||||
|
||||
html.js .scene.is-live[data-scene="fly-into-the-shape"] > .scene-stage {
|
||||
display: block;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
background: var(--_paper);
|
||||
color: var(--_ink);
|
||||
}
|
||||
|
||||
/* the outgoing paper, under everything */
|
||||
.scene.is-live[data-scene="fly-into-the-shape"] .portal-outside {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background: var(--_paper);
|
||||
}
|
||||
.scene.is-live[data-scene="fly-into-the-shape"] .portal-aside { color: var(--_ink); }
|
||||
.scene.is-live[data-scene="fly-into-the-shape"] .portal-cue {
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: var(--_inset);
|
||||
translate: -50% 0;
|
||||
margin: 0;
|
||||
font-family: var(--font-text, inherit);
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
color: var(--_ink);
|
||||
}
|
||||
.scene.is-live[data-scene="fly-into-the-shape"][data-dir="out"] .portal-cue { display: none; }
|
||||
.scene.is-live[data-scene="fly-into-the-shape"][data-dir="out"] .portal-aside { opacity: 0; }
|
||||
|
||||
/* the title copies: centred on the stage, scaled about their centre, never moved */
|
||||
.scene.is-live[data-scene="fly-into-the-shape"] .portal-title {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
translate: -50% -50%;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
.scene.is-live[data-scene="fly-into-the-shape"] .portal-title--out { z-index: 1; color: var(--_ink); }
|
||||
|
||||
/* the extruded side: the mark swept down and to the right (the script writes its clip-path), over the
|
||||
ink title and under the world */
|
||||
.scene.is-live[data-scene="fly-into-the-shape"] .portal-depth {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
background: var(--_side);
|
||||
clip-path: inset(50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* the side deepens towards the night as the face clears (the script sets its opacity), so it always
|
||||
reads darker than the face beside it */
|
||||
.scene.is-live[data-scene="fly-into-the-shape"] .portal-depth-night {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--_night);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* the light title, over the world, clipped to the whole mark (face and side) by the script */
|
||||
.scene.is-live[data-scene="fly-into-the-shape"] .portal-lit {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
clip-path: inset(50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.scene.is-live[data-scene="fly-into-the-shape"] .portal-title--light { color: var(--_light); }
|
||||
|
||||
/* the world inside the mark: never transformed, only clipped (the script writes its clip-path) */
|
||||
.scene.is-live[data-scene="fly-into-the-shape"] .portal-world {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
background: none;
|
||||
color: var(--_light);
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
/* its night ground is painted by a child, not by the clipped layer itself: Chrome antialiases an
|
||||
element's own background against its clip-path differently from its children, which leaves a dark
|
||||
fringe along the face's edge */
|
||||
.scene.is-live[data-scene="fly-into-the-shape"] .portal-world::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -3;
|
||||
background: var(--_night);
|
||||
}
|
||||
/* the picture grows about the stage's centre (the script sets the origin wherever its box is). It is
|
||||
not promoted to its own layer: under a clip-path that changes every frame, a composited picture adds
|
||||
a full-screen mask and an offscreen pass, which cost more than painting it with the world */
|
||||
.scene.is-live[data-scene="fly-into-the-shape"] .portal-media {
|
||||
display: block;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
.scene.is-live[data-scene="fly-into-the-shape"] .portal-scrim { display: block; }
|
||||
.scene.is-live[data-scene="fly-into-the-shape"] .portal-tint {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background: var(--_accent);
|
||||
}
|
||||
.scene.is-live[data-scene="fly-into-the-shape"] .portal-mark { display: none; }
|
||||
.scene.is-live[data-scene="fly-into-the-shape"] .portal-sub {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: calc(50% + var(--portal-th, 2em) / 2 + 28px);
|
||||
z-index: 1;
|
||||
width: min(38ch, calc(100% - 32px));
|
||||
max-width: none;
|
||||
translate: -50% 0;
|
||||
margin: 0;
|
||||
color: var(--_soft);
|
||||
opacity: 0;
|
||||
}
|
||||
.scene.is-live[data-scene="fly-into-the-shape"][data-dir="out"] .portal-sub { opacity: 1; }
|
||||
@@ -0,0 +1,743 @@
|
||||
// tasteskill / scenes / fly-into-the-shape.js — the brand's own mark turns to face you, then you fly into it.
|
||||
//
|
||||
// The mark rides up with the content and arrives at the centre of the screen exactly as the stage is
|
||||
// held: a big, solid mark with a real extruded side, standing behind a large centred title. It turns to
|
||||
// face you, then you fly at it: the camera moves at a steady speed, so the mark grows slowly while it
|
||||
// turns and the next chapter (a photograph of its world) shows through its face, then rushes past every
|
||||
// edge of the screen, the way a near object does when you fly through it. It grows about the centre of
|
||||
// its largest inscribed circle, and the world inside keeps coming towards you for the rest of the hold.
|
||||
// The title is never faded: two identical copies split it along the mark's edges (ink on the paper,
|
||||
// light on the face and on the dark side), so every fragment of every glyph sits on its own ground. The
|
||||
// face starts as the solid accent and clears to the picture while the mark is still a shape on the
|
||||
// paper; the chapter's first sentence settles last.
|
||||
// data-dir="out" plays it backwards at a chapter's end: the world shrinks back into the mark, the face
|
||||
// fills with the accent, and the mark turns upright as it lands behind the closing title, so a pair of
|
||||
// scenes lets the reader leave the way they came in.
|
||||
//
|
||||
// Needs gsap + ScrollTrigger, stage.js and fly-into-the-shape.css. Use it as the doorway into a dark
|
||||
// or immersive chapter on a page whose brand has an ownable, solid symbol (a monogram, an emblem, a
|
||||
// building's silhouette, a keyhole). Skip it when there is no such mark: a plain circle is the generic
|
||||
// version. The clips are written as clip-path: path() in screen pixels every frame, so the edges stay
|
||||
// vector-sharp at any zoom; browsers without clip-path: path() get the finished composition.
|
||||
//
|
||||
// DOM (the CSS file draws the finished composition from the same markup):
|
||||
// <section class="scene" data-scene="fly-into-the-shape" data-dir="in" data-options='{}' aria-labelledby="t">
|
||||
// <div class="scene-stage">
|
||||
// <div class="portal-outside"> the outgoing paper (var(--ground))
|
||||
// <p class="portal-aside">3-6 words</p> optional: top-left, fades as the dive starts
|
||||
// <p class="portal-cue" aria-hidden="true">Scroll</p> optional: bottom centre (live only)
|
||||
// </div>
|
||||
// <h2 class="portal-title portal-title--out" id="t"> the title, ink on the paper
|
||||
// <span class="portal-line">Line one</span> <span class="portal-line">line two</span>
|
||||
// </h2> 2-6 words on 2 lines, 12 characters a line at most
|
||||
// <div class="portal-world"> the next chapter; never transformed, only clipped
|
||||
// <picture><source media="(max-width: 767px)" srcset="9:16 crop">
|
||||
// <img class="portal-media" src alt width height></picture> or <video class="portal-media" muted loop playsinline poster>
|
||||
// <div class="portal-scrim" aria-hidden="true"></div> flat scrim so light type reads on the picture
|
||||
// <div class="portal-tint" aria-hidden="true"></div> the mark's face: the solid accent, cleared by scroll
|
||||
// <svg class="portal-mark" viewBox="0 0 100 100" aria-hidden="true"><use href="#mark"/></svg> finished state only
|
||||
// <p class="portal-sub">10-18 words that open the chapter</p>
|
||||
// </div>
|
||||
// <svg class="portal-src" viewBox="0 0 100 100" width="0" height="0" aria-hidden="true">
|
||||
// <path id="mark" d="one closed path" data-anchor="50 61.2" data-rin="34.7"/>
|
||||
// </svg>
|
||||
// </div>
|
||||
// </section>
|
||||
// While it runs, the script adds the extruded side (<div class="portal-depth">, between the ink title
|
||||
// and the world) and the light copy of the title (<div class="portal-lit">, over the world, clipped to
|
||||
// the whole mark). An older .portal-title--in inside the world is not needed and stays hidden.
|
||||
// The picture may be sized and placed by the page (for example taller than the stage, hanging below
|
||||
// it, so a bright foreground stays out of sight): the script grows it about the stage's centre,
|
||||
// wherever its box is.
|
||||
//
|
||||
// The split title and the page inspector: scripts/inspect.mjs reports "covered-text" for this scene,
|
||||
// because the ink h2 lies under the mark, and the light copy over it is what the reader sees there.
|
||||
// That is the design, not a collision: run the inspector with --allow covered-text for a page that
|
||||
// uses this scene, and never "fix" it by removing a copy of the title (the mark's edge would then pass
|
||||
// over ink on blue, or white on paper).
|
||||
//
|
||||
// The mark: one closed path in its viewBox (a 100x100 box is usual), any SVG commands (relative,
|
||||
// shorthand and arcs are converted on load). It needs a solid core: an inscribed circle of at least
|
||||
// 15% of the box height, or the cover zoom runs long and the edge sweeps for too long.
|
||||
// data-anchor (the centre of the largest inscribed circle) and data-rin (its radius) are computed when
|
||||
// they are missing. fill-rule="evenodd" on the path is honoured.
|
||||
//
|
||||
// Options (data-options JSON; defaults, ranges):
|
||||
// dir "in" "in" | "out" prefer the data-dir attribute, which the CSS also reads
|
||||
// hold 2 1.4-2.6 screens the stage is held on desktop
|
||||
// phoneHold 1.6 1.2-2.4 screens on a phone (under 768px)
|
||||
// size 0.4 0.2-0.6 height of the mark's box at rest, as a share of the stage's height (and
|
||||
// at most 0.45 of its width): the hero of the first frame, behind the
|
||||
// title, which its edges split. A number above 1 is read as px (96-720).
|
||||
// "fit": the smallest mark that holds the whole title inside its face.
|
||||
// When a size leaves the mark only a little smaller than the title (65%
|
||||
// or more of the size that would hold it, as on a portrait tablet), its
|
||||
// edges would cut just the ends of the lines into small ink chips, so the
|
||||
// mark grows to hold the whole title instead.
|
||||
// phoneSize "fit" "fit" | 0.3-0.75 | px 64-420 on a phone. "fit" (default): the smallest mark,
|
||||
// from half the width up to 0.98 of the width (the mark itself, not its
|
||||
// box) and half the height, whose face holds every line of the title at
|
||||
// rest (turned and upright), so no glyph is cut into small ink chips on a
|
||||
// narrow screen; when the title is too wide for that, a mark of 0.45 of
|
||||
// the width sits behind the middle of the title and splits it.
|
||||
// A share is of the stage's width (at most 0.4 of its height).
|
||||
// turn 45 0-180 degrees of the turn: "in" runs -turn -> 0 while the mark is still
|
||||
// small, "out" lands from +turn -> 0 as the mark shrinks home
|
||||
// phoneTurn 12 0-180 a smaller turn on a phone keeps the big mark on the screen
|
||||
// depth 0.06 0-0.15 thickness of the extruded side at rest, as a share of the mark's box
|
||||
// (above 1 = px); it thickens as the mark comes closer. 0 = a flat mark
|
||||
// phoneDepth 0.05 0-0.15
|
||||
// ease "fly" the approach: "fly" (default: the camera moves at a steady speed, so the mark
|
||||
// grows slowly, then rushes past the edges of the screen), "sine.inOut"
|
||||
// (log space: a legible lock, then a gentle arrival) or "none" (log space:
|
||||
// a constant rate of growth)
|
||||
// zoom 1.4 1-1.8 how far the world's picture grows over the whole hold (about the
|
||||
// stage's centre, at a steady rate), so you keep travelling into the
|
||||
// world after the mark has passed
|
||||
// blur 0 0-6 px of blur on the world's picture through the fastest part of the dive
|
||||
// (desktop only). Off by default: a full-screen filter every frame cost
|
||||
// 2-3x the frame time in testing; 2-3 is enough when the machines allow it
|
||||
// scrub true with Lenis, else 0.8 seconds of catch-up, or true (locked to the scrollbar)
|
||||
// phone "same" "same" | "rest" (the phone gets the finished composition)
|
||||
//
|
||||
// Colours come from the page: var(--ground) paper, var(--ink) title outside, var(--accent) the mark's
|
||||
// face (light type sits on it, so keep 3:1 or more against --portal-light), --portal-side the extruded
|
||||
// side (default: the accent mixed with 45% black), --portal-night the world's ground (give the next
|
||||
// section exactly this ground), --portal-light the title inside, --portal-soft the sub-line.
|
||||
// --portal-focus sets the picture's focal point (object-position, default 50% 50%); the picture always
|
||||
// grows about the anchor, the centre of the stage. The side follows the face: the accent mixed with
|
||||
// black while the face is solid, deepening towards --portal-night as the face clears to the picture,
|
||||
// so it always reads about half as light as the face beside it.
|
||||
//
|
||||
// Pictures: one landscape photograph of the chapter's world, 16:10 or 16:9, 2000px or more, dark
|
||||
// (mean luminance about 20% or less) with a calm centre across 40% x 30% where the title and the
|
||||
// sub-line sit, a dark bottom edge (the stage scrolls up into the next section's flat ground), and a
|
||||
// 9:16 crop for phones. A night sky, a deep interior, a lit street all suit. Pictures decode before
|
||||
// they move: the script holds the picture still until img.decode() resolves.
|
||||
// Or a loop: <video class="portal-media" muted loop playsinline poster="…"> with a 6-10 s seamless
|
||||
// clip of 4MB or less (stars turning, snow falling through a lamp's light, traffic trails). It plays
|
||||
// only while the section is on screen, grows like a picture, and keeps the world alive in the dwell.
|
||||
//
|
||||
// Testing: the section carries data-z, data-rot, data-cover, data-box (the mark's box at rest, px)
|
||||
// and data-fit (the box that would hold the whole title, px; 0 when none fits) while it runs.
|
||||
// Cover is reached at 76% of the hold ("in") and left at 10% ("out"), on every screen and mark.
|
||||
(() => {
|
||||
const ID = 'fly-into-the-shape';
|
||||
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
|
||||
const num = (v, d, a, b) => { const n = typeof v === 'string' && v.trim() !== '' ? +v : v; return typeof n === 'number' && isFinite(n) ? clamp(n, a, b) : d; };
|
||||
// a share (0-1) or, above 1, px
|
||||
const share = (v, d, a, b, pa, pb) => { const n = num(v, d, 0, 1e4); return n > 1 ? clamp(n, pa, pb) : clamp(n, a, b); };
|
||||
const TAU = Math.PI * 2;
|
||||
const f1 = (v) => v.toFixed(1);
|
||||
if (window.ScrollTrigger) ScrollTrigger.config({ ignoreMobileResize: true });
|
||||
|
||||
// ---------- one SVG path -> absolute M / L / C / Q / Z segments ----------
|
||||
// Relative commands, H/V, S/T shorthand and arcs are rewritten, so the per-frame transform only has
|
||||
// to move points.
|
||||
function arcToCubics(x1, y1, rx, ry, phi, fa, fs, x2, y2) {
|
||||
if (!rx || !ry || (x1 === x2 && y1 === y2)) return [[x1, y1, x2, y2, x2, y2]];
|
||||
rx = Math.abs(rx); ry = Math.abs(ry);
|
||||
const sp = Math.sin((phi * Math.PI) / 180), cp = Math.cos((phi * Math.PI) / 180);
|
||||
const dx = (x1 - x2) / 2, dy = (y1 - y2) / 2;
|
||||
const xp = cp * dx + sp * dy, yp = -sp * dx + cp * dy;
|
||||
const lam = (xp * xp) / (rx * rx) + (yp * yp) / (ry * ry);
|
||||
if (lam > 1) { rx *= Math.sqrt(lam); ry *= Math.sqrt(lam); }
|
||||
const nume = rx * rx * ry * ry - rx * rx * yp * yp - ry * ry * xp * xp;
|
||||
const deno = rx * rx * yp * yp + ry * ry * xp * xp;
|
||||
const co = (fa === fs ? -1 : 1) * Math.sqrt(Math.max(0, nume / (deno || 1)));
|
||||
const cxp = (co * rx * yp) / ry, cyp = (-co * ry * xp) / rx;
|
||||
const cx = cp * cxp - sp * cyp + (x1 + x2) / 2, cy = sp * cxp + cp * cyp + (y1 + y2) / 2;
|
||||
const ang = (ux, uy, vx, vy) => Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy);
|
||||
const t1 = ang(1, 0, (xp - cxp) / rx, (yp - cyp) / ry);
|
||||
let dt = ang((xp - cxp) / rx, (yp - cyp) / ry, (-xp - cxp) / rx, (-yp - cyp) / ry);
|
||||
if (!fs && dt > 0) dt -= TAU; else if (fs && dt < 0) dt += TAU;
|
||||
const n = Math.max(1, Math.ceil(Math.abs(dt) / (Math.PI / 2) - 1e-9)), d = dt / n, k = (4 / 3) * Math.tan(d / 4);
|
||||
const e = (ux, uy) => [cp * rx * ux - sp * ry * uy + cx, sp * rx * ux + cp * ry * uy + cy];
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a1 = t1 + i * d, a2 = a1 + d, c1 = Math.cos(a1), s1 = Math.sin(a1), c2 = Math.cos(a2), s2 = Math.sin(a2);
|
||||
out.push([...e(c1 - k * s1, s1 + k * c1), ...e(c2 + k * s2, s2 - k * c2), ...e(c2, s2)]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parsePath(d) {
|
||||
const t = String(d).match(/[a-df-zA-DF-Z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g) || [];
|
||||
const segs = [];
|
||||
let i = 0, cmd = '', x = 0, y = 0, sx = 0, sy = 0, lc = null, lq = null;
|
||||
const isCmd = (s) => /^[a-zA-Z]$/.test(s);
|
||||
const n = () => { const v = +t[i++]; if (!isFinite(v)) throw new Error('bad number'); return v; };
|
||||
const flag = () => { const s = t[i]; if (s && s.length > 1 && (s[0] === '0' || s[0] === '1')) { t[i] = s.slice(1); return +s[0]; } i++; return +s; };
|
||||
while (i < t.length) {
|
||||
if (isCmd(t[i])) cmd = t[i++];
|
||||
else if (!cmd) throw new Error('path must start with M');
|
||||
const C = cmd.toUpperCase(), rel = cmd !== C, ox = rel ? x : 0, oy = rel ? y : 0;
|
||||
if (C === 'Z') { segs.push({ c: 'Z', p: [] }); x = sx; y = sy; lc = lq = null; if (i < t.length && !isCmd(t[i])) throw new Error('numbers after Z'); continue; }
|
||||
if (C === 'M') { x = ox + n(); y = oy + n(); sx = x; sy = y; segs.push({ c: 'M', p: [x, y] }); cmd = rel ? 'l' : 'L'; lc = lq = null; }
|
||||
else if (C === 'L') { x = ox + n(); y = oy + n(); segs.push({ c: 'L', p: [x, y] }); lc = lq = null; }
|
||||
else if (C === 'H') { x = ox + n(); segs.push({ c: 'L', p: [x, y] }); lc = lq = null; }
|
||||
else if (C === 'V') { y = oy + n(); segs.push({ c: 'L', p: [x, y] }); lc = lq = null; }
|
||||
else if (C === 'C' || C === 'S') {
|
||||
const r = C === 'S' ? (lc ? [2 * x - lc[0], 2 * y - lc[1]] : [x, y]) : [ox + n(), oy + n()];
|
||||
const p = [r[0], r[1], ox + n(), oy + n(), ox + n(), oy + n()];
|
||||
segs.push({ c: 'C', p }); lc = [p[2], p[3]]; lq = null; x = p[4]; y = p[5];
|
||||
} else if (C === 'Q' || C === 'T') {
|
||||
const r = C === 'T' ? (lq ? [2 * x - lq[0], 2 * y - lq[1]] : [x, y]) : [ox + n(), oy + n()];
|
||||
const p = [r[0], r[1], ox + n(), oy + n()];
|
||||
segs.push({ c: 'Q', p }); lq = [p[0], p[1]]; lc = null; x = p[2]; y = p[3];
|
||||
} else if (C === 'A') {
|
||||
const rx = n(), ry = n(), phi = n(), fa = flag(), fs = flag(), ex = ox + n(), ey = oy + n();
|
||||
for (const p of arcToCubics(x, y, rx, ry, phi, fa, fs, ex, ey)) segs.push({ c: 'C', p });
|
||||
x = ex; y = ey; lc = lq = null;
|
||||
} else throw new Error('unknown command ' + cmd);
|
||||
}
|
||||
if (!segs.length || segs[0].c !== 'M') throw new Error('empty path');
|
||||
return segs;
|
||||
}
|
||||
|
||||
// ---------- segments -> closed sub-paths of edges ----------
|
||||
// Every edge carries its own start: { c: 'L', p: [x0, y0, x1, y1] } or { c: 'C', p: [x0, y0, c1, c2, x1, y1] }
|
||||
// (quadratics become cubics), and every sub-path is closed with a line when it ends elsewhere.
|
||||
function toSubs(segs) {
|
||||
const subs = [];
|
||||
let cur = null, x = 0, y = 0;
|
||||
const end = () => {
|
||||
if (!cur) return;
|
||||
if (Math.hypot(x - cur.x0, y - cur.y0) > 1e-6) cur.edges.push({ c: 'L', p: [x, y, cur.x0, cur.y0] });
|
||||
x = cur.x0; y = cur.y0; cur = null;
|
||||
};
|
||||
for (const s of segs) {
|
||||
const p = s.p;
|
||||
if (s.c === 'M') { end(); x = p[0]; y = p[1]; continue; }
|
||||
if (s.c === 'Z') { end(); continue; }
|
||||
if (!cur) { cur = { x0: x, y0: y, edges: [] }; subs.push(cur); }
|
||||
if (s.c === 'L') cur.edges.push({ c: 'L', p: [x, y, p[0], p[1]] });
|
||||
else if (s.c === 'Q') cur.edges.push({ c: 'C', p: [x, y, x + (2 / 3) * (p[0] - x), y + (2 / 3) * (p[1] - y), p[2] + (2 / 3) * (p[0] - p[2]), p[3] + (2 / 3) * (p[1] - p[3]), p[2], p[3]] });
|
||||
else cur.edges.push({ c: 'C', p: [x, y, ...p] });
|
||||
x = p[p.length - 2]; y = p[p.length - 1];
|
||||
}
|
||||
end();
|
||||
return subs.filter((s) => s.edges.length);
|
||||
}
|
||||
const reverseSub = (s) => {
|
||||
const edges = s.edges.slice().reverse().map((e) => { const q = []; for (let k = e.p.length - 2; k >= 0; k -= 2) q.push(e.p[k], e.p[k + 1]); return { c: e.c, p: q }; });
|
||||
return { x0: edges[0].p[0], y0: edges[0].p[1], edges };
|
||||
};
|
||||
|
||||
// Points along the outline (densest where it is longest), one closed ring per sub-path.
|
||||
const ctrlLen = (p) => { let L = 0; for (let k = 2; k < p.length; k += 2) L += Math.hypot(p[k] - p[k - 2], p[k + 1] - p[k - 1]); return L; };
|
||||
const bez = (p, u) => { const v = 1 - u; return [v * v * v * p[0] + 3 * v * v * u * p[2] + 3 * v * u * u * p[4] + u * u * u * p[6], v * v * v * p[1] + 3 * v * v * u * p[3] + 3 * v * u * u * p[5] + u * u * u * p[7]]; };
|
||||
function sample(subs, total = 2048) {
|
||||
const sum = subs.reduce((a, s) => a + s.edges.reduce((b, e) => b + ctrlLen(e.p), 0), 0) || 1;
|
||||
return subs.map((s) => {
|
||||
const ring = [[s.x0, s.y0]];
|
||||
for (const e of s.edges) {
|
||||
const p = e.p, m = Math.max(2, Math.round((total * ctrlLen(p)) / sum));
|
||||
for (let k = 1; k <= m; k++) { const u = k / m; ring.push(e.c === 'L' ? [p[0] + (p[2] - p[0]) * u, p[1] + (p[3] - p[1]) * u] : bez(p, u)); }
|
||||
}
|
||||
return ring;
|
||||
});
|
||||
}
|
||||
const area = (r) => { let a = 0; for (let i = 0, j = r.length - 1; i < r.length; j = i++) a += r[j][0] * r[i][1] - r[i][0] * r[j][1]; return a / 2; };
|
||||
|
||||
const inside = (rings, x, y) => {
|
||||
let c = false;
|
||||
for (const r of rings) for (let i = 0, j = r.length - 1; i < r.length; j = i++) {
|
||||
const [xi, yi] = r[i], [xj, yj] = r[j];
|
||||
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) c = !c;
|
||||
}
|
||||
return c;
|
||||
};
|
||||
const edgeDist = (rings, x, y) => {
|
||||
let m = Infinity;
|
||||
for (const r of rings) for (let i = 0, j = r.length - 1; i < r.length; j = i++) {
|
||||
const ax = r[j][0], ay = r[j][1], dx = r[i][0] - ax, dy = r[i][1] - ay;
|
||||
const q = clamp(((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy || 1), 0, 1);
|
||||
m = Math.min(m, Math.hypot(x - ax - q * dx, y - ay - q * dy));
|
||||
}
|
||||
return m;
|
||||
};
|
||||
|
||||
// The pole of inaccessibility (centre of the largest inscribed circle): a grid search, refined twice.
|
||||
function pole(rings) {
|
||||
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||
for (const r of rings) for (const [x, y] of r) { x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y); }
|
||||
const coarse = rings.map((r) => r.filter((_, k) => k % 4 === 0));
|
||||
let best = [(x0 + x1) / 2, (y0 + y1) / 2, 0];
|
||||
let step = Math.max(x1 - x0, y1 - y0) / 48;
|
||||
const scan = (ax, ay, bx, by, st, rs) => {
|
||||
for (let y = ay; y <= by; y += st) for (let x = ax; x <= bx; x += st) {
|
||||
if (!inside(rs, x, y)) continue;
|
||||
const d = edgeDist(rs, x, y);
|
||||
if (d > best[2]) best = [x, y, d];
|
||||
}
|
||||
};
|
||||
scan(x0, y0, x1, y1, step, coarse);
|
||||
for (let k = 0; k < 2; k++) { const [bx, by] = best; best[2] = 0; scan(bx - step, by - step, bx + step, by + step, step / 6, rings); step /= 6; }
|
||||
best[2] = edgeDist(rings, best[0], best[1]);
|
||||
return best;
|
||||
}
|
||||
|
||||
// First-exit radius from the anchor in each 1-degree bin (in the shape's own frame).
|
||||
function exitTable(rings, ax, ay) {
|
||||
const bins = new Array(360).fill(Infinity);
|
||||
for (const r of rings) for (const [x, y] of r) {
|
||||
const a = ((Math.atan2(y - ay, x - ax) * 180) / Math.PI + 360) % 360;
|
||||
const b = Math.floor(a) % 360, d = Math.hypot(x - ax, y - ay);
|
||||
if (d < bins[b]) bins[b] = d;
|
||||
}
|
||||
// empty bins take the nearer of their nearest filled neighbours
|
||||
const filled = bins.map((v) => v < Infinity);
|
||||
if (!filled.some(Boolean)) return null;
|
||||
return bins.map((v, b) => {
|
||||
if (filled[b]) return v;
|
||||
for (let k = 1; k < 180; k++) {
|
||||
const l = bins[(b - k + 360) % 360], r = bins[(b + k) % 360];
|
||||
if (l < Infinity || r < Infinity) return Math.min(l, r);
|
||||
}
|
||||
return v;
|
||||
});
|
||||
}
|
||||
|
||||
// Zoom at which the mark's thinnest part, rotated by rot degrees, passes every point of a W x H
|
||||
// screen's edge (96 points with the corners), with a 3% and 2px safety margin.
|
||||
function coverZoom(table, rin, s0, W, H, rot) {
|
||||
const cx = W / 2, cy = H / 2;
|
||||
const fallback = (1.03 * (Math.hypot(cx, cy) + 2)) / (s0 * rin);
|
||||
if (!table) return fallback;
|
||||
const pts = [[0, 0], [W, 0], [W, H], [0, H]];
|
||||
const per = 2 * (W + H);
|
||||
for (let k = 0; k < 92; k++) {
|
||||
let d = (per * (k + 0.5)) / 92;
|
||||
if (d < W) pts.push([d, 0]); else if ((d -= W) < H) pts.push([W, d]); else if ((d -= H) < W) pts.push([W - d, H]); else pts.push([0, H - (d - W)]);
|
||||
}
|
||||
let z = 0;
|
||||
for (const [px, py] of pts) {
|
||||
const a = ((((Math.atan2(py - cy, px - cx) * 180) / Math.PI - rot) % 360) + 360) % 360;
|
||||
let r = Infinity;
|
||||
for (let k = -2; k <= 2; k++) r = Math.min(r, table[(Math.floor(a) + k + 360) % 360]);
|
||||
if (!(r > 0) || r === Infinity) return fallback;
|
||||
z = Math.max(z, (Math.hypot(px - cx, py - cy) + 2) / (s0 * r));
|
||||
}
|
||||
return Math.max(1, 1.03 * z);
|
||||
}
|
||||
|
||||
const P = (x, y) => f1(x) + ' ' + f1(y);
|
||||
|
||||
// ---------- the extruded side, exactly ----------
|
||||
// The mark swept along a vector v is the union of the mark, the mark moved by v, and, for every edge,
|
||||
// the ribbon that edge sweeps. A ribbon folds over itself where its edge runs parallel to v, so curves
|
||||
// are split there; then every ribbon is wound the same way as the mark's outer rings, and one nonzero
|
||||
// path holds the exact solid in a few dozen pieces (a stack of copies needs hundreds, and costs a
|
||||
// full-screen mask far more to paint every frame).
|
||||
const cubicSplit = (q, t) => {
|
||||
const l = (a, b) => a + (b - a) * t;
|
||||
const ax = l(q[0], q[2]), ay = l(q[1], q[3]), bx = l(q[2], q[4]), by = l(q[3], q[5]), cx = l(q[4], q[6]), cy = l(q[5], q[7]);
|
||||
const dx = l(ax, bx), dy = l(ay, by), ex = l(bx, cx), ey = l(by, cy), fx = l(dx, ex), fy = l(dy, ey);
|
||||
return [[q[0], q[1], ax, ay, dx, dy, fx, fy], [fx, fy, ex, ey, cx, cy, q[6], q[7]]];
|
||||
};
|
||||
// where a cubic's tangent is parallel to (vx, vy): roots in (0, 1) of cross(B'(t), v) = 0
|
||||
const parallelAt = (q, vx, vy) => {
|
||||
const cr = (x, y) => x * vy - y * vx;
|
||||
const a = cr(q[2] - q[0], q[3] - q[1]), b = cr(q[4] - q[2], q[5] - q[3]), c = cr(q[6] - q[4], q[7] - q[5]);
|
||||
const A = a - 2 * b + c, B = 2 * (b - a), C = a, eps = 1e-9 * (Math.abs(a) + Math.abs(b) + Math.abs(c) + 1);
|
||||
let ts = [];
|
||||
if (Math.abs(A) < eps) { if (Math.abs(B) > eps) ts = [-C / B]; } else {
|
||||
const D = B * B - 4 * A * C;
|
||||
if (D >= 0) { const r = Math.sqrt(D); ts = [(-B - r) / (2 * A), (-B + r) / (2 * A)]; }
|
||||
}
|
||||
return ts.filter((t) => t > 1e-4 && t < 1 - 1e-4).sort((x, y) => x - y);
|
||||
};
|
||||
// one ribbon piece (a line [x0 y0 x1 y1] or a cubic [x0 y0 c1 c2 x1 y1] that never turns parallel to v)
|
||||
function ribbon(q, vx, vy, sg) {
|
||||
const cub = q.length === 8, L = q.length;
|
||||
const tx = cub ? q[6] + q[4] - q[2] - q[0] : q[2] - q[0], ty = cub ? q[7] + q[5] - q[3] - q[1] : q[3] - q[1];
|
||||
const o = Math.sign(tx * vy - ty * vx);
|
||||
if (!o) return '';
|
||||
const fw = (a, dx, dy) => (cub ? 'C' + P(a[2] + dx, a[3] + dy) + ' ' + P(a[4] + dx, a[5] + dy) + ' ' + P(a[6] + dx, a[7] + dy) : 'L' + P(a[2] + dx, a[3] + dy));
|
||||
const bw = (a, dx, dy) => (cub ? 'C' + P(a[4] + dx, a[5] + dy) + ' ' + P(a[2] + dx, a[3] + dy) + ' ' + P(a[0] + dx, a[1] + dy) : 'L' + P(a[0] + dx, a[1] + dy));
|
||||
// forward (along the edge, back along its copy) winds with the sign of cross(tangent, v)
|
||||
if (o === sg) return 'M' + P(q[0], q[1]) + fw(q, 0, 0) + 'L' + P(q[L - 2] + vx, q[L - 1] + vy) + bw(q, vx, vy) + 'Z';
|
||||
return 'M' + P(q[0], q[1]) + 'L' + P(q[0] + vx, q[1] + vy) + fw(q, vx, vy) + 'L' + P(q[L - 2], q[L - 1]) + bw(q, 0, 0) + 'Z';
|
||||
}
|
||||
function ribbons(scr, vx, vy, sg) {
|
||||
let s = '';
|
||||
for (const sp of scr) for (const e of sp) {
|
||||
if (e.length === 4) { s += ribbon(e, vx, vy, sg); continue; }
|
||||
let rest = e, done = 0;
|
||||
for (const t of parallelAt(e, vx, vy)) { const [a, b] = cubicSplit(rest, (t - done) / (1 - done)); s += ribbon(a, vx, vy, sg); rest = b; done = t; }
|
||||
s += ribbon(rest, vx, vy, sg);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Parsed marks are kept per path element, so a page can hold a pair of scenes on one mark.
|
||||
const cache = new WeakMap();
|
||||
function readMark(path) {
|
||||
if (cache.has(path)) return cache.get(path);
|
||||
const subs = toSubs(parsePath(path.getAttribute('d') || ''));
|
||||
if (!subs.length) throw new Error('no closed shape');
|
||||
const rings = sample(subs);
|
||||
const svg = path.ownerSVGElement;
|
||||
const vb = svg && svg.viewBox && svg.viewBox.baseVal && svg.viewBox.baseVal.height ? svg.viewBox.baseVal : null;
|
||||
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||
for (const r of rings) for (const [x, y] of r) { x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y); }
|
||||
const box = vb ? Math.max(vb.width, vb.height) : Math.max(x1 - x0, y1 - y0);
|
||||
const given = String(path.dataset.anchor || '').trim().split(/[\s,]+/).map(Number);
|
||||
let ax, ay, rin;
|
||||
if (given.length === 2 && given.every(isFinite) && inside(rings, given[0], given[1])) {
|
||||
[ax, ay] = given;
|
||||
rin = num(path.dataset.rin, edgeDist(rings, ax, ay), 0.001, 1e6);
|
||||
} else [ax, ay, rin] = pole(rings);
|
||||
const table = exitTable(rings, ax, ay);
|
||||
const evenodd = (path.getAttribute('fill-rule') || '').trim() === 'evenodd';
|
||||
if (rin / box < 0.15) console.warn(`${ID}: the mark's solid core is thin (inscribed radius ${(Math.floor(1000 * rin / box) / 10).toFixed(1)}% of its box); the dive will run long. 15% or more reads best.`);
|
||||
// the side is a nonzero union of copies: wind every outer ring one way (sg) and, for an even-odd
|
||||
// mark, its holes the other way; a nonzero mark keeps its own windings inside each outer ring
|
||||
const areas = rings.map(area);
|
||||
let big = 0;
|
||||
areas.forEach((a, i) => { if (Math.abs(a) > Math.abs(areas[big])) big = i; });
|
||||
const sg = Math.sign(areas[big]) || 1;
|
||||
const depthOf = rings.map((r, i) => rings.reduce((d, q, j) => d + (j !== i && inside([q], r[0][0], r[0][1]) ? 1 : 0), 0));
|
||||
const rootOf = (i) => (depthOf[i] === 0 ? i : rings.findIndex((q, j) => j !== i && depthOf[j] === 0 && inside([q], rings[i][0][0], rings[i][0][1])));
|
||||
const sideSubs = subs.map((s, i) => {
|
||||
const flip = evenodd ? Math.sign(areas[i]) !== (depthOf[i] % 2 ? -sg : sg) : Math.sign(areas[rootOf(i)] ?? areas[i]) !== sg;
|
||||
return flip ? reverseSub(s) : s;
|
||||
});
|
||||
// a lighter copy of the outline for the title-fit test
|
||||
const coarse = rings.map((r) => r.filter((_, k) => k % 4 === 0));
|
||||
const mark = { subs, sideSubs, sg, ax, ay, rin, box, table, coarse, bw: (x1 - x0) / box, bh: (y1 - y0) / box, rule: evenodd ? 'evenodd, ' : '' };
|
||||
cache.set(path, mark);
|
||||
return mark;
|
||||
}
|
||||
|
||||
window.Scenes = window.Scenes || {};
|
||||
window.Scenes[ID] = (root, opts = {}) => {
|
||||
const dir = String(root.dataset.dir || opts.dir || 'in') === 'out' ? 'out' : 'in';
|
||||
root.dataset.dir = dir;
|
||||
// a share, px above 1, or "fit"; anything else keeps the default
|
||||
const sizeOpt = (v, d, a, b, pa, pb) => {
|
||||
if (v === 'fit') return 'fit';
|
||||
const n = typeof v === 'string' && v.trim() !== '' ? +v : v;
|
||||
return typeof n === 'number' && isFinite(n) && n > 0 ? share(n, 0, a, b, pa, pb) : d;
|
||||
};
|
||||
const o = {
|
||||
hold: num(opts.hold, 2, 1.4, 2.6),
|
||||
phoneHold: num(opts.phoneHold, 1.6, 1.2, 2.4),
|
||||
size: sizeOpt(opts.size, 0.4, 0.2, 0.6, 96, 720),
|
||||
phoneSize: sizeOpt(opts.phoneSize, 'fit', 0.3, 0.75, 64, 420),
|
||||
turn: num(opts.turn, 45, 0, 180),
|
||||
phoneTurn: num(opts.phoneTurn, 12, 0, 180),
|
||||
depth: share(opts.depth, 0.06, 0, 0.15, 0, 48),
|
||||
phoneDepth: share(opts.phoneDepth, 0.05, 0, 0.15, 0, 24),
|
||||
ease: opts.ease === 'none' || opts.ease === 'sine.inOut' ? opts.ease : 'fly',
|
||||
zoom: num(opts.zoom, 1.4, 1, 1.8),
|
||||
blur: num(opts.blur, 0, 0, 6),
|
||||
scrub: opts.scrub === true ? true : typeof opts.scrub === 'number' ? clamp(opts.scrub, 0, 2) : null,
|
||||
phone: opts.phone === 'rest' ? 'rest' : 'same',
|
||||
};
|
||||
const $ = (s) => root.querySelector(s);
|
||||
const $$ = (s) => [...root.querySelectorAll(s)];
|
||||
|
||||
// Without clip-path: path() the scene keeps its finished composition.
|
||||
const canClip = window.CSS && CSS.supports && CSS.supports('clip-path', 'path("M0 0L1 0L1 1Z")');
|
||||
const srcPath = $('.portal-src path') || (() => { const u = $('.portal-mark use'); const id = u && (u.getAttribute('href') || u.getAttribute('xlink:href') || '').replace(/^#/, ''); return id ? document.getElementById(id) : null; })();
|
||||
let mark = null;
|
||||
try { if (srcPath) mark = readMark(srcPath); } catch (e) { console.warn(`${ID}: the mark's path could not be read (${e.message}); showing the finished composition.`); }
|
||||
if (!srcPath) console.warn(`${ID}: needs <svg class="portal-src"><path d="…"></svg> for the mark; showing the finished composition.`);
|
||||
|
||||
function build(tl, ctx) {
|
||||
const { stage, phone } = ctx;
|
||||
const world = $('.portal-world'), outTitle = $('.portal-title--out');
|
||||
const media = $('.portal-media'), tint = $('.portal-tint'), sub = $('.portal-sub'), outside = $('.portal-outside');
|
||||
const IN = dir === 'in';
|
||||
// the paper's notes leave as the dive starts ("in"), or arrive once the mark is home ("out";
|
||||
// a scroll cue makes no sense there and stays hidden in the CSS)
|
||||
const notes = $$(IN ? '.portal-aside, .portal-cue' : '.portal-aside');
|
||||
const turn = phone ? o.phoneTurn : o.turn;
|
||||
const sizeWant = phone ? o.phoneSize : o.size;
|
||||
const depthOpt = phone ? o.phoneDepth : o.depth;
|
||||
const blurPx = phone ? 0 : o.blur;
|
||||
const FLY = o.ease === 'fly';
|
||||
const TS0 = 0.94; // the title's scale at rest
|
||||
let dead = false;
|
||||
const made = [];
|
||||
|
||||
gsap.context(() => {
|
||||
// the extruded side: a dark accent layer between the ink title and the world, clipped to the
|
||||
// mark swept along the side's vector. A night layer inside it deepens it as the face clears,
|
||||
// so the side always reads darker than the face (solid accent, then the picture)
|
||||
let side = null, sideNight = null;
|
||||
if (depthOpt > 0) {
|
||||
side = document.createElement('div');
|
||||
side.className = 'portal-depth';
|
||||
side.setAttribute('aria-hidden', 'true');
|
||||
sideNight = document.createElement('div');
|
||||
sideNight.className = 'portal-depth-night';
|
||||
side.appendChild(sideNight);
|
||||
outTitle.parentNode.insertBefore(side, outTitle.nextSibling);
|
||||
made.push(side);
|
||||
}
|
||||
// the light title: one copy over the world, clipped to the whole mark (face and side), so every
|
||||
// glyph is light wherever the mark is and ink wherever the paper is. One copy, not one in the
|
||||
// world and another on the side: Chrome antialiases text and boxes differently under one clip,
|
||||
// and two light copies meeting at the face's edge leave a 1px dark seam through the letters.
|
||||
// The clip sits on a stage-sized layer (stage px); the title inside it scales freely. To the eye
|
||||
// it is a heading (so page checks read it as one block); assistive tech hears only the h2.
|
||||
const lit = document.createElement('div');
|
||||
lit.className = 'portal-lit';
|
||||
lit.setAttribute('aria-hidden', 'true');
|
||||
const light = document.createElement('div');
|
||||
light.className = 'portal-title portal-title--light';
|
||||
light.setAttribute('role', 'heading');
|
||||
light.setAttribute('aria-level', /^H[1-6]$/.test(outTitle.tagName) ? outTitle.tagName[1] : '2');
|
||||
light.innerHTML = outTitle.innerHTML;
|
||||
light.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
|
||||
lit.appendChild(light);
|
||||
world.parentNode.insertBefore(lit, world.nextSibling);
|
||||
made.push(lit);
|
||||
const titles = [outTitle, light];
|
||||
|
||||
const st = { zl: 0, rot: 0, tint: 1, ts: 1, note: 1, sub: 0, bl: 0, mz: 1 };
|
||||
const g = { W: 0, H: 0, s0: 1, k0: 0, zCover: 1, key: '', box: 0 };
|
||||
const last = {};
|
||||
// writes a style only when it changed (this build's own memory, so a rebuild starts clean)
|
||||
const written = new Map();
|
||||
const put = (el, prop, v) => {
|
||||
if (!el) return;
|
||||
let m = written.get(el);
|
||||
if (!m) written.set(el, (m = {}));
|
||||
if (m[prop] !== v) { m[prop] = v; el.style[prop] = v; }
|
||||
};
|
||||
|
||||
// "fit": the smallest mark whose face holds every line of the title at rest, turned and upright.
|
||||
// The lines' ink boxes are read at the title's rest scale, around the stage's centre (the anchor).
|
||||
const fitBox = (W, H, from) => {
|
||||
const tw = outTitle.offsetWidth;
|
||||
if (!tw) return 0;
|
||||
const tr = outTitle.getBoundingClientRect();
|
||||
const k = TS0 / ((tr.width / tw) || 1), cx = tr.left + tr.width / 2, cy = tr.top + tr.height / 2, M = 6;
|
||||
const els = outTitle.querySelectorAll('.portal-line').length ? [...outTitle.querySelectorAll('.portal-line')] : [outTitle];
|
||||
const pts = [];
|
||||
for (const el of els) {
|
||||
const rg = document.createRange();
|
||||
rg.selectNodeContents(el);
|
||||
const a = rg.getBoundingClientRect(), b = el.getBoundingClientRect();
|
||||
if (!a.width) continue;
|
||||
const x0 = (a.left - cx) * k - M, x1 = (a.right - cx) * k + M, y0 = (b.top - cy) * k - M, y1 = (b.bottom - cy) * k + M;
|
||||
for (let i = 0; i <= 10; i++) { const t = i / 10; pts.push([x0 + (x1 - x0) * t, y0], [x0 + (x1 - x0) * t, y1], [x0, y0 + (y1 - y0) * t], [x1, y0 + (y1 - y0) * t]); }
|
||||
}
|
||||
if (!pts.length) return 0;
|
||||
const holds = (B, rot) => {
|
||||
const s = B / mark.box, a = (-rot * Math.PI) / 180, c = Math.cos(a), n = Math.sin(a);
|
||||
return pts.every(([x, y]) => inside(mark.coarse, mark.ax + (c * x - n * y) / s, mark.ay + (n * x + c * y) / s));
|
||||
};
|
||||
const rots = IN ? [-turn, 0] : [0];
|
||||
// the mark itself (not its box) may be at most 0.98 of the width and half the height
|
||||
const hi = Math.min((0.98 * W) / (mark.bw || 1), (0.5 * H) / (mark.bh || 1)), step = Math.max(4, 0.02 * Math.min(W, H));
|
||||
for (let B = from; B < hi + step; B += step) if (rots.every((r) => holds(Math.min(B, hi), r))) return Math.min(B, hi);
|
||||
return 0;
|
||||
};
|
||||
// the mark's box at rest: a share of the stage (its height on desktop, its width on a phone)
|
||||
const boxPx = (W, H) => {
|
||||
if (sizeWant === 'fit') {
|
||||
const F = fitBox(W, H, phone ? 0.5 * W : 0.25 * H);
|
||||
root.dataset.fit = Math.round(F);
|
||||
return F || (phone ? 0.45 * W : Math.min(0.4 * H, 0.45 * W));
|
||||
}
|
||||
const B = phone ? (sizeWant > 1 ? sizeWant : Math.min(sizeWant * W, 0.4 * H)) : sizeWant > 1 ? sizeWant : Math.min(sizeWant * H, 0.45 * W);
|
||||
// a mark a little smaller than the title cuts only the ends of its lines, into small ink chips
|
||||
// (a narrow window turns this up): within 65% of the size that holds the whole title, grow it
|
||||
// to that size instead. data-fit tells a test which size that was (0: none on this screen)
|
||||
const F = fitBox(W, H, 0.65 * B);
|
||||
root.dataset.fit = Math.round(F);
|
||||
return F && B < F && B >= 0.65 * F ? F : B;
|
||||
};
|
||||
const measure = () => {
|
||||
if (dead) return;
|
||||
const W = stage.clientWidth || innerWidth, H = stage.clientHeight || innerHeight;
|
||||
// on a phone the URL bar never re-measures it (100svh is stable): only a new width, a real
|
||||
// change of height, or a title whose text changed size (late fonts) counts
|
||||
const rg = document.createRange();
|
||||
rg.selectNodeContents(outTitle);
|
||||
const ink = outTitle.offsetHeight + ' ' + Math.round(rg.getBoundingClientRect().width);
|
||||
const key = [W, phone ? Math.round(H / 120) : H, outTitle.offsetWidth, ink].join(' ');
|
||||
if (key !== g.key) {
|
||||
g.box = boxPx(W, H);
|
||||
g.s0 = g.box / mark.box;
|
||||
// "in" covers once it is upright; "out" leaves the cover still turned
|
||||
g.zCover = coverZoom(mark.table, mark.rin, g.s0, W, H, IN ? 0 : turn);
|
||||
g.k0 = depthOpt > 1 ? depthOpt : depthOpt * g.box;
|
||||
g.key = key;
|
||||
}
|
||||
g.W = W; g.H = H;
|
||||
root.style.setProperty('--portal-th', outTitle.offsetHeight + 'px');
|
||||
root.dataset.cover = g.zCover.toFixed(2);
|
||||
root.dataset.box = Math.round(g.box);
|
||||
// the picture grows about the stage's centre, wherever the page put its box
|
||||
if (media) put(media, 'transformOrigin', `${f1(W / 2 - media.offsetLeft)}px ${f1(H / 2 - media.offsetTop)}px`);
|
||||
};
|
||||
|
||||
// sub-paths in screen px: rotated and scaled by (c, n), moved by (tx, ty), as path text
|
||||
const subsPath = (subs, c, n, tx, ty, dx = 0, dy = 0) => {
|
||||
let s = '';
|
||||
tx += dx; ty += dy;
|
||||
for (const sp of subs) {
|
||||
s += 'M' + P(c * sp.x0 - n * sp.y0 + tx, n * sp.x0 + c * sp.y0 + ty);
|
||||
for (const e of sp.edges) {
|
||||
const p = e.p;
|
||||
s += e.c;
|
||||
for (let k = 2; k < p.length; k += 2) s += ' ' + P(c * p[k] - n * p[k + 1] + tx, n * p[k] + c * p[k + 1] + ty);
|
||||
}
|
||||
s += 'Z';
|
||||
}
|
||||
return s;
|
||||
};
|
||||
// ... and as numbers, one flat array per edge, for the side's ribbons
|
||||
const subsScreen = (subs, c, n, tx, ty) => subs.map((sp) => sp.edges.map((e) => {
|
||||
const p = e.p, q = new Array(p.length);
|
||||
for (let k = 0; k < p.length; k += 2) { q[k] = c * p[k] - n * p[k + 1] + tx; q[k + 1] = n * p[k] + c * p[k + 1] + ty; }
|
||||
return q;
|
||||
}));
|
||||
// the side's direction on screen: down and to the right, fixed while the mark turns (a light
|
||||
// from the upper left)
|
||||
const VX = 0.6 / Math.hypot(0.6, 1), VY = 1 / Math.hypot(0.6, 1);
|
||||
|
||||
let mediaOk = !media || media.tagName !== 'IMG' || (media.complete && media.naturalWidth > 0);
|
||||
const render = () => {
|
||||
if (dead || !g.W) return;
|
||||
const zl = clamp(st.zl, 0, 1);
|
||||
// "fly": the distance to the mark falls at a steady rate, so its size grows as 1 / distance.
|
||||
// The curve is that of a flight of at most 8x, stretched in log space to the real cover zoom,
|
||||
// so a thin mark (a cover of 15-30x) never crowds its last sweep into a single wheel tick
|
||||
const lz = Math.log(g.zCover), R = Math.min(g.zCover, 8);
|
||||
const z = Math.exp(lz * (FLY && R > 1.01 ? -Math.log(1 - zl * (1 - 1 / R)) / Math.log(R) : zl));
|
||||
const s = g.s0 * z, a = (st.rot * Math.PI) / 180, c = Math.cos(a) * s, n = Math.sin(a) * s;
|
||||
const tx = g.W / 2 - (c * mark.ax - n * mark.ay), ty = g.H / 2 - (n * mark.ax + c * mark.ay);
|
||||
const covered = zl >= 0.9999;
|
||||
const face = covered ? '' : subsPath(mark.subs, c, n, tx, ty);
|
||||
put(world, 'clipPath', covered ? 'none' : `path(${mark.rule}"${face}")`);
|
||||
// once the world covers the stage the paper side is out of sight: stop painting it, and clip
|
||||
// the ink h2 to nothing the screen-reader way (it stays the section's accessible name)
|
||||
put(outside, 'visibility', covered ? 'hidden' : '');
|
||||
put(outTitle, 'clipPath', covered ? 'inset(50%)' : '');
|
||||
let whole = `path(${mark.rule}"${face}")`;
|
||||
if (side) {
|
||||
// the side thickens as the mark comes closer (z^0.35: perspective, not a drop shadow)
|
||||
const k = g.k0 * Math.pow(z, 0.35);
|
||||
const show = !covered && k > 0.5;
|
||||
put(side, 'visibility', show ? '' : 'hidden');
|
||||
if (show) {
|
||||
const vx = VX * k, vy = VY * k;
|
||||
// the face itself (under the world, so the face's edge never shows the paper through its
|
||||
// antialiasing), the face moved by v, and the ribbons between them; nonzero
|
||||
const near = subsPath(mark.sideSubs, c, n, tx, ty);
|
||||
const sp = near + subsPath(mark.sideSubs, c, n, tx, ty, vx, vy) + ribbons(subsScreen(mark.subs, c, n, tx, ty), vx, vy, mark.sg);
|
||||
put(side, 'clipPath', `path("${sp}")`);
|
||||
whole = `path("${sp}")`;
|
||||
// the side deepens with the face: accent-dark on the solid face, towards the night once it
|
||||
// clears, so its lightness stays about half the face's (measured 0.5-0.55 at 40-60%)
|
||||
put(sideNight, 'opacity', ((1 - clamp(st.tint, 0, 1)) * 0.55).toFixed(3));
|
||||
}
|
||||
}
|
||||
put(lit, 'clipPath', covered ? 'none' : whole);
|
||||
put(tint, 'opacity', st.tint.toFixed(3));
|
||||
const ts = `scale(${st.ts.toFixed(4)})`;
|
||||
for (const t of titles) put(t, 'transform', ts);
|
||||
if (media) {
|
||||
// the world comes towards you for the whole hold; the picture stays still until it decoded
|
||||
const ms = mediaOk ? st.mz : 1;
|
||||
put(media, 'transform', ms > 1.0001 ? `scale(${ms.toFixed(4)})` : 'none');
|
||||
const b = blurPx * clamp(st.bl, 0, 1);
|
||||
put(media, 'filter', b > 0.05 ? `blur(${b.toFixed(2)}px)` : 'none');
|
||||
}
|
||||
for (const el of notes) put(el, 'opacity', st.note.toFixed(3));
|
||||
if (sub) { put(sub, 'opacity', st.sub.toFixed(3)); put(sub, 'transform', `translate3d(0, ${(16 * (1 - st.sub)).toFixed(2)}px, 0)`); }
|
||||
const zs = z.toFixed(2), rs = st.rot.toFixed(1);
|
||||
if (last.z !== zs) { last.z = zs; root.dataset.z = zs; }
|
||||
if (last.r !== rs) { last.r = rs; root.dataset.rot = rs; }
|
||||
};
|
||||
|
||||
// the timeline: every tween drives the state object; render() writes the DOM once per update
|
||||
const E = FLY ? 'none' : o.ease;
|
||||
if (IN) {
|
||||
tl.fromTo(st, { zl: 0 }, { zl: 1, duration: 0.7, ease: E }, 0.06)
|
||||
.fromTo(st, { rot: -turn }, { rot: 0, duration: 0.3, ease: 'power2.out' }, 0.06)
|
||||
// the face clears while the mark is still a shape on the paper, so the next chapter is
|
||||
// already alive inside it well before it covers the screen
|
||||
.fromTo(st, { tint: 1 }, { tint: 0, duration: 0.44, ease: 'sine.inOut' }, 0.14)
|
||||
.fromTo(st, { ts: TS0 }, { ts: 1, duration: 0.7, ease: 'sine.inOut' }, 0.06)
|
||||
.fromTo(st, { note: 1 }, { note: 0, duration: 0.08 }, 0.06)
|
||||
.fromTo(st, { sub: 0 }, { sub: 1, duration: 0.1, ease: 'power2.out' }, 0.8)
|
||||
// the world keeps coming at a steady rate, through the rush and on through the dwell
|
||||
.fromTo(st, { mz: 1 }, { mz: o.zoom, duration: 0.94 }, 0.06)
|
||||
// the rush: the world's picture blurs a little as the edges stream past (a full-screen
|
||||
// filter is not free)
|
||||
.fromTo(st, { bl: 0 }, { bl: 1, duration: 0.12, ease: 'sine.inOut' }, FLY ? 0.58 : 0.4)
|
||||
.to(st, { bl: 0, duration: 0.12, ease: 'sine.inOut' }, FLY ? 0.7 : 0.55);
|
||||
} else {
|
||||
tl.fromTo(st, { zl: 1 }, { zl: 0, duration: 0.7, ease: E }, 0.1)
|
||||
.fromTo(st, { tint: 0 }, { tint: 1, duration: 0.44, ease: 'sine.inOut' }, 0.26)
|
||||
// still turned as its edge comes into view, the mark rights itself as it lands
|
||||
.fromTo(st, { rot: turn }, { rot: 0, duration: 0.33, ease: 'power2.out' }, 0.45)
|
||||
.fromTo(st, { ts: 1 }, { ts: TS0, duration: 0.7, ease: 'sine.inOut' }, 0.1)
|
||||
.fromTo(st, { sub: 1 }, { sub: 0, duration: 0.08 }, 0.04)
|
||||
.fromTo(st, { note: 0 }, { note: 1, duration: 0.08 }, 0.8)
|
||||
.fromTo(st, { mz: o.zoom }, { mz: 1, duration: 0.8 }, 0)
|
||||
.fromTo(st, { bl: 0 }, { bl: 1, duration: 0.1, ease: 'sine.inOut' }, FLY ? 0.08 : 0.24)
|
||||
.to(st, { bl: 0, duration: 0.14, ease: 'sine.inOut' }, FLY ? 0.18 : 0.39);
|
||||
}
|
||||
tl.set({}, {}, 1);
|
||||
tl.eventCallback('onUpdate', render);
|
||||
|
||||
measure();
|
||||
render();
|
||||
// re-measure on every refresh (resize, fonts, pictures), then redraw
|
||||
ScrollTrigger.create({ trigger: root, start: 'top bottom', end: 'bottom top', onRefresh: () => { measure(); render(); } });
|
||||
// late fonts change the title's size, which places the sub-line and can resize a "fit" mark
|
||||
let ro = null;
|
||||
if ('ResizeObserver' in window) { ro = new ResizeObserver(() => { if (!dead) { measure(); render(); } }); ro.observe(outTitle); }
|
||||
// pictures decode before they move; a video plays only while the section is on screen. The picture
|
||||
// stays painted with the world, never on a layer of its own: under the moving clip a layer adds a
|
||||
// full-screen mask every frame, and promoting it at cover costs one long raster right at the rush
|
||||
if (media && blurPx > 0) media.style.willChange = 'filter';
|
||||
if (media && media.tagName === 'IMG') {
|
||||
media.loading = 'eager';
|
||||
if (!mediaOk) {
|
||||
const go = () => { if (dead || mediaOk) return; mediaOk = true; render(); };
|
||||
if (media.decode) media.decode().then(go, go); else media.addEventListener('load', go, { once: true });
|
||||
}
|
||||
}
|
||||
let io = null;
|
||||
if (media && media.tagName === 'VIDEO' && 'IntersectionObserver' in window) {
|
||||
io = new IntersectionObserver(([e]) => { if (e.isIntersecting) media.play().catch(() => {}); else media.pause(); });
|
||||
io.observe(root);
|
||||
}
|
||||
|
||||
return () => {
|
||||
dead = true;
|
||||
tl.eventCallback('onUpdate', null);
|
||||
if (ro) ro.disconnect();
|
||||
if (io) io.disconnect();
|
||||
for (const el of made) el.remove();
|
||||
world.style.removeProperty('clip-path');
|
||||
outTitle.style.removeProperty('clip-path');
|
||||
if (outside) outside.style.removeProperty('visibility');
|
||||
if (media) { media.style.removeProperty('filter'); media.style.removeProperty('will-change'); media.style.removeProperty('transform-origin'); }
|
||||
for (const el of [outTitle, media, tint, sub, ...notes]) if (el) { el.style.removeProperty('transform'); el.style.removeProperty('opacity'); }
|
||||
for (const k of ['z', 'rot', 'cover', 'box', 'fit']) delete root.dataset[k];
|
||||
root.style.removeProperty('--portal-th');
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const plan = canClip && mark && $('.portal-world') && $('.portal-title--out') ? build : null;
|
||||
if (mark && !plan && canClip) console.warn(`${ID}: needs .portal-world and an h2.portal-title--out; showing the finished composition.`);
|
||||
return Stage.scene(root, {
|
||||
hold: o.hold,
|
||||
scrub: o.scrub ?? (Stage.lenis ? true : 0.8),
|
||||
build: plan,
|
||||
phone: o.phone === 'rest' || !plan ? 'rest' : { hold: o.phoneHold, build: plan },
|
||||
});
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,357 @@
|
||||
/* tasteskill / scenes / hinged-sheets.css — paper with two printed sides, dealt from a face-down deck.
|
||||
By default (no script, reduced motion, a failed CDN, a phone set to 'rest') this is the finished
|
||||
composition: headline, the line over the row, the tickets face-up, the fine print. Under .is-live the
|
||||
deal runs on the held stage; under .is-live.hs-flow (phones, tablets, short windows, more than four
|
||||
tickets) the finished layout stays in the page and each ticket turns over as it scrolls in.
|
||||
Weights come from the page: --display-weight (headline, ticket names, prices, the big word on the back),
|
||||
--strong-weight (the line over the row, the points, the button, the name on the back) and
|
||||
--text-weight; set them to weights the page loads, or the browser draws the nearest weight it has (fake bold is
|
||||
off). Corners take the
|
||||
page's --radius (0 without one). */
|
||||
|
||||
[data-scene="hinged-sheets"] { font-synthesis-weight: none; }
|
||||
[data-scene="hinged-sheets"] {
|
||||
--hs-ink: var(--accent, #b62118);
|
||||
--hs-paper: var(--paper, #fafaf9);
|
||||
--hs-ground-a: #a9cbe8; /* the ground as the deal starts */
|
||||
--hs-ground-b: #d5e6f4; /* clearly lighter: the stage brightens as the tickets land */
|
||||
--hs-radius: min(var(--radius, 0px), 24px); /* the page's corner, at most 24px on a ticket */
|
||||
/* small words that sit on ground-a: the ink a step deeper, so they keep 4.5:1 there */
|
||||
--hs-ink-deep: color-mix(in srgb, var(--hs-ink) 80%, #000);
|
||||
/* the turn shade: the back darkens in its own ink; the paper front greys, cool, like a sheet tipping
|
||||
out of the light (an ink-tinted shade on white reads as dusty rose) */
|
||||
--hs-shade: color-mix(in srgb, var(--hs-ink) 40%, #000);
|
||||
--hs-shade-paper: color-mix(in oklch, var(--hs-ground-a) 30%, #000);
|
||||
--hs-gap: 28px;
|
||||
--n: 3; /* tickets in the row (2-4), from the markup */
|
||||
--max-cols: 4; /* what the width allows: every ticket at least 296px */
|
||||
--cols: min(var(--n), var(--max-cols));
|
||||
--hs-w: min(400px, calc((92vw - (var(--cols) - 1) * var(--hs-gap)) / var(--cols)));
|
||||
--hs-h: calc(var(--hs-w) * 1.414);
|
||||
/* The deal's layout, top to bottom: a band for the stepped-back headline and the line over the row,
|
||||
the row, the fine print. The tickets take the height that is left (the script reads --hs-deal-w to
|
||||
decide whether the deal runs at all: every ticket 296px or wider). --hs-title-lines is measured. */
|
||||
--hs-lead-size: clamp(18px, 1.5vw, 22px);
|
||||
--hs-title-end: clamp(36px, min(4.2vw, 7svh), 72px); /* the headline once it has stepped back */
|
||||
--hs-top: calc(max(24px, 4svh) + var(--hs-title-lines, 2) * 0.92 * var(--hs-title-end) + 12px + 1.4 * var(--hs-lead-size) + 24px);
|
||||
--hs-foot: 104px; /* 28px of air, two lines of fine print, 32px to the edge */
|
||||
--hs-deal-w: min(400px, calc((92vw - (var(--n) - 1) * var(--hs-gap)) / var(--n)), calc((100svh - var(--hs-top) - var(--hs-foot)) / 1.414));
|
||||
/* the perforation: a row of punched holes and two notches at 70% of the ticket, cut by a mask */
|
||||
--hs-perf: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 1414' preserveAspectRatio='none'%3E%3Cpath fill-rule='evenodd' d='M0 0H1000V1414H0zM-26 990a26 26 0 1 0 52 0a26 26 0 1 0 -52 0zM974 990a26 26 0 1 0 52 0a26 26 0 1 0 -52 0zM67 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM101 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM135 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM169 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM203 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM237 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM271 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM305 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM339 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM373 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM407 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM441 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM475 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM509 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM543 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM577 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM611 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM645 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM679 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM713 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM747 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM781 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM815 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM849 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM883 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0zM917 990a8 8 0 1 0 16 0a8 8 0 1 0 -16 0z'/%3E%3C/svg%3E");
|
||||
color: var(--hs-ink);
|
||||
font-family: var(--font-text, inherit);
|
||||
}
|
||||
/* the column count without script: two, three or four across; five and more wrap */
|
||||
[data-scene="hinged-sheets"]:has(.hs-slot:nth-child(4)) { --n: 4; }
|
||||
[data-scene="hinged-sheets"]:has(.hs-slot:nth-child(5):last-child),
|
||||
[data-scene="hinged-sheets"]:has(.hs-slot:nth-child(6):last-child),
|
||||
[data-scene="hinged-sheets"]:has(.hs-slot:nth-child(9):last-child) { --n: 3; }
|
||||
[data-scene="hinged-sheets"]:not(:has(.hs-slot:nth-child(3))) { --n: 2; }
|
||||
[data-scene="hinged-sheets"]:not(:has(.hs-slot:nth-child(2))) { --n: 1; }
|
||||
@media (max-width: 1395px) { [data-scene="hinged-sheets"] { --max-cols: 3; } }
|
||||
@media (max-width: 1038px) { [data-scene="hinged-sheets"] { --max-cols: 2; } }
|
||||
|
||||
/* ---------- the finished composition ---------- */
|
||||
|
||||
[data-scene="hinged-sheets"] > .scene-stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 120px 4vw;
|
||||
background: var(--hs-ground-b);
|
||||
text-align: center;
|
||||
}
|
||||
[data-scene="hinged-sheets"] .hs-wash { display: none; }
|
||||
/* measures the deal's ticket width and the headline's stepped-back size for the script */
|
||||
[data-scene="hinged-sheets"] .hs-gauge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--hs-deal-w);
|
||||
height: 0;
|
||||
font-size: var(--hs-title-end);
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-scene="hinged-sheets"] .hs-head { max-width: 100%; }
|
||||
[data-scene="hinged-sheets"] .hs-title {
|
||||
margin: 0 auto;
|
||||
max-width: 15ch;
|
||||
font-family: var(--font-display, inherit);
|
||||
font-size: clamp(44px, 5.6vw, 84px);
|
||||
font-weight: var(--display-weight, 500);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.035em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
[data-scene="hinged-sheets"] .hs-sub,
|
||||
[data-scene="hinged-sheets"] .hs-lead {
|
||||
font-size: var(--hs-lead-size);
|
||||
font-weight: var(--text-weight, 400);
|
||||
line-height: 1.4;
|
||||
}
|
||||
[data-scene="hinged-sheets"] .hs-sub { margin: 24px auto 0; max-width: 32em; text-wrap: pretty; }
|
||||
[data-scene="hinged-sheets"] .hs-lead { margin: 56px 0 0; font-weight: var(--strong-weight, 600); letter-spacing: -0.01em; }
|
||||
/* a wrapping row, so a last ticket on its own sits in the middle */
|
||||
[data-scene="hinged-sheets"] .hs-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--hs-gap);
|
||||
width: calc(var(--cols) * var(--hs-w) + (var(--cols) - 1) * var(--hs-gap));
|
||||
max-width: 100%;
|
||||
margin: 28px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
[data-scene="hinged-sheets"] .hs-slot { position: relative; flex: 0 0 var(--hs-w); }
|
||||
[data-scene="hinged-sheets"] .hs-sheet {
|
||||
position: relative;
|
||||
width: var(--hs-w);
|
||||
aspect-ratio: 1 / 1.414;
|
||||
}
|
||||
[data-scene="hinged-sheets"] .hs-note {
|
||||
margin: 32px auto 0;
|
||||
max-width: 38em;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* The ticket. Everything inside sizes in cqi (1cqi = 1% of the ticket's width), so a ticket keeps its
|
||||
proportions at any width; text never drops under 14px. Nothing on a face may use cq units on the face
|
||||
itself: those would read the viewport. */
|
||||
[data-scene="hinged-sheets"] .hs-face {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: var(--hs-radius);
|
||||
container-type: inline-size;
|
||||
-webkit-mask: var(--hs-perf) 0 0 / 100% 100% no-repeat;
|
||||
mask: var(--hs-perf) 0 0 / 100% 100% no-repeat;
|
||||
text-align: left;
|
||||
}
|
||||
[data-scene="hinged-sheets"] .hs-face--front { background: var(--hs-paper); color: var(--hs-ink); }
|
||||
[data-scene="hinged-sheets"] .hs-face--back { display: none; background: var(--hs-ink); color: var(--hs-paper); }
|
||||
[data-scene="hinged-sheets"] .hs-shade { display: none; }
|
||||
|
||||
[data-scene="hinged-sheets"] .hs-photo {
|
||||
display: block;
|
||||
flex: 0 0 25%;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
[data-scene="hinged-sheets"] .hs-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 5.5cqi 6.5cqi 0;
|
||||
}
|
||||
[data-scene="hinged-sheets"] .hs-name {
|
||||
margin: 0;
|
||||
font-family: var(--font-display, inherit);
|
||||
font-size: 10.5cqi;
|
||||
font-weight: var(--display-weight, 500);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
[data-scene="hinged-sheets"] .hs-pitch,
|
||||
[data-scene="hinged-sheets"] .hs-points,
|
||||
[data-scene="hinged-sheets"] .hs-per,
|
||||
[data-scene="hinged-sheets"] .hs-cta,
|
||||
[data-scene="hinged-sheets"] .hs-small,
|
||||
[data-scene="hinged-sheets"] .hs-brand {
|
||||
font-size: max(14px, 4.2cqi);
|
||||
font-weight: var(--text-weight, 400);
|
||||
line-height: 1.34;
|
||||
}
|
||||
[data-scene="hinged-sheets"] .hs-pitch { margin: 2.6cqi 0 0; text-wrap: pretty; }
|
||||
/* a printed ticket (no photograph, as in the demo): the name large at the top and the points set down on
|
||||
the perforation, so the type fills the sheet instead of leaving a pricing card's empty middle */
|
||||
[data-scene="hinged-sheets"] .hs-face--front:not(:has(> .hs-photo)) .hs-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 7cqi;
|
||||
padding-bottom: 5cqi;
|
||||
}
|
||||
[data-scene="hinged-sheets"] .hs-face--front:not(:has(> .hs-photo)) .hs-name { font-size: 15cqi; line-height: 0.92; text-wrap: balance; }
|
||||
[data-scene="hinged-sheets"] .hs-face--front:not(:has(> .hs-photo)) .hs-pitch { margin-top: 4.5cqi; font-size: max(14px, 4.8cqi); }
|
||||
[data-scene="hinged-sheets"] .hs-face--front:not(:has(> .hs-photo)) .hs-points { margin-top: auto; padding-top: 4cqi; }
|
||||
@container (min-width: 380px) {
|
||||
[data-scene="hinged-sheets"] .hs-face--front:not(:has(> .hs-photo)) .hs-pitch { font-size: 5.4cqi; }
|
||||
}
|
||||
[data-scene="hinged-sheets"] .hs-points { margin: 3.2cqi 0 0; padding: 0; list-style: none; font-weight: var(--strong-weight, 600); }
|
||||
[data-scene="hinged-sheets"] .hs-points li { position: relative; padding-left: 4.4cqi; }
|
||||
[data-scene="hinged-sheets"] .hs-points li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.5em;
|
||||
width: 1.7cqi;
|
||||
height: 1.7cqi;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
/* the stub, below the perforation: the price, what it buys, and the action */
|
||||
[data-scene="hinged-sheets"] .hs-stub {
|
||||
flex: 0 0 30%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 4cqi;
|
||||
padding: 0 6.5cqi 5cqi;
|
||||
}
|
||||
[data-scene="hinged-sheets"] .hs-price { margin: 0; display: grid; }
|
||||
[data-scene="hinged-sheets"] .hs-amount {
|
||||
font-family: var(--font-display, inherit);
|
||||
font-size: 14cqi;
|
||||
font-weight: var(--display-weight, 500);
|
||||
line-height: 0.84;
|
||||
letter-spacing: -0.04em;
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
}
|
||||
[data-scene="hinged-sheets"] .hs-per { margin-top: 2cqi; }
|
||||
[data-scene="hinged-sheets"] .hs-cta {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 1.2cqi;
|
||||
padding: 3cqi 4.6cqi;
|
||||
border-radius: var(--radius, 0px);
|
||||
background: var(--hs-ink);
|
||||
color: var(--hs-paper);
|
||||
font-weight: var(--strong-weight, 600);
|
||||
line-height: 1.2;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-scene="hinged-sheets"] .hs-cta:hover { background: color-mix(in srgb, var(--hs-ink) 82%, #000); }
|
||||
[data-scene="hinged-sheets"] .hs-cta:focus-visible { outline: 3px solid var(--hs-ink); outline-offset: 3px; }
|
||||
|
||||
/* the back: one ink, one very large word at the top, the name and a line of detail on the stub */
|
||||
[data-scene="hinged-sheets"] .hs-big {
|
||||
margin: 6cqi 6.5cqi 0;
|
||||
font-family: var(--font-display, inherit);
|
||||
font-size: 27cqi;
|
||||
font-weight: var(--display-weight, 500);
|
||||
line-height: 0.86;
|
||||
letter-spacing: -0.055em;
|
||||
}
|
||||
[data-scene="hinged-sheets"] .hs-brand,
|
||||
[data-scene="hinged-sheets"] .hs-small { position: absolute; left: 6.5cqi; right: 6.5cqi; }
|
||||
[data-scene="hinged-sheets"] .hs-brand { bottom: calc(6.5cqi + 1.34em); font-weight: var(--strong-weight, 600); }
|
||||
[data-scene="hinged-sheets"] .hs-small { bottom: 6.5cqi; }
|
||||
/* during the deal the script moves the back's words into data-print: the backs overlap in the deck, and
|
||||
as generated content they are drawn as print, never read or measured as text */
|
||||
[data-scene="hinged-sheets"] .hs-face--back [data-print]::before { content: attr(data-print); content: attr(data-print) / ""; }
|
||||
|
||||
/* ---------- live: both versions ---------- */
|
||||
|
||||
[data-scene="hinged-sheets"].is-live .hs-sheet { transform-style: preserve-3d; will-change: transform; }
|
||||
/* While live a face has no mask and no clip-path: a moving 3D layer with either is drawn through a mask
|
||||
texture the size of its projection on every frame. The script punches the perforation into each
|
||||
surface's own background instead (a bitmap in the page's colours, set on the face and its shade), so
|
||||
the holes are still real; until it has, the faces are solid. */
|
||||
[data-scene="hinged-sheets"].is-live .hs-face {
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
-webkit-mask: none;
|
||||
mask: none;
|
||||
}
|
||||
[data-scene="hinged-sheets"].is-live .hs-face--front { transform: translateZ(0.1px); }
|
||||
[data-scene="hinged-sheets"].is-live .hs-face--back { display: flex; flex-direction: column; transform: rotateY(180deg) translateZ(0.1px); }
|
||||
/* backface-visibility alone decides which face shows; the face turned away only loses the pointer */
|
||||
[data-scene="hinged-sheets"].is-live .hs-sheet[data-face="back"] > .hs-face--front,
|
||||
[data-scene="hinged-sheets"].is-live .hs-sheet[data-face="front"] > .hs-face--back { pointer-events: none; }
|
||||
[data-scene="hinged-sheets"].is-live .hs-shade {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--hs-shade);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-scene="hinged-sheets"].is-live .hs-face--front > .hs-shade { background: var(--hs-shade-paper); }
|
||||
|
||||
/* ---------- live: the deal on the held stage ---------- */
|
||||
|
||||
[data-scene="hinged-sheets"].is-live:not(.hs-flow) {
|
||||
--cols: var(--n);
|
||||
--hs-w: var(--hs-deal-w);
|
||||
/* the row sits in the middle of what the band over it and the fine print leave */
|
||||
--hs-row-top: calc(var(--hs-top) + (100svh - var(--hs-top) - var(--hs-foot) - var(--hs-h)) / 2);
|
||||
}
|
||||
html.js [data-scene="hinged-sheets"].is-live:not(.hs-flow) > .scene-stage {
|
||||
display: grid;
|
||||
grid-template: 100% / 100%;
|
||||
place-items: stretch;
|
||||
padding: 0;
|
||||
background: var(--hs-ground-a);
|
||||
}
|
||||
[data-scene="hinged-sheets"].is-live:not(.hs-flow) > .scene-stage > * { grid-area: 1 / 1; }
|
||||
[data-scene="hinged-sheets"].is-live:not(.hs-flow) .hs-wash {
|
||||
display: block;
|
||||
background: var(--hs-ground-b);
|
||||
opacity: 0;
|
||||
}
|
||||
/* the opening: the headline large, its centre near 44% of the stage, with 12svh of margin below its block.
|
||||
The script then scales it down about its bottom edge into the band over the row (--hs-title-end). */
|
||||
[data-scene="hinged-sheets"].is-live:not(.hs-flow) .hs-head {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
width: min(92vw, 1400px);
|
||||
margin-bottom: 12svh;
|
||||
}
|
||||
[data-scene="hinged-sheets"].is-live:not(.hs-flow) .hs-title {
|
||||
max-width: 15ch;
|
||||
font-size: clamp(56px, 10vw, 168px);
|
||||
line-height: 0.92;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
/* a layer only while the headline steps back; the rest of the time it is painted flat, sharp at its size */
|
||||
[data-scene="hinged-sheets"].is-live.hs-moving:not(.hs-flow) .hs-title { will-change: transform; }
|
||||
[data-scene="hinged-sheets"].is-live:not(.hs-flow) .hs-sub { margin-top: 28px; color: var(--hs-ink-deep); }
|
||||
/* reserved bands: the line over the row ends 24px above it, the fine print starts 28px below it */
|
||||
[data-scene="hinged-sheets"].is-live:not(.hs-flow) .hs-lead {
|
||||
align-self: end;
|
||||
justify-self: center;
|
||||
margin: 0 0 calc(100svh - var(--hs-row-top) + 24px);
|
||||
}
|
||||
[data-scene="hinged-sheets"].is-live:not(.hs-flow) .hs-row {
|
||||
align-self: start;
|
||||
justify-self: center;
|
||||
margin: var(--hs-row-top) 0 0;
|
||||
}
|
||||
[data-scene="hinged-sheets"].is-live:not(.hs-flow) .hs-row[data-shut] { pointer-events: none; }
|
||||
[data-scene="hinged-sheets"].is-live:not(.hs-flow) .hs-slot {
|
||||
z-index: calc(var(--n) - var(--i, 0));
|
||||
perspective: 1500px;
|
||||
/* one vanishing point for the whole row: the row's centre, seen from each slot */
|
||||
perspective-origin: calc(50% + ((var(--n) - 1) / 2 - var(--i, 0)) * (var(--hs-w) + var(--hs-gap))) 50%;
|
||||
}
|
||||
[data-scene="hinged-sheets"].is-live:not(.hs-flow) .hs-note {
|
||||
align-self: start;
|
||||
justify-self: center;
|
||||
margin: calc(var(--hs-row-top) + var(--hs-h) + 28px) 0 0;
|
||||
}
|
||||
|
||||
/* ---------- live: the flow (phones, tablets, short windows, many tickets) ---------- */
|
||||
|
||||
html.js [data-scene="hinged-sheets"].is-live.hs-flow { height: auto; }
|
||||
html.js [data-scene="hinged-sheets"].is-live.hs-flow > .scene-stage {
|
||||
position: relative;
|
||||
height: auto;
|
||||
}
|
||||
[data-scene="hinged-sheets"].is-live.hs-flow .hs-slot {
|
||||
perspective: 1100px;
|
||||
perspective-origin: 50% 40%;
|
||||
}
|
||||
|
||||
/* ---------- phone ---------- */
|
||||
|
||||
@media (max-width: 767px) {
|
||||
[data-scene="hinged-sheets"] { --max-cols: 1; --hs-w: min(86vw, 340px); }
|
||||
[data-scene="hinged-sheets"] > .scene-stage { padding: 96px 16px; }
|
||||
[data-scene="hinged-sheets"] .hs-title { font-size: clamp(40px, 11vw, 56px); max-width: 12ch; }
|
||||
[data-scene="hinged-sheets"] .hs-lead { margin-top: 48px; }
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
// tasteskill / scenes / hinged-sheets.js — paper with two printed sides, dealt from a face-down deck.
|
||||
//
|
||||
// Two to four tickets (prices, passes, stats, letters) rise together from one face-down deck below
|
||||
// the fold. Each peels off, turns from its ink-coloured back to its paper front on a shared vanishing
|
||||
// point, lifts towards the viewer at mid-turn and lands in its slot, one by one, left to right. The
|
||||
// stagger comes from the rise durations, not from delays. Before the deck comes up, the headline steps
|
||||
// out of its way: it rises and shrinks to the top of the stage and stays there, sharp, as the heading
|
||||
// over the row. It is in place before the deck's top edge reaches it, so no ticket ever passes over a
|
||||
// word. The line over the row and the fine print under it arrive as the last ticket lands, and the
|
||||
// finished row holds still for a short beat (the last 12% of the hold), not a dead screen.
|
||||
// Use it when the brand has a paper, ticket, stationery or print idea; without one the turning is a
|
||||
// trick.
|
||||
//
|
||||
// The deal runs when every ticket can be at least 296px wide in one row with the band over the row and
|
||||
// the fine print in their room (three tickets: about 1040 x 700 and up). Phones, tablets, short windows
|
||||
// and more than four tickets get the flow version: no held stage; the tickets stand in a column or a
|
||||
// grid, and each one rises, turns and lands on its own as it scrolls in, face-up and still before it is
|
||||
// read; the fine print under them comes in once the last one is down. Reduced motion, no
|
||||
// script and a failed CDN show the finished composition: headline, the line over the row, the tickets
|
||||
// face-up, the fine print.
|
||||
//
|
||||
// DOM (optional parts can be left out):
|
||||
// <section class="scene" data-scene="hinged-sheets" id="tickets" aria-labelledby="hs-title">
|
||||
// <div class="scene-stage">
|
||||
// <div class="hs-head">
|
||||
// <h2 class="hs-title" id="hs-title">The sea is open all winter</h2> 4-7 words of plain text,
|
||||
// two lines
|
||||
// <p class="hs-sub">One sentence, under 20 words.</p> optional; leaves before the deal
|
||||
// </div>
|
||||
// <p class="hs-lead">Prices for October to April</p> 3-8 words: what the prices are for (the
|
||||
// season, the unit); it sits between the
|
||||
// headline and the row
|
||||
// <ol class="hs-row" role="list">
|
||||
// <li class="hs-slot"> 2-4 of them (5-12 run as the flow version)
|
||||
// <div class="hs-sheet"> nothing here may fade, clip or filter
|
||||
// <article class="hs-face hs-face--front">
|
||||
// <img class="hs-photo" src alt="" width height> optional, the top quarter; better left out:
|
||||
// without it the ticket is printed type, the
|
||||
// name large and the copy filling the sheet
|
||||
// (a photo on top reads as a pricing card)
|
||||
// <div class="hs-body">
|
||||
// <h3 class="hs-name">One swim</h3> 1-2 words
|
||||
// <p class="hs-pitch">…</p> 10-16 words with a photo, up to 26 without
|
||||
// <ul class="hs-points"><li>…</li></ul> 2-3 points of 2-5 words
|
||||
// </div>
|
||||
// <div class="hs-stub"> below the perforation
|
||||
// <p class="hs-price"><span class="hs-amount">€14</span><span class="hs-per">a visit</span></p>
|
||||
// <a class="hs-cta" href="…">Buy one swim</a> optional
|
||||
// </div>
|
||||
// </article>
|
||||
// <div class="hs-face hs-face--back" aria-hidden="true"> shown only while live
|
||||
// <span class="hs-big">1 swim</span> one or two short words, very large
|
||||
// <span class="hs-brand">Selkie Sea Baths</span>
|
||||
// <span class="hs-small">Any day of the season</span>
|
||||
// </div>
|
||||
// </div>
|
||||
// </li>
|
||||
// </ol>
|
||||
// <p class="hs-note">One or two lines of fine print, 15-25 words.</p>
|
||||
// </div>
|
||||
// </section>
|
||||
// The script makes the ground wash and the turn shades itself, and while live it gives each face and
|
||||
// shade its paper, ink or shade as a bitmap background with the perforation punched through, so a
|
||||
// moving ticket needs no mask. Give every front the same amount of copy: tickets keep a 1:1.414 shape.
|
||||
//
|
||||
// Options (data-options JSON; defaults in brackets):
|
||||
// hold 1.2-4 [0.6 + 0.5 x tickets: 2.1 for three] screens the stage is held
|
||||
// scrub true | 0.2-0.6 [true with Lenis, else 0.5]
|
||||
// deck 0-1 [1] how far the tickets start towards one centre deck, in slot
|
||||
// pitches: 0 rises each one straight up from its own slot
|
||||
// stagger 0.08-0.18 [0.14; 0.12 with four] scene between one landing and the next
|
||||
// wobble 0-8 (degrees) [7] starting rotation of the deck (pattern -1, +0.7, -0.4, +0.6)
|
||||
// lean 0-18 (degrees) [16] starting rotationX
|
||||
// lift 0-120 (px) [120] how far each ticket comes towards the viewer at mid-turn
|
||||
// phone 'flip' | 'rest' ['flip'] the phone's per-ticket turn, or the finished composition
|
||||
//
|
||||
// Page tokens: --accent (the one ink: the backs and every word, #b62118), --paper (#fafaf9),
|
||||
// --font-display, --font-text, --radius (the page's one corner radius: the tickets and the button take
|
||||
// it, up to 24px on a ticket; 0 when the page has none), and the weights --display-weight (headline,
|
||||
// ticket names, prices, the big word on the back; 500), --strong-weight (the line over the row, the
|
||||
// points, the button, the name on the back; 600) and --text-weight (400). Set each weight to one the
|
||||
// page loads: a weight the font lacks is drawn at the nearest weight it has (fake bold is off). Scene
|
||||
// tokens on the section:
|
||||
// --hs-ground-a and --hs-ground-b (the ground at the start and at the end, a tint of a hue that sets off
|
||||
// the ink; b clearly lighter, so the stage brightens as the tickets land), --hs-title-end (the
|
||||
// headline's size once it has stepped back over the row; clamp(36px, min(4.2vw, 7svh), 72px)) and
|
||||
// --hs-shade-paper (what a front greys to as it turns away: a cool dark, never the ink). Timeline
|
||||
// positions are fractions of the scene (0-1).
|
||||
//
|
||||
// Scenes['hinged-sheets'](root, opts) returns a handle: { mm (the live gsap.matchMedia), revert() }.
|
||||
// revert() stops the scene for good and leaves the finished composition.
|
||||
(() => {
|
||||
const ID = 'hinged-sheets';
|
||||
if (!window.Stage) { console.warn(ID + ': load stage.js before this file'); return; }
|
||||
|
||||
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
||||
const opt = (v, d, lo, hi) => (typeof v === 'number' && Number.isFinite(v) ? clamp(v, lo, hi) : d);
|
||||
const RAD = Math.PI / 180;
|
||||
const WOBBLE = [-1, 0.714, -0.429, 0.571];
|
||||
const SHADE = 0.42; // darkest a face gets, edge-on
|
||||
const MIN_W = 296; // narrowest ticket the deal runs with (its text is 14px there)
|
||||
// The deal's clock, in fractions of the hold. The headline steps back (0.03-0.30) while the deck rises
|
||||
// from below the fold (from 0.04; its top edge first shows near 0.18, far below the headline), the last
|
||||
// ticket lands at 0.80, the line over the row and the fine print are in by 0.88, and the finished row
|
||||
// holds for the rest.
|
||||
const STEP = [0.03, 0.27], DECK = 0.04, LAND = 0.80;
|
||||
const GAP = 12; // headline to the line over the row, once it has stepped back
|
||||
|
||||
// Any CSS colour as sRGB numbers, through a 1px canvas (it reads oklch() and color-mix() results).
|
||||
let pen = null;
|
||||
const rgbOf = (css) => {
|
||||
try {
|
||||
pen = pen || Object.assign(document.createElement('canvas'), { width: 1, height: 1 }).getContext('2d', { willReadFrequently: true });
|
||||
pen.clearRect(0, 0, 1, 1);
|
||||
pen.fillStyle = '#010203';
|
||||
pen.fillStyle = css;
|
||||
if (pen.fillStyle === '#010203') return null; // a colour this browser's canvas cannot read
|
||||
pen.fillRect(0, 0, 1, 1);
|
||||
const d = pen.getImageData(0, 0, 1, 1).data;
|
||||
return [d[0], d[1], d[2]];
|
||||
} catch { return null; }
|
||||
};
|
||||
const make = (tag, cls, parent, first) => {
|
||||
const el = document.createElement(tag);
|
||||
el.className = cls;
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
first ? parent.prepend(el) : parent.append(el);
|
||||
return el;
|
||||
};
|
||||
|
||||
// The perforation while live. The finished composition cuts it with a CSS mask; a moving 3D face
|
||||
// must not have a mask or a clip-path: Chrome draws such a layer through a mask texture the size of
|
||||
// its projection, rebuilt as it turns, and a ticket lifted towards the viewer at mid-turn needs one of
|
||||
// several screens of pixels on every frame. Nor an SVG picture: on a moving 3D layer Chrome records
|
||||
// it again on every frame. So while live each surface (paper, ink and the two turn shades) is a
|
||||
// bitmap, drawn once at the ticket's size and the screen's density, with the holes already punched
|
||||
// out: a row of 26 holes and two edge notches on the line at 70% of the ticket, in the same
|
||||
// 1000 x 1414 box as the mask. The holes stay real (they show whatever is behind the ticket).
|
||||
const punched = (rgb, w, h) => {
|
||||
const k = Math.min(2, window.devicePixelRatio || 1);
|
||||
const c = document.createElement('canvas');
|
||||
c.width = Math.max(10, Math.round(w * k));
|
||||
c.height = Math.max(14, Math.round(h * k));
|
||||
const g = c.getContext('2d');
|
||||
g.fillStyle = `rgb(${rgb.join(',')})`;
|
||||
g.fillRect(0, 0, c.width, c.height);
|
||||
g.globalCompositeOperation = 'destination-out';
|
||||
const s = c.width / 1000, y = (990 / 1414) * c.height;
|
||||
const hole = (cx, r) => { g.moveTo((cx + r) * s, y); g.arc(cx * s, y, r * s, 0, 2 * Math.PI); };
|
||||
g.beginPath();
|
||||
hole(0, 26);
|
||||
hole(1000, 26);
|
||||
for (let x = 75; x <= 925; x += 34) hole(x, 8);
|
||||
g.fill();
|
||||
return `url("${c.toDataURL('image/png')}")`;
|
||||
};
|
||||
|
||||
Scenes[ID] = (root, opts = {}) => {
|
||||
const stage = root.querySelector(':scope > .scene-stage');
|
||||
const row = root.querySelector('.hs-row');
|
||||
const slots = [...root.querySelectorAll('.hs-slot')].filter((s) => s.querySelector('.hs-sheet'));
|
||||
const sheets = slots.map((s) => s.querySelector('.hs-sheet'));
|
||||
const N = sheets.length;
|
||||
if (!stage || !row || !N) { console.warn(ID + ': needs .scene-stage, .hs-row and at least one .hs-slot > .hs-sheet'); return null; }
|
||||
|
||||
const title = root.querySelector('.hs-title');
|
||||
const sub = root.querySelector('.hs-sub');
|
||||
const lead = root.querySelector('.hs-lead');
|
||||
const note = root.querySelector('.hs-note');
|
||||
const wash = stage.querySelector(':scope > .hs-wash') || make('div', 'hs-wash', stage, true);
|
||||
// reads the deal's ticket width and the headline's stepped-back size from the CSS (one source)
|
||||
const gauge = stage.querySelector(':scope > .hs-gauge') || make('span', 'hs-gauge', stage);
|
||||
const shades = sheets.map((sh) => [...sh.querySelectorAll(':scope > .hs-face')].map((f) => f.querySelector(':scope > .hs-shade') || make('span', 'hs-shade', f)));
|
||||
// The backs are print (aria-hidden). During the deal they overlap one another in the deck, so their
|
||||
// words become generated content there and are never counted as text lying under a ticket; in the
|
||||
// flow they are ordinary text again.
|
||||
const printed = [...root.querySelectorAll('.hs-face--back > *:not(.hs-shade)')].filter((el) => !el.children.length);
|
||||
const asPrint = (on) => {
|
||||
for (const el of printed) {
|
||||
if (on && el.dataset.print == null && el.textContent.trim()) { el.dataset.print = el.textContent.replace(/\s+/g, ' ').trim(); el.textContent = ''; }
|
||||
else if (!on && el.dataset.print != null) { el.textContent = el.dataset.print; delete el.dataset.print; }
|
||||
}
|
||||
};
|
||||
// The punched surfaces, in the page's own colours (read from the scene's tokens on every build).
|
||||
// Until they are set, or if a colour cannot be read, the faces stay solid, without holes. Each
|
||||
// face and shade gets its bitmap as its own inline background, never through a custom property: the
|
||||
// stage sets --p on the section on every frame, a background that reads a variable is resolved again
|
||||
// each time, and every face would be repainted on every frame.
|
||||
const surfaces = sheets.flatMap((sheet) => [...sheet.querySelectorAll(':scope > .hs-face')].flatMap((face) => {
|
||||
const front = face.classList.contains('hs-face--front');
|
||||
return [[face, front ? '--hs-paper' : '--hs-ink'], [face.querySelector(':scope > .hs-shade'), front ? '--hs-shade-paper' : '--hs-shade']];
|
||||
})).filter(([el]) => el);
|
||||
let cutW = 0;
|
||||
const unpunch = () => { for (const [el] of surfaces) el.style.removeProperty('background'); cutW = 0; };
|
||||
const punch = () => {
|
||||
const w = sheets[0].offsetWidth, h = sheets[0].offsetHeight;
|
||||
if (!w || !h) return;
|
||||
const probe = make('span', 'hs-probe', stage);
|
||||
probe.style.display = 'none';
|
||||
const pics = {};
|
||||
for (const token of ['--hs-paper', '--hs-ink', '--hs-shade-paper', '--hs-shade']) {
|
||||
probe.style.color = `var(${token})`;
|
||||
const rgb = rgbOf(getComputedStyle(probe).color);
|
||||
pics[token] = rgb && punched(rgb, w, h);
|
||||
}
|
||||
probe.remove();
|
||||
if (Object.values(pics).some((p) => !p)) return; // a colour this browser cannot read: solid faces
|
||||
for (const [el, token] of surfaces) el.style.background = `${pics[token]} 0 0 / 100% 100% no-repeat`;
|
||||
cutW = w;
|
||||
};
|
||||
slots.forEach((s, i) => s.style.setProperty('--i', i));
|
||||
if (N <= 4) root.style.setProperty('--n', N);
|
||||
root.querySelectorAll('img').forEach((img) => { if (img.decode) img.decode().catch(() => {}); }); // decoded before they move
|
||||
|
||||
const o = {
|
||||
deck: opt(opts.deck, 1, 0, 1),
|
||||
stagger: opt(opts.stagger, N > 3 ? 0.12 : 0.14, 0.08, 0.18),
|
||||
wobble: opt(opts.wobble, 7, 0, 8),
|
||||
lean: opt(opts.lean, 16, 0, 18),
|
||||
lift: opt(opts.lift, 120, 0, 120),
|
||||
};
|
||||
const hold = opt(opts.hold, 0.6 + 0.5 * Math.min(N, 4), 1.2, 4);
|
||||
const scrub = typeof opts.scrub === 'boolean' ? opts.scrub : typeof opts.scrub === 'number' ? clamp(opts.scrub, 0.2, 0.6) : (Stage.lenis ? true : 0.5);
|
||||
|
||||
// Turning: |sin(rotationY)| shades both faces, so the edge-on moment reads as paper, not a stray
|
||||
// line. Which face shows is left to backface-visibility alone: the browser knows where the eye is,
|
||||
// so nothing here can make a ticket vanish. data-face only takes the pointer off the face that is
|
||||
// turned away. Everything is read live from the sheet, never from a cache, and turn() runs on every
|
||||
// timeline update and after every refresh (a refresh renders the timeline with its events muted).
|
||||
const turn = (i) => {
|
||||
const sheet = sheets[i];
|
||||
const ry = gsap.getProperty(sheet, 'rotationY') * RAD;
|
||||
const s = Math.round(Math.abs(Math.sin(ry)) * SHADE * 1000) / 1000;
|
||||
const sh = shades[i];
|
||||
if (sh.last !== s) { sh.last = s; for (const el of sh) el.style.opacity = s; }
|
||||
const face = Math.cos(ry) >= 0 ? 'front' : 'back';
|
||||
if (sheet.dataset.face !== face) sheet.dataset.face = face;
|
||||
};
|
||||
const untouched = () => {
|
||||
sheets.forEach((sheet, i) => { delete sheet.dataset.face; shades[i].last = 0; for (const el of shades[i]) el.style.opacity = ''; });
|
||||
unpunch();
|
||||
row.removeAttribute('data-shut');
|
||||
root.classList.remove('hs-moving');
|
||||
};
|
||||
|
||||
// Is there room for the deal: one row, every ticket MIN_W or wider once the band over the row (the
|
||||
// stepped-back headline and the line over the row) and the fine print under it have their room. The
|
||||
// CSS works the width out (--hs-deal-w); the gauge reads it.
|
||||
const roomy = () => {
|
||||
const W = window.innerWidth, H = window.innerHeight;
|
||||
if (N > 4 || W < 768 || H < 560) return false;
|
||||
return gauge.getBoundingClientRect().width >= MIN_W;
|
||||
};
|
||||
// where an element sits in the stage, untransformed (offsets ignore transforms, so this holds mid-tween)
|
||||
const topIn = (el) => { let y = 0; for (let n = el; n && n !== stage; n = n.offsetParent) y += n.offsetTop; return y; };
|
||||
|
||||
// the deal currently running (null in the flow), for the keyboard guard below
|
||||
let current = null;
|
||||
row.addEventListener('focusin', () => {
|
||||
if (!current || !root.classList.contains('is-live')) return;
|
||||
const { tl, open } = current, st = tl.scrollTrigger;
|
||||
if (!st || tl.progress() >= open) return;
|
||||
const y = st.start + 0.94 * (st.end - st.start); // a keyboard user lands on the finished row
|
||||
if (Stage.lenis) Stage.lenis.scrollTo(y, { immediate: true, force: true }); else window.scrollTo(0, y);
|
||||
});
|
||||
|
||||
// ---- the deal: held stage, desktop ----
|
||||
function deal(tl, { stage: st }) {
|
||||
root.classList.remove('hs-flow');
|
||||
untouched();
|
||||
// the headline's line count at its live size decides how tall the band over the row is (CSS)
|
||||
if (title) {
|
||||
const lh = parseFloat(getComputedStyle(title).lineHeight) || 0.92 * parseFloat(getComputedStyle(title).fontSize);
|
||||
root.style.setProperty('--hs-title-lines', lh > 0 ? Math.max(1, Math.round(title.offsetHeight / lh)) : 2);
|
||||
}
|
||||
punch();
|
||||
asPrint(true);
|
||||
// the last ticket lands at LAND; the first no sooner than 0.40, so the deck has time to rise
|
||||
const first = Math.max(0.40, LAND - (N - 1) * o.stagger);
|
||||
const ends = sheets.map((_, i) => first + i * o.stagger);
|
||||
const last = ends[N - 1];
|
||||
const mid = (N - 1) / 2;
|
||||
// slot pitch, measured whenever the timeline is (re)built or refreshed
|
||||
const pitch = () => (N > 1 ? slots[1].offsetLeft - slots[0].offsetLeft : 0);
|
||||
// the deck's top edge starts 40px below the fold, plus what the wobble raises its corners by
|
||||
const below = () => st.clientHeight - topIn(row) + 40 + (sheets[0].offsetWidth / 2) * Math.sin(o.wobble * RAD);
|
||||
|
||||
tl.fromTo(wash, { opacity: 0 }, { opacity: 1, duration: last - DECK }, DECK);
|
||||
if (sub) tl.fromTo(sub, { autoAlpha: 1, y: 0 }, { autoAlpha: 0, y: -12, ease: 'power1.in', duration: 0.10 }, STEP[0]);
|
||||
|
||||
sheets.forEach((sheet, i) => {
|
||||
const end = ends[i];
|
||||
const fs = DECK + 0.35 * (end - DECK), fd = end - 0.04 - fs; // turn starts with a quarter showing, ends before landing
|
||||
tl.fromTo(sheet,
|
||||
{ x: () => (mid - i) * pitch() * o.deck, y: below, rotation: WOBBLE[i % 4] * o.wobble, rotationX: o.lean, transformOrigin: '50% 50%' },
|
||||
{ x: 0, y: 0, rotation: 0, rotationX: 0, ease: 'power1.inOut', duration: end - DECK }, DECK);
|
||||
tl.fromTo(sheet, { rotationY: 180 }, { rotationY: 0, ease: 'power2.inOut', duration: fd }, fs);
|
||||
tl.fromTo(sheet, { z: 0 }, { z: o.lift, ease: 'sine.out', duration: fd / 2 }, fs);
|
||||
tl.to(sheet, { z: 0, ease: 'sine.in', duration: fd / 2 }, fs + fd / 2);
|
||||
});
|
||||
|
||||
// The headline steps back before the deck reaches it: from 0.03 to 0.30 it rises and shrinks (a 2D
|
||||
// scale about its bottom edge, so the words stay sharp) until its bottom edge sits GAP above the line
|
||||
// over the row, at --hs-title-end. The deck is still below the fold when it starts, and the band
|
||||
// over the row is reserved in the CSS, so no ticket ever passes over the headline, and the finished
|
||||
// row keeps it as its heading.
|
||||
if (title) {
|
||||
gsap.set(title, { transformOrigin: '50% 100%' });
|
||||
const k = () => {
|
||||
const end = parseFloat(getComputedStyle(gauge).fontSize), now = parseFloat(getComputedStyle(title).fontSize);
|
||||
return end > 0 && now > 0 ? Math.min(1, end / now) : 0.4;
|
||||
};
|
||||
const y = () => topIn(lead || row) - GAP - (topIn(title) + title.offsetHeight);
|
||||
tl.fromTo(title, { y: 0, scale: 1 }, { y, scale: k, ease: 'power2.inOut', duration: STEP[1] }, STEP[0]);
|
||||
}
|
||||
if (lead) tl.fromTo(lead, { autoAlpha: 0, y: 14 }, { autoAlpha: 1, y: 0, ease: 'power2.out', duration: 0.08 }, last - 0.04);
|
||||
if (note) tl.fromTo(note, { autoAlpha: 0, y: 14 }, { autoAlpha: 1, y: 0, ease: 'power2.out', duration: 0.08 }, last);
|
||||
tl.set({}, {}, 1);
|
||||
|
||||
// the row takes the pointer only once the last ticket is down; the headline is its own layer
|
||||
// (will-change) only while it steps back, and painted flat and sharp into the stage the rest of the time
|
||||
const open = last + 0.02;
|
||||
let shut = null, moving = null;
|
||||
const update = () => {
|
||||
for (let i = 0; i < N; i++) turn(i);
|
||||
const p = tl.progress();
|
||||
const s = p < open;
|
||||
if (s !== shut) { shut = s; if (s) row.setAttribute('data-shut', ''); else row.removeAttribute('data-shut'); }
|
||||
const m = p > 0.005 && p < STEP[0] + STEP[1] + 0.02;
|
||||
if (m !== moving) { moving = m; root.classList.toggle('hs-moving', m); }
|
||||
};
|
||||
tl.eventCallback('onUpdate', update);
|
||||
update();
|
||||
current = { tl, open, update };
|
||||
}
|
||||
|
||||
// ---- the flow: no held stage; each ticket turns over as it scrolls into view ----
|
||||
function flow(tl, { phone }) {
|
||||
root.classList.add('hs-flow');
|
||||
untouched();
|
||||
punch();
|
||||
asPrint(false);
|
||||
current = null;
|
||||
const colOf = (i) => { let c = 0; for (let k = i - 1; k >= 0 && slots[k].offsetTop === slots[i].offsetTop; k--) c++; return c; };
|
||||
sheets.forEach((sheet, i) => {
|
||||
const d = Math.min(0.3, colOf(i) * 0.1); // tickets side by side still land left to right
|
||||
const len = 1 - d;
|
||||
const t = gsap.timeline({
|
||||
defaults: { ease: 'none' },
|
||||
scrollTrigger: { trigger: slots[i], start: 'top 96%', end: 'top 38%', scrub: phone || scrub === true ? 0.4 : scrub, invalidateOnRefresh: true },
|
||||
});
|
||||
t.fromTo(sheet, { y: 140, rotation: WOBBLE[i % 4] * Math.min(o.wobble, 5), rotationX: 14, transformOrigin: '50% 50%' },
|
||||
{ y: 0, rotation: 0, rotationX: 0, ease: 'power1.inOut', duration: len }, d)
|
||||
.fromTo(sheet, { rotationY: 180 }, { rotationY: 0, ease: 'power2.inOut', duration: 0.65 * len }, d + 0.25 * len)
|
||||
.fromTo(sheet, { z: 0 }, { z: Math.min(o.lift, 60), ease: 'sine.out', duration: 0.325 * len }, d + 0.25 * len)
|
||||
.to(sheet, { z: 0, ease: 'sine.in', duration: 0.325 * len }, d + 0.575 * len);
|
||||
// the fine print sits under the last row, where its tickets rise from: it comes in once they are down
|
||||
if (note && i === N - 1) t.fromTo(note, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.1 }, 0.9);
|
||||
t.set({}, {}, 1);
|
||||
t.eventCallback('onUpdate', () => turn(i));
|
||||
turn(i);
|
||||
});
|
||||
}
|
||||
|
||||
// A refresh (resize, fonts, pictures, another scene) renders every timeline at its new progress
|
||||
// with events muted, so the shades and data-face are brought up to date after each one; and when
|
||||
// the tickets changed size, their punched surfaces are drawn again at the new size.
|
||||
const refreshed = () => {
|
||||
if (!root.classList.contains('is-live')) return;
|
||||
if (current) current.update(); else for (let i = 0; i < N; i++) turn(i);
|
||||
if (Math.abs(sheets[0].offsetWidth - cutW) > 2) punch();
|
||||
};
|
||||
|
||||
let mm = null, dealing = null, timer = 0;
|
||||
const start = () => {
|
||||
mm = Stage.scene(root, {
|
||||
hold,
|
||||
scrub,
|
||||
phone: opts.phone === 'rest' ? 'rest' : { hold: 1, build: flow },
|
||||
build: (tl, ctx) => { dealing = roomy(); return dealing ? deal(tl, ctx) : flow(tl, ctx); },
|
||||
});
|
||||
};
|
||||
const stop = () => {
|
||||
if (!mm) return;
|
||||
const k = Stage.scenes.indexOf(mm);
|
||||
if (k > -1) Stage.scenes.splice(k, 1);
|
||||
mm.revert();
|
||||
mm = null;
|
||||
current = null;
|
||||
untouched();
|
||||
};
|
||||
// The stage only switches at the phone width; this scene also switches between the deal and the
|
||||
// flow when a resize changes whether the row fits.
|
||||
const resized = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (!mm || Stage.phone || !root.classList.contains('is-live') || roomy() === dealing) return;
|
||||
stop();
|
||||
start();
|
||||
ScrollTrigger.refresh();
|
||||
}, 150);
|
||||
};
|
||||
start();
|
||||
if (mm) {
|
||||
window.addEventListener('resize', resized);
|
||||
ScrollTrigger.addEventListener('refresh', refreshed);
|
||||
}
|
||||
// one stable handle for the life of the section, whatever the resizes rebuild underneath it
|
||||
return {
|
||||
get mm() { return mm; },
|
||||
revert() {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('resize', resized);
|
||||
if (window.ScrollTrigger) ScrollTrigger.removeEventListener('refresh', refreshed);
|
||||
stop();
|
||||
asPrint(false);
|
||||
root.classList.remove('hs-flow', 'is-live', 'is-rest');
|
||||
},
|
||||
};
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,350 @@
|
||||
/* tasteskill / scenes / instrument-dial-stage.css — the instrument dial stage (see instrument-dial-stage.js
|
||||
for the DOM and the options). By default: the finished composition that reduced motion, no script and
|
||||
a phone set to 'rest' show, every item a small stage of its own (its note, its name standing on its
|
||||
ground, its picture and its number on that ground). Under .is-live: the held dial.
|
||||
Weights follow the page: --display-weight (the title, the word and the value; 600 and 500 here when
|
||||
the page sets none). A weight the page has not loaded is never faked: the nearest loaded one shows. */
|
||||
|
||||
[data-scene="instrument-dial-stage"] { font-synthesis-weight: none; }
|
||||
|
||||
[data-scene="instrument-dial-stage"] {
|
||||
--idl-ink: var(--ink, #111);
|
||||
--idl-ground: var(--ground, var(--bg, #f4f3ef));
|
||||
--idl-accent: var(--accent, #e8481c);
|
||||
--idl-step: color-mix(in srgb, var(--idl-ink) 8%, var(--idl-ground));
|
||||
--idl-display: var(--font-display, inherit);
|
||||
--idl-text: var(--font-text, inherit);
|
||||
--idl-pad: clamp(20px, 3.4vw, 48px);
|
||||
background: var(--idl-ground);
|
||||
color: var(--idl-ink);
|
||||
font-family: var(--idl-text);
|
||||
}
|
||||
/* each item's ground and the text on it: a deep step of the page ink and a light step of the page
|
||||
ground in turn, both apart from the ink of the names standing on them (an item's data-tone
|
||||
overrides it, set by the script) */
|
||||
[data-scene="instrument-dial-stage"] .idl-item:nth-child(odd) { --idl-tone: color-mix(in srgb, var(--idl-ink) 76%, var(--idl-ground)); --idl-on: var(--idl-ground); }
|
||||
[data-scene="instrument-dial-stage"] .idl-item:nth-child(even) { --idl-tone: var(--idl-step); --idl-on: var(--idl-ink); }
|
||||
[data-scene="instrument-dial-stage"].idl-no-field .idl-item { --idl-tone: var(--idl-ground); --idl-on: var(--idl-ink); }
|
||||
|
||||
/* ---------- finished composition ---------- */
|
||||
|
||||
[data-scene="instrument-dial-stage"] > .scene-stage { padding: clamp(80px, 10vw, 160px) var(--idl-pad); }
|
||||
[data-scene="instrument-dial-stage"] .idl-head { max-width: 46rem; margin: 0 0 clamp(48px, 6vw, 96px); }
|
||||
[data-scene="instrument-dial-stage"] .idl-title {
|
||||
margin: 0;
|
||||
font-family: var(--idl-display);
|
||||
font-weight: var(--display-weight, 600);
|
||||
font-size: clamp(36px, 4.6vw, 72px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.025em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
[data-scene="instrument-dial-stage"] .idl-items {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: clamp(64px, 7vw, 112px) clamp(16px, 2.2vw, 32px);
|
||||
}
|
||||
/* three columns for 3, 6, 9 … items; one under 720px */
|
||||
[data-scene="instrument-dial-stage"] .idl-items:has(> .idl-item:nth-child(3n):last-child) { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
[data-scene="instrument-dial-stage"] .idl-items:has(> .idl-item:only-child) { grid-template-columns: minmax(0, 1fr); }
|
||||
|
||||
/* one item: its note, its name standing on its ground, its picture and number on the ground */
|
||||
[data-scene="instrument-dial-stage"] .idl-item {
|
||||
container-type: inline-size;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-rows: auto auto auto;
|
||||
min-width: 0;
|
||||
--idl-in: clamp(14px, 4cqi, 28px);
|
||||
}
|
||||
[data-scene="instrument-dial-stage"] .idl-item::before { content: ""; grid-row: 3; grid-column: 1 / -1; background: var(--idl-tone); }
|
||||
[data-scene="instrument-dial-stage"] .idl-code { grid-row: 1; grid-column: 1 / -1; margin: 0 0 clamp(10px, 2.4cqi, 18px); font-size: 17px; line-height: 1.4; }
|
||||
[data-scene="instrument-dial-stage"] .idl-word {
|
||||
grid-row: 2;
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
font-family: var(--idl-display);
|
||||
font-weight: var(--display-weight, 600);
|
||||
font-size: min(clamp(48px, 7vw, 120px), 16.5cqi);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
text-transform: uppercase;
|
||||
text-box: trim-both cap alphabetic;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
[data-scene="instrument-dial-stage"] .idl-subject {
|
||||
grid-row: 3;
|
||||
grid-column: 1;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: var(--idl-in) 0 var(--idl-in) var(--idl-in);
|
||||
aspect-ratio: 4 / 5;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--idl-on) 14%, var(--idl-tone));
|
||||
}
|
||||
[data-scene="instrument-dial-stage"] .idl-clip,
|
||||
[data-scene="instrument-dial-stage"] .idl-turn,
|
||||
[data-scene="instrument-dial-stage"] .idl-subject img,
|
||||
[data-scene="instrument-dial-stage"] .idl-subject picture { display: block; width: 100%; height: 100%; }
|
||||
[data-scene="instrument-dial-stage"] .idl-subject img { object-fit: cover; }
|
||||
[data-scene="instrument-dial-stage"] .idl-stat {
|
||||
grid-row: 3;
|
||||
grid-column: 2;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
align-self: end;
|
||||
margin: 0;
|
||||
padding: var(--idl-in);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: var(--idl-on);
|
||||
}
|
||||
[data-scene="instrument-dial-stage"] .idl-value {
|
||||
font-family: var(--idl-display);
|
||||
font-size: clamp(28px, 7.4cqi, 52px);
|
||||
font-weight: var(--display-weight, 500);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-scene="instrument-dial-stage"] .idl-caption { font-size: 16px; line-height: 1.35; }
|
||||
/* the instrument's reading in words, added by the script when every item has data-at */
|
||||
[data-scene="instrument-dial-stage"] .idl-reading { display: block; margin-top: 2px; font-size: 15px; line-height: 1.35; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* split letters (the script adds them; they read as plain text when the scene is not running) */
|
||||
[data-scene="instrument-dial-stage"] .idl-m,
|
||||
[data-scene="instrument-dial-stage"] .idl-c { display: inline-block; }
|
||||
[data-scene="instrument-dial-stage"] .idl-base { display: inline-block; inline-size: 0; block-size: 0; vertical-align: baseline; }
|
||||
[data-scene="instrument-dial-stage"] .idl-probe { position: absolute; left: 0; top: 0; visibility: hidden; white-space: pre; }
|
||||
[data-scene="instrument-dial-stage"] .idl-sr {
|
||||
position: absolute;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* what the script draws exists only while the scene runs */
|
||||
[data-scene="instrument-dial-stage"] .idl-field,
|
||||
[data-scene="instrument-dial-stage"] .idl-instrument { display: none; }
|
||||
|
||||
@media (max-width: 719px) {
|
||||
[data-scene="instrument-dial-stage"] .idl-items,
|
||||
[data-scene="instrument-dial-stage"] .idl-items:has(> .idl-item:nth-child(3n):last-child) { grid-template-columns: minmax(0, 1fr); gap: 56px; }
|
||||
[data-scene="instrument-dial-stage"] .idl-code,
|
||||
[data-scene="instrument-dial-stage"] .idl-caption { font-size: 15px; }
|
||||
}
|
||||
|
||||
|
||||
/* ---------- the live stage ---------- */
|
||||
|
||||
html.js [data-scene="instrument-dial-stage"].is-live > .scene-stage {
|
||||
container-type: size;
|
||||
padding: 0;
|
||||
background: var(--idl-ground);
|
||||
/* hz: the horizon. The names stand on it, the dial's wide arc spans the stage above them, and below
|
||||
it the whole width of the stage is the item's ground with its picture standing on it. The script
|
||||
measures the rest (the name size, the dial, the picture box) from here */
|
||||
--hz: 48cqh;
|
||||
--idl-gap: clamp(12px, 2cqh, 20px);
|
||||
/* stage.js writes --p on the section on every scroll update. Inherited, it re-styles every split
|
||||
letter and every path below (about 4 ms a frame); the stage stops it here. Read --p on the
|
||||
section itself. */
|
||||
--p: initial;
|
||||
}
|
||||
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-head {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
left: var(--idl-pad);
|
||||
top: var(--idl-pad);
|
||||
max-width: min(23cqw, 380px);
|
||||
margin: 0;
|
||||
}
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-title { font-size: clamp(24px, 2.45cqw, 38px); line-height: 1.04; letter-spacing: -0.02em; }
|
||||
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-items { position: absolute; inset: 0; display: block; }
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-item { display: block; container-type: normal; }
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-item::before { content: none; }
|
||||
|
||||
/* the instrument, drawn by the script in stage px: the scale, the pins, the bezel and the pointer */
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-instrument {
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-scene="instrument-dial-stage"] .idl-ring { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
|
||||
[data-scene="instrument-dial-stage"] .idl-hz { position: absolute; left: 0; top: var(--hz); width: 0; height: 0; }
|
||||
[data-scene="instrument-dial-stage"] .idl-tk { fill: none; stroke: currentColor; stroke-width: 2px; }
|
||||
[data-scene="instrument-dial-stage"] .idl-tk--major { stroke-width: 3px; }
|
||||
[data-scene="instrument-dial-stage"] .idl-tk-base { color: color-mix(in srgb, var(--idl-ink) 28%, transparent); }
|
||||
[data-scene="instrument-dial-stage"] .idl-tk-lit { color: var(--idl-ink); }
|
||||
[data-scene="instrument-dial-stage"] .idl-tk-done { color: var(--idl-accent); }
|
||||
[data-scene="instrument-dial-stage"] .idl-pin { fill: var(--idl-accent); }
|
||||
[data-scene="instrument-dial-stage"] .idl-bz-band { fill: none; stroke: color-mix(in srgb, var(--idl-ink) 11%, transparent); }
|
||||
[data-scene="instrument-dial-stage"] .idl-bz-notch { fill: none; stroke: color-mix(in srgb, var(--idl-ink) 34%, transparent); stroke-width: 2px; }
|
||||
[data-scene="instrument-dial-stage"] .idl-bz-sec { fill: none; stroke: color-mix(in srgb, var(--idl-ink) 22%, transparent); }
|
||||
[data-scene="instrument-dial-stage"] .idl-bz-sec--lit { stroke: var(--idl-accent); }
|
||||
[data-scene="instrument-dial-stage"] .idl-ptr { fill: var(--idl-accent); }
|
||||
[data-scene="instrument-dial-stage"] .idl-dial {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 20cqh;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
overflow: clip;
|
||||
padding-block: 0.15em;
|
||||
font-size: clamp(14px, 1.1cqw, 17px);
|
||||
line-height: 1.25;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
[data-scene="instrument-dial-stage"] .idl-dial .idl-roll { grid-area: 1 / 1; display: block; }
|
||||
|
||||
/* the names: one size for all, standing on the horizon */
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-word {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(var(--hz) - var(--idl-bl, 0.8em));
|
||||
margin: 0;
|
||||
font-size: var(--idl-f, 12cqw);
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: normal;
|
||||
text-box: normal;
|
||||
}
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-word .idl-chars {
|
||||
display: inline-block;
|
||||
transform-origin: 100% var(--idl-gy, 80%);
|
||||
will-change: transform;
|
||||
}
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-m {
|
||||
vertical-align: top;
|
||||
padding-block: 0.14em;
|
||||
margin-block: -0.14em;
|
||||
/* clipped to the letters' own ink (measured by the script), a little wider than the letter */
|
||||
clip-path: inset(var(--idl-ct, 0px) -0.25em var(--idl-cb, 0px) -0.25em);
|
||||
}
|
||||
/* the letters are not promoted one by one: fifty-odd small layers cost the compositor more on every
|
||||
frame than repainting a name during its short roll */
|
||||
|
||||
/* each item: its ground, the full width under the horizon, laid out from the wash's crest (--idl-pt)
|
||||
and slid by the script; and its picture, standing in front of the foot of the name. Each item stacks
|
||||
above the one before, so its ground and picture cover the last item as they rise */
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-subject {
|
||||
position: absolute;
|
||||
z-index: calc(3 + var(--idl-k, 0) * 2);
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
aspect-ratio: auto;
|
||||
overflow: visible;
|
||||
background: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-field {
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: var(--idl-pt, var(--hz));
|
||||
height: calc(100% - var(--idl-pt, var(--hz)) + 4px);
|
||||
background: var(--idl-tone);
|
||||
transform: translate3d(0, 110cqh, 0);
|
||||
will-change: transform;
|
||||
}
|
||||
/* the scan line rides the top edge of the rising ground, across the whole stage */
|
||||
[data-scene="instrument-dial-stage"] .idl-edge {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: -1px;
|
||||
height: 3px;
|
||||
background: var(--idl-accent);
|
||||
opacity: 0;
|
||||
}
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-clip {
|
||||
position: absolute;
|
||||
left: calc(50% - var(--idl-box-w, 36cqw) / 2);
|
||||
top: var(--idl-pic, var(--hz));
|
||||
width: var(--idl-box-w, 36cqw);
|
||||
height: var(--idl-box-h, 52cqh);
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--idl-on) 14%, var(--idl-tone));
|
||||
translate: 0 110cqh;
|
||||
will-change: transform;
|
||||
}
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-turn { position: absolute; inset: 0; will-change: transform; }
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-subject img { will-change: transform; }
|
||||
|
||||
/* the note bottom left and the number bottom right, on the item's ground either side of the picture.
|
||||
Both stack with their item, so the next ground covers them as it rises */
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-roll { display: block; }
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-code {
|
||||
position: absolute;
|
||||
z-index: calc(4 + var(--idl-k, 0) * 2);
|
||||
left: var(--idl-pad);
|
||||
right: calc(50% + var(--idl-box-w, 36cqw) / 2 + var(--idl-gap) * 1.5);
|
||||
bottom: var(--idl-pad);
|
||||
margin: 0;
|
||||
overflow: clip;
|
||||
padding-block: 0.12em;
|
||||
text-align: left;
|
||||
font-size: clamp(16px, 1.3cqw, 20px);
|
||||
line-height: 1.3;
|
||||
color: var(--idl-on);
|
||||
}
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-stat {
|
||||
position: absolute;
|
||||
z-index: calc(4 + var(--idl-k, 0) * 2);
|
||||
left: calc(50% + var(--idl-box-w, 36cqw) / 2 + var(--idl-gap) * 1.5);
|
||||
right: var(--idl-pad);
|
||||
bottom: var(--idl-pad);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: block;
|
||||
text-align: right;
|
||||
color: var(--idl-on);
|
||||
}
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-value {
|
||||
display: block;
|
||||
font-size: clamp(40px, 4.4cqw, 68px);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-caption {
|
||||
display: block;
|
||||
overflow: clip;
|
||||
padding-block: 0.12em;
|
||||
margin-top: 10px;
|
||||
font-size: clamp(15px, 1.2cqw, 18px);
|
||||
line-height: 1.3;
|
||||
}
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-reading { display: none; }
|
||||
|
||||
/* ---------- the live stage on a phone: the same dial, other numbers ---------- */
|
||||
@media (max-width: 767px) {
|
||||
html.js [data-scene="instrument-dial-stage"].is-live > .scene-stage {
|
||||
--hz: 36cqh;
|
||||
--idl-pad: 20px;
|
||||
--idl-gap: 14px;
|
||||
}
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-head { max-width: calc(100cqw - 40px); }
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-title { font-size: 24px; line-height: 1.08; }
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-code { right: var(--idl-pad); bottom: calc(var(--idl-pad) + 82px); font-size: 15px; }
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-stat { left: var(--idl-pad); text-align: left; }
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-value { font-size: clamp(34px, 10cqw, 42px); }
|
||||
[data-scene="instrument-dial-stage"].is-live .idl-caption { font-size: 15px; margin-top: 6px; }
|
||||
[data-scene="instrument-dial-stage"] .idl-dial { font-size: 14px; }
|
||||
}
|
||||
@@ -0,0 +1,761 @@
|
||||
// tasteskill / scenes / instrument-dial-stage.js — an instrument that reads off 3 to 6 items.
|
||||
//
|
||||
// A horizon cuts the stage. Above it, the wide arc of a large dial spans the stage over the item's
|
||||
// name: a scale of long ticks that reads each item's own number (a height, a weight, a price), lit one
|
||||
// whole tick at a time, with a pin where every item reads; under it a bezel that clicks round, a notch
|
||||
// at a time, until the item in hand sits under the pointer in the accent. The name stands huge on the
|
||||
// horizon. Below it the whole width of the stage is the item's ground, and its picture stands in front
|
||||
// of the foot of the name. At each beat the next picture rises from the bottom of the screen in front
|
||||
// of the last one, which sinks back, and overshoots up in front of the lower half of the name that is
|
||||
// leaving; its ground surges up behind it on a full-width scan line, washes over the name while the
|
||||
// letters are swapped, and falls back to the horizon with the picture as the new letters jump up; the
|
||||
// scale climbs to the new reading and the number rolls in on the new ground. Built on
|
||||
// stage.js. The finished composition (reduced motion, no script) shows every item as a small stage of
|
||||
// its own: its note, its name standing on its ground, its picture, its number and its reading.
|
||||
//
|
||||
// DOM the scene expects (the script draws the dial, the grounds and the scan line itself, all
|
||||
// aria-hidden, and splits the names and numbers into masked letters):
|
||||
//
|
||||
// <section class="scene" data-scene="instrument-dial-stage" data-options='{}' aria-labelledby="t">
|
||||
// <div class="scene-stage">
|
||||
// <header class="idl-head"><h2 id="t" class="idl-title">One climb, four heights</h2></header>
|
||||
// <ol class="idl-items">
|
||||
// <li class="idl-item" data-at="640"> data-at, data-tone: optional, see below
|
||||
// <h3 class="idl-word">Valley</h3> one word, 8 letters or fewer
|
||||
// <figure class="idl-subject"><img src="…" alt="…" width="840" height="1050"></figure> 4:5
|
||||
// <p class="idl-code">Set off at six</p> a few words
|
||||
// <p class="idl-stat"><span class="idl-value">640 m</span>
|
||||
// <span class="idl-caption">Car park by the fjord</span></p>
|
||||
// </li>
|
||||
// … 3 to 6 items read best; up to 12 work (the hold grows)
|
||||
// </ol>
|
||||
// </div>
|
||||
// </section>
|
||||
//
|
||||
// Per item, optional:
|
||||
// data-at the item's reading on the dial, a number ("3,060" works). Without it the dial reads the
|
||||
// number in the item's .idl-value ("1,085 °C" reads 1085); when a value has no number,
|
||||
// the items read evenly. The scale runs from 0 (or a little under the lowest reading) to
|
||||
// the highest, each pin stands at its reading and the lit ticks climb to it. With data-at
|
||||
// on every item the finished composition also prints each reading ("640 of 3,060 m").
|
||||
// data-tone a CSS colour for the item's ground below the horizon. Without it the grounds alternate
|
||||
// between a deep step of the page ink and a light step of the page ground. Text on a
|
||||
// ground takes the page ink or the page ground, whichever reads better on it.
|
||||
//
|
||||
// Options (data-options JSON), defaults in brackets:
|
||||
// hold screens the stage is held [0.85 x items + 0.6, at most 7]; the phone holds 0.9 of it
|
||||
// scrub seconds of catch-up, or true to lock to the scrollbar [0.8]
|
||||
// grow start scale of the first name's lead-in, 0.18-0.40 [0.28]; it starts right of centre,
|
||||
// its right edge on the right edge of the finished name
|
||||
// ticks ticks across the whole scale, 16-200 [about one every 12px of arc]
|
||||
// range [low, high] of the scale [see data-at]
|
||||
// turn degrees each picture rolls across its time on stage, 0-3 [1.2]
|
||||
// tilt pictures lean toward a fine pointer on desktop [true]
|
||||
// field each item's own ground below the horizon [true]; false keeps the page ground under every item
|
||||
// wash the incoming ground washes up over the name before it settles on the horizon [true]
|
||||
// snap when scrolling stops inside a change, finish it or undo it [false]; stops inside a hold stay
|
||||
// phone 'same' (the dial with phone numbers) or 'rest' (the finished composition) ['same']
|
||||
//
|
||||
// Page tokens it takes: --ink, --ground (or --bg), --accent, --font-display, --font-text.
|
||||
// stage.js's --p (the scene's progress) is readable on the section only: the live stage stops it from
|
||||
// inheriting into the split letters, which would re-style them all on every frame.
|
||||
(() => {
|
||||
const ID = 'instrument-dial-stage';
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
|
||||
// how far letters roll out of their masks (yPercent): names, numbers (their commas hang low), lines
|
||||
const WR = 125; const VR = 135; const RR = 120;
|
||||
// where a new name's letters start under the falling ground: just below their masks
|
||||
const WI = 100;
|
||||
const num = (v, d) => (v === null || v === undefined || v === '' || typeof v === 'boolean' || !Number.isFinite(+v) ? d : +v);
|
||||
const r1 = (n) => Math.round(n * 10) / 10;
|
||||
const r2 = (n) => Math.round(n * 100) / 100;
|
||||
let uid = 0;
|
||||
|
||||
const el = (tag, cls, attrs) => {
|
||||
const e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (attrs) for (const k in attrs) e.setAttribute(k, attrs[k]);
|
||||
return e;
|
||||
};
|
||||
const svg = (tag, attrs) => {
|
||||
const e = document.createElementNS(NS, tag);
|
||||
for (const k in attrs) e.setAttribute(k, attrs[k]);
|
||||
return e;
|
||||
};
|
||||
|
||||
// The first number in a text: "3,060" → 3060, "1 085 °C" → 1085, "12,5 kg" → 12.5, "€49" → 49.
|
||||
const numOf = (s) => {
|
||||
if (s === null || s === undefined) return NaN;
|
||||
let t = String(s).replace(/[\s ']/g, '').replace(/−/g, '-');
|
||||
t = t.replace(/(\d),(?=\d{3}(?!\d))/g, '$1');
|
||||
const m = t.match(/-?\d+(?:[.,]\d+)?/);
|
||||
return m ? parseFloat(m[0].replace(',', '.')) : NaN;
|
||||
};
|
||||
|
||||
// Dial geometry in stage px about the centre g = { cx, cy }, angles clockwise from 12 o'clock.
|
||||
const pt = (g, r, deg) => { const a = ((deg - 90) * Math.PI) / 180; return [g.cx + r * Math.cos(a), g.cy + r * Math.sin(a)]; };
|
||||
const seg = (g, ra, rb, deg) => { const [x1, y1] = pt(g, ra, deg); const [x2, y2] = pt(g, rb, deg); return `M${r2(x1)} ${r2(y1)}L${r2(x2)} ${r2(y2)}`; };
|
||||
const arc = (g, r, a0, a1) => { const [x0, y0] = pt(g, r, a0); const [x1, y1] = pt(g, r, a1); return `M${r2(x0)} ${r2(y0)}A${r2(r)} ${r2(r)} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${r2(x1)} ${r2(y1)}`; };
|
||||
const tri = (g, rApex, rBase, half, deg) => {
|
||||
const [ax, ay] = pt(g, rApex, deg); const [bx, by] = pt(g, rBase, deg);
|
||||
const a = (deg * Math.PI) / 180; const px = Math.cos(a) * half; const py = Math.sin(a) * half;
|
||||
return `M${r2(ax)} ${r2(ay)}L${r2(bx + px)} ${r2(by + py)}L${r2(bx - px)} ${r2(by - py)}Z`;
|
||||
};
|
||||
|
||||
const graphemes = (t) => {
|
||||
try { if (window.Intl && Intl.Segmenter) return [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(t)].map((x) => x.segment); } catch (e) { /* fall through */ }
|
||||
return Array.from(t);
|
||||
};
|
||||
|
||||
// Masked letters: <span class="idl-m"><span class="idl-c">A</span></span>, the full text kept for
|
||||
// screen readers. A zero-size mark on the baseline lets the script stand the name on the horizon.
|
||||
function letters(node) {
|
||||
if (!node) return { node: null, masks: [], chars: [] };
|
||||
if (node.__idl) return node.__idl;
|
||||
const text = node.textContent.replace(/\s+/g, ' ').trim();
|
||||
const sr = el('span', 'idl-sr'); sr.textContent = text;
|
||||
const vis = el('span', 'idl-chars', { 'aria-hidden': 'true' });
|
||||
for (const g of graphemes(text)) {
|
||||
const m = el('span', 'idl-m'); const c = el('span', 'idl-c');
|
||||
c.textContent = g === ' ' ? ' ' : g;
|
||||
m.append(c); vis.append(m);
|
||||
}
|
||||
const mark = el('i', 'idl-base');
|
||||
vis.append(mark);
|
||||
node.replaceChildren(sr, vis);
|
||||
node.__idl = { node, vis, mark, masks: [...vis.querySelectorAll('.idl-m')], chars: [...vis.querySelectorAll('.idl-c')], text };
|
||||
return node.__idl;
|
||||
}
|
||||
function roller(node) {
|
||||
if (!node) return null;
|
||||
let r = node.querySelector(':scope > .idl-roll');
|
||||
if (!r) { r = el('span', 'idl-roll'); r.append(...node.childNodes); node.append(r); }
|
||||
return r;
|
||||
}
|
||||
|
||||
// Colours: an item's data-tone becomes its ground, and its text takes the page ink or the page
|
||||
// ground, whichever has more contrast on it. Read through a 1px canvas so any CSS colour works.
|
||||
let cv = null;
|
||||
const rgbOf = (css) => {
|
||||
try {
|
||||
cv = cv || Object.assign(document.createElement('canvas'), { width: 1, height: 1 }).getContext('2d', { willReadFrequently: true });
|
||||
cv.clearRect(0, 0, 1, 1); cv.fillStyle = '#000'; cv.fillStyle = css; cv.fillRect(0, 0, 1, 1);
|
||||
const d = cv.getImageData(0, 0, 1, 1).data; return [d[0], d[1], d[2]];
|
||||
} catch (e) { return null; }
|
||||
};
|
||||
const lum = (c) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]); };
|
||||
const contrast = (a, b) => { const x = lum(a); const y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
|
||||
function tones(root) {
|
||||
if (root.classList.contains('idl-no-field')) return;
|
||||
const items = [...root.querySelectorAll('.idl-item')].filter((i) => i.dataset.tone);
|
||||
if (!items.length) return;
|
||||
const probe = el('i', '', { 'aria-hidden': 'true' });
|
||||
probe.style.cssText = 'position:absolute;inline-size:0;block-size:0;visibility:hidden';
|
||||
root.append(probe);
|
||||
const read = (c) => { probe.style.color = ''; probe.style.color = c; return rgbOf(getComputedStyle(probe).color); };
|
||||
const ink = read('var(--idl-ink)'); const ground = read('var(--idl-ground)');
|
||||
for (const it of items) {
|
||||
it.style.setProperty('--idl-tone', it.dataset.tone);
|
||||
const t = read(it.dataset.tone);
|
||||
if (t && ink && ground) it.style.setProperty('--idl-on', contrast(t, ink) >= contrast(t, ground) ? 'var(--idl-ink)' : 'var(--idl-ground)');
|
||||
}
|
||||
probe.remove();
|
||||
}
|
||||
|
||||
// The finished composition keeps the instrument's reading as a line of text under each number, when
|
||||
// every item carries data-at: "640 of 3,060 m". Added up front, so reduced motion shows it too.
|
||||
function notes(root) {
|
||||
const items = [...root.querySelectorAll('.idl-item')];
|
||||
if (!items.length || root.querySelector('.idl-reading')) return;
|
||||
const at = items.map((i) => numOf(i.dataset.at));
|
||||
if (!at.every(Number.isFinite)) return;
|
||||
const mx = Math.max(...at);
|
||||
let fmt = (x) => String(x);
|
||||
try { const nf = new Intl.NumberFormat(document.documentElement.lang || undefined, { maximumFractionDigits: 2 }); fmt = (x) => nf.format(x); } catch (e) { /* plain numbers */ }
|
||||
items.forEach((it, k) => {
|
||||
const stat = it.querySelector('.idl-stat'); const val = it.querySelector('.idl-value');
|
||||
if (!stat) return;
|
||||
const unit = val ? val.textContent.replace(/\s+/g, ' ').trim().replace(/^[\s\S]*\d/, '').trim() : '';
|
||||
const s = el('span', 'idl-reading');
|
||||
s.textContent = `${fmt(at[k])} of ${fmt(mx)}${unit ? ' ' + unit : ''}`;
|
||||
stat.append(' ', s);
|
||||
});
|
||||
}
|
||||
|
||||
// Where each item reads on the scale, 0-1: data-at on every item, else the number in every value,
|
||||
// else evenly.
|
||||
function readings(its, o) {
|
||||
const N = its.length;
|
||||
const fromAt = its.map((it) => { const s = it.item.dataset.at; return s === undefined || s === '' ? NaN : numOf(s); });
|
||||
const v = fromAt.every(Number.isFinite) ? fromAt : its.every((it) => Number.isFinite(it.valNum)) ? its.map((it) => it.valNum) : null;
|
||||
if (!N || !v) return its.map((_, k) => (k + 1) / N);
|
||||
const mn = Math.min(...v); const mx = Math.max(...v);
|
||||
let lo = mn >= 0 && mn <= 0.5 * mx ? 0 : mn - (mx - mn) / Math.max(2, N);
|
||||
let hi = mx;
|
||||
if (Array.isArray(o.range) && o.range.length === 2 && o.range.every((x) => Number.isFinite(+x)) && +o.range[0] !== +o.range[1]) { lo = +o.range[0]; hi = +o.range[1]; }
|
||||
if (hi === lo) hi = lo + 1;
|
||||
return v.map((x) => clamp((x - lo) / (hi - lo), 0, 1));
|
||||
}
|
||||
|
||||
// Everything the script adds, made once per section and kept across desktop / phone rebuilds.
|
||||
function assemble(root, o) {
|
||||
if (root.__idlDom) return root.__idlDom;
|
||||
const stage = root.querySelector(':scope > .scene-stage') || root.firstElementChild;
|
||||
const id = `idl-${++uid}`;
|
||||
const its = [...root.querySelectorAll('.idl-item')].map((item, k) => {
|
||||
let fig = item.querySelector('.idl-subject');
|
||||
const word = item.querySelector('.idl-word');
|
||||
// an item without a picture still gets its ground
|
||||
if (!fig) { fig = el('figure', 'idl-subject', { 'aria-hidden': 'true' }); if (word) word.after(fig); else item.prepend(fig); }
|
||||
const img = fig.querySelector('img');
|
||||
let box = fig.querySelector('.idl-clip');
|
||||
let turn = fig.querySelector('.idl-turn');
|
||||
if (img && !box) {
|
||||
const media = img.closest('picture') || img;
|
||||
box = el('div', 'idl-clip'); turn = el('div', 'idl-turn');
|
||||
media.replaceWith(box); turn.append(media); box.append(turn);
|
||||
}
|
||||
// the item's ground: a full-width field the script slides, its top edge the scan line
|
||||
const field = el('i', 'idl-field', { 'aria-hidden': 'true' });
|
||||
const edge = el('i', 'idl-edge');
|
||||
field.append(edge);
|
||||
fig.prepend(field);
|
||||
if (img && img.decode) img.decode().catch(() => {});
|
||||
// each item stacks above the one before: its ground and picture cover the last item as they rise
|
||||
item.style.setProperty('--idl-k', k);
|
||||
const valueEl = item.querySelector('.idl-value');
|
||||
const valNum = numOf(valueEl && valueEl.textContent);
|
||||
return {
|
||||
item, fig, img, box, turn, field, edge, valNum,
|
||||
word: letters(word),
|
||||
value: letters(valueEl),
|
||||
code: roller(item.querySelector('.idl-code')),
|
||||
caption: roller(item.querySelector('.idl-caption')),
|
||||
};
|
||||
});
|
||||
const N = its.length;
|
||||
const fr = readings(its, o);
|
||||
|
||||
// the dial, drawn in stage px by measure(): the scale (base, lit and finished ticks), the pins,
|
||||
// the bezel turning inside a window, and the fixed pointer
|
||||
const dsvg = svg('svg', { class: 'idl-ring', focusable: 'false' });
|
||||
const defs = svg('defs', {}); const cp = svg('clipPath', { id: `${id}-win` }); const win = svg('path', {});
|
||||
cp.append(win); defs.append(cp); dsvg.append(defs);
|
||||
const p = (cls) => svg('path', { class: cls });
|
||||
const layer = (cls) => { const g = svg('g', { class: cls }); const a = p('idl-tk'); const b = p('idl-tk idl-tk--major'); g.append(a, b); dsvg.append(g); return [a, b]; };
|
||||
const base = layer('idl-tk-base'); const lit = layer('idl-tk-lit'); const done = layer('idl-tk-done');
|
||||
const doneG = done[0].parentNode;
|
||||
const pinG = svg('g', { class: 'idl-pins' });
|
||||
const pins = its.map(() => { const e = p('idl-pin'); pinG.append(e); return e; });
|
||||
dsvg.append(pinG);
|
||||
const per = Math.max(2, Math.round(24 / N)); const notches = N * per; const bstep = 360 / notches;
|
||||
const bzWin = svg('g', { class: 'idl-bezel', 'clip-path': `url(#${id}-win)` });
|
||||
const bzRot = svg('g', {});
|
||||
const band = svg('circle', { class: 'idl-bz-band' });
|
||||
const notch = p('idl-bz-notch'); const secBase = p('idl-bz-sec');
|
||||
const secs = its.map(() => p('idl-bz-sec idl-bz-sec--lit'));
|
||||
bzRot.append(band, notch, secBase, ...secs);
|
||||
bzWin.append(bzRot);
|
||||
const ptr = p('idl-ptr');
|
||||
dsvg.append(bzWin, ptr);
|
||||
|
||||
// the reading under the pointer: "2 of 4", only the active one shown
|
||||
const dial = el('p', 'idl-dial');
|
||||
const labels = its.map((_, k) => { const s = el('span', 'idl-roll'); s.textContent = `${k + 1} of ${N}`; dial.append(s); return s; });
|
||||
|
||||
const inst = el('div', 'idl-instrument', { 'aria-hidden': 'true' });
|
||||
const hzp = el('i', 'idl-hz');
|
||||
inst.append(dsvg, dial, hzp);
|
||||
const head = stage.querySelector('.idl-head');
|
||||
if (head) head.after(inst); else stage.prepend(inst);
|
||||
root.style.setProperty('--idl-n', N);
|
||||
|
||||
root.__idlDom = { stage, head, its, N, fr, dsvg, win, base, lit, done, doneG, pins, bzRot, band, notch, secBase, secs, per, notches, bstep, ptr, labels, dial, inst, hzp };
|
||||
return root.__idlDom;
|
||||
}
|
||||
|
||||
// Pair kerning is lost when letters become inline blocks; measure each pair in the live face and
|
||||
// give it back as a margin in em.
|
||||
function kern(w) {
|
||||
if (!w.masks.length) return;
|
||||
const probe = el('span', 'idl-probe', { 'aria-hidden': 'true' });
|
||||
w.node.append(probe);
|
||||
const fs = parseFloat(getComputedStyle(w.node).fontSize) || 100;
|
||||
const cache = {};
|
||||
const wide = (t) => { if (!(t in cache)) { probe.textContent = t; cache[t] = probe.getBoundingClientRect().width; } return cache[t]; };
|
||||
const ch = w.chars.map((c) => c.textContent);
|
||||
for (let i = 0; i < w.masks.length; i++) {
|
||||
let k = 0;
|
||||
if (i < ch.length - 1) k = wide(ch[i] + ch[i + 1]) - wide(ch[i]) - wide(ch[i + 1]);
|
||||
w.masks[i].style.marginRight = Math.abs(k) > fs * 0.002 ? (k / fs).toFixed(4) + 'em' : '';
|
||||
}
|
||||
probe.remove();
|
||||
}
|
||||
|
||||
// Ink extent of a split text above and below its baseline, in px, read from a canvas in the same face.
|
||||
let canvas = null;
|
||||
function ink(w, sample) {
|
||||
try {
|
||||
canvas = canvas || document.createElement('canvas').getContext('2d');
|
||||
const cs = getComputedStyle(w.node);
|
||||
const fs = parseFloat(cs.fontSize) || 16;
|
||||
canvas.font = `${cs.fontStyle} ${cs.fontWeight} ${fs}px ${cs.fontFamily}`;
|
||||
let t = sample || w.text || 'H';
|
||||
if (cs.textTransform === 'uppercase') t = t.toUpperCase();
|
||||
const m = canvas.measureText(t);
|
||||
const asc = m.actualBoundingBoxAscent; const desc = m.actualBoundingBoxDescent;
|
||||
if (!(asc > 0.3 * fs && asc < 1.3 * fs)) return { fs };
|
||||
return { fs, asc, desc: Math.max(0, desc) };
|
||||
} catch (e) { return { fs: 16 }; }
|
||||
}
|
||||
function clipToInk(w) {
|
||||
if (!w || !w.masks.length) return;
|
||||
const k = ink(w);
|
||||
if (!k.asc) { w.node.style.removeProperty('--idl-ct'); w.node.style.removeProperty('--idl-cb'); return; }
|
||||
const bl = w.mark.offsetTop; const m = w.masks[0]; const mt = m.offsetTop; const mh = m.offsetHeight;
|
||||
w.node.style.setProperty('--idl-ct', Math.max(0, bl - k.asc - 0.035 * k.fs - mt).toFixed(1) + 'px');
|
||||
w.node.style.setProperty('--idl-cb', Math.max(0, mt + mh - bl - k.desc - 0.012 * k.fs).toFixed(1) + 'px');
|
||||
}
|
||||
|
||||
// Sizes that depend on the face and the viewport, all in stage px:
|
||||
// - the dial's stack at 12 o'clock (pins, ticks, pointer, bezel, the "2 of 4" line), from the top of
|
||||
// the stage, or from under the heading when the heading crosses the middle (a phone);
|
||||
// - the name size F, one size for every name, fitted from the longest and kept under that stack;
|
||||
// - each item's ground line (the baseline, plus the overshoot of round letters, or the descenders);
|
||||
// - the picture box, standing in front of the lower fifth of the name;
|
||||
// - the dial's arc: a circle through the stack whose scale spans about 62% of the stage width
|
||||
// (86% on a phone) where it meets the names' band. The scale and the bezel's window are the widest
|
||||
// arcs about 12 o'clock that keep clear of the names, the heading and the stage's edges.
|
||||
function measure(root, D, o, live) {
|
||||
const W = D.stage.clientWidth; const H = D.stage.clientHeight;
|
||||
if (!W || !H) return;
|
||||
const ph = live.phone;
|
||||
const hz = Math.round(D.hzp.offsetTop) || Math.round(H * 0.48);
|
||||
const k = ph ? 0.62 * clamp(W / 390, 0.85, 1.25) : clamp(Math.min(W / 1440, H / 900), 0.62, 1.3);
|
||||
const S = { pin: 22 * k, pinW: 12 * k, minor: 34 * k, major: 50 * k, gap: 10 * k, ptr: 15 * k, ptrW: 10 * k, bz: 16 * k, lab: 6 * k };
|
||||
|
||||
// the heading's ink (its lines of text, not its box), in stage px
|
||||
let hb = null;
|
||||
if (D.head) {
|
||||
const sr = D.stage.getBoundingClientRect();
|
||||
const tw = document.createTreeWalker(D.head, NodeFilter.SHOW_TEXT);
|
||||
const rg = document.createRange();
|
||||
let x0 = Infinity; let y0 = Infinity; let x1 = -Infinity; let y1 = -Infinity;
|
||||
for (let t = tw.nextNode(); t; t = tw.nextNode()) {
|
||||
if (!t.textContent.trim()) continue;
|
||||
rg.selectNodeContents(t);
|
||||
for (const q of rg.getClientRects()) { if (!q.width) continue; x0 = Math.min(x0, q.left); y0 = Math.min(y0, q.top); x1 = Math.max(x1, q.right); y1 = Math.max(y1, q.bottom); }
|
||||
}
|
||||
if (x0 < x1) hb = { x: x0 - sr.left, y: y0 - sr.top, r: x1 - sr.left, b: y1 - sr.top };
|
||||
}
|
||||
const across = hb && hb.x < W * 0.6 && hb.r > W * 0.4;
|
||||
const stackTop = across ? hb.b + 16 * k : Math.max(12, Math.round(H * 0.03));
|
||||
const labH = D.dial.offsetHeight || 22;
|
||||
const yIn = stackTop + S.pin + 3 + S.major; // the scale's inner edge at 12 o'clock
|
||||
const yLab = yIn + S.gap + S.ptr + 2 + S.bz + S.lab; // the "2 of 4" line under the bezel
|
||||
const stackB = yLab + labH;
|
||||
|
||||
const words = D.its.map((x) => x.word).filter((w) => w.masks.length);
|
||||
const fz = D.its.map(() => hz);
|
||||
let cap = 0; let wordW = 0;
|
||||
if (words.length) {
|
||||
for (const w of words) kern(w);
|
||||
const f0 = parseFloat(getComputedStyle(words[0].node).fontSize) || 100;
|
||||
const widest = Math.max(...words.map((w) => w.vis.offsetWidth)) || 1;
|
||||
const capR = (ink(words[0], 'H').asc || 0.7 * f0) / f0;
|
||||
const fitW = ((ph ? 0.9 : 0.78) * W * f0) / widest;
|
||||
const fitH = (hz - 20 - stackB) / capR;
|
||||
const F = Math.max(16, Math.min(fitW, fitH));
|
||||
root.style.setProperty('--idl-f', F.toFixed(2) + 'px');
|
||||
const w0 = words[0];
|
||||
const bl = w0.mark.offsetTop;
|
||||
root.style.setProperty('--idl-bl', bl + 'px');
|
||||
cap = ink(w0, 'H').asc || capR * F;
|
||||
for (const w of D.its.flatMap((x) => [x.word, x.value])) clipToInk(w);
|
||||
D.its.forEach((it, i) => {
|
||||
if (!it.word.masks.length) return;
|
||||
// the ground meets the bottom of the ink, so round letters keep their overshoot
|
||||
fz[i] = hz + Math.max(0, Math.round(ink(it.word).desc || 0));
|
||||
wordW = Math.max(wordW, it.word.vis.offsetWidth);
|
||||
});
|
||||
root.style.setProperty('--idl-gy', Math.max(0, bl - w0.vis.offsetTop) + 'px');
|
||||
}
|
||||
D.its.forEach((it, i) => it.item.style.setProperty('--idl-fz', fz[i] + 'px'));
|
||||
// the incoming ground washes up to a little above the cap line
|
||||
const pk = Math.round(hz - cap - 16);
|
||||
root.style.setProperty('--idl-pt', pk + 'px');
|
||||
|
||||
// the picture: its top a tenth of the cap height above the horizon, in front of the foot of the
|
||||
// name (no deeper, so an L keeps its foot and an E its last bar); down to the foot of the stage on
|
||||
// desktop, down to the note and number on a phone
|
||||
const pic = Math.round(hz - 0.1 * cap);
|
||||
const it0 = D.its[0];
|
||||
const codeP = it0 && it0.code ? it0.code.parentNode : null;
|
||||
const pad = codeP && codeP.offsetLeft > 0 ? codeP.offsetLeft : clamp(0.034 * window.innerWidth, 20, 48);
|
||||
let bw; let bh;
|
||||
if (ph) {
|
||||
const statP = it0 ? it0.item.querySelector('.idl-stat') : null;
|
||||
const foot = Math.min(codeP ? codeP.offsetTop : H, statP ? statP.offsetTop : H);
|
||||
const room = Math.max(120, foot - 16 - pic);
|
||||
bw = Math.min(0.8 * W, room * 0.8); bh = Math.min(bw * 1.25, room);
|
||||
} else {
|
||||
bh = H - pic + 2;
|
||||
bw = Math.max(160, Math.min(0.375 * W, bh * 1.1, W - 2 * (pad + 280)));
|
||||
}
|
||||
root.style.setProperty('--idl-pic', pic + 'px');
|
||||
root.style.setProperty('--idl-box-w', Math.round(bw) + 'px');
|
||||
root.style.setProperty('--idl-box-h', Math.round(bh) + 'px');
|
||||
|
||||
// the dial's circle
|
||||
const bandTop = Math.round(hz - cap - Math.max(12, 18 * k));
|
||||
const half = wordW ? wordW / 2 + 14 : 0;
|
||||
const s = Math.max(24, bandTop - yIn);
|
||||
const h = (ph ? 0.43 : 0.31) * W;
|
||||
const rIn = Math.max(s, (h * h + s * s) / (2 * s));
|
||||
const g = { cx: W / 2, cy: yIn + rIn };
|
||||
const rMinor = rIn + S.minor; const rMajor = rIn + S.major;
|
||||
const rPinA = rMajor + 3; const rPinB = rPinA + S.pin;
|
||||
const rPtrB = rIn - S.gap; const rPtrA = rPtrB - S.ptr;
|
||||
const rBzO = rPtrA - 2; const rBzI = rBzO - S.bz; const rBz = (rBzO + rBzI) / 2;
|
||||
|
||||
// what the dial keeps clear of: the names' band, the heading, the stage's edges
|
||||
const m = 6 + S.pinW; const hm = 14 * k;
|
||||
const free = (x, y) => {
|
||||
if (x < m || x > W - m || y < 1) return false;
|
||||
if (y > bandTop - 3 && (y > hz - 3 || Math.abs(x - g.cx) < half + 3)) return false;
|
||||
if (hb && x > hb.x - hm && x < hb.r + hm && y > hb.y - hm && y < hb.b + hm) return false;
|
||||
return true;
|
||||
};
|
||||
const reach = (radii) => {
|
||||
const ok = (deg) => radii.every((r) => { const [x1, y1] = pt(g, r, deg); const [x2, y2] = pt(g, r, -deg); return free(x1, y1) && free(x2, y2); });
|
||||
let t = 0;
|
||||
while (t < 88 && ok(t + 0.25)) t += 0.25;
|
||||
return t;
|
||||
};
|
||||
const tS = Math.max(3, reach([rIn, (rIn + rMajor) / 2, rMajor, rPinB]));
|
||||
const arcLen = (2 * rIn * tS * Math.PI) / 180;
|
||||
const want = o.ticks || arcLen / (ph ? 8.5 : 12 * Math.max(0.85, k));
|
||||
const steps = Math.max(4, Math.round(clamp(want, 16, 200) / 2));
|
||||
const pitch = tS / steps;
|
||||
const minor = []; const major = [];
|
||||
for (let i = -steps; i <= steps; i++) {
|
||||
const deg = i * pitch; const big = i % 5 === 0;
|
||||
minor.push(big ? '' : seg(g, rIn, rMinor, deg));
|
||||
major.push(big ? seg(g, rIn, rMajor, deg) : '');
|
||||
}
|
||||
D.dsvg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
||||
D.base[0].setAttribute('d', minor.join('')); D.base[1].setAttribute('d', major.join(''));
|
||||
live.scale = { count: 2 * steps + 1, minor, major };
|
||||
live.n = -1;
|
||||
// each pin pops in about its own centre (the render writes its scale about pinC, so a pin that
|
||||
// lands after a resize pops where it now stands)
|
||||
const pinC = D.pins.map((e, i) => {
|
||||
const deg = -tS + D.fr[i] * 2 * tS;
|
||||
e.setAttribute('d', tri(g, rPinA, rPinB, S.pinW, deg));
|
||||
return pt(g, (rPinA + rPinB) / 2, deg);
|
||||
});
|
||||
D.ptr.setAttribute('d', tri(g, rPtrA, rPtrB, S.ptrW, 0));
|
||||
|
||||
// the bezel: a band with notches and one sector per item, turning inside a wedge about 12 o'clock
|
||||
const tB = Math.max(4, Math.min(tS, reach([rBzI, rBzO])));
|
||||
const rw = (rBzO + 40) / Math.cos(((tB / 8) * Math.PI) / 180);
|
||||
let wd = `M${r2(g.cx)} ${r2(g.cy)}`;
|
||||
for (let i = 0; i <= 8; i++) { const [x, y] = pt(g, rw, -tB + (2 * tB * i) / 8); wd += `L${r2(x)} ${r2(y)}`; }
|
||||
D.win.setAttribute('d', wd + 'Z');
|
||||
for (const [a, v] of [['cx', g.cx], ['cy', g.cy], ['r', rBz], ['stroke-width', S.bz]]) D.band.setAttribute(a, r2(v));
|
||||
let nd = '';
|
||||
for (let i = 0; i < D.notches; i++) if (i % D.per) nd += seg(g, rBzI, rBzO, i * D.bstep);
|
||||
D.notch.setAttribute('d', nd);
|
||||
const hs = Math.min((360 / D.N) * 0.2, (((110 * k) / rBz) * 180) / Math.PI, tB * 0.45);
|
||||
let sd = '';
|
||||
D.secs.forEach((e, i) => { const a = (360 * i) / D.N; const d = arc(g, rBz, a - hs, a + hs); sd += d; e.setAttribute('d', d); e.setAttribute('stroke-width', r2(S.bz)); });
|
||||
D.secBase.setAttribute('d', sd); D.secBase.setAttribute('stroke-width', r2(S.bz));
|
||||
D.dial.style.top = r1(yLab) + 'px';
|
||||
|
||||
// where the first picture waits before the intro stands it up: on its ground, under the horizon
|
||||
const p0 = clamp(1 - Math.max(0, fz[0] + clamp(Math.round(H * 0.02), 12, 20) - pic) / (H - pic + 40), 0, 1);
|
||||
// how far a rising picture overshoots its place during a change: up in front of the lower half of
|
||||
// the name that is leaving, before it settles with its ground
|
||||
const lift = Math.round(0.45 * cap);
|
||||
live.geo = { W, H, hz, fz, pk, pic, bh, g, p0, lift, pinC };
|
||||
}
|
||||
|
||||
// Pictures lean toward a fine pointer on desktop (perspective 1200, lerped on the ticker).
|
||||
function tilt(root, D, live) {
|
||||
if (root.__idlTilt) return;
|
||||
root.__idlTilt = true;
|
||||
const fine = window.matchMedia('(hover: hover) and (pointer: fine)');
|
||||
let tx = 0; let ty = 0; let x = 0; let y = 0; let last = '';
|
||||
window.addEventListener('pointermove', (e) => { tx = e.clientX / window.innerWidth - 0.5; ty = e.clientY / window.innerHeight - 0.5; }, { passive: true });
|
||||
const els = D.its.map((i) => i.box).filter(Boolean);
|
||||
gsap.ticker.add(() => {
|
||||
const on = live.tilt && !live.phone && fine.matches && root.classList.contains('is-live') && live.st && live.st.isActive;
|
||||
const gx = on ? tx : 0; const gy = on ? ty : 0;
|
||||
x += (gx - x) * 0.12; y += (gy - y) * 0.12;
|
||||
if (!on && Math.abs(x) < 0.002 && Math.abs(y) < 0.002) {
|
||||
x = 0; y = 0;
|
||||
if (last) { last = ''; for (const e of els) e.style.transform = ''; }
|
||||
return;
|
||||
}
|
||||
const t = `perspective(1200px) rotateX(${(-y * 10).toFixed(2)}deg) rotateY(${(x * 16).toFixed(2)}deg)`;
|
||||
if (t !== last) { last = t; for (const e of els) e.style.transform = t; }
|
||||
});
|
||||
}
|
||||
|
||||
window.Scenes = window.Scenes || {};
|
||||
window.Scenes[ID] = (root, opts = {}) => {
|
||||
const N = root.querySelectorAll('.idl-item').length;
|
||||
if (!N || !window.Stage) return null;
|
||||
const o = {
|
||||
scrub: opts.scrub === true || num(opts.scrub, 0.8) <= 0 ? true : Math.min(3, num(opts.scrub, 0.8)),
|
||||
grow: clamp(num(opts.grow, 0.28), 0.18, 0.4),
|
||||
ticks: opts.ticks === undefined || opts.ticks === null ? 0 : Math.round(clamp(num(opts.ticks, 0), 16, 200)),
|
||||
range: opts.range,
|
||||
turn: clamp(num(opts.turn, 1.2), 0, 3),
|
||||
tilt: opts.tilt !== false,
|
||||
field: opts.field !== false,
|
||||
wash: opts.wash !== false,
|
||||
snap: opts.snap === true,
|
||||
};
|
||||
if (!o.field) root.classList.add('idl-no-field');
|
||||
// tones and readings up front: Stage.scene's matchMedia never fires on a desktop with reduced
|
||||
// motion (neither of its conditions matches), so neither build nor rest runs there
|
||||
tones(root);
|
||||
notes(root);
|
||||
// a face that arrives after load changes every measured size: measure again (once per page)
|
||||
if (Stage.live && window.ScrollTrigger && !window.__idlFonts && document.fonts && document.fonts.addEventListener) {
|
||||
window.__idlFonts = true;
|
||||
let t = 0;
|
||||
document.fonts.addEventListener('loadingdone', () => { clearTimeout(t); t = setTimeout(() => ScrollTrigger.refresh(), 150); });
|
||||
}
|
||||
const holdD = r2(clamp(num(opts.hold, Math.min(7, 0.85 * N + 0.6)), 1, 12));
|
||||
const holdP = r2(holdD * 0.9);
|
||||
const live = { n: -1, deg: 0, bz: { a: 0 }, rot: null, st: null, phone: false, tilt: o.tilt, geo: null, scale: null };
|
||||
|
||||
// the finished composition: the item tones, and nothing the live stage wrote by hand
|
||||
const rest = () => {
|
||||
tones(root);
|
||||
const D = root.__idlDom;
|
||||
if (!D) return;
|
||||
for (const it of D.its) {
|
||||
it.fig.style.visibility = ''; it.vis = null;
|
||||
it.field.style.transform = ''; it.fy = null;
|
||||
it.edge.style.opacity = ''; it.ea = null;
|
||||
if (it.box) { it.box.style.transform = ''; it.box.style.translate = ''; it.box.style.scale = ''; it.bt = null; }
|
||||
}
|
||||
};
|
||||
|
||||
const build = (tl, ctx) => {
|
||||
const D = assemble(root, o);
|
||||
const its = D.its;
|
||||
tones(root);
|
||||
live.phone = ctx.phone;
|
||||
live.st = tl.scrollTrigger || null;
|
||||
measure(root, D, o, live);
|
||||
|
||||
const hold = ctx.phone ? holdP : holdD;
|
||||
const w = Math.min(0.03, 0.12 / hold); // half of a change, in progress; about a quarter screen
|
||||
const u = w / 0.03;
|
||||
const Tk = (k) => 0.1 + (0.84 * k) / N;
|
||||
// 2D transforms: letters and lines roll inside their name's layer instead of each being promoted
|
||||
// (and dropped again) by a 3D transform at every change
|
||||
const IR = { immediateRender: false, force3D: false };
|
||||
// the scrubbed state the render below turns into grounds, pictures, ticks and the bezel's clicks
|
||||
// p: the picture rising, r: the ground rising, s: the ground settling on the horizon.
|
||||
// The first item is in place from the start.
|
||||
const plates = its.map((_, k) => ({ p: k === 0 ? 1 : 0, r: k === 0 ? 1 : 0, s: 0 }));
|
||||
const fill = { f: -0.05 };
|
||||
const start = Math.round(180 / N / D.bstep) * D.bstep;
|
||||
const dial = { deg: start };
|
||||
|
||||
// the state before the first beat
|
||||
its.forEach((it, k) => {
|
||||
gsap.set(it.word.chars, { yPercent: k === 0 ? 0 : WR });
|
||||
if (it.value.chars.length) gsap.set(it.value.chars, { yPercent: VR });
|
||||
const rolls = [it.code, it.caption].filter(Boolean);
|
||||
if (rolls.length) gsap.set(rolls, { yPercent: RR });
|
||||
if (it.img) gsap.set(it.img, { yPercent: 4, scale: 1.08 });
|
||||
if (it.turn) gsap.set(it.turn, { rotation: -o.turn, yPercent: 3, scale: 1.1 });
|
||||
it.fy = null; it.ea = null; it.bt = null; it.vis = null;
|
||||
});
|
||||
gsap.set(D.labels, { yPercent: RR });
|
||||
// each pin's pop, 0 → 1: its opacity, and its scale from 1.6 to 1 (written by the render)
|
||||
const pinA = its.map(() => ({ a: 0 }));
|
||||
live.pa = its.map(() => null);
|
||||
gsap.set(D.secs, { opacity: 0 });
|
||||
gsap.set(D.doneG, { opacity: 0 });
|
||||
live.deg = start;
|
||||
|
||||
// the picture settles as it rises (never so far that the box shows behind it)
|
||||
const enter = (it, at, dur) => { if (it.img) tl.fromTo(it.img, { yPercent: 4, scale: 1.08 }, { yPercent: 0, scale: 1, duration: dur, ease: 'power2.out', ...IR }, at); };
|
||||
const valueIn = (it, at) => {
|
||||
if (it.value.chars.length) tl.fromTo(it.value.chars, { yPercent: VR }, { yPercent: 0, duration: 0.018 * u, ease: 'power3.out', stagger: { amount: 0.008 * u }, ...IR }, at);
|
||||
if (it.caption) tl.fromTo(it.caption, { yPercent: RR }, { yPercent: 0, duration: 0.02 * u, ease: 'power3.out', ...IR }, at + 0.004 * u);
|
||||
};
|
||||
|
||||
// INTRO: the stage arrives whole (the first ground, its picture lying under the horizon and a
|
||||
// small name right of centre); the name grows to its full size as the picture stands up in front
|
||||
// of its foot, the bezel clicks round to the first item, the scale climbs to the first reading,
|
||||
// and the note and number roll in
|
||||
tl.fromTo(dial, { deg: start }, { deg: 0, duration: 0.07, ease: 'expo.inOut', ...IR }, 0.01);
|
||||
if (its[0].word.vis) tl.fromTo(its[0].word.vis, { scale: o.grow }, { scale: 1, duration: 0.07, ease: 'power3.inOut', immediateRender: true }, 0);
|
||||
tl.fromTo(plates[0], { p: () => (live.geo ? live.geo.p0 : 1) }, { p: 1, duration: 0.07, ease: 'power3.inOut', immediateRender: true }, 0);
|
||||
enter(its[0], 0, 0.12);
|
||||
if (its[0].code) tl.fromTo(its[0].code, { yPercent: RR }, { yPercent: 0, duration: 0.025, ease: 'power3.out', ...IR }, 0.05);
|
||||
valueIn(its[0], 0.056);
|
||||
tl.fromTo(D.labels[0], { yPercent: RR }, { yPercent: 0, duration: 0.02, ease: 'power3.out', ...IR }, 0.07);
|
||||
tl.fromTo(D.secs[0], { opacity: 0 }, { opacity: 1, duration: 0.01, ...IR }, 0.075);
|
||||
tl.fromTo(fill, { f: -0.05 }, { f: D.fr[0], duration: 0.055, ...IR }, 0.05);
|
||||
tl.fromTo(pinA[0], { a: 0 }, { a: 1, duration: 0.012, ease: 'power3.out', ...IR }, 0.1);
|
||||
|
||||
// BEATS: the next picture rises from below the stage in front of the last one, which sinks back.
|
||||
// Its ground surges up behind it on the scan line. With wash the ground runs on over the name
|
||||
// (a fast clean cut across a still word), the letters are swapped at its crest, and it falls
|
||||
// back to the horizon as the new letters jump up out of it. Without wash it stops on the horizon
|
||||
// while the letters reel over. The bezel clicks round, the scale climbs to the new reading, and
|
||||
// the note, the number and the reading roll over.
|
||||
for (let k = 1; k < N; k++) {
|
||||
const T = Tk(k); const a = its[k - 1]; const b = its[k];
|
||||
tl.fromTo(plates[k], { p: 0 }, { p: 1, duration: (o.wash ? 0.026 : 0.03) * u, ease: 'power3.out', ...IR }, T - 0.03 * u);
|
||||
enter(b, T - 0.03 * u, 0.06 * u);
|
||||
// the last note and number roll out before the new picture and ground pass over them
|
||||
// (quick, all gone by T - 0.022, so no letter is caught half out while the new picture rises)
|
||||
if (a.code) tl.fromTo(a.code, { yPercent: 0 }, { yPercent: -RR, duration: 0.007 * u, ease: 'power2.in', ...IR }, T - 0.03 * u);
|
||||
if (a.value.chars.length) tl.fromTo(a.value.chars, { yPercent: 0 }, { yPercent: -VR, duration: 0.006 * u, ease: 'power2.in', stagger: { amount: 0.002 * u }, ...IR }, T - 0.03 * u);
|
||||
if (a.caption) tl.fromTo(a.caption, { yPercent: 0 }, { yPercent: -RR, duration: 0.007 * u, ease: 'power2.in', ...IR }, T - 0.029 * u);
|
||||
if (o.wash) {
|
||||
tl.fromTo(plates[k], { r: 0 }, { r: 1, duration: 0.032 * u, ease: 'power2.in', ...IR }, T - 0.032 * u);
|
||||
tl.fromTo(a.word.chars, { yPercent: 0 }, { yPercent: -WR, duration: 0.0003 * u, ...IR }, T - 0.0002 * u);
|
||||
// the fall is short and the letters start just under their masks, so the stage is without
|
||||
// a readable name for under 0.01 of progress
|
||||
tl.fromTo(plates[k], { s: 0 }, { s: 1, duration: 0.018 * u, ease: 'power3.out', ...IR }, T);
|
||||
tl.fromTo(b.word.chars, { yPercent: WI }, { yPercent: 0, duration: 0.013 * u, ease: 'power3.out', stagger: { amount: 0.006 * u }, ...IR }, T + 0.0003 * u);
|
||||
} else {
|
||||
tl.fromTo(plates[k], { r: 0 }, { r: 1, duration: 0.05 * u, ease: 'power3.inOut', ...IR }, T - 0.03 * u);
|
||||
tl.fromTo(a.word.chars, { yPercent: 0 }, { yPercent: -WR, duration: 0.016 * u, ease: 'power3.in', stagger: { amount: 0.012 * u }, ...IR }, T - 0.026 * u);
|
||||
tl.fromTo(b.word.chars, { yPercent: WR }, { yPercent: 0, duration: 0.02 * u, ease: 'power3.out', stagger: { amount: 0.012 * u }, ...IR }, T - 0.018 * u);
|
||||
}
|
||||
tl.fromTo(dial, { deg: (-360 * (k - 1)) / N }, { deg: (-360 * k) / N, duration: 0.05 * u, ease: 'expo.inOut', ...IR }, T - 0.025 * u);
|
||||
tl.fromTo(D.secs[k - 1], { opacity: 1 }, { opacity: 0, duration: 0.004 * u, ...IR }, T - 0.01 * u);
|
||||
tl.fromTo(D.secs[k], { opacity: 0 }, { opacity: 1, duration: 0.004 * u, ...IR }, T + 0.008 * u);
|
||||
tl.fromTo(D.labels[k - 1], { yPercent: 0 }, { yPercent: -RR, duration: 0.012 * u, ease: 'power2.in', ...IR }, T - 0.025 * u);
|
||||
tl.fromTo(D.labels[k], { yPercent: RR }, { yPercent: 0, duration: 0.012 * u, ease: 'power3.out', ...IR }, T + 0.013 * u);
|
||||
// the new note and number roll in on the new ground
|
||||
if (b.code) tl.fromTo(b.code, { yPercent: RR }, { yPercent: 0, duration: 0.018 * u, ease: 'power3.out', ...IR }, T);
|
||||
valueIn(b, T + 0.004 * u);
|
||||
tl.fromTo(fill, { f: D.fr[k - 1] }, { f: D.fr[k], duration: 0.05 * u, ...IR }, T - 0.02 * u);
|
||||
tl.fromTo(pinA[k], { a: 0 }, { a: 1, duration: 0.01 * u, ease: 'power3.out', ...IR }, T + 0.02 * u);
|
||||
}
|
||||
|
||||
// DRIFT: each picture keeps rolling slowly while it is on stage, so no hold is dead
|
||||
its.forEach((it, k) => {
|
||||
if (!it.turn) return;
|
||||
const from = k === 0 ? 0.006 : Tk(k) - 0.03 * u;
|
||||
const to = k === N - 1 ? 1 : Tk(k + 1) + 0.03 * u;
|
||||
tl.fromTo(it.turn, { rotation: -o.turn, yPercent: 3 }, { rotation: o.turn, yPercent: -3, duration: to - from, ease: 'none', ...IR }, from);
|
||||
});
|
||||
|
||||
// OUTRO: the lit scale turns to the accent
|
||||
tl.fromTo(D.doneG, { opacity: 0 }, { opacity: 1, duration: 0.03, ...IR }, 0.94);
|
||||
|
||||
// the bezel moves in 360/notches steps, each one a short time-based click, in both directions
|
||||
const paintBz = () => { const gg = live.geo && live.geo.g; if (gg) D.bzRot.setAttribute('transform', `rotate(${live.bz.a.toFixed(3)} ${r2(gg.cx)} ${r2(gg.cy)})`); };
|
||||
live.bz = { a: start };
|
||||
live.rot = gsap.quickTo(live.bz, 'a', { duration: 0.2, ease: 'power4.out', onUpdate: paintBz });
|
||||
live.rot(start, start);
|
||||
paintBz();
|
||||
|
||||
// the grounds, the pictures, the scan line, the lit ticks and the bezel follow the scrubbed proxies
|
||||
const render = () => {
|
||||
const g = live.geo;
|
||||
if (g) {
|
||||
let top = -1;
|
||||
for (let k = 0; k < N; k++) {
|
||||
const pl = plates[k]; const it = its[k];
|
||||
const fzk = g.fz[k];
|
||||
const peak = k === 0 || !o.wash ? fzk : g.pk;
|
||||
const y = pl.s > 0 ? peak + (fzk - peak) * pl.s : g.H + (peak - g.H) * pl.r;
|
||||
const fy = r1(y - g.pk);
|
||||
if (fy !== it.fy) { it.field.style.transform = `translate3d(0,${fy}px,0)`; it.fy = fy; }
|
||||
// the scan line shows only below the horizon, so it never crosses the name
|
||||
const ea = pl.r > 0 && pl.r < 1 && pl.s <= 0 ? (clamp((y - g.hz - 4) / 64, 0, 1) * clamp(pl.r / 0.04, 0, 1)).toFixed(2) : '0';
|
||||
if (ea !== it.ea) { it.edge.style.opacity = ea; it.ea = ea; }
|
||||
if (it.box) {
|
||||
// rising: from under the stage to above its place (with wash), then down with its ground;
|
||||
// the one before sinks back behind it
|
||||
const out = k + 1 < N ? plates[k + 1].p : 0;
|
||||
const lift = k > 0 && o.wash ? g.lift : 0;
|
||||
const ty = r1((1 - pl.p) * (g.H - g.pic + 40) - lift * pl.p * (1 - pl.s) + out * 0.1 * g.bh);
|
||||
const sc = (1 - 0.1 * out).toFixed(4);
|
||||
const bt = ty + '|' + sc;
|
||||
if (bt !== it.bt) { it.box.style.translate = `0 ${ty}px`; it.box.style.scale = sc; it.bt = bt; }
|
||||
}
|
||||
// once a ground has crested it covers every earlier item (and the earlier ground must not
|
||||
// show as a strip under it when the new name stands a pixel or two lower)
|
||||
if (pl.r >= 1 && (peak === fzk || pl.s > 0)) top = k;
|
||||
}
|
||||
// items wholly covered by a later settled one, or not yet risen, are not painted
|
||||
for (let k = 0; k < N; k++) {
|
||||
const v = k < top || (plates[k].p <= 0 && plates[k].r <= 0) ? 'hidden' : '';
|
||||
if (v !== its[k].vis) { its[k].fig.style.visibility = v; its[k].vis = v; }
|
||||
}
|
||||
// the pins: opacity, and a scale about each pin's own centre
|
||||
for (let i = 0; i < N; i++) {
|
||||
const a = clamp(pinA[i].a, 0, 1).toFixed(3);
|
||||
if (a === live.pa[i] || !g.pinC[i]) continue;
|
||||
live.pa[i] = a;
|
||||
const sc = 1.6 - 0.6 * a; const [cx, cy] = g.pinC[i];
|
||||
D.pins[i].setAttribute('opacity', a);
|
||||
D.pins[i].setAttribute('transform', `matrix(${r2(sc)} 0 0 ${r2(sc)} ${r2(cx * (1 - sc))} ${r2(cy * (1 - sc))})`);
|
||||
}
|
||||
}
|
||||
const S = live.scale;
|
||||
if (S) {
|
||||
const n = fill.f < 0 ? 0 : clamp(Math.floor(fill.f * (S.count - 1) + 1e-6) + 1, 0, S.count);
|
||||
if (n !== live.n) {
|
||||
live.n = n;
|
||||
const mi = S.minor.slice(0, n).join(''); const ma = S.major.slice(0, n).join('');
|
||||
D.lit[0].setAttribute('d', mi); D.lit[1].setAttribute('d', ma);
|
||||
D.done[0].setAttribute('d', mi); D.done[1].setAttribute('d', ma);
|
||||
}
|
||||
}
|
||||
const s = Math.round(dial.deg / D.bstep) * D.bstep + 0;
|
||||
if (s !== live.deg) { live.deg = s; live.rot(s); }
|
||||
const pr = tl.progress(); let beat = 0;
|
||||
for (let k = 1; k < N; k++) if (pr >= Tk(k)) beat = k;
|
||||
if (root.dataset.beat !== String(beat)) root.dataset.beat = String(beat);
|
||||
};
|
||||
// after a refresh every tween is invalidated and would read its start values lazily, at the first
|
||||
// change a reader scrolls into; read them all now instead, then come back to where the page is
|
||||
live.warm = () => { const pr = tl.progress(); tl.progress(1, true); tl.progress(pr, true); };
|
||||
live.render = () => {
|
||||
live.n = -1;
|
||||
live.pa = its.map(() => null);
|
||||
for (const it of its) { it.fy = null; it.ea = null; it.bt = null; it.vis = null; }
|
||||
render();
|
||||
if (live.rot) { live.rot(live.deg, live.deg); live.bz.a = live.deg; paintBz(); }
|
||||
};
|
||||
tl.eventCallback('onUpdate', render);
|
||||
render();
|
||||
|
||||
// snap: a stop inside a hold stays where it is; a stop inside a change (or the intro) finishes
|
||||
// or undoes it, whichever is nearer
|
||||
if (o.snap) {
|
||||
const wins = [[0.004, 0.125]];
|
||||
for (let k = 1; k < N; k++) wins.push([Tk(k) - 0.034 * u, Tk(k) + 0.03 * u]);
|
||||
const snapTo = (v) => { for (const [a, b] of wins) if (v > a && v < b) return v - a < b - v ? Math.max(0, a - 0.004) : b; return v; };
|
||||
ScrollTrigger.create({ trigger: root, start: 'top top', end: 'bottom bottom', snap: { snapTo, duration: { min: 0.25, max: 0.6 }, delay: 0.12, ease: 'power1.inOut' } });
|
||||
}
|
||||
|
||||
if (!root.__idlRefresh) {
|
||||
root.__idlRefresh = true;
|
||||
ScrollTrigger.addEventListener('refreshInit', () => { if (root.classList.contains('is-live')) measure(root, root.__idlDom, o, live); });
|
||||
ScrollTrigger.addEventListener('refresh', () => { if (root.classList.contains('is-live') && live.render) { if (live.warm) live.warm(); live.render(); } });
|
||||
}
|
||||
tilt(root, D, live);
|
||||
};
|
||||
|
||||
return Stage.scene(root, { hold: holdD, scrub: o.scrub, build, rest, phone: opts.phone === 'rest' ? 'rest' : { hold: holdP, build } });
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,354 @@
|
||||
/* tasteskill / scenes / lens-over-thumbnail-stream.css — a lens over a thumbnail stream.
|
||||
By default (no script, reduced motion, a phone set to 'rest') the section is a contact-sheet index: the
|
||||
title, then one row per project with its name as a link, its year and category, and every picture in
|
||||
full colour. Under .is-live it becomes the held stage: three bands (head, collage, caption), a strip of
|
||||
greyscale thumbnails on the lane and the playhead line. Takes the page's --ink, --ground, --accent,
|
||||
--font-display and --font-text when they exist.
|
||||
Weights follow the page: --display-weight (the title, the names and the caption; 500 here when the
|
||||
page sets none) and --text-weight (the years; 400 here when the page sets none). A weight the page
|
||||
has not loaded is never faked: the nearest loaded one shows. */
|
||||
|
||||
[data-scene="lens-over-thumbnail-stream"] { font-synthesis-weight: none; }
|
||||
|
||||
[data-scene="lens-over-thumbnail-stream"] {
|
||||
--lens-ink: var(--ink, #151515);
|
||||
--lens-paper: var(--ground, #f1f0ec);
|
||||
--lens-accent: var(--accent, var(--lens-ink));
|
||||
--lens-display: var(--font-display, inherit);
|
||||
--lens-text: var(--font-text, inherit);
|
||||
--lens-gutter: 32px;
|
||||
background: var(--lens-paper);
|
||||
color: var(--lens-ink);
|
||||
font-family: var(--lens-text);
|
||||
}
|
||||
|
||||
/* ---------- the finished composition ---------- */
|
||||
|
||||
[data-scene="lens-over-thumbnail-stream"] > .scene-stage { padding: 120px var(--lens-gutter) 136px; }
|
||||
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-head {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
column-gap: 32px;
|
||||
row-gap: 24px;
|
||||
}
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-title {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
font-family: var(--lens-display);
|
||||
font-weight: var(--display-weight, 500);
|
||||
font-size: clamp(44px, 7.2vw, 128px);
|
||||
line-height: .9;
|
||||
letter-spacing: -.035em;
|
||||
}
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-range,
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-count { margin: 0; font-size: 16px; line-height: 1.4; }
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-count { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-field { position: relative; }
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-track { list-style: none; margin: 56px 0 0; padding: 0; }
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-cap,
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-playhead,
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-foot,
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-count-live { display: none; }
|
||||
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-group {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 22%) minmax(0, 1fr);
|
||||
grid-template-rows: auto 1fr;
|
||||
column-gap: 32px;
|
||||
padding: 44px 0;
|
||||
}
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-group + .lens-group { padding-top: 0; }
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-name {
|
||||
grid-column: 1;
|
||||
margin: 0;
|
||||
font-family: var(--lens-display);
|
||||
font-weight: var(--display-weight, 500);
|
||||
font-size: clamp(28px, 3.6vw, 52px);
|
||||
line-height: 1;
|
||||
letter-spacing: -.03em;
|
||||
}
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-link { color: inherit; text-decoration: none; }
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-link:hover { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: .12em; }
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-link:focus-visible { outline: 2px solid var(--lens-accent); outline-offset: 4px; }
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-year {
|
||||
display: block;
|
||||
margin-top: 14px;
|
||||
font-family: var(--lens-text);
|
||||
font-size: 16px;
|
||||
font-weight: var(--text-weight, 400);
|
||||
letter-spacing: 0;
|
||||
line-height: 1.4;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-group::after {
|
||||
content: attr(data-cat);
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-thumbs {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-thumb { flex: none; }
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-thumb img { display: block; height: 80px; width: auto; max-width: none; }
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-thumb--key { order: -1; }
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-thumb--key img { height: 200px; }
|
||||
|
||||
/* a matchMedia switch back to rest leaves inline transforms behind; the contact sheet ignores them */
|
||||
[data-scene="lens-over-thumbnail-stream"]:not(.is-live) .lens-track,
|
||||
[data-scene="lens-over-thumbnail-stream"]:not(.is-live) .lens-thumb img { transform: none !important; filter: none !important; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
[data-scene="lens-over-thumbnail-stream"] { --lens-gutter: 16px; }
|
||||
[data-scene="lens-over-thumbnail-stream"] > .scene-stage { padding: 88px var(--lens-gutter) 96px; }
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-head { grid-template-columns: 1fr; row-gap: 12px; }
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-count { text-align: left; }
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-track { margin-top: 40px; }
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-group { grid-template-columns: 1fr; grid-template-rows: none; padding: 36px 0; }
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-group + .lens-group { padding-top: 12px; }
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-group::after { grid-row: 2; margin-bottom: 16px; }
|
||||
/* the key full width, then justified rows: each picture grows in proportion to its ratio, so every
|
||||
picture in a row shares one height and nothing is cropped */
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-thumbs { grid-column: 1; grid-row: 3; gap: 6px; align-items: flex-start; }
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-thumbs::after { content: ""; flex: 99999 1 0; }
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-thumb { flex: calc(var(--r, 1) * 100) 1 calc(var(--r, 1) * 104px); min-width: 0; }
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-thumb--key { flex: 1 1 100%; margin-bottom: 2px; }
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-thumb img,
|
||||
[data-scene="lens-over-thumbnail-stream"] .lens-thumb--key img { width: 100%; height: auto; }
|
||||
}
|
||||
|
||||
/* ---------- the live lens ---------- */
|
||||
|
||||
/* the strip height follows the width and the height (40px at 1440x900, 26px on a tablet); the collage
|
||||
size is set in the script from the screen, so a thin strip never means a small bloom */
|
||||
html.js [data-scene="lens-over-thumbnail-stream"].is-live > .scene-stage {
|
||||
--h0: clamp(26px, min(2.78vw, 4.4vh), 44px);
|
||||
--h0: clamp(26px, min(2.78vw, 4.4svh), 44px);
|
||||
padding: 0;
|
||||
background: var(--lens-paper);
|
||||
}
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-title,
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-name,
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-count {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px; /* taste-skill-ignore: visually hidden text for screen readers, not a drawn rule */
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* head band: 0-13% */
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-head {
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
z-index: 6;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 24px;
|
||||
padding: 28px var(--lens-gutter) 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-range { font-size: 16px; }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-count-live {
|
||||
display: grid;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* collage band: 13-77%, the strip on the lane at 45% */
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-field { position: absolute; inset: 0; }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-track {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(45% - var(--h0) / 2);
|
||||
width: max-content;
|
||||
height: var(--h0);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
will-change: transform;
|
||||
}
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-cap {
|
||||
display: block;
|
||||
position: absolute;
|
||||
right: calc(100% + 18px);
|
||||
top: 50%;
|
||||
translate: 0 -50%;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* only a label before the strip is drawn: at 100% the last collage covers the space after it */
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-group ~ .lens-cap { display: none; }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-group {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: none;
|
||||
display: block;
|
||||
padding: 0;
|
||||
}
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-group::after { content: none; }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-thumbs { display: flex; flex-wrap: nowrap; gap: 4px; }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-thumbs::after { content: none; }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-thumb {
|
||||
position: relative;
|
||||
flex: none;
|
||||
order: 0;
|
||||
width: calc(var(--h0) * var(--r, 1));
|
||||
height: var(--h0);
|
||||
}
|
||||
/* laid out at its largest bloom and scaled down, so a bloom is never an upscaled blur */
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-thumb img {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: calc(var(--h0) * var(--r, 1) * var(--s, 1));
|
||||
height: calc(var(--h0) * var(--s, 1));
|
||||
margin: calc(var(--h0) * var(--s, 1) / -2) 0 0 calc(var(--h0) * var(--r, 1) * var(--s, 1) / -2);
|
||||
max-width: none;
|
||||
object-fit: cover;
|
||||
transform-origin: 50% 50%;
|
||||
transform: scale(calc(1 / var(--s, 1)));
|
||||
filter: grayscale(1);
|
||||
}
|
||||
[data-scene="lens-over-thumbnail-stream"].lens-classcolour.is-live .lens-thumb img { transition: filter .25s linear; }
|
||||
[data-scene="lens-over-thumbnail-stream"].lens-classcolour.is-live .lens-thumb.is-lit img { filter: none; }
|
||||
|
||||
/* the playhead runs only through the collage band, 8px past each edge */
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-playhead {
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(13% - 8px);
|
||||
height: calc(64% + 16px);
|
||||
width: 0;
|
||||
z-index: 7;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
}
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-line {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 -1px;
|
||||
width: 2px;
|
||||
background: var(--lens-accent);
|
||||
transform: scaleY(0);
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-flag {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 8px;
|
||||
padding: 5px 10px 6px;
|
||||
background: var(--lens-accent);
|
||||
color: var(--lens-paper);
|
||||
font-size: 16px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
visibility: hidden;
|
||||
}
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-flag.is-left { left: auto; right: 10px; }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-flag-text { display: block; }
|
||||
|
||||
/* caption band: 77-100% */
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-foot {
|
||||
position: absolute;
|
||||
inset: auto 0 0;
|
||||
height: 23%;
|
||||
z-index: 6;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 32px;
|
||||
padding: 0 var(--lens-gutter) 34px;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* the script sets max-width to the room left of the meta row, and a font-size (em) on each line whose
|
||||
name needs to shrink to fit it; a name too long for one line wraps to two (.is-wrap) */
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-caption {
|
||||
position: relative;
|
||||
display: grid;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
font-family: var(--lens-display);
|
||||
font-weight: var(--display-weight, 500);
|
||||
font-size: clamp(44px, 7.2vw, 128px);
|
||||
line-height: .88;
|
||||
letter-spacing: -.035em;
|
||||
}
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-caption-line { grid-area: 1 / 1; align-self: end; white-space: nowrap; }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-caption-line.is-wrap { white-space: normal; }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-measure {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
visibility: hidden;
|
||||
white-space: pre;
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-w {
|
||||
display: inline-block;
|
||||
overflow: clip;
|
||||
padding: .12em 0 .14em;
|
||||
margin: -.12em 0 -.14em;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-c { display: inline-block; }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 28px;
|
||||
margin: 0 0 .35em;
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
}
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-meta.is-empty { visibility: hidden; }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-meta-link { color: inherit; text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: .2em; }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live.is-kb .lens-caption { outline: 2px solid var(--lens-accent); outline-offset: 10px; }
|
||||
|
||||
/* the phone: a fixed centre lens, a finer strip, the caption over two lines at most */
|
||||
@media (max-width: 767px) {
|
||||
html.js [data-scene="lens-over-thumbnail-stream"].is-live > .scene-stage { --h0: 28px; }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-head { padding-top: 20px; }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-range,
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-count-live { font-size: 15px; }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-track { top: calc(44% - var(--h0) / 2); gap: 20px; }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-thumbs { gap: 3px; }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-thumb[data-phone="hide"] { display: none; }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-cap { font-size: 15px; right: calc(100% + 14px); }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-playhead { top: calc(14% - 8px); height: calc(60% + 16px); }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-flag { font-size: 15px; }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-foot {
|
||||
height: 26%;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
gap: 14px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-caption { max-width: 100%; font-size: clamp(40px, 11vw, 64px); line-height: .9; }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-caption-line { white-space: normal; }
|
||||
[data-scene="lens-over-thumbnail-stream"].is-live .lens-meta { margin: 0; font-size: 15px; gap: 20px; }
|
||||
}
|
||||