From 090af3f5db989d9489b8212e5f8db9aab352e05e Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Fri, 30 Oct 2026 08:48:04 +0100 Subject: [PATCH 01/46] docs: rebuild the website as an absurd ink drawing The landing page becomes a felt-pen drawing of a lighthouse on a hill, drawn in a canvas behind paper notes: a one-eyed lighthouse in a pom-pom hat, a fried-egg sun, sheep, ducks, gulls and a sea monster. The light follows the pointer and highlights the headline words it crosses, grass and clouds lean away from it, scrolling brings the evening, the moon tile brings the night, and a click on the hill or the sea drops a surprise there. The bar demos stay: popups, Edit Bar in Place, the right-click menu, the Store review, theme picking and the bar turning into the site header. Each section is a different paper (a taped note, a washing line of themes, a notebook page, a postcard, a recipe card), with its own small scene in the drawing. The terminal, the draggable windows, the disk image mock and the Popups and Every display sections go; the guide covers them. Shantell Sans replaces Host Grotesk for headings, captions and labels. The privacy and terms pages move to the same paper without the drawing. Reduced motion keeps the drawing still, and the page works without scene.js. --- site/fonts/host-grotesk.woff2 | Bin 20296 -> 0 bytes ...-grotesk-OFL.txt => shantell-sans-OFL.txt} | 4 +- site/fonts/shantell-sans-display.woff2 | Bin 0 -> 30716 bytes site/fonts/shantell-sans-hand.woff2 | Bin 0 -> 31312 bytes site/index.html | 535 +++-- site/main.js | 561 +++--- site/privacy.html | 6 +- site/scene.js | 1748 +++++++++++++++++ site/style.css | 931 ++++----- site/terms.html | 6 +- 10 files changed, 2797 insertions(+), 994 deletions(-) delete mode 100644 site/fonts/host-grotesk.woff2 rename site/fonts/{host-grotesk-OFL.txt => shantell-sans-OFL.txt} (95%) create mode 100644 site/fonts/shantell-sans-display.woff2 create mode 100644 site/fonts/shantell-sans-hand.woff2 create mode 100644 site/scene.js diff --git a/site/fonts/host-grotesk.woff2 b/site/fonts/host-grotesk.woff2 deleted file mode 100644 index 44d652b50726ae572b4eff1ee44f0b0fe9c95b76..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20296 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 E2SuFg@&Et; literal 0 HcmV?d00001 diff --git a/site/index.html b/site/index.html index ba9114dc..534064c9 100644 --- a/site/index.html +++ b/site/index.html @@ -35,10 +35,10 @@ "sameAs": "https://github.com/Seb-GRAF/StableBar" } - + - + + @@ -60,6 +61,8 @@ + + @@ -96,357 +99,265 @@ + + + + + + -
-
- StableBar - -
+ -
-
-

A native, configurable
menu bar for macOS.

-

Themes, widgets and popups, set up in the app or in plain JSON.

-
- Download for Mac v0.9.0 -

macOS 14 or later on Apple silicon.
Free and open source, MIT License.

+
+ -
-
- -
- - -
- - -
-
Music
-
Midnight CityM83Hurry Up, We’re Dreaming
-
-
1:274:03
-
- - - - - -
- -
-
-

Today

-
09:15Standup
-
10:30Design review
-
12:30Lunch with Ana
-
15:001:1 with Sam
-
16:30Release planning
-
-
Calendar
-
-
-

System

- -
CPU18 %
-
-
Memory54 %
-
-
-
Activity Monitor
-
-
- -
- Right-click the bar, or - - Drag to move · − removes · + adds - - -
- - -

Drag it, or use the Left and Right arrow keys, to move it. Delete removes it.

-

+
+ + +
+
Music
+
Midnight CityM83Hurry Up, We’re Dreaming
+
+
1:274:03
+
+ + + + +
+ +
+
+

Today

+
09:15Standup
+
10:30Design review
+
12:30Lunch with Ana
+
15:001:1 with Sam
+
16:30Release planning
+
+
Calendar
+
+
+

System

+ +
CPU18 %
+
+
Memory54 %
+
+
+
Activity Monitor
+
+
-
-
-
ThemeSave as Theme…
-
-
+
+ Drag to move · − removes · + adds + + +
+ + +

Drag it, or use the Left and Right arrow keys, to move it. Delete removes it.

+

+
+ +
+
+
+

A native, configurable menu bar for macOS.

+

Themes, widgets and popups, set up in the app or in plain JSON.

+
+ Download for Mac v0.9.0 +

macOS 14 or later on Apple silicon.
Free and open source, MIT License.

-
-
-
zsh
-
Click a line to run it on the bar above.
-
+
+

This is not a menu bar.* *It is.

+

User guideGitHub

-
-
-

Configure all of it.

-
-

Edit the bar where it is. Drag widgets between positions, take one off with −, and add one with + right where it should go. Changes save as you go, with Undo.

- -

Every widget, style and popup is also a setting, per display and per kind of screen, in Settings or in plain JSON. StableBar applies the file when you save it, and keeps the last working bar if a change is invalid.

-

Tiling, natively. Workspaces come straight from AeroSpace, yabai or OmniWM, with app icons or one item per window, and update in place instead of recreating windows.

- Workspaces in the guide -
-
-
-

Window managers

-
    -
  • AeroSpace and OmniWMWorkspace, monitor, window and focus events, live. Reconnects when the window manager restarts.
  • -
  • yabaiSpaces, windows and displays, refreshed by yabai signals.
  • -
  • One item per windowWindow titles in the workspace; click one to focus it.
  • -
  • No tiling managermacOS Spaces, and windows kept clear of the bar.
  • -
-
-
-

Configuration

-
    -
  • On the bar itselfDrag, add and remove widgets where they sit, with Undo.
  • -
  • JSON with a schemaCompletion and validation in your editor.
  • -
  • Every styleFonts, colors, padding, corners and borders, per widget or per theme.
  • -
  • Per kind of screenIts own layout, size and theme on the laptop or a monitor.
  • -
  • ProfilesA different bar on battery, in dark mode or for one app.
  • -
+
+

The light follows your pointer.Drag sideways to steer the light.

+
+ + +
+

Or click the hill or the sea.Or tap the hill or the sea.

-
-

Popups

-
-

Any widget can open a popup. Build it from rows, columns, text, graphs, lists, buttons, sliders, toggles and search fields, filled with the widget's data.

- Custom popups in the guide -
-
-
-
-

System

- -
CPU18 %
-
-
Memory54 %
-
-
-
Activity Monitor
-
-
-

Today

-
09:15Standup
-
10:30Design review
-
12:30Lunch with Ana
-
15:001:1 with Sam
-
16:30Release planning
-
-
Calendar
-
-
-

Controls

- - -
Mode
-
- -
Finder
Calendar
Activity Monitor
Terminal
-
-
+
+
+ +

Edit the bar where it is.

+

Drag widgets between positions, take one off with −, and add one with + right where it should go. Changes save as you go, with Undo.

+ + +

Every widget, style and popup is also a setting, per display and per kind of screen, in Settings or in plain JSON. StableBar applies the file when you save it, and keeps the last working bar if a change is invalid.

+

The keeper rearranges things when bored.

-
-

Themes

-
-

Choose a theme during setup, cycle through them on your own bar with Try Themes…, or get more from the store. Save as Theme… turns your own look into one.

+
+
+

Themes

+

Choose a theme during setup, cycle through them on your own bar with Try Themes…, or get more from the Store. Save as Theme… turns your own look into one.

+
+ + + + + + + +

Pick one here and the bar at the top changes with it.

- Themes in the guide -
-
- + +

Same lighthouse, different pyjamas.

-
-

Any script is a widget

-
+
+
+

Any script is a widget.

A widget can run any command that prints text or a JSON object. Refresh it on a timer or on events, and give it a popup with graphs, lists and controls.

-

The command line queries and edits the running bar, and other tools can send it events.

- Create a widget -
-
-
-
widgets/disk.shsh
-
#!/bin/sh
+      
+
+
widgets/disk.shsh
+
#!/bin/sh
 used=$(df -h / | awk 'NR == 2 { print $5 }')
 printf '{"text":"%s","icon":"internaldrive"}' "$used"
+
+
61%
82%
09:41
-
-
config.jsonjson
-
{
-  "id": "disk", "type": "script",
-  "position": "right", "interval": 300,
-  "command": "~/.config/stablebar/widgets/disk.sh"
-}
-
-
61%
82%
09:41
-
-
+ -
-

The Store

-
-

Add widgets other people wrote, from Settings or the bar’s +, without writing a script. Before anything runs, StableBar shows what a package does: the commands it runs, the hosts it contacts and the macOS permissions it uses.

-

Checked on the way in. Every Store package is open source, and the Store builds and publishes it, not its author. A maintainer reads each version that runs commands, and StableBar checks every download against its SHA-256. Tokens stay in your Keychain, and versions stay pinned until you choose another.

-

Your team’s own widgets. Host a private source for packages that can’t be public, and invite your team with a join link. A stablebar:// link opens one package’s page in StableBar.

- Packages and the store in the guide -
-
-
-
- -
-
Midnight City · M83
Now playing

Shows the playing or paused track from Music or Spotify with its artwork and bouncing bars while it plays.

Widget · by Seb-GRAFInstalled
-
42%  18%
AI usage

Shows your Claude Code and Codex limits in one widget, with reset countdowns and weekly pace.

Widget · by Seb-GRAF
-
2
Dock badges

Shows the total unread count from running apps’ Dock badges, with a popup that opens each app.

Widget · by Seb-GRAF
-
3
GitHub notifications

Shows your unread GitHub notification count and lists the latest ones, each opening on GitHub.

Widget · by Seb-GRAF
-
4
Homebrew outdated

Shows how many Homebrew formulae and casks are outdated, and lists them.

Widget · by Seb-GRAF
-
14°
Weather

Shows the current temperature and conditions from wttr.in, with feels-like, humidity, wind and today’s high and low.

Widget · by Seb-GRAF
+
+

Or get one from the Store.

+

Add widgets other people wrote, from Settings or the bar’s +, without writing a script. Before anything runs, StableBar shows what a package does: the commands it runs, the hosts it contacts and the macOS permissions it uses.

+
+
+
+
+
Midnight City · M83
Now playing

Shows the playing or paused track from Music or Spotify with its artwork and bouncing bars while it plays.

Widget · by Seb-GRAFInstalled
+
42%  18%
AI usage

Shows your Claude Code and Codex limits in one widget, with reset countdowns and weekly pace.

Widget · by Seb-GRAF
+
2
Dock badges

Shows the total unread count from running apps’ Dock badges, with a popup that opens each app.

Widget · by Seb-GRAF
+
3
GitHub notifications

Shows your unread GitHub notification count and lists the latest ones, each opening on GitHub.

Widget · by Seb-GRAF
+
4
Homebrew outdated

Shows how many Homebrew formulae and casks are outdated, and lists them.

Widget · by Seb-GRAF
+
14°
Weather

Shows the current temperature and conditions from wttr.in, with feels-like, humidity, wind and today’s high and low.

Widget · by Seb-GRAF
+
+
+
+
- +

This one was written by a sheep. Allegedly.

-
-

Every display

-
-

Placement that knows about the notch, widgets per kind of screen, and a separate bar for each screen setup. Each kind of screen can have its own theme.

- Displays and layout -
-
-
- - -
Monitor. Léman, with more room for widgets.
-
-
- -
+
+
+

Every window gets its own little room.

+ +
-
-
-

See it on your own bar.

-

Setup walks you through a preset, a theme and a placement, and shows each choice on your real bar. Nothing is saved until you click Finish setup.

+
+
+

See it on your own bar.

+
    +
  1. Download StableBar.
  2. +
  3. Open it from Applications.
  4. +
  5. Setup walks you through a preset, a theme and a placement, and shows each choice on your real bar.
  6. +
  7. Nothing is saved until you click Finish setup.
  8. +
- Download for Mac v0.9.0 + Download for Mac v0.9.0

macOS 14 or later on Apple silicon.
Releases update themselves.

-

Or with Homebrew: brew install --cask seb-graf/tap/stablebar

-
-
-
-
-
StableBar
-
-
StableBar
-
Drag to download
-
Applications
-
-

Then open StableBar from Applications.

-
+

Or with Homebrew: brew install --cask seb-graf/tap/stablebar

+

The lamp is on. Come in.

-
+
-
- StableBar is a free, open-source alternative to SketchyBar, built by Sebastien Graf and Steve Mendes Reis. It is available under the MIT License, including commercial use and redistribution. + -
+ + diff --git a/site/main.js b/site/main.js index 36b85a69..1c4e7200 100644 --- a/site/main.js +++ b/site/main.js @@ -1,3 +1,8 @@ +/* stablebar.app: the bar demos, the paper notes, and the drawing behind them (scene.js). + Everything here works without the drawing; the drawing only adds to it. */ +(() => { +"use strict"; +const root = document.documentElement; const THEMES = [ ["built-in", "Built-in", "ws-fill"], ["catppuccin", "Catppuccin", "ws-fill"], @@ -7,7 +12,6 @@ const THEMES = [ ["notch", "Notch", "ws-text"], ["phosphor", "Phosphor", "ws-fill"], ]; -const desk = document.getElementById("desk"); const dock = document.getElementById("dock"); const bar = document.getElementById("bar"); const gl = document.getElementById("gl"); @@ -17,11 +21,25 @@ const more = bar.querySelector(".more"); const pop = document.getElementById("pop"); const panes = [...pop.querySelectorAll(".pane")]; const menu = pop.querySelector('[data-id="more"]'); +const heroNote = document.querySelector(".hero-note"); const reduce = matchMedia("(prefers-reduced-motion: reduce)"); const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); let openId = null; let editing = false; +/* The drawing, if scene.js is there. Every call goes through S, so a scene that fails never takes the page with it. */ +let scene = null; +let sceneFailed = false; +function S(method, ...args) { + if (!scene || typeof scene[method] !== "function") return undefined; + try { return scene[method](...args); } catch (err) { + if (!sceneFailed) { sceneFailed = true; console.warn("The drawing failed:", err); } + return undefined; + } +} +/* The bar's height when it isn't being edited; Nord is shorter */ +const restHeight = () => parseFloat(getComputedStyle(dock).getPropertyValue("--rest-h")) || 40; + /* Now playing: the track moves on while it plays */ const track = { at: 87, length: 243, playing: true }; const trackAt = document.getElementById("np-at"); @@ -49,28 +67,19 @@ function tick() { tick(); setInterval(tick, 1000); -/* Sample bars: three workspaces, Finder, battery and clock, as in Settings' theme cards */ -function mini() { - return `
-
123Finder
-
82%9:41
`; -} -const cards = document.getElementById("cards"); -const store = document.querySelector("#board .store"); -for (const [id, name, ws] of THEMES) { - const card = ``; - cards.insertAdjacentHTML("beforeend", card); - store.insertAdjacentHTML("beforebegin", card); -} - +/* Themes: the tags on the washing line restyle the bar, and dress the lighthouse to match */ function setTheme(id) { - desk.dataset.theme = id; + dock.dataset.theme = id; bar.className = `bar ${THEMES.find(t => t[0] === id)[2]}`; for (const b of document.querySelectorAll("[data-theme-pick]")) { b.classList.toggle("on", b.dataset.themePick === id); b.setAttribute("aria-pressed", b.dataset.themePick === id); } - requestAnimationFrame(fit); + S("setTheme", id); + requestAnimationFrame(() => { + fit(); + S("setTopInset", restHeight()); + }); } for (const b of document.querySelectorAll("[data-theme-pick]")) b.addEventListener("click", () => setTheme(b.dataset.themePick)); @@ -119,6 +128,8 @@ function place() { pop.style.width = `${pane.offsetWidth}px`; pop.style.height = `${pane.offsetHeight}px`; } +/* The control that opens a widget's popup: the widget itself, or the app name in StableBar's menus */ +const popButton = w => w.matches("button") ? w : w.querySelector(":scope > button"); /* Opening another widget's popup morphs the open one into it, as the app does */ function open(id) { if (id !== openId && anchorFor(id).hidden) return; @@ -129,7 +140,7 @@ function open(id) { for (const p of panes) p.classList.toggle("on", p.dataset.id === openId); for (const w of bar.querySelectorAll("[data-pop]")) { w.classList.toggle("open", w.dataset.pop === openId || Boolean(openId && anchorFor(openId) === w)); - if (w.matches("button")) w.setAttribute("aria-expanded", w.classList.contains("open")); + popButton(w)?.setAttribute("aria-expanded", w.classList.contains("open")); } place(); } @@ -139,29 +150,33 @@ for (const w of bar.querySelectorAll("[data-pop]")) w.addEventListener("click", open(w.dataset.pop); }); for (const b of menu.querySelectorAll("[data-open]")) b.addEventListener("click", e => { e.stopPropagation(); open(b.dataset.open); }); -document.addEventListener("click", e => { if (openId && !e.target.closest(".pop, .win, .card, .w")) open(openId); }); +document.addEventListener("click", e => { if (openId && !e.target.closest(".pop, .win, .tag, .w")) open(openId); }); /* Escape closes the popup and returns focus to its widget */ document.addEventListener("keydown", e => { if (e.key !== "Escape" || !openId) return; const from = pop.contains(document.activeElement) && anchorFor(openId); open(openId); - if (from) from.focus(); + if (from) (popButton(from) || from).focus(); }); -/* Workspaces: the focus highlight slides to the one you click, as on the real bar */ +/* Workspaces: the focus highlight slides to the one you click, as on the real bar. + The postcard's large widget is the same four workspaces, and the drawing's beach huts follow. */ const ind = bar.querySelector(".ind"); function slide() { const on = bar.querySelector(".wsi.on"); ind.style.setProperty("--x", `${on.offsetLeft}px`); ind.style.setProperty("--w", `${on.offsetWidth}px`); } -for (const el of bar.querySelectorAll(".wsi")) el.addEventListener("click", () => { - bar.querySelectorAll(".wsi").forEach(e => { - e.classList.toggle("on", e === el); - e.setAttribute("aria-pressed", e === el); - }); +function focusWorkspace(n) { + for (const e of document.querySelectorAll(".wsi[data-ws]")) { + const on = e.dataset.ws === String(n); + e.classList.toggle("on", on); + e.setAttribute("aria-pressed", on); + } slide(); -}); + S("setWorkspace", n); +} +for (const el of document.querySelectorAll(".wsi[data-ws]")) el.addEventListener("click", () => focusWorkspace(Number(el.dataset.ws))); /* The app's menus open on hover when the bar has room for them */ const finder = bar.querySelector(".finder"); @@ -187,48 +202,12 @@ volume.addEventListener("input", () => { volume.style.setProperty("--v", `${volume.value}%`); }); -/* Controls */ -for (const seg of document.querySelectorAll(".seg")) for (const b of seg.querySelectorAll("button")) b.addEventListener("click", () => { - seg.querySelectorAll("button").forEach(x => { - x.classList.toggle("on", x === b); - x.setAttribute("aria-pressed", x === b); - }); -}); -for (const s of document.querySelectorAll(".search")) s.addEventListener("input", () => { - const q = s.value.toLowerCase(); - s.nextElementSibling.querySelectorAll("div").forEach(d => d.hidden = !d.textContent.toLowerCase().includes(q)); - place(); -}); - -/* The terminal runs its commands against the bar */ -const out = document.getElementById("out"); -for (const b of document.querySelectorAll("[data-run]")) b.addEventListener("click", e => { - e.stopPropagation(); - endEditing(); - if (b.dataset.run === "popup") { - open("agenda"); - out.textContent = `{"popup":"agenda","open":${openId === "agenda"}}`; - } else { - bar.querySelector(".clock").dataset.format = "seconds"; - menu.querySelector(".clock").dataset.format = "seconds"; - tick(); - fit(); - out.textContent = '{"set":"clock"}'; - } - b.classList.add("ran"); -}); - -/* The bar stays at the top of the window once the desk reaches it, - and becomes the page's header once the desk has scrolled away. */ +/* Once the page scrolls, a strip of paper slides in behind the bar. Once the headline has gone + under it, the bar becomes the page's header. While the bar is being edited it stays the demo. */ function dockState() { - const r = desk.getBoundingClientRect(); - const header = r.bottom <= 120; - const docked = r.top <= 0; - dock.classList.toggle("docked", docked); - dock.style.left = docked ? `${r.left}px` : ""; - dock.style.width = docked ? `${r.width}px` : ""; + dock.classList.toggle("scrolled", scrollY > 2); + const header = !editing && heroNote.getBoundingClientRect().bottom < restHeight() + 56; if (header === dock.classList.contains("header")) return; - if (header) endEditing(); dock.classList.add("measure"); dock.classList.toggle("header", header); fit(); @@ -237,133 +216,11 @@ function dockState() { dock.classList.remove("measure"); dock.classList.toggle("header", header); if (openId) open(openId); - setTimeout(fit, 450); } - -let queued = false; -addEventListener("scroll", () => { - if (queued) return; - queued = true; - requestAnimationFrame(() => { queued = false; dockState(); }); -}, { passive: true }); - -/* Drag a window by its title bar. It leans into the motion and glides when let go. */ -const layers = [...desk.querySelectorAll(".layer")]; -const px = (el, name) => parseFloat(el.style.getPropertyValue(name)) || 0; -function bounds(layer) { - return { - x: [8 - layer.offsetLeft, desk.clientWidth - layer.offsetWidth - 8 - layer.offsetLeft], - y: [bar.offsetHeight + 8 - layer.offsetTop, desk.clientHeight - 48 - layer.offsetTop], - }; -} -function moveTo(layer, dx, dy) { - const b = bounds(layer); - const x = clamp(dx, ...b.x), y = clamp(dy, ...b.y); - layer.style.setProperty("--dx", `${x}px`); - layer.style.setProperty("--dy", `${y}px`); - return [x !== dx, y !== dy]; -} -for (const layer of layers) { - const handle = layer.querySelector(".tb"); - let drag = null, frame = 0, vx = 0, vy = 0; - - function animate() { - const tilt = px(layer, "--tilt"); - if (!drag) { - const [hitX, hitY] = moveTo(layer, px(layer, "--dx") + vx * 16, px(layer, "--dy") + vy * 16); - vx = hitX ? 0 : vx * .92; - vy = hitY ? 0 : vy * .92; - } else { - vx *= .85; - } - const target = drag ? clamp(vx * 6, -8, 8) : 0; - layer.style.setProperty("--tilt", `${tilt + (target - tilt) * .2}deg`); - if (drag || Math.abs(vx) + Math.abs(vy) > .01 || Math.abs(tilt) > .05) frame = requestAnimationFrame(animate); - else layer.style.setProperty("--tilt", "0deg"); - } - - handle.addEventListener("pointerdown", e => { - if (e.button !== 0) return; - e.preventDefault(); - handle.setPointerCapture(e.pointerId); - layers.forEach(l => l.style.zIndex = l === layer ? 5 : 4); - const r = layer.getBoundingClientRect(); - layer.style.transformOrigin = `${e.clientX - r.left}px ${e.clientY - r.top}px`; - drag = { x: e.clientX, y: e.clientY, dx: px(layer, "--dx"), dy: px(layer, "--dy"), lx: e.clientX, ly: e.clientY, t: e.timeStamp }; - vx = vy = 0; - layer.classList.add("dragging"); - cancelAnimationFrame(frame); - if (!reduce.matches) frame = requestAnimationFrame(animate); - }); - handle.addEventListener("pointermove", e => { - if (!drag) return; - moveTo(layer, drag.dx + e.clientX - drag.x, drag.dy + e.clientY - drag.y); - const dt = Math.max(1, e.timeStamp - drag.t); - vx = vx * .5 + (e.clientX - drag.lx) / dt * .5; - vy = vy * .5 + (e.clientY - drag.ly) / dt * .5; - Object.assign(drag, { lx: e.clientX, ly: e.clientY, t: e.timeStamp }); - }); - const release = () => { - if (!drag) return; - drag = null; - layer.classList.remove("dragging"); - }; - handle.addEventListener("pointerup", release); - handle.addEventListener("pointercancel", release); -} - -/* The disk image: drag StableBar onto Applications to download the DMG the buttons link */ -const ico = document.getElementById("app-ico"); -const folder = document.getElementById("folder"); -const dmg = document.querySelector(".install .cta").href; -let grab = null; -const overFolder = e => { - const r = folder.getBoundingClientRect(); - return e.clientX > r.left - 20 && e.clientX < r.right + 20 && e.clientY > r.top - 20 && e.clientY < r.bottom + 20; -}; -ico.addEventListener("pointerdown", e => { - if (ico.classList.contains("in")) return; - e.preventDefault(); - ico.setPointerCapture(e.pointerId); - grab = { x: e.clientX, y: e.clientY }; - ico.classList.add("dragging"); -}); -ico.addEventListener("pointermove", e => { - if (!grab) return; - ico.style.transform = `translate(${e.clientX - grab.x}px, ${e.clientY - grab.y}px) scale(1.06)`; - folder.classList.toggle("over", overFolder(e)); +/* The bar measures itself again once the header's menus and Download have finished opening or closing */ +bar.addEventListener("transitionend", e => { + if (e.propertyName === "grid-template-columns" && e.target.matches(".site .menus, .w.dl")) fit(); }); -ico.addEventListener("pointerup", e => { - if (!grab) return; - grab = null; - ico.classList.remove("dragging"); - folder.classList.remove("over"); - if (!overFolder(e)) { ico.style.transform = ""; return; } - // Started from the drop itself, so the browser counts it as the visitor's own download. - location.href = dmg; - const f = folder.getBoundingClientRect(); - const home = ico.parentElement.getBoundingClientRect(); - ico.style.transform = `translate(${f.left + f.width / 2 - (home.left + home.width / 2)}px, ${f.top + f.height / 2 - (home.top + home.height / 2)}px) scale(.4)`; - ico.classList.add("in"); - setTimeout(() => { - ico.style.transition = "none"; - ico.style.transform = ""; - requestAnimationFrame(() => requestAnimationFrame(() => { - ico.style.transition = ""; - ico.classList.remove("in"); - })); - }, 1600); -}); -ico.addEventListener("pointercancel", () => { grab = null; ico.classList.remove("dragging"); ico.style.transform = ""; }); - -/* The product pictures ease in as they scroll into view */ -const io = new IntersectionObserver(entries => { - for (const e of entries) if (e.isIntersecting) { e.target.classList.add("in"); io.unobserve(e.target); } -}, { rootMargin: "0px 0px -10% 0px" }); -for (const el of document.querySelectorAll(".pops, .board, .scripts, .store-win, .setup, .install .desktop")) { - el.classList.add("reveal"); - io.observe(el); -} /* Edit Bar in Place: the bar becomes its own editor, as in the app. Each position gets a tray, widgets keep their look, − takes one off, + adds one where it sits, and Undo and Done wait below. */ @@ -373,10 +230,11 @@ const NAMES = { apple: "Apple menu", ws: "Workspaces", app: "App menus", np: "No const ease = "cubic-bezier(.22, 1, .36, 1)"; const editCtl = document.getElementById("edit-ctl"); const undoButton = document.getElementById("edit-undo"); +const tryEdit = document.getElementById("try-edit"); const addPop = document.getElementById("addpop"); const ctx = document.getElementById("ctx"); const live = document.getElementById("edit-live"); -let past = [], future = [], drag = null, adding = null, made = 0; +let past = [], future = [], drag = null, adding = null, made = 0, opener = null; const lane = i => trays[i].querySelector(".lane"); const tilesIn = (i, except) => [...lane(i).querySelectorAll(":scope > .tile:not([hidden])")].filter(t => t !== except); @@ -405,11 +263,13 @@ function tile(w) { return t; } -function beginEditing() { - if (editing || dock.classList.contains("header")) return; +function beginEditing(from) { + if (editing) return; if (openId) open(openId); closeMenu(); + opener = from || (bar.contains(document.activeElement) ? document.activeElement : null); editing = true; + dockState(); past = []; future = []; sound.classList.remove("expand"); @@ -417,7 +277,7 @@ function beginEditing() { for (const [i, g] of trays.entries()) { const l = document.createElement("div"); l.className = "lane"; - for (const w of [...g.children].filter(w => w.dataset.key && !w.matches(".more, .dl, .site"))) l.append(tile(w)); + for (const w of [...g.children].filter(w => w.dataset.key && !w.matches(".more, .dl, .site, .moon"))) l.append(tile(w)); const add = document.createElement("button"); add.className = "add"; add.title = add.ariaLabel = `Add a widget to ${TRAYS[i]}`; @@ -469,9 +329,16 @@ function endEditing() { editing = false; dock.classList.remove("editing", "dragging"); updateControls(); - requestAnimationFrame(() => { fit(); slide(); }); + requestAnimationFrame(() => { fit(); slide(); dockState(); }); say("Finished editing the bar."); } +/* Done and Escape hand focus back to whatever started editing */ +function finishEditing() { + const back = opener; + opener = null; + endEditing(); + if (back?.isConnected && !back.closest("[inert]")) back.focus({ preventScroll: true }); +} function updateControls() { undoButton.disabled = !past.length; @@ -588,7 +455,7 @@ function lift(e) { const copy = t.firstElementChild.cloneNode(true); copy.querySelector(".ind")?.remove(); ghost.append(copy); - desk.append(ghost); + dock.append(ghost); drag.ghost = ghost; t.classList.add("lifted"); dock.classList.add("dragging"); @@ -783,7 +650,7 @@ function add(c) { record(before, `Added ${c.name} to ${TRAYS[i]}.`); } -/* The Store section: Get reviews a package, and Add to bar puts it on the bar at the top of the page */ +/* The Store: Get reviews a package, and Add to bar puts it on the bar at the top of the page */ const pkgReview = document.getElementById("pkg-review"); const storeWin = document.querySelector(".store-win"); let reviewing = null; @@ -797,6 +664,7 @@ function closeReview() { reviewing = null; pkgReview.hidden = true; storeWin.classList.remove("sheet"); + storeWin.inert = false; if (pkgReview.contains(document.activeElement) && get.isConnected) get.focus({ preventScroll: true }); } document.querySelector(".pkgs").addEventListener("click", e => { @@ -809,6 +677,8 @@ document.querySelector(".pkgs").addEventListener("click", e => { pkgReview.innerHTML = reviewHTML(c, "Cancel", "Add to bar"); pkgReview.hidden = false; storeWin.classList.add("sheet"); + // the sheet covers the window, as on the Mac: what's under it can't take focus until it closes + storeWin.inert = true; pkgReview.querySelector(".back").addEventListener("click", closeReview); pkgReview.querySelector(".go").addEventListener("click", () => { closeReview(); @@ -845,41 +715,29 @@ function showMenu(x, y, items) { } function closeMenu() { ctx.hidden = true; } bar.addEventListener("contextmenu", e => { - if (dock.classList.contains("header") || innerWidth < 720 || e.target.closest(".menus a")) return; + if (innerWidth < 720 || e.target.closest(".menus a")) return; e.preventDefault(); const t = editing && e.target.closest(".tile"); - if (!editing) showMenu(e.clientX, e.clientY, [["Edit Bar in Place", beginEditing]]); - else if (t) showMenu(e.clientX, e.clientY, [["Move Left", () => step(t, -1)], ["Move Right", () => step(t, 1)], ["Remove Widget", () => remove(t)], null, ["Finish Editing Bar", endEditing]]); - else showMenu(e.clientX, e.clientY, [["Finish Editing Bar", endEditing]]); + if (!editing) showMenu(e.clientX, e.clientY, [["Edit Bar in Place", () => beginEditing(opener)]]); + else if (t) showMenu(e.clientX, e.clientY, [["Move Left", () => step(t, -1)], ["Move Right", () => step(t, 1)], ["Remove Widget", () => remove(t)], null, ["Finish Editing Bar", finishEditing]]); + else showMenu(e.clientX, e.clientY, [["Finish Editing Bar", finishEditing]]); }); +bar.addEventListener("contextmenu", () => { if (!editing && bar.contains(document.activeElement)) opener = document.activeElement; }, true); ctx.addEventListener("keydown", e => { const items = [...ctx.querySelectorAll("button")], k = items.indexOf(document.activeElement); if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); items[(k + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length].focus(); } }); -document.getElementById("edit-start").addEventListener("click", e => { +/* From the Edit section: the bar above turns into its own editor, right where you are */ +tryEdit.addEventListener("click", e => { e.stopPropagation(); - beginEditing(); + beginEditing(tryEdit); bar.querySelector(".tile")?.focus({ preventScroll: true }); }); -/* From the Configure section: back to the bar, then edit it */ -document.getElementById("try-edit").addEventListener("click", e => { - e.stopPropagation(); - scrollTo({ top: 0, behavior: reduce.matches ? "auto" : "smooth" }); - const until = performance.now() + 2500; - const go = () => { - if (scrollY > 1) { if (performance.now() < until) requestAnimationFrame(go); return; } - dockState(); - beginEditing(); - bar.querySelector(".tile")?.focus({ preventScroll: true }); - }; - requestAnimationFrame(go); -}); undoButton.addEventListener("click", e => { e.stopPropagation(); undo(); }); document.getElementById("edit-done").addEventListener("click", e => { e.stopPropagation(); - endEditing(); - document.getElementById("edit-start").focus({ preventScroll: true }); + finishEditing(); }); document.addEventListener("click", e => { if (!ctx.hidden && !e.target.closest(".ctx")) closeMenu(); @@ -892,7 +750,7 @@ document.addEventListener("keydown", e => { if (!ctx.hidden) { closeMenu(); return; } if (adding) { closeAdd(); return; } if (drag?.started) { settle(false); drag = null; return; } - if (editing) { endEditing(); document.getElementById("edit-start").focus({ preventScroll: true }); } + if (editing) finishEditing(); } else if (editing && (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z" && !e.target.closest("input")) { e.preventDefault(); e.shiftKey ? redo() : undo(); @@ -900,29 +758,260 @@ document.addEventListener("keydown", e => { }); addEventListener("scroll", closeMenu, { passive: true }); -/* Light and dark: starts from the system setting, remembers the visitor's choice */ +/* ---------- The drawing ---------- */ + +/* Scrolling brings the evening: each section holds its own hour, and the hours blend in between. + The night switch brings the night at once and keeps it. */ +const sections = [...document.querySelectorAll("main > section[data-state]")]; +let marks = []; +function measureMarks() { + marks = sections.map(s => { + const r = s.getBoundingClientRect(), top = r.top + scrollY, h = r.height, pad = Math.min(h * .25, innerHeight * .4); + return { name: s.dataset.state, d: Number(s.dataset.dusk), top, bottom: top + h, a: top + pad, b: top + h - pad }; + }); +} +const smooth = t => t * t * (3 - 2 * t); +function hour() { + const y = scrollY + innerHeight / 2; + let d = marks[0].d, name = marks[0].name; + for (let i = 0; i < marks.length; i++) { + const m = marks[i], n = marks[i + 1]; + if (y >= m.top) name = m.name; + if (y >= m.a) d = m.d; + if (n && y > m.b && y < n.a) d = m.d + (n.d - m.d) * smooth((y - m.b) / (n.a - m.b)); + } + return { d, name }; +} +let lastDusk = -1, lastState = ""; +function updateDusk() { + if (!marks.length) return; + const { d, name } = hour(); + const dusk = root.dataset.mode === "dark" ? 1 : d; + if (Math.abs(dusk - lastDusk) > .001) { lastDusk = dusk; S("setDusk", dusk); } + if (name !== lastState) { lastState = name; S("setState", name); } +} + +/* Words in the headline light up when the beam crosses them */ +const lumens = [...document.querySelectorAll(".lumen")]; +let lumRects = null; +function measureLumens() { + lumRects = lumens.map(el => { + const r = el.getBoundingClientRect(); + return { el, x: r.left + r.width / 2, y: r.top + r.height / 2, on: el.classList.contains("lit"), seen: r.bottom > 0 && r.top < innerHeight }; + }); +} +function lightWords(s) { + if (!lumRects) measureLumens(); + const I = s.beamI(); + for (const r of lumRects) { + const on = r.seen && s.inBeam(r.x, r.y) * (.45 + .55 * I) > .35; + if (on !== r.on) { r.on = on; r.el.classList.toggle("lit", on); } + } +} + +/* The night switch: the moon tile on the bar and the key's Night. Starts from the system setting, + remembers the visitor's choice, shared with the privacy and terms pages. */ const themeColor = document.querySelector('meta[name="theme-color"]'); -const modeButton = document.getElementById("mode"); +const nightSwitches = [document.getElementById("night"), document.querySelector(".key .night")]; function setMode(mode) { root.dataset.mode = mode; - themeColor.content = mode === "light" ? "#f5f0e8" : "#15110d"; - modeButton.setAttribute("aria-pressed", mode === "dark"); + themeColor.content = mode === "light" ? "#86cbf2" : "#232662"; + for (const b of nightSwitches) b.setAttribute("aria-pressed", mode === "dark"); + updateDusk(); } -setMode(root.dataset.mode); -modeButton.addEventListener("click", () => { +for (const b of nightSwitches) b.addEventListener("click", e => { + e.stopPropagation(); const mode = root.dataset.mode === "light" ? "dark" : "light"; localStorage.setItem("mode", mode); - if (document.startViewTransition && !reduce.matches) document.startViewTransition(() => setMode(mode)); - else setMode(mode); + setMode(mode); }); -addEventListener("resize", () => { fit(); dockState(); }); -document.fonts.ready.then(fit); +measureMarks(); +try { + if (window.Hill?.Scene) scene = new window.Hill.Scene(document.getElementById("scene"), { topInset: restHeight(), dusk: root.dataset.mode === "dark" ? 1 : hour().d }); +} catch (err) { + console.warn("The drawing didn't start:", err); + scene = null; +} +root.classList.toggle("has-scene", Boolean(scene)); + +if (scene) { + scene.onFrame = s => { + try { lightWords(s); } catch (err) { + if (!sceneFailed) { sceneFailed = true; console.warn("The drawing failed:", err); } + } + }; + + /* The pointer and a finger steer the light and stir the grass, clouds and water. + On touch only a sideways drag steers; the page keeps its vertical scrolling (touch-action: pan-y). */ + addEventListener("pointermove", e => S("pointer", e.clientX, e.clientY, e.pointerType), { passive: true }); + addEventListener("pointerdown", e => S("pointer", e.clientX, e.clientY, e.pointerType), { passive: true }); + addEventListener("pointerup", e => { if (e.pointerType !== "mouse") S("release"); }, { passive: true }); + addEventListener("pointercancel", () => S("release"), { passive: true }); + let swipe = null; + addEventListener("touchstart", e => { const t = e.touches[0]; swipe = t && { x: t.clientX, y: t.clientY, side: null }; }, { passive: true }); + addEventListener("touchmove", e => { + const t = e.touches[0]; + if (!t || !swipe) return; + if (swipe.side === null && Math.hypot(t.clientX - swipe.x, t.clientY - swipe.y) > 8) swipe.side = Math.abs(t.clientX - swipe.x) > Math.abs(t.clientY - swipe.y); + if (swipe.side) S("pointer", t.clientX, t.clientY, "touch"); + }, { passive: true }); + addEventListener("touchend", () => { swipe = null; S("release"); }, { passive: true }); + addEventListener("touchcancel", () => { swipe = null; S("release"); }, { passive: true }); + root.addEventListener("mouseleave", () => S("release")); + addEventListener("blur", () => S("release")); + + /* A click or tap on the landscape, not on the page, drops something there. + A click that only closes a popup or a menu doesn't. */ + let overlay = false; + document.addEventListener("click", () => { overlay = Boolean(openId || adding || !ctx.hidden || reviewing); }, true); + document.addEventListener("click", e => { + if (overlay || editing || e.detail === 0) return; + if (e.target.closest("a, button, input, label, pre, .note, .dock, .site-foot, [role='dialog']")) return; + if (String(getSelection?.() || "")) return; + S("tap", e.clientX, e.clientY); + }); + + /* The key's buttons drop the same surprises, for keyboards and screen readers too */ + for (const b of document.querySelectorAll("[data-drop]")) b.addEventListener("click", () => S("surprise", b.dataset.drop)); +} + +/* ---------- The pen: every note, Download button, tag and key button gets a felt-pen outline, + drawn at its size in three slightly different states that the CSS steps through (the boil). + Inline SVG paths rather than images, so an outline never counts as the page's largest paint. ---------- */ +const INK = ".note, .cta, .tag, .keys button"; +const hash = n => { const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); }; +const noise = x => { const i = Math.floor(x), f = x - i, u = f * f * (3 - 2 * f); return (hash(i) + (hash(i + 1) - hash(i)) * u) * 2 - 1; }; +const r1 = v => Math.round(v * 10) / 10; +const PAD = 8; + +/* The torn top of the notebook page: one tooth every 12 px or so, the same each time */ +function teeth(W, seed) { + const n = Math.max(6, Math.round(W / 12)), out = []; + for (let i = 0; i <= n; i++) out.push([W * i / n, 2 + 8 * hash(seed + i * 1.7) * (i % 2 ? 1 : .45)]); + return out; +} +/* The centre line of an outline, clockwise, starting a little way along the top */ +function perimeter(W, H, shape, radii, seed) { + const pts = [], step = 6; + const seg = (ax, ay, bx, by) => { + const n = Math.max(1, Math.round(Math.hypot(bx - ax, by - ay) / step)); + for (let i = 0; i < n; i++) pts.push([ax + (bx - ax) * i / n, ay + (by - ay) * i / n]); + }; + const arc = (cx, cy, r, a0, a1) => { + const n = Math.max(2, Math.round(r * Math.abs(a1 - a0) / step)); + for (let i = 0; i < n; i++) { const a = a0 + (a1 - a0) * i / n; pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]); } + }; + const [tl, tr, br, bl] = radii.map(r => Math.min(r, W / 2, H / 2)); + const x0 = PAD, y0 = PAD, x1 = PAD + W, y1 = PAD + H, Q = Math.PI / 2; + if (shape === "torn") { + const top = teeth(W, seed).map(([x, y]) => [x0 + x, y0 + y]); + for (let i = 0; i < top.length - 1; i++) seg(...top[i], ...top[i + 1]); + seg(x1, top.at(-1)[1], x1, y1 - br); arc(x1 - br, y1 - br, br, 0, Q); + seg(x1 - br, y1, x0 + bl, y1); arc(x0 + bl, y1 - bl, bl, Q, 2 * Q); + seg(x0, y1 - bl, x0, top[0][1]); + for (let i = 0; i < 3; i++) pts.push(top[i]); + return pts; + } + const sx = x0 + tl + (W - tl - tr) * (.12 + .2 * hash(seed)); + seg(sx, y0, x1 - tr, y0); arc(x1 - tr, y0 + tr, tr, -Q, 0); + seg(x1, y0 + tr, x1, y1 - br); arc(x1 - br, y1 - br, br, 0, Q); + seg(x1 - br, y1, x0 + bl, y1); arc(x0 + bl, y1 - bl, bl, Q, 2 * Q); + seg(x0, y1 - bl, x0, y0 + tl); arc(x0 + tl, y0 + tl, tl, 2 * Q, 3 * Q); + seg(x0 + tl, y0, Math.min(sx + 26, x1 - tr), y0); + return pts; +} +/* A felt-pen stroke: a ribbon that wobbles a little, swells and thins, and tapers where the pen lifts. + The three boil states share the shape and differ only in the fine jitter. */ +function outline(W, H, shape, radii, seed, boil, weight) { + const c = perimeter(W, H, shape, radii, seed), n = c.length, L = [], R = [], j = seed + boil * 9.1; + for (let i = 0; i < n; i++) { + const [ax, ay] = c[Math.max(0, i - 1)], [bx, by] = c[Math.min(n - 1, i + 1)]; + const len = Math.hypot(bx - ax, by - ay) || 1, nx = -(by - ay) / len, ny = (bx - ax) / len; + const off = noise(i * .031 + seed * 7.7) * 1.4 + noise(i * .21 + j * 3.1) * .55; + const ends = Math.min(1, (i + 1) / 7, (n - i) / 6); + const w = weight * (.8 + .22 * noise(i * .07 + seed * 5.3 + 40) + .1 * noise(i * .3 + j * 2.3)) * (.3 + .7 * ends) / 2; + const [x, y] = c[i]; + L.push([x + nx * (off + w), y + ny * (off + w)]); + R.push([x + nx * (off - w), y + ny * (off - w)]); + } + const ring = [...L, ...R.reverse()]; + let d = `M${r1(ring[0][0])} ${r1(ring[0][1])}`; + for (let i = 1; i < ring.length; i++) { + const [x, y] = ring[i], [nx, ny] = ring[(i + 1) % ring.length]; + d += `Q${r1(x)} ${r1(y)} ${r1((x + nx) / 2)} ${r1((y + ny) / 2)}`; + } + return `${d}Z`; +} +function ink(el, seed) { + const W = el.offsetWidth, H = el.offsetHeight; + if (!W || !H || el.dataset.inkSize === `${W}x${H}`) return; + el.dataset.inkSize = `${W}x${H}`; + const shape = el.dataset.ink === "torn" ? "torn" : el.matches(".cta, .keys button") ? "pill" : "rect"; + const cs = getComputedStyle(el); + const radii = shape === "pill" ? [H / 2, H / 2, H / 2, H / 2] + : [cs.borderTopLeftRadius, cs.borderTopRightRadius, cs.borderBottomRightRadius, cs.borderBottomLeftRadius].map(v => parseFloat(v) || 0); + const weight = el.matches(".note") ? 2.9 : 2.5; + let svg = el.querySelector(":scope > .pen"); + if (!svg) { + svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("class", "pen"); + svg.setAttribute("aria-hidden", "true"); + el.append(svg); + } + svg.setAttribute("viewBox", `0 0 ${W + PAD * 2} ${H + PAD * 2}`); + svg.innerHTML = [0, 1, 2].map(k => ``).join(""); + if (shape === "torn") el.style.setProperty("--torn", `polygon(${teeth(W, seed).map(([x, y]) => `${r1(x)}px ${r1(y)}px`).join(", ")}, 100% 100%, 0 100%)`); + el.classList.add("inked"); +} +const inked = [...document.querySelectorAll(INK)]; +const seeds = new Map(inked.map((el, i) => [el, 1 + i * 2.3])); +const pen = new ResizeObserver(entries => { for (const e of entries) ink(e.target, seeds.get(e.target)); }); +for (const el of inked) pen.observe(el); + +/* The boil rests after a few quiet seconds, like the drawing */ +let idleTimer = 0, lastWake = 0; +function wake() { + const now = performance.now(); + if (now - lastWake < 250) return; + lastWake = now; + root.classList.remove("idle"); + clearTimeout(idleTimer); + idleTimer = setTimeout(() => root.classList.add("idle"), 6000); +} +for (const type of ["pointermove", "pointerdown", "keydown", "scroll", "wheel", "touchstart"]) addEventListener(type, wake, { passive: true }); +wake(); + +/* ---------- Start ---------- */ +let queued = false; +addEventListener("scroll", () => { + if (queued) return; + queued = true; + requestAnimationFrame(() => { + queued = false; + dockState(); + updateDusk(); + lumRects = null; + }); +}, { passive: true }); +addEventListener("resize", () => { + fit(); + dockState(); + measureMarks(); + updateDusk(); + lumRects = null; + S("setTopInset", restHeight()); +}); +document.fonts.ready.then(() => { + fit(); + measureMarks(); + updateDusk(); + lumRects = null; +}); +setMode(root.dataset.mode); setTheme("built-in"); +focusWorkspace(2); fit(); dockState(); - -/* Arrival: the headline settles, the bar's widgets come in from the left, then the windows and the agenda */ -bar.querySelectorAll(".g > .w").forEach((w, i) => w.style.setProperty("--i", i)); -requestAnimationFrame(() => requestAnimationFrame(() => root.classList.add("arrived"))); -if (innerWidth >= 720) setTimeout(() => { if (!openId && !editing) open("agenda"); }, reduce.matches ? 0 : 1100); +updateDusk(); +})(); diff --git a/site/privacy.html b/site/privacy.html index 71874028..54a9a047 100644 --- a/site/privacy.html +++ b/site/privacy.html @@ -6,14 +6,16 @@ Privacy Policy · StableBar - + - + +
StableBar diff --git a/site/scene.js b/site/scene.js new file mode 100644 index 00000000..a3de7dbd --- /dev/null +++ b/site/scene.js @@ -0,0 +1,1748 @@ +/* Lighthouse Hill, in absurd ink: the scene behind stablebar.app. + A one-eyed lighthouse in a pom-pom hat on a hill above the sea, drawn in Canvas 2D by one + felt-pen hand: tapered ink ribbons that boil a little, and flat colours printed slightly off + the line. main.js creates one scene and forwards input to it (the API is at the end). */ +(() => { +'use strict'; + +/* ---------- small maths ---------- */ +const TAU = Math.PI * 2; +const clamp = (v, a = 0, b = 1) => (v < a ? a : v > b ? b : v); +const lerp = (a, b, t) => a + (b - a) * t; +const smooth = (e0, e1, x) => { const t = clamp((x - e0) / (e1 - e0)); return t * t * (3 - 2 * t); }; +const easeOut = t => t * (2 - t); +const hash = n => { const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); }; +const noise = x => { const i = Math.floor(x), f = x - i, u = f * f * (3 - 2 * f); return lerp(hash(i), hash(i + 1), u) * 2 - 1; }; +const rng = seed => { let a = (seed >>> 0) || 1; return () => { 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 hex = h => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; +const mix = (a, b, t) => [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; +const mul = (a, b) => [a[0] * b[0] / 255, a[1] * b[1] / 255, a[2] * b[2] / 255]; +const rgba = (c, a = 1) => `rgba(${c[0] | 0},${c[1] | 0},${c[2] | 0},${a})`; +const angDiff = (a, b) => { let d = (a - b) % TAU; if (d > Math.PI) d -= TAU; if (d < -Math.PI) d += TAU; return d; }; +const stops = list => list.map(([d, p]) => [d, Object.fromEntries(Object.entries(p).map(([k, v]) => [k, hex(v)]))]); +function palAt(list, d) { + let i = 0; + while (i < list.length - 2 && d > list[i + 1][0]) i++; + const [d0, p0] = list[i], [d1, p1] = list[i + 1], t = clamp((d - d0) / (d1 - d0)); + const out = {}; + for (const k in p0) out[k] = mix(p0[k], p1[k], t); + return out; +} +/* Catmull-Rom through control points, then evenly spaced points every `step` px */ +function curve(ctrl, step, closed = false) { + const p = closed ? [ctrl[ctrl.length - 1], ...ctrl, ctrl[0], ctrl[1]] : [ctrl[0], ...ctrl, ctrl[ctrl.length - 1]]; + const raw = []; + for (let i = 1; i < p.length - 2; i++) { + const p0 = p[i - 1], p1 = p[i], p2 = p[i + 1], p3 = p[i + 2]; + for (let s = 0; s < 8; s++) { + const t = s / 8, t2 = t * t, t3 = t2 * t; + const f = k => 0.5 * (2 * p1[k] + (-p0[k] + p2[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (-p0[k] + 3 * p1[k] - 3 * p2[k] + p3[k]) * t3); + raw.push([f(0), f(1)]); + } + } + if (!closed) raw.push(ctrl[ctrl.length - 1].slice()); + return dens(raw, step, closed); +} +/* Points every `step` px along a polyline */ +function dens(pts, step, closed = false) { + const out = [], src = closed ? [...pts, pts[0]] : pts; + for (let i = 0; i < src.length - 1; i++) { + const [x0, y0] = src[i], [x1, y1] = src[i + 1]; + const n = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / step)); + for (let k = 0; k < n; k++) out.push([lerp(x0, x1, k / n), lerp(y0, y1, k / n)]); + } + if (!closed) out.push(src[src.length - 1].slice()); + return out; +} +const ellipse = (cx, cy, rx, ry, n = 32, a0 = 0, a1 = TAU, rot = 0) => { + const out = [], c = Math.cos(rot), s = Math.sin(rot); + for (let i = 0; i < n; i++) { + const a = lerp(a0, a1, i / (a1 - a0 >= TAU - 1e-6 ? n : n - 1)), x = Math.cos(a) * rx, y = Math.sin(a) * ry; + out.push([cx + x * c - y * s, cy + x * s + y * c]); + } + return out; +}; +function poly(c, pts, close = true, dx = 0, dy = 0) { + c.beginPath(); + for (let i = 0; i < pts.length; i++) i ? c.lineTo(pts[i][0] + dx, pts[i][1] + dy) : c.moveTo(pts[i][0] + dx, pts[i][1] + dy); + if (close) c.closePath(); +} +function inPoly(pts, x, y) { + let ins = false; + for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { + const [xi, yi] = pts[i], [xj, yj] = pts[j]; + if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / ((yj - yi) || 1e-9) + xi) ins = !ins; + } + return ins; +} +/* A cumulus: the upper envelope of a few bumps over a flat base */ +function cloudPts(cx, cy, w, seed) { + const R = rng(seed), n = 4 + ((R() * 2) | 0), bumps = []; + for (let i = 0; i < n; i++) { + const fx = (i + 0.5) / n; + bumps.push([cx - w / 2 + fx * w + (R() - 0.5) * w * 0.06, w * (0.14 + R() * 0.1) * (1.3 - Math.abs(fx - 0.5))]); + } + const x0 = cx - w / 2 - w * 0.05, x1 = cx + w / 2 + w * 0.05, top = []; + for (let i = 0; i <= 64; i++) { + const x = lerp(x0, x1, i / 64); + let y = cy; + for (const [bx, r] of bumps) { const dx = x - bx; if (Math.abs(dx) < r) y = Math.min(y, cy - Math.sqrt(r * r - dx * dx) * 0.92); } + top.push([x, y]); + } + return top.filter((p, i) => p[1] < cy - 0.3 || i === 0 || i === top.length - 1); +} + +/* ---------- colours ---------- */ +/* Five hours of the day. The felt pen stays black; only the printed colours move. */ +const PAL = stops([ + [0, { sky: '#86CBF2', sky2: '#B8E2F8', sea: '#2E58D8', sea2: '#8DB0FF', foam: '#FFFFFF', hill: '#83CF5F', hill2: '#66B24C', ink: '#111111', paper: '#FFFFFF', beam: '#FFE04A', cloud: '#FFFFFF', far: '#A2C9EC', glass: '#B8E2F8' }], + [0.2, { sky: '#A6B6F4', sky2: '#E6C4EE', sea: '#2E50CC', sea2: '#93AAFF', foam: '#FFFFFF', hill: '#7CC95E', hill2: '#5FA84B', ink: '#111111', paper: '#FFFBF4', beam: '#FFE04A', cloud: '#FFFFFF', far: '#B9B4E6', glass: '#E6C4EE' }], + [0.44, { sky: '#FF8F73', sky2: '#FFC07F', sea: '#3043B8', sea2: '#9EA3FF', foam: '#FFF1E6', hill: '#6DB65A', hill2: '#539A4A', ink: '#111111', paper: '#FFF7EC', beam: '#FFE04A', cloud: '#FFE1CF', far: '#D88BA0', glass: '#FFC07F' }], + [0.7, { sky: '#6A4CC4', sky2: '#B0609C', sea: '#22287A', sea2: '#6E77D8', foam: '#D9CCFF', hill: '#3E7D56', hill2: '#2F6447', ink: '#111111', paper: '#F0E6FF', beam: '#FFE24E', cloud: '#C3B2EE', far: '#5E4B9A', glass: '#FFD86A' }], + [1, { sky: '#232662', sky2: '#30357E', sea: '#14184A', sea2: '#4A54B4', foam: '#9EA6E8', hill: '#244F3E', hill2: '#1B3F31', ink: '#0B0B14', paper: '#E8E2FF', beam: '#FFE65C', cloud: '#565C9E', far: '#2E3270', glass: '#FFE65C' }], +]); +const STEPS = 48; // palette steps that the cached layers are keyed on + +/* The lighthouse's pyjamas, one per StableBar theme: stripes and hat */ +const THEMES = { + 'built-in': { stripes: ['#FF4A36'], hat: '#FF4A36' }, + catppuccin: { stripes: ['#CBA6F7'], hat: '#CBA6F7' }, + gruvbox: { stripes: ['#FABD2F', '#FE8019', '#FB4934'], hat: '#FE8019' }, + leman: { stripes: ['#5CC5B8'], hat: '#5CC5B8' }, + nord: { stripes: ['#88C0D0'], hat: '#88C0D0' }, + notch: { stripes: ['#111111'], hat: '#111111' }, + phosphor: { stripes: ['#FFB000'], hat: '#FFB000' }, +}; +const STATES = ['hero', 'edit', 'themes', 'widgets', 'tiling', 'install']; +const KINDS = ['sheep', 'cow', 'duck', 'monster', 'gull']; +const C = { + wool: hex('#FFFFFF'), yolk: hex('#FFC21A'), duck: hex('#FFD21F'), beak: hex('#FF7A1A'), pink: hex('#FF9CB0'), + monster: hex('#FF8FC0'), chimney: hex('#E8452F'), roof: hex('#2B2B36'), door: hex('#2E58D8'), skin: hex('#F6C9A6'), + navy: hex('#25305E'), wood: hex('#C98B4A'), steel: hex('#D8DCE6'), moon: hex('#FFE89A'), tile: hex('#1D1D24'), + huts: ['#FF4A36', '#2E58D8', '#FFC21A', '#36A852'].map(hex), +}; +const HAND = '"Shantell Sans Hand", "Shantell Sans", "Chalkboard SE", sans-serif'; + +/* ---------- the pen ---------- + One felt pen in three weights. Every stroke is a filled ribbon whose width follows the pen's + pressure: thin where it lands and lifts, a little uneven along the way. The line boils between + three states; the printed fills don't boil and sit one constant vector off the line. */ +let BOIL = 0, PW = 1, INK = '#111', REGX = 2.6, REGY = 1.9; +const SIL = 3.2, DET = 2.1, TEX = 1.35; + +function ribbon(c, pts, w, sd = 0, o = {}) { + const n = pts.length; + if (n < 2) return; + const len = new Float64Array(n); + for (let i = 1; i < n; i++) len[i] = len[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]); + const total = len[n - 1] || 1, bo = BOIL * 7.13 + sd * 3.71; + const amp = (o.amp ?? 0.85) * PW, tin = Math.min(total * 0.4, (o.tin ?? 9) * PW), tout = Math.min(total * 0.5, (o.tout ?? 15) * PW); + const w0 = o.w0 ?? 0.4, w1 = o.w1 ?? 0.22, hw0 = w * PW * 0.5; + const lx = new Float64Array(n), ly = new Float64Array(n), rx = new Float64Array(n), ry = new Float64Array(n); + let ex = 0, ey = 0, etx = 0, ety = 0, ehw = 0, sx = 0, sy = 0, stx = 0, sty = 0, shw = 0; + for (let i = 0; i < n; i++) { + const a = pts[i > 0 ? i - 1 : 0], b = pts[i < n - 1 ? i + 1 : n - 1]; + let tx = b[0] - a[0], ty = b[1] - a[1]; + const tl = Math.hypot(tx, ty) || 1; tx /= tl; ty /= tl; + const s = len[i], off = amp * (noise(s / 70 + bo) * 0.9 + noise(s / 19 + bo * 1.7 + 5) * 0.28); + const x = pts[i][0] - ty * off, y = pts[i][1] + tx * off; + const p = lerp(w0, 1, easeOut(clamp(s / tin))) * lerp(w1, 1, easeOut(clamp((total - s) / tout))) * (1 + 0.15 * noise(s / 43 + bo * 0.6 + 17)); + const hw = hw0 * p; + lx[i] = x - ty * hw; ly[i] = y + tx * hw; rx[i] = x + ty * hw; ry[i] = y - tx * hw; + if (i === 0) { sx = x; sy = y; stx = tx; sty = ty; shw = hw; } + if (i === n - 1) { ex = x; ey = y; etx = tx; ety = ty; ehw = hw; } + } + c.moveTo(lx[0], ly[0]); + for (let i = 1; i < n; i++) c.lineTo(lx[i], ly[i]); + for (let k = 1; k < 5; k++) { const th = k / 5 * Math.PI, co = Math.cos(th), si = Math.sin(th); c.lineTo(ex + ehw * (-ety * co + etx * si), ey + ehw * (etx * co + ety * si)); } + for (let i = n - 1; i >= 0; i--) c.lineTo(rx[i], ry[i]); + for (let k = 1; k < 5; k++) { const th = k / 5 * Math.PI, co = Math.cos(th), si = Math.sin(th); c.lineTo(sx + shw * (sty * co - stx * si), sy + shw * (-stx * co - sty * si)); } + c.closePath(); +} +/* A single stroke in ink */ +function ink(c, pts, w, sd, o) { c.beginPath(); ribbon(c, pts, w, sd, o); c.fillStyle = o?.color || INK; c.fill(); } +/* A closed contour drawn in one go, ending a little past where it started */ +function loop(c, pts, w, sd = 0, o = {}) { + const n = pts.length, k = Math.abs(Math.round(sd * 7919)) % n, over = Math.max(2, Math.round(n * (o.over ?? 0.08))); + let cx = 0, cy = 0; + for (const p of pts) { cx += p[0]; cy += p[1]; } + cx /= n; cy /= n; + const out = []; + for (let i = 0; i <= n + over; i++) { + const p = pts[(k + i) % n]; + if (i <= n) out.push(p); + else { const f = (i - n) / over * 1.6 * PW, dx = p[0] - cx, dy = p[1] - cy, dl = Math.hypot(dx, dy) || 1; out.push([p[0] + dx / dl * f, p[1] + dy / dl * f]); } + } + ribbon(c, out, w, sd, { tin: 5, tout: 12, w0: 0.55, w1: 0.12, ...o }); +} +/* Straight edges, each its own stroke running a little past the corners */ +function edges(c, corners, w, sd = 0, closed = true, o = {}) { + const m = corners.length, ov = (o.ov ?? 2.6) * PW; + for (let i = 0; i < (closed ? m : m - 1); i++) { + const [x0, y0] = corners[i], [x1, y1] = corners[(i + 1) % m], l = Math.hypot(x1 - x0, y1 - y0) || 1, ux = (x1 - x0) / l, uy = (y1 - y0) / l; + const a = hash(sd * 3.1 + i) * ov, b = hash(sd * 5.7 + i + 0.5) * ov; + ribbon(c, dens([[x0 - ux * a, y0 - uy * a], [x1 + ux * b, y1 + uy * b]], 5), w, sd + i * 1.7, { tin: 4, tout: 6, w0: 0.6, w1: 0.45, ...o }); + } +} +/* A printed fill, off the line by the register vector */ +function print(c, pts, col) { poly(c, pts, true, REGX, REGY); c.fillStyle = typeof col === 'string' ? col : rgba(col); c.fill(); } +function shape(c, pts, col, w = DET, sd = 0, o) { print(c, pts, col); c.beginPath(); loop(c, pts, w, sd, o); c.fillStyle = INK; c.fill(); } +function dot(c, x, y, r, col) { c.beginPath(); c.arc(x, y, r, 0, TAU); c.fillStyle = typeof col === 'string' ? col : rgba(col); c.fill(); } +function label(c, text, x, y, size, col, align = 'left', rot = -0.035) { + c.save(); c.translate(x, y); c.rotate(rot); + c.font = `400 ${size}px ${HAND}`; c.textAlign = align; c.textBaseline = 'middle'; c.fillStyle = col; + c.fillText(text, 0, 0); c.restore(); +} + +/* ---------- the scene: geometry, state, input and time ---------- */ +const IDLE_AFTER = 4000, IDLE_FPS = 14, BOIL_FPS = 7, MAX_OBJS = 36; + +class Scene { + constructor(host, opts = {}) { + this.o = Object.assign({ topInset: 0, dusk: 0, seed: 11 }, opts); + this.host = host; + const cv = this.canvas = document.createElement('canvas'); + cv.setAttribute('aria-hidden', 'true'); + cv.style.cssText = 'position:absolute;left:0;top:0;display:block;pointer-events:none'; + host.appendChild(cv); + this.ctx = cv.getContext('2d'); + this.top = Math.max(0, +this.o.topInset || 0); + this.t = 0; this.d = this.dTarget = clamp(+this.o.dusk || 0); + this.state = 'hero'; this.theme = 'built-in'; this.ws = 1; + this.pres = Object.fromEntries(STATES.map(k => [k, k === 'hero' ? 1 : 0])); + this.since = Object.fromEntries(STATES.map(k => [k, 0])); + this.ptr = { x: 0, y: 0, on: false, last: -1e9, kind: 'mouse' }; + this.vel = { x: 0, y: 0 }; + this.wind = 0; this.windAmb = 0.3; this.windTotal = 0.3; this.drift = 0; + this.beam = { a: Math.PI * 0.96, len: 1, flash: 0, phi: Math.PI * 0.96, steer: 0, sa: Math.PI * 0.96 }; + this.blink = { next: 2.5, at: -9, wink: -9 }; + this.ripples = []; this.objs = []; + this.layers = { back: [], front: [] }; + this.sprites = new Map(); + this.fontsV = 0; this.layoutV = 0; this.sparkle = -9; this.rebuilds = 0; + this.onFrame = null; + this.times = []; + this.awake = 0; this.kick = 0; this.raf = 0; this.timer = 0; this.last = null; + this.mq = matchMedia('(prefers-reduced-motion: reduce)'); + this.reduced = this.o.reduced ?? this.mq.matches; + this.onMQ = () => { if (this.o.reduced == null) { this.reduced = this.mq.matches; if (this.reduced) { this.still(); this.awake = 0; } this.wake(); } }; + this.mq.addEventListener?.('change', this.onMQ); + this.frame = this.frame.bind(this); + this.measure(true); + this.ro = new ResizeObserver(() => { if (this.measure()) this.wake(); }); + this.ro.observe(host); + this.onVis = () => { if (document.hidden) this.sleep(); else { this.last = null; this.wake(); } }; + document.addEventListener('visibilitychange', this.onVis); + const fontsIn = () => { this.fontsV++; this.wake(); }; + if (document.fonts) { + document.fonts.load(`400 16px ${HAND}`).then(fontsIn, () => {}); + document.fonts.addEventListener?.('loadingdone', fontsIn); + } + this.wake(); + } + + /* ----- page state ----- */ + setDusk(d) { this.dTarget = clamp(+d || 0); if (this.reduced) this.d = this.dTarget; this.wake(); } + setState(name) { + if (!STATES.includes(name)) return; + if (name !== this.state) { this.state = name; this.since[name] = this.t; } + this.wake(); + } + setTheme(id) { + if (!THEMES[id]) return; + if (id !== this.theme) { this.theme = id; if (this.t > 0.5) this.sparkle = this.t; } + this.wake(); + } + setWorkspace(n) { this.ws = clamp(Math.round(+n || 1), 1, 4); this.wake(); } + setTopInset(px) { const v = Math.max(0, +px || 0); if (v !== this.top) { this.top = v; this.layout(); } this.wake(); } + destroy() { + this.dead = true; this.sleep(); this.ro.disconnect(); + this.mq.removeEventListener?.('change', this.onMQ); + document.removeEventListener('visibilitychange', this.onVis); + this.canvas.remove(); + } + + /* ----- what the page reads after every frame ----- */ + inBeam(x, y) { + x -= this.ox; y -= this.oy; + const b = this.beam, L = this.g.lamp, dx = x - L.x, dy = y - L.y, dist = Math.hypot(dx, dy); + if (dist < 6 || dist > b.len * this.diag || b.len < 0.08) return 0; + const w = this.g.beamHalf + 10 * this.g.u / dist; + return 1 - smooth(w * 0.7, w * 1.2, Math.abs(angDiff(Math.atan2(dy, dx), b.a))); + } + beamI() { return Math.max(0.3 + 0.7 * smooth(0.22, 0.72, this.d), this.pres.install); } + lamp() { return { x: this.g.lamp.x + this.ox, y: this.g.lamp.y + this.oy }; } + + /* ----- input, in client coordinates ----- */ + pointer(x, y, kind = 'mouse') { + x -= this.ox; y -= this.oy; + const now = performance.now() / 1000; + if (this.ptr.on && now - this.ptr.last < 0.25) { + const dt = Math.max(0.008, now - this.ptr.last); + this.vel.x = lerp(this.vel.x, (x - this.ptr.x) / dt, 0.35); + this.vel.y = lerp(this.vel.y, (y - this.ptr.y) / dt, 0.35); + } + Object.assign(this.ptr, { x, y, on: true, last: now, kind: kind || 'mouse' }); + const sp = Math.hypot(this.vel.x, this.vel.y); + if (!this.reduced && sp > 650 && this.isSea(x, y) && now - (this.lastRip || 0) > 0.11) { + this.lastRip = now; + this.ripples.push({ x, y, t0: this.t, a: Math.min(1, sp / 2400) * 0.9 }); + } + if (this.reduced) this.aim(x, y); + this.wake(); + } + release() { this.ptr.on = false; this.wake(); } + tap(x, y) { + x -= this.ox; y -= this.oy; + this.aim(x, y); + this.wake(); + const g = this.g, T = g.lh; + if (y < this.top) return false; + // the lighthouse winks instead of catching things + if (Math.abs(x - T.x) < T.wB * 0.75 && y > T.hatTop && y < T.base) { this.blink.wink = this.t; return false; } + this.spawn(x, y); + return true; + } + surprise(kind) { + if (!KINDS.includes(kind)) return; + const p = this.spot(kind); + const o = p.cloud ? this.ride(kind, p.cloud) : this.spawn(p.x, p.y, kind, false, p.fromSky); + if (o.kind === 'gull' && !this.reduced) { o.vx *= 0.5; o.calm = 1.6; } + this.wake(); + } + aim(x, y) { + const L = this.g.lamp; + Object.assign(this.ptr, { x, y, on: true, last: performance.now() / 1000 }); + this.beam.sa = this.clampAim(Math.atan2(y - L.y, x - L.x)); + if (this.reduced) this.beam.steer = 1; + } + clampAim(a) { + // keep the light off the lighthouse's own feet: never closer than this to straight down + const lim = 0.14, down = Math.PI / 2; + if (a > down - lim && a <= down) return down - lim; + if (a > down && a < down + lim) return down + lim; + return a; + } + + /* ----- size and layout ----- */ + measure(first) { + const r = this.host.getBoundingClientRect(); + this.ox = r.left; this.oy = r.top; + let W = Math.max(2, Math.round(r.width)), H = Math.max(2, Math.round(r.height)); + // phones: the browser's toolbars change the height while scrolling; keep the larger one + if (!first && W === this.W && this.g.portrait && H < this.H && H > this.H * 0.7) H = this.H; + if (W === this.W && H === this.H) return false; + this.W = W; this.H = H; this.diag = Math.hypot(W, H); + const budget = 9.4e6; // device pixels for one full canvas + this.dpr = Math.min(2, window.devicePixelRatio || 1, Math.sqrt(budget / (W * H))); + this.canvas.width = Math.round(W * this.dpr); this.canvas.height = Math.round(H * this.dpr); + this.canvas.style.width = W + 'px'; this.canvas.style.height = H + 'px'; + this.layout(); + return true; + } + + layout() { + const { W, H } = this, top = this.top, Hs = H - top; + const portrait = W < 700 || H > W * 1.1; + const u = clamp(Math.sqrt(W * H) / 1138, 0.42, 1.35); + const was = this.g ? [...this.g.clouds, this.g.sheepCloud] : []; + const g = this.g = { W, H, u, portrait, top }; + g.pw = clamp(0.55 + 0.45 * u, 0.74, 1.15); + const at = (fx, fy) => [fx * W, top + fy * Hs]; + let ridgeCtl, shoreCtl; + if (portrait) { + // a headland in the upper right, the sea to the left and below + g.horizon = top + Hs * 0.375; + ridgeCtl = [at(0.165, 0.398), at(0.2, 0.384), at(0.3, 0.366), at(0.42, 0.343), at(0.55, 0.322), at(0.65, 0.312), at(0.72, 0.31), at(0.8, 0.312), at(0.9, 0.318), at(1.06, 0.33)]; + shoreCtl = [at(0.165, 0.398), at(0.2, 0.414), at(0.3, 0.432), at(0.44, 0.466), at(0.6, 0.5), at(0.78, 0.53), at(0.92, 0.545), at(1.06, 0.552)]; + } else { + g.horizon = top + Hs * 0.6; + ridgeCtl = [[0.43, 1.04], [0.5, 0.915], [0.56, 0.81], [0.605, 0.71], [0.64, 0.636], [0.678, 0.58], [0.727, 0.549], [0.775, 0.539], [0.84, 0.547], [0.92, 0.575], [1.05, 0.615]].map(([x, y]) => at(x, y)); + shoreCtl = null; + } + g.ridge = curve(ridgeCtl, 6); + g.shore = shoreCtl ? curve(shoreCtl, 6) : null; + g.hillPoly = g.shore ? [...g.ridge, ...[...g.shore].reverse()] : [...g.ridge, [W + 40, H + 40], [g.ridge[0][0] - 40, H + 40]]; + g.beamHalf = 0.1; + const ins = 2.6 * g.pw; + g.hillClip = g.shore ? [...g.ridge.map(([x, y]) => [x, y + ins]), ...g.shore.slice().reverse().map(([x, y]) => [x, y - ins])] : [...g.ridge.map(([x, y]) => [x, y + ins]), [W + 40, H + 40], [g.ridge[0][0] - 40, H + 40]]; + + // the lighthouse: lamp, eye, gallery, tower and hat + const lx = W * (portrait ? 0.72 : 0.775); + const T = g.lh = { x: lx }; + if (portrait) { + T.base = top + Hs * 0.31 + 2 * u; + T.er = clamp(W * 0.036, 11.5, 22); + const lampY = top + H * 0.1; + T.gal = T.er * 0.62; + T.top = lampY + T.er * 1.02 + T.gal; + T.h = T.base - T.top; + } else { + T.base = this.ridgeY(lx) + 2 * u; + T.h = Math.min(Hs * 0.27 * 1.32, W * 0.3); + T.er = T.h * 0.062; + T.gal = T.er * 0.62; + T.top = T.base - T.h; + } + T.wT = T.er * 1.62; T.wB = T.er * 2.6; + g.lamp = { x: lx, y: T.top - T.gal - T.er * 1.02 }; + T.hatTop = g.lamp.y - T.er * 3.6; + // the keeper's cottage, to the right of the lighthouse + const cx = portrait ? lx + T.wB * 0.5 + T.h * 0.4 : lx + T.wB * 0.5 + T.h * 0.3; + const cw = portrait ? Math.min(T.h * 0.34, W * 0.13) : T.h * 0.3; + g.cot = { x: cx, base: this.ridgeY(cx) + 3 * u, w: cw, h: cw * 0.62 }; + + const R = rng(this.o.seed); + const sky = g.horizon - top; + // clouds; the third one has legs and may be a sheep. It wanders where it can be seen. + g.clouds = (portrait ? [0.08, 0.55, 0.9] : [0.06, 0.3, 0.7, 0.93]).map((x, i) => ({ + x0: x * (W + 360 * u), y: top + sky * (portrait ? 0.08 + R() * 0.3 : 0.1 + R() * 0.38), w: (110 + R() * 80) * u * (portrait ? 0.95 : 1), + sp: 5 + R() * 8, seed: 31 * i + this.o.seed, ox: 0, oy: 0, + })); + g.sheepCloud = portrait + ? { x0: 0.1 * W, x1: 0.16 * W, y: top + H * 0.058, w: 84 * u + 22, seed: 77, ox: 0, oy: 0 } + : { x0: 0.5 * W, x1: 0.585 * W, y: top + sky * 0.13, w: 128 * u, seed: 77, ox: 0, oy: 0 }; + g.sheepCloud.x = lerp(g.sheepCloud.x0, g.sheepCloud.x1, 0.3); g.sheepCloud.dir = 1; + // animals riding a cloud move to the same cloud in the new layout + for (const o of this.objs) if (o.ride) { + const i = was.indexOf(o.ride), cl = i === was.length - 1 ? g.sheepCloud : g.clouds[i] || g.sheepCloud; + o.dx *= cl.w / (o.ride.w || cl.w); o.ride = cl; + } + const n = clamp(Math.round(W * sky / 6500), 26, 180); + g.stars = Array.from({ length: n }, () => ({ x: R() * W, y: top + 8 + R() * (sky - 24), s: 0.55 + R() * R() * 1.6, p: R() * TAU })) + .filter(s => Math.hypot(s.x - g.lamp.x, s.y - g.lamp.y) > T.er * 4); + // the far island and its smaller lighthouse + g.isle = portrait ? { x0: 0.03 * W, x1: 0.24 * W, h: 20 * u + 8 } : { x0: 0.49 * W, x1: 0.585 * W, h: 22 * u }; + g.sun = portrait ? { x: 0.3 * W, y0: top + H * 0.115 } : { x: 0.57 * W, y0: top + sky * 0.3 }; + g.moon = portrait ? { x: 0.08 * W + 6, y1: top + H * 0.15 } : { x: 0.6 * W, y1: top + sky * 0.44 }; + + // the sea: rows of scallops, sparse and small near the horizon + const rows = portrait ? Math.round(clamp((H - g.horizon) / 11, 26, 60)) : Math.round(18 + 14 * Math.min(1, u)); + g.rows = []; + for (let i = 1; i <= rows; i++) { + const f = i / rows, y = g.horizon + (H - g.horizon) * Math.pow(f, 1.6); + g.rows.push({ y, f, seed: i * 97 + this.o.seed, spans: this.seaSpans(y) }); + } + // grass tufts along the ridge, a few + g.tufts = []; + for (let x = g.ridge[0][0] + 14 * u; x < W; x += (26 + R() * 30) * u) { + const y = this.ridgeY(x); + if (y == null || y > H + 4 || Math.abs(x - lx) < T.wB * 0.7 || (x > g.cot.x - g.cot.w * 0.55 && x < g.cot.x + g.cot.w * 0.5)) continue; + g.tufts.push({ x, y, l: (8 + R() * 7) * Math.max(u, 0.62), p: R() * TAU, n: 2 + ((R() * 2.2) | 0) }); + } + // where the page's notes sit (a guess, for placing labels and surprises) + g.note = portrait ? { x0: 0, x1: W, y0: top + Hs * 0.24, y1: H } : { x0: 0, x1: W * 0.05 + Math.min(W * 0.46, 620) + 12, y0: top, y1: H * 0.84 }; + g.key = portrait ? null : { x0: 0, x1: W * 0.05 + 440, y0: H - 160, y1: H }; + this.layoutV++; + for (const o of this.objs) this.settle(o); + } + seaSpans(y) { + const spans = [], W = this.W; + let x0 = null; + for (let x = -8; x <= W + 8; x += 4) { + const sea = !this.inHill(x, y); + if (sea && x0 == null) x0 = x; + if ((!sea || x > W + 4) && x0 != null) { spans.push([x0, x - 4]); x0 = null; } + } + return spans; + } + ridgeY(x) { + const r = this.g.ridge; + if (x < r[0][0] || x > r[r.length - 1][0]) return null; + let lo = 0, hi = r.length - 1; + while (hi - lo > 1) { const m = (lo + hi) >> 1; if (r[m][0] <= x) lo = m; else hi = m; } + const [x0, y0] = r[lo], [x1, y1] = r[hi]; + return lerp(y0, y1, (x - x0) / ((x1 - x0) || 1)); + } + shoreY(x) { + const s = this.g.shore; + if (!s) return this.H + 40; + if (x < s[0][0]) return s[0][1]; + for (let i = 1; i < s.length; i++) if (s[i][0] >= x) return lerp(s[i - 1][1], s[i][1], (x - s[i - 1][0]) / ((s[i][0] - s[i - 1][0]) || 1)); + return s[s.length - 1][1]; + } + inHill(x, y) { return inPoly(this.g.hillPoly, x, y); } + isSea(x, y) { return y > this.g.horizon && !this.inHill(x, y); } + isLand(x, y) { const ry = this.ridgeY(x); return ry != null && y >= ry - 1 && y <= this.shoreY(x); } + depthScale(y) { const g = this.g; return clamp(0.8 + (y - g.horizon) / (g.H - g.horizon) * 1.0, 0.7, 1.9) * (g.portrait ? 1.15 : 1.1); } + + /* ----- where things go ----- */ + // Is a point of the scene visible, or covered by page content? A guess from the DOM. + seen(x, y) { + const els = document.elementsFromPoint?.(x + this.ox, y + this.oy); + if (!els) return true; + for (const el of els) { + if (el === this.canvas || el === this.host) return true; + if (el === document.body || el === document.documentElement) return true; + if (/^(IMG|SVG|VIDEO|BUTTON|INPUT|TEXTAREA|SELECT|CANVAS|svg)$/.test(el.tagName)) return false; + for (const pseudo of [null, '::before', '::after']) { + const cs = getComputedStyle(el, pseudo); + if (pseudo && cs.content === 'none') continue; + const bg = cs.backgroundColor.match(/[\d.]+/g); + if ((bg && bg.length === 4 ? +bg[3] > 0.15 : bg && bg.length === 3) || (cs.backgroundImage && cs.backgroundImage !== 'none')) return false; + } + } + return true; + } + spot(kind) { + const g = this.g, u = g.u, R = Math.random, T = g.lh, W = g.W, H = g.H; + const free = (x, y) => !(g.key && x > g.key.x0 && x < g.key.x1 && y > g.key.y0) && Math.abs(x - T.x) > T.wB + 26 * u; + const tries = []; + for (let i = 0; i < 40; i++) { + let x, y; + if (kind === 'gull') { + x = g.portrait ? lerp(0.08, 0.6, R()) * W : lerp(0.48, 0.68, R()) * W; + y = g.portrait ? g.top + lerp(0.04, 0.17, R()) * H : lerp(g.top + 40 * u, g.horizon - 70 * u, R()); + } else if (kind === 'duck' || kind === 'monster') { + y = lerp(g.horizon + 18 * u, H - 24 * u, R() ** (g.portrait ? 1 : 0.8)); + const sp = this.seaSpans(y), span = sp[(R() * sp.length) | 0]; + if (!span) continue; + x = lerp(Math.max(span[0], 0) + 22 + 20 * u, Math.min(span[1], W) - 10 - (kind === 'monster' ? 60 : 24) * u, R()); + if (!g.portrait && i < 30) x = lerp(Math.max(g.note.x1 + 30 * u, span[0] + 30 * u), span[1] - (kind === 'monster' ? 60 : 24) * u, R()); + } else { + x = g.portrait ? lerp(0.36, 0.95, R()) * W : lerp(0.56, 0.97, R()) * W; + const ry = this.ridgeY(x); + if (ry == null) continue; + y = ry + lerp(8 * u, Math.min(this.shoreY(x) - ry - 20 * u, H * 0.32), R()); + if (y > H - 20 * u) continue; + } + if (!free(x, y)) continue; + if (i < 32 && this.objs.some(o => o.dying == null && Math.abs(o.x - x) < 56 * u && Math.abs((o.ty ?? o.y) - y) < 30 * u)) continue; + tries.push({ x, y }); + if (this.seen(x, y) && this.seen(x, y - 20 * u)) return { x, y }; + } + // no land in sight (a phone, with the notes over the hill): it lands on a cloud + if (kind === 'sheep' || kind === 'cow') { + const riders = cl => this.objs.filter(o => o.ride === cl && o.dying == null).length + R() * 0.5; + for (const cl of [...g.clouds, g.sheepCloud].sort((a, b) => riders(a) - riders(b))) { + const p = this.cloudXY(cl); + if (p.x > 40 * u && p.x < W - 40 * u && Math.hypot(p.x - g.lamp.x, p.y - g.lamp.y) > T.er * 5 && this.seen(p.x, p.y - cl.w * 0.2)) return { x: p.x, y: p.y, cloud: cl }; + } + } + // nothing visible: drop it where it belongs anyway, falling from the sky + const p = tries[0] || { x: W * 0.8, y: H * 0.8 }; + return { ...p, fromSky: true }; + } + + spawn(x, y, kind, settled, fromSky) { + const g = this.g, u = g.u, R = Math.random; + const land = this.isLand(x, y), sea = this.isSea(x, y); + const o = { x, y, vx: 0, vy: 0, t0: this.t, flip: R() < 0.5, seed: (R() * 1e4) | 0, lit: 0, kind }; + if (!o.kind) { + if (land) o.kind = R() < 0.6 ? 'sheep' : 'cow'; + else if (sea) o.kind = R() < 0.55 ? 'duck' : 'monster'; + else if (y < lerp(g.top, g.horizon, 0.55) && R() < 0.45) o.kind = 'gull'; + else o.kind = this.ridgeY(x) != null && y < this.shoreY(x) && x > (g.portrait ? 0 : this.hillLeftX(g.horizon) - 20 * u) ? (R() < 0.6 ? 'sheep' : 'cow') : (R() < 0.6 ? 'duck' : 'monster'); + } + const still = settled || this.reduced; + if (o.kind === 'gull') { + o.state = 'fly'; o.vx = (o.flip ? -1 : 1) * (60 + R() * 60) * u; o.vy = -(6 + R() * 18) * u; o.sc = 1.15; + if (this.reduced) { o.vx = 0; o.vy = 0; o.state = 'hover'; } + } else if (o.kind === 'sheep' || o.kind === 'cow') { + let ry = this.ridgeY(x); + if (ry == null) { o.x = x = clamp(x, g.ridge[0][0] + 20 * u, g.W - 14 * u); ry = this.ridgeY(x); } + const room = this.shoreY(x) - ry - 14 * u; + o.depth = land ? clamp(y - ry, 3 * u, Math.min(room, g.H - ry - 24 * u)) : clamp((6 + R() * 40) * u, 3 * u, room); + o.ty = ry + o.depth; + if (still || land && !fromSky) { o.state = 'land'; o.y = o.ty; o.land = still ? -9 : this.t; } + else { o.state = 'fall'; if (fromSky) o.y = Math.min(o.ty - 120 * u, g.top - 20); } + o.sc = this.depthScale(o.ty); + } else { + o.ty = sea ? y : g.horizon + (g.H - g.horizon) * (0.16 + R() * 0.55); + if (!sea) { const sp = this.seaSpans(o.ty); const s = sp.find(q => q[0] <= x && x <= q[1]) || sp[0]; if (s) o.x = clamp(x, s[0] + 24 * u, s[1] - 30 * u); } + if (o.kind === 'monster' || still || sea && !fromSky) { o.y = o.ty; o.state = o.kind === 'monster' ? 'swim' : 'float'; if (!this.reduced) this.ripples.push({ x: o.x, y: o.ty, t0: this.t, a: 0.8 }); } + else { o.state = 'fall'; if (fromSky) o.y = Math.min(o.ty - 120 * u, g.top - 20); } + o.sc = this.depthScale(o.ty); + } + this.objs.push(o); + const live = this.objs.filter(q => q.dying == null); + if (live.length > MAX_OBJS) live[0].dying = this.t; + return o; + } + ride(kind, cl) { + const g = this.g, R = Math.random, p = this.cloudXY(cl); + const o = { kind, ride: cl, dx: (R() - 0.5) * cl.w * 0.3, x: p.x, y: 0, vx: 0, vy: 0, t0: this.t, flip: R() < 0.5, seed: (R() * 1e4) | 0, lit: 0, sc: 0.85 * (g.portrait ? 1.1 : 1) }; + o.x = p.x + o.dx; o.ty = this.rideY(o); + if (this.reduced) { o.state = 'ride'; o.y = o.ty; o.land = -9; } else { o.state = 'fall'; o.y = g.top - 30; } + this.objs.push(o); + const live = this.objs.filter(q => q.dying == null); + if (live.length > MAX_OBJS) live[0].dying = this.t; + return o; + } + rideY(o) { const p = this.cloudXY(o.ride); return p.y - o.ride.w * 0.17; } + // reduced motion switched on: whatever is moving stops where it is + still() { + for (const o of this.objs) { + if (o.state === 'fly') { o.state = 'hover'; o.vx = o.vy = 0; } + else if (o.state === 'fall') { o.y = o.ty; o.vy = 0; o.state = o.ride ? 'ride' : o.kind === 'duck' ? 'float' : 'land'; } + o.land = -9; + if (o.dying != null) o.dead = true; + } + this.objs = this.objs.filter(o => !o.dead); + this.ripples = []; + } + settle(o) { + // after a resize, put land animals back on the ground and sea animals back in the water + if (o.ride) return; + if (o.kind === 'sheep' || o.kind === 'cow') { const ry = this.ridgeY(clamp(o.x, this.g.ridge[0][0], this.W - 10)); if (ry != null) { o.ty = ry + Math.min(o.depth, this.shoreY(o.x) - ry - 14); if (o.state !== 'fall') o.y = o.ty; } } + else if (o.kind !== 'gull' && !this.isSea(o.x, o.ty)) o.dying ??= this.t; + } + hillLeftX(y) { + const r = this.g.ridge; + for (let i = 1; i < r.length; i++) if (r[i][1] <= y) { const [x0, y0] = r[i - 1], [x1, y1] = r[i]; return lerp(x0, x1, (y - y0) / ((y1 - y0) || 1)); } + return this.W; + } + + /* ----- time ----- */ + wake() { + this.awake = Math.max(this.awake, performance.now() + (this.reduced ? 300 : IDLE_AFTER)); + this.kick = 3; // at least a few frames, however late the first one comes + if (this.timer) { clearTimeout(this.timer); this.timer = 0; } + this.schedule(); + } + sleep() { cancelAnimationFrame(this.raf); clearTimeout(this.timer); this.raf = this.timer = 0; } + schedule() { + if (this.raf || this.timer || this.dead || document.hidden) return; + const active = this.kick > 0 || performance.now() < this.awake || this.busy(); + if (active) this.raf = requestAnimationFrame(this.frame); + else if (!this.reduced) this.timer = setTimeout(() => { this.timer = 0; this.raf = requestAnimationFrame(this.frame); }, 1000 / IDLE_FPS - 6); + // reduced motion and nothing moving: the scene sleeps until the next call or input + } + busy() { + if (this.d !== (this.dEff ?? this.dTarget) || (this.state === 'install') !== (this.pres.install > 0.999)) return true; + for (const k of STATES) { const p = this.pres[k]; if (p > 0.001 && p < 0.999) return true; } + if (this.ripples.length) return true; + for (const o of this.objs) if (o.state === 'fall' || (o.state === 'fly' && !this.reduced) || o.dying != null || (o.land > 0 && this.t - o.land < 0.4)) return true; + return this.beam.steer > 0.01 && this.beam.steer < 0.99 && !this.reduced; + } + frame(now) { + this.raf = 0; + if (this.dead || document.hidden) return; + this.kick--; + const dt = this.last == null ? 1 / 60 : Math.min(0.1, Math.max(0, (now - this.last) / 1000)); + this.last = now; + this.update(dt); + const t0 = performance.now(); + this.render(); + this.times.push(performance.now() - t0); + if (this.times.length > 240) this.times.shift(); + this.onFrame?.(this); + this.schedule(); + } + update(dt) { + const g = this.g, u = g.u, rm = this.reduced; + this.t += dt; + for (const k of STATES) { + const want = k === this.state ? 1 : 0; + this.pres[k] = rm ? want : clamp(this.pres[k] + Math.sign(want - this.pres[k]) * dt * 1.8); + } + // the install state brings the night with it + const dTarget = this.dEff = Math.max(this.dTarget, this.pres.install > 0 ? lerp(this.dTarget, 1, this.pres.install) : 0); + this.d = rm ? dTarget : this.d + (dTarget - this.d) * Math.min(1, dt * 2.4); + if (Math.abs(dTarget - this.d) < 0.0005) this.d = dTarget; + this.boil = rm ? 0 : Math.floor(this.t * BOIL_FPS) % 3; + this.vel.x *= Math.exp(-dt * 2.5); this.vel.y *= Math.exp(-dt * 2.5); + this.windAmb = rm ? 0.2 : 0.28 + 0.22 * noise(this.t * 0.13 + 3); + this.wind = lerp(this.wind, clamp(this.vel.x / 1300, -1.5, 1.5), Math.min(1, dt * 1.8)); + this.windTotal = this.windAmb + this.wind; + if (!rm) this.drift += dt * (1 + this.windTotal * 2.4); + // clouds lean away from the pointer + for (const cl of [...g.clouds, g.sheepCloud]) { + let tx = 0, ty = 0; + if (this.ptr.on && !rm) { + const p = this.cloudXY(cl, true), dx = p.x - this.ptr.x, dy = p.y - this.ptr.y, r = 170 * u, dd = Math.hypot(dx, dy); + if (dd < r) { const k = (1 - dd / r) ** 2 * 48 * u / (dd || 1); tx = dx * k; ty = dy * k; } + } + const k = rm ? 1 : Math.min(1, dt * 3); + cl.ox = lerp(cl.ox, tx, k); cl.oy = lerp(cl.oy, ty, k); + } + // the cloud with legs wanders to and fro, against the wind if it likes + const sc = g.sheepCloud; + if (!rm) { + sc.x += sc.dir * 7 * u * dt; + if (sc.x > sc.x1) sc.dir = -1; else if (sc.x < sc.x0) sc.dir = 1; + } + // the eye blinks now and then + if (!rm && this.t > this.blink.next) { this.blink.at = this.t; this.blink.next = this.t + 3 + Math.random() * 5; } + this.updateBeam(dt); + this.ripples = this.ripples.filter(r => this.t - r.t0 < 2.2); + this.updateObjs(dt); + } + updateBeam(dt) { + const b = this.beam, L = this.g.lamp, now = performance.now() / 1000, rm = this.reduced; + const quiet = this.ptr.kind === 'touch' ? 3.2 : 5; + const steering = this.ptr.on && now - this.ptr.last < quiet; + b.steer = rm ? (steering || b.steer > 0.5 ? 1 : 0) : lerp(b.steer, steering ? 1 : 0, Math.min(1, dt * (steering ? 4 : 0.7))); + if (!rm) b.phi += dt * TAU / 15; + const c = Math.cos(b.phi), sn = Math.sin(b.phi); + if (this.ptr.on && steering) { + const want = this.clampAim(Math.atan2(this.ptr.y - L.y, this.ptr.x - L.x)); + b.sa = rm ? want : b.sa + angDiff(want, b.sa) * Math.min(1, dt * 5); + } + const vx = lerp(c, Math.cos(b.sa), b.steer), vy = lerp(0.03 * Math.abs(c), Math.sin(b.sa), b.steer); + b.a = Math.atan2(vy, vx); + b.len = clamp(Math.hypot(vx, vy)); + b.flash = Math.pow(Math.max(0, sn), 14) * (1 - b.steer); + } + updateObjs(dt) { + const g = this.g, u = g.u, G = 1500 * u, rm = this.reduced; + for (const o of this.objs) { + if (o.ride) { + // riding a cloud: follow it, landing first if still falling + const p = this.cloudXY(o.ride); + o.x = p.x + o.dx; o.ty = this.rideY(o); + if (o.state === 'fall') { o.vy += G * dt; o.y += o.vy * dt; if (o.y >= o.ty) { o.y = o.ty; o.state = 'ride'; o.land = this.t; } } + else o.y = o.ty; + o.lit = lerp(o.lit, this.inBeam(o.x + this.ox, o.y - 12 * u * o.sc + this.oy), rm ? 1 : Math.min(1, dt * 8)); + if (o.dying != null && (rm || this.t - o.dying > 0.7)) o.dead = true; + continue; + } + if (o.state === 'fall') { + o.vy += G * dt; o.y += o.vy * dt; + if (o.y >= o.ty) { + o.y = o.ty; o.vy = 0; o.land = this.t; + o.state = o.kind === 'duck' ? 'float' : 'land'; + if (o.kind === 'duck') this.ripples.push({ x: o.x, y: o.ty, t0: this.t, a: 1 }); + } + } else if (rm) { + // reduced motion: everything holds still where it appeared + } else if (o.state === 'land') { + const dx = o.x - this.ptr.x, dy = o.y - this.ptr.y, near = this.ptr.on && o.kind === 'sheep' && dx * dx + dy * dy < (110 * u) ** 2; + const stroll = Math.sin(this.t * 0.35 + o.seed) > 0.8 ? (o.flip ? -1 : 1) * (o.kind === 'cow' ? 7 : 12) * u : 0; + o.vx = lerp(o.vx, near ? Math.sign(dx || 1) * 70 * u : stroll, Math.min(1, dt * 3)); + if (Math.abs(o.vx) > 3 * u) o.flip = o.vx < 0; + const nx = o.x + o.vx * dt, ry = this.ridgeY(nx); + if (ry == null || ry + o.depth > Math.min(g.H - 8 * u, this.shoreY(nx) - 12 * u) || nx > g.W - 12 * u || Math.abs(nx - g.lh.x) < g.lh.wB * 0.6) o.vx = 0; + else { o.x = nx; o.y = ry + o.depth; } + } else if (o.state === 'float') { + o.x += this.windTotal * 6 * u * dt; + o.y = o.ty + Math.sin(this.t * 2.2 + o.seed) * 1.8 * u; + if (!this.isSea(o.x + 16 * u, o.ty - 1) || o.x > g.W - 10) o.x -= this.windTotal * 6 * u * dt + 0.5; + } else if (o.state === 'swim') { + o.x += (o.flip ? -1 : 1) * 16 * u * o.sc * dt; + if (!this.isSea(o.x + (o.flip ? -40 : 64) * u * o.sc, o.ty - 1) || o.x < 50 * u || o.x > g.W - 60 * u) o.flip = !o.flip; + } else if (o.state === 'fly') { + if (o.calm > 0) { o.calm -= dt; o.x += o.vx * 0.15 * dt; o.y += Math.sin(this.t * 2 + o.seed) * 10 * u * dt; } + else { o.x += o.vx * dt; o.y += (o.vy + Math.sin(this.t * 2 + o.seed) * 14 * u) * dt; o.vy *= 1 - dt * 0.4; } + if (o.x < -100 || o.x > g.W + 100 || o.y < -100) o.dead = true; + } + o.lit = rm ? this.inBeam(o.x + this.ox, o.y - 12 * u * o.sc + this.oy) : lerp(o.lit, this.inBeam(o.x + this.ox, o.y - 12 * u * o.sc + this.oy), Math.min(1, dt * 8)); + if (o.dying != null && (rm || this.t - o.dying > 0.7)) o.dead = true; + } + this.objs = this.objs.filter(o => !o.dead); + } + cloudXY(cl, raw) { + const g = this.g; + if (cl === g.sheepCloud) return { x: cl.x + (raw ? 0 : cl.ox), y: cl.y + (raw ? 0 : cl.oy) }; + const span = g.W + 360 * g.u; + let x = (cl.x0 + this.drift * cl.sp * g.u) % span; + if (x < 0) x += span; + return { x: x - 180 * g.u + (raw ? 0 : cl.ox), y: cl.y + (raw ? 0 : cl.oy) }; + } + sunPos() { const g = this.g, k = smooth(0, 0.62, this.d); return { x: g.sun.x, y: lerp(g.sun.y0, g.horizon + 30 * g.u, k), a: 1 - smooth(0.56, 0.66, this.d) }; } + moonPos() { const g = this.g, k = smooth(0.58, 1, this.d); return { x: g.moon.x, y: lerp(g.horizon + 24 * g.u, g.moon.y1, k), a: smooth(0.56, 0.7, this.d) }; } + waveY(x, row) { + const g = this.g, t = this.t, u = g.u, ph = this.reduced ? 0 : t; + let y = row.y + Math.sin(x * 0.011 / (0.35 + row.f) + ph * 1.15 + row.seed) * (0.5 + row.f * 2.4) * Math.max(u, 0.66) * (0.8 + 0.5 * Math.abs(this.windTotal)); + if (this.ptr.on && !this.reduced) { + const dx = x - this.ptr.x, dy = (row.y - this.ptr.y) * 2.2, d2 = dx * dx + dy * dy, r = 150 * u; + if (d2 < r * r) y += Math.sin(Math.sqrt(d2) * 0.085 / u - t * 6) * 5 * u * Math.exp(-d2 / (2 * 3600 * u * u)); + } + for (const r of this.ripples) { + const age = t - r.t0, rad = age * 150 * u, dx = x - r.x, dy = (row.y - r.y) * 2.4; + const dist = Math.sqrt(dx * dx + dy * dy), band = Math.exp(-((dist - rad) ** 2) / (2 * (16 * u) ** 2)); + if (band > 0.01) y += Math.sin(dist * 0.1 / u - age * 9) * 6 * u * r.a * band * (1 - age / 2.8); + } + return y; + } + squash(o) { if (!(o.land > 0)) return 0; const a = this.t - o.land; return a < 0.3 ? Math.sin(a / 0.3 * Math.PI) * 0.28 * (1 - a / 0.3) : 0; } + + /* ----- drawing ----- */ + render() { + const c = this.ctx, g = this.g, k = this.dpr; + const step = Math.round(this.d * STEPS); + this.P = palAt(PAL, step / STEPS); + this.pstep = step; + BOIL = this.boil; PW = g.pw; INK = rgba(this.P.ink); REGX = 2.6 * g.pw; REGY = 1.9 * g.pw; + c.setTransform(k, 0, 0, k, 0, 0); + c.globalAlpha = 1; c.globalCompositeOperation = 'source-over'; + drawScene(this, c); + } + /* A cached layer: one per boil state, redrawn when the hour, the theme or the layout changes */ + layer(name, rect, draw) { + const key = `${this.layoutV}|${this.pstep}|${this.theme}|${this.fontsV}`; + const L = this.layers[name][this.boil] ||= { cv: document.createElement('canvas'), key: '' }; + const k = this.dpr, x0 = Math.floor(rect.x * k), y0 = Math.floor(rect.y * k), w = Math.ceil(rect.w * k), h = Math.ceil(rect.h * k); + if (L.key !== key) { + if (L.cv.width !== w || L.cv.height !== h) { L.cv.width = w; L.cv.height = h; } + const c = L.cv.getContext('2d'); + c.setTransform(1, 0, 0, 1, 0, 0); c.clearRect(0, 0, w, h); + c.setTransform(k, 0, 0, k, -x0, -y0); + draw(c); + L.key = key; + this.rebuilds++; + } + const c = this.ctx; + c.save(); c.setTransform(1, 0, 0, 1, 0, 0); c.drawImage(L.cv, x0, y0); c.restore(); + } + /* A small cached drawing (a cloud, the sun), drawn around its own origin */ + sprite(name, w, h, x, y, draw) { + const key = `${name}|${this.layoutV}|${this.boil}|${this.pstep}|${this.fontsV}`, k = this.dpr; + let s = this.sprites.get(key); + if (!s) { + const cv = document.createElement('canvas'); + cv.width = Math.ceil(w * k); cv.height = Math.ceil(h * k); + const c = cv.getContext('2d'); + c.setTransform(k, 0, 0, k, w * k / 2, h * k / 2); + draw(c); + s = { cv, w, h }; + this.sprites.set(key, s); + if (this.sprites.size > 60) this.sprites.delete(this.sprites.keys().next().value); + } + const c = this.ctx; + c.save(); c.setTransform(1, 0, 0, 1, 0, 0); + c.drawImage(s.cv, Math.round((x - w / 2) * k), Math.round((y - h / 2) * k)); + c.restore(); + } +} + +/* ===================================================================== + THE DRAWING + Back to front: sky and sea, the cached band, stars, the scallops, the + beam, then the sun, moon, clouds and far island in front of it, the + cached hill with its cottage and tower, then everything that moves. + ===================================================================== */ +function drawScene(s, c) { + const g = s.g, { W, H, u } = g, P = s.P; + c.lineCap = 'round'; c.lineJoin = 'round'; + c.fillStyle = rgba(P.sky); c.fillRect(0, 0, W, g.horizon + 2); + c.fillStyle = rgba(P.sea); c.fillRect(0, g.horizon, W, H - g.horizon); + const bandTop = g.horizon - (g.horizon - g.top) * (g.portrait ? 0.42 : 0.36) - 20 * u; + s.layer('back', { x: 0, y: bandTop, w: W, h: g.horizon - bandTop + 6 * u }, x => drawBand(s, x, bandTop)); + drawStars(s, c); + drawWaves(s, c); + drawHalo(s, c); + drawBeam(s, c); + drawSun(s, c); + drawMoon(s, c); + for (let i = 0; i < g.clouds.length; i++) drawCloud(s, c, g.clouds[i], 'cloud' + i); + drawCloud(s, c, g.sheepCloud, 'sheepcloud', true); + drawIsle(s, c); + const T = g.lh, x0 = g.portrait ? g.ridge[0][0] - 8 : Math.max(0, g.ridge[0][0] - 10); + const y0 = Math.min(T.top - T.gal * 1.2, g.cot.base - g.cot.h * 4.2) - 10 * u; + const y1 = g.portrait ? Math.max(...g.shore.map(p => p[1])) + 14 * u : H; + s.layer('front', { x: x0, y: y0, w: W - x0, h: y1 - y0 }, x => { drawHill(s, x); drawCottage(s, x); drawTower(s, x); }); + drawHillLight(s, c); + drawTufts(s, c); + drawSmoke(s, c); + drawEye(s, c); + drawHat(s, c); + drawStates(s, c); + const objs = s.objs.slice().sort((a, b) => a.y - b.y); + for (const o of objs) drawObj(s, c, o); + if (s.pres.hero > 0.01) drawLabels(s, c); +} + +/* the lower sky, a lighter band with a soft wave at its top, and the horizon */ +function drawBand(s, c, bandTop) { + const g = s.g, { W, u } = g, P = s.P; + const by = bandTop + 20 * u, band = []; + for (let x = -30; x <= W + 30; x += 16 * u) band.push([x, by + Math.sin(x * 0.006 + 1) * 11 * u + Math.sin(x * 0.019 + 2) * 3 * u]); + band.push([W + 30, g.horizon + 4], [-30, g.horizon + 4]); + poly(c, band); c.fillStyle = rgba(P.sky2); c.fill(); + c.beginPath(); ribbon(c, dens([[-10, g.horizon], [W + 10, g.horizon]], 8), DET, 2, { amp: 0.6, tin: 1, tout: 1, w0: 1, w1: 1 }); c.fillStyle = INK; c.fill(); +} + +function drawStars(s, c) { + const g = s.g, u = g.u, st = smooth(0.55, 0.9, s.d); + if (st <= 0) return; + c.beginPath(); + for (const p of g.stars) { + if (p.s < 0.75) continue; + const r = p.s * 2.7 * u * (s.reduced ? 1 : 0.78 + 0.22 * Math.sin(s.t * 2 + p.p)); + for (let k = 0; k < 3; k++) { + const a = k / 3 * Math.PI + p.p + BOIL * 0.07; + ribbon(c, [[p.x - Math.cos(a) * r, p.y - Math.sin(a) * r], [p.x + Math.cos(a) * r, p.y + Math.sin(a) * r]], 1.7, k, { amp: 0.15, tin: 1, tout: 1, w0: 0.7, w1: 0.6 }); + } + } + c.fillStyle = rgba(s.P.beam, st); c.fill(); + // and a few small dots between them + c.fillStyle = rgba(s.P.beam, st * 0.8); + for (const p of g.stars) if (p.s < 0.75) { c.beginPath(); c.arc(p.x, p.y, 0.9 * u + 0.4, 0, TAU); c.fill(); } +} + +/* a fried-egg sun that sets into the sea, behind the far island */ +function drawSun(s, c) { + const g = s.g, u = g.u, sun = s.sunPos(); + if (sun.a <= 0) return; + const r = 38 * u, sz = r * 3; + c.save(); c.beginPath(); c.rect(0, 0, g.W, g.horizon); c.clip(); + c.globalAlpha = sun.a; + s.sprite('sun', sz, sz, sun.x, sun.y, x => { + const white = []; + for (let i = 0; i < 48; i++) { const a = i / 48 * TAU; white.push([Math.cos(a) * r * (1 + 0.12 * Math.sin(a * 3 + 1) + 0.06 * Math.sin(a * 5 + 2)), Math.sin(a) * r * 0.86 * (1 + 0.09 * Math.sin(a * 4))]); } + shape(x, dens(white, 4, true), mul(hex('#FFFFFF'), s.P.paper), DET, 5); + const yolk = ellipse(3 * u, -3 * u, r * 0.42, r * 0.4, 30); + shape(x, yolk, C.yolk, DET * 0.9, 7); + // the shine on the yolk + x.beginPath(); ribbon(x, ellipse(3 * u, -3 * u, r * 0.27, r * 0.25, 10, Math.PI * 1.1, Math.PI * 1.55), TEX * 1.3, 3, { tin: 2, tout: 2 }); x.fillStyle = '#FFFFFF'; x.fill(); + dot(x, 3 * u - r * 0.12, -3 * u - r * 0.27, 1.3 * u + 0.4, '#FFFFFF'); + }); + c.restore(); +} + +/* a crescent moon, asleep, with a z */ +function drawMoon(s, c) { + const g = s.g, u = g.u, m = s.moonPos(); + if (m.a <= 0) return; + const r = 25 * u, sz = r * 3.4; + c.save(); c.beginPath(); c.rect(0, 0, g.W, g.horizon); c.clip(); + c.globalAlpha = m.a; + s.sprite('moon', sz, sz, m.x, m.y, x => { + const cres = []; + for (let i = 0; i <= 26; i++) { const a = -Math.PI / 2 + i / 26 * Math.PI; cres.push([Math.cos(a) * r, Math.sin(a) * r]); } + for (let i = 25; i >= 1; i--) { const a = -Math.PI / 2 + i / 26 * Math.PI; cres.push([Math.cos(a) * r * 0.38 + r * 0.02, Math.sin(a) * r * 0.97]); } + shape(x, dens(cres, 3, true), C.moon, DET, 11); + // a closed eye with lashes, and a small smile + x.beginPath(); + ribbon(x, ellipse(r * 0.62, -r * 0.2, r * 0.17, r * 0.11, 9, 0.15 * Math.PI, 0.85 * Math.PI), TEX * 1.2, 2, { tin: 2, tout: 2 }); + for (const k of [0.3, 0.5, 0.7]) { const a = k * Math.PI, ex = r * 0.62 + Math.cos(a) * r * 0.17, ey = -r * 0.2 + Math.sin(a) * r * 0.11; ribbon(x, [[ex, ey], [ex + Math.cos(a) * 3 * u, ey + Math.sin(a) * 3 * u]], TEX, k * 9, { tin: 1, tout: 1 }); } + ribbon(x, ellipse(r * 0.52, r * 0.3, r * 0.15, r * 0.1, 9, 0.2 * Math.PI, 0.8 * Math.PI), TEX * 1.1, 4, { tin: 2, tout: 2 }); + x.fillStyle = INK; x.fill(); + }); + c.restore(); + { + const zz = s.reduced ? 0.4 : (s.t * 0.4) % 1, col = rgba(mix(s.P.paper, s.P.beam, 0.3), m.a * (1 - zz) * 0.95); + label(c, 'z', m.x + r * 1.1 + zz * 10 * u, m.y - r * 0.7 - zz * 16 * u, (11 + zz * 5) * u + 3, col, 'left', -0.2); + } +} + +function drawCloud(s, c, cl, name, legs) { + const g = s.g, u = g.u, P = s.P, p = s.cloudXY(cl), w = cl.w; + if (p.x < -w || p.x > g.W + w) return; + if (legs) { + // four legs, walking when it walks + const walk = s.reduced ? 0 : s.t * 5; + c.beginPath(); + const feet = []; + [-0.24, -0.11, 0.1, 0.23].forEach((lx, i) => { + const x = p.x + lx * w * (cl.dir < 0 ? -1 : 1), sw = Math.sin(walk + i * 1.7 + (i % 2) * Math.PI) * 2.6 * u; + ribbon(c, [[x, p.y - 4 * u], [x + sw * 0.5, p.y + 5 * u], [x + sw, p.y + 12 * u]], DET * 1.25, i * 3, { tin: 2, tout: 2, w0: 0.9, w1: 0.9 }); + feet.push([x + sw, p.y + 13 * u]); + }); + c.fillStyle = INK; c.fill(); + for (const [fx, fy] of feet) { c.beginPath(); c.ellipse(fx + 0.6 * u, fy, 2.6 * u, 1.9 * u, 0, 0, TAU); c.fill(); } + } + const sz = w * 1.3; + s.sprite(name + (legs && cl.dir < 0 ? 'L' : ''), sz, w * 0.75, p.x, p.y - w * 0.12, x => { + const top = cloudPts(0, w * 0.12, w, cl.seed); + const pts = dens(top, 5, true); + print(x, pts, P.cloud); + x.beginPath(); + loop(x, pts, DET, cl.seed % 13); + // a curl or two of puff inside, drawn lightly + const R = rng(cl.seed + 5); + for (let i = 0; i < 2; i++) { + const cx = (R() - 0.5) * w * 0.5, cy = w * 0.02 - R() * w * 0.06, rr = w * (0.07 + R() * 0.05); + ribbon(x, ellipse(cx, cy, rr, rr * 0.8, 10, Math.PI * 1.05, Math.PI * 1.75), TEX, i + 3, { tin: 2, tout: 3 }); + } + x.fillStyle = INK; x.fill(); + if (legs) { + // a tail, and the hint of an ear: it might be a sheep + const f = cl.dir < 0 ? -1 : 1; + x.beginPath(); + ribbon(x, [[-f * w * 0.5, w * 0.02], [-f * w * 0.57, -w * 0.03], [-f * w * 0.6, w * 0.03]], TEX, 4, { tin: 2, tout: 2 }); + x.fillStyle = INK; x.fill(); + } + }); +} + +/* the competition: a much smaller lighthouse on a far island */ +function drawIsle(s, c) { + const g = s.g, u = g.u, P = s.P, I = g.isle, hz = g.horizon; + const w = I.x1 - I.x0, h = I.h, cx = (I.x0 + I.x1) / 2, lx = I.x0 + w * 0.4, ltop = hz - h * 0.86 - 24 * u; + s.sprite('isle', w + 30 * u, (h + 44 * u) * 2, cx, hz, x => { + const hump = curve([[-w / 2, 0.5], [-w * 0.32, -h * 0.5], [-w * 0.08, -h * 0.86], [w * 0.16, -h * 0.8], [w * 0.38, -h * 0.4], [w / 2, 0.5]], 4); + print(x, [...hump, [w / 2, 3], [-w / 2, 3]], P.far); + x.beginPath(); ribbon(x, hump, DET * 0.9, 21, { tin: 3, tout: 3 }); x.fillStyle = INK; x.fill(); + // the little tower: white, two stripes, a lamp + const tx = lx - cx, by = -h * 0.84, th = 24 * u, bw = 5.5 * u, tw = 3.6 * u; + const body = [[tx - bw / 2, by], [tx - tw / 2, by - th], [tx + tw / 2, by - th], [tx + bw / 2, by]]; + print(x, body, mul(hex('#FFFFFF'), P.paper)); + for (const k of [0.3, 0.68]) { + const y0 = by - th * k, y1 = by - th * (k + 0.16), w0 = lerp(bw, tw, k) / 2, w1 = lerp(bw, tw, k + 0.16) / 2; + print(x, [[tx - w0, y0], [tx - w1, y1], [tx + w1, y1], [tx + w0, y0]], hex('#FF4A36')); + } + x.beginPath(); edges(x, body, TEX * 1.1, 4, false, { ov: 1 }); x.fillStyle = INK; x.fill(); + }); + // its lamp, which blinks at night in a hurry + const night = smooth(0.4, 0.75, s.d), on = s.reduced ? 1 : (Math.sin(s.t * 3.1) > 0.2 ? 1 : 0.35); + const ly = ltop - 3 * u; + if (night > 0.05) { c.beginPath(); c.arc(lx, ly, (5 + 5 * on) * u, 0, TAU); c.fillStyle = rgba(P.beam, 0.35 * night * on); c.fill(); } + dot(c, lx, ly, 2.9 * u, rgba(mix(mul(hex('#FFFFFF'), P.paper), P.beam, Math.max(0.5, night * on)))); + c.beginPath(); ribbon(c, ellipse(lx, ly, 3.2 * u, 3.2 * u, 12), TEX, 5, { tin: 1, tout: 1, w0: 1, w1: 1 }); c.fillStyle = INK; c.fill(); +} + +/* the sea: rows of scallops, thinning toward the horizon; ripples where things land */ +function waveScallops(s, c, lit) { + const g = s.g, u = g.u; + for (let i = 0; i < g.rows.length; i += 2) { + const row = g.rows[i], step = (16 + 34 * row.f) * Math.max(u, 0.66), w = (0.75 + 1.3 * row.f) * g.pw * (lit ? 1.15 : 1); + if (row.f < 0.08 && !lit) continue; + c.beginPath(); + for (const [a, b] of row.spans) { + for (let x = a + hash(i) * step; x < b - step; x += step * (1.3 + hash(x * 0.1 + i) * 0.8)) { + if (hash(x * 0.37 + i * 3.1) < 0.18 - row.f * 0.2) continue; + const y = s.waveY(x, row), y2 = s.waveY(x + step, row); + c.moveTo(x, y); c.quadraticCurveTo(x + step / 2, (y + y2) / 2 - step * 0.34, x + step, y2); + } + } + c.lineWidth = w; c.stroke(); + } +} +function drawWaves(s, c) { + const g = s.g, u = g.u, P = s.P; + c.strokeStyle = rgba(P.sea2); waveScallops(s, c, false); + for (const r of s.ripples) { + const age = s.t - r.t0, rx = 8 * u + age * 70 * u; + c.beginPath(); ribbon(c, ellipse(r.x, r.y, rx, rx * 0.3, 40), DET * 0.8, 4, { tin: 1, tout: 1, w0: 1, w1: 1 }); + c.fillStyle = rgba(P.foam, clamp(1 - age / 2.2) * r.a); c.fill(); + } +} + +/* the light: a flat yellow fan with felt-pen edges, brighter at night */ +function beamPoly(s) { + const L = s.g.lamp, B = s.beam, len = B.len * s.diag * 1.05, hw = s.g.beamHalf; + return { L, len, a0: B.a - hw, a1: B.a + hw }; +} +function drawBeam(s, c) { + const g = s.g, u = g.u, P = s.P, I = s.beamI(), { L, len, a0, a1 } = beamPoly(s); + if (len < 10) return; + const night = smooth(0.3, 0.8, s.d); + const e0 = [L.x + Math.cos(a0) * len, L.y + Math.sin(a0) * len], e1 = [L.x + Math.cos(a1) * len, L.y + Math.sin(a1) * len]; + const fan = [[L.x, L.y], e0]; + for (let i = 1; i < 12; i++) { const a = lerp(a0, a1, i / 12); fan.push([L.x + Math.cos(a) * len, L.y + Math.sin(a) * len]); } + fan.push(e1); + const col = mix(P.beam, hex('#FFF6C2'), night * 0.45); + poly(c, fan); c.fillStyle = rgba(col, 0.84 + 0.12 * night * I); c.fill(); + // the scallops it crosses light up + c.save(); poly(c, fan); c.clip(); + c.strokeStyle = rgba(mix(col, hex('#FFFFFF'), 0.5), 0.95); waveScallops(s, c, true); + c.restore(); + // the pen goes along both edges, from just outside the eye + const r0 = g.lh.er * 2.3; + c.beginPath(); + for (const [a, sd] of [[a0, 13], [a1, 17]]) ribbon(c, dens([[L.x + Math.cos(a) * r0, L.y + Math.sin(a) * r0], [L.x + Math.cos(a) * len, L.y + Math.sin(a) * len]], 9), DET * 0.95, sd, { tin: 20, tout: 1, w0: 0.2, w1: 1 }); + if (s.beam.len < 0.85) ribbon(c, dens(fan.slice(1), 6), DET * 0.9, 19, { tin: 3, tout: 3 }); + c.fillStyle = INK; c.fill(); +} + +/* where the light lands on the hill: a flat, lighter print on the grass */ +function drawHillLight(s, c) { + const { L, len, a0, a1 } = beamPoly(s); + if (len < 10 || Math.sin(s.beam.a) < 0.08) return; + const night = smooth(0.3, 0.8, s.d); + c.save(); poly(c, s.g.hillClip); c.clip(); + c.beginPath(); c.moveTo(L.x, L.y); + for (let i = 0; i <= 12; i++) { const a = lerp(a0, a1, i / 12); c.lineTo(L.x + Math.cos(a) * len, L.y + Math.sin(a) * len); } + c.closePath(); c.fillStyle = rgba(mix(s.P.hill, s.P.beam, 0.5 + 0.2 * night)); c.fill(); + c.restore(); +} + +/* the lamp's halo at night: hand-drawn rings and short rays, no blur */ +function drawHalo(s, c) { + const g = s.g, u = g.u, P = s.P, L = g.lamp, er = g.lh.er; + const night = smooth(0.35, 0.85, s.d), f = s.beam.flash, k = Math.max(night * s.beamI(), f * 0.9); + if (k < 0.02) return; + const r1 = er * (2.1 + f * 0.6), r2 = er * (3 + f * 1.4); + // the lit lantern: a flat disc of light just around the eye + c.globalAlpha = clamp(k * 1.4); + print(c, ellipse(L.x, L.y, er * 1.75, er * 1.6, 30), mix(P.beam, hex('#FFFFFF'), 0.25)); + c.globalAlpha = 1; + c.beginPath(); + ribbon(c, ellipse(L.x, L.y, r1, r1, 30, 0.3, TAU - 0.2), TEX, 7, { tin: 4, tout: 6 }); + ribbon(c, ellipse(L.x, L.y, r2, r2, 40, 1.4, TAU + 0.6), TEX, 9, { tin: 4, tout: 8 }); + const spin = s.reduced ? 0 : s.t * 0.25; + for (let i = 0; i < 14; i++) { + const a = i / 14 * TAU + spin + (i % 2) * 0.12, a1 = r2 * (1.15 + (i % 3) * 0.06), a2 = r2 * (1.42 + (i % 2) * 0.22 + f * 0.5); + ribbon(c, [[L.x + Math.cos(a) * a1, L.y + Math.sin(a) * a1], [L.x + Math.cos(a) * a2, L.y + Math.sin(a) * a2]], TEX, i, { tin: 1, tout: 2, w0: 0.7 }); + } + c.fillStyle = rgba(P.beam, clamp(k * 1.1)); c.fill(); +} + +/* the hill: printed green, a shaded flank, the ridge in the heaviest line, a few hatches and flowers */ +function drawHill(s, c) { + const g = s.g, { W, H, u } = g, P = s.P, T = g.lh, rd = g.ridge; + print(c, g.hillPoly, P.hill); + // the shady flank, a second flat green + const sh = []; + for (const [x, y] of rd) if (x < T.x - T.wB * 2) sh.push([x, y]); + if (g.portrait) { + // under the headland, a darker band of cliff above the shore + const sl = g.shore, x0 = sl[0][0]; + print(c, [...sl, ...sl.slice().reverse().map(([x, y]) => [x + 4 * u, y - (8 + (x - x0) / W * 26) * u])], P.hill2); + } else { + // it narrows to nothing near the top, so it reads as the hill turning away from the light + const n = sh.length, inner = sh.map(([x, y], i) => { const f = smooth(0, 0.35, (n - 1 - i) / n); return [x + (34 + (H - y) * 0.12) * u * f, y + (16 + (H - y) * 0.05) * u * f]; }).reverse(); + print(c, [...sh, ...inner], P.hill2); + } + // hatching on the shady side: a few short groups, lightest line + c.beginPath(); + const R = rng(5); + const groups = g.portrait ? 7 : 11; + for (let i = 0; i < groups; i++) { + const fx = R(), x = g.portrait ? lerp(rd[0][0] + 20 * u, T.x - T.wB * 3, fx) : lerp(rd[0][0] + 30 * u, T.x - T.wB * 2.2, fx); + const ry = s.ridgeY(x); + if (ry == null) continue; + const y = ry + (14 + R() * (g.portrait ? 26 : 70)) * u; + if (g.portrait && y > s.shoreY(x) - 12 * u) continue; + const n = 3 + ((R() * 3) | 0), l = (13 + R() * 10) * u; + for (let k = 0; k < n; k++) { + const hx = x + k * 5 * u, hy = y + k * 1.6 * u, lk = l * (1 - Math.abs(k - (n - 1) / 2) * 0.16); + ribbon(c, [[hx, hy], [hx + lk * 0.42, hy + lk]], TEX, i * 5 + k, { tin: 3, tout: 5, w0: 0.45, w1: 0.2, amp: 0.4 }); + } + } + c.fillStyle = INK; c.fill(); + // foam where the hill meets the sea + c.beginPath(); + const foam = g.portrait ? g.shore : rd.filter(p => p[1] > g.horizon + 3 && p[0] < T.x); + for (let i = 2; i < foam.length - 2; i += 3) { + const [x, y] = foam[i], [x2, y2] = foam[i + 2], nx = g.portrait ? 0 : -1, ny = g.portrait ? 1 : 0; + const ox = nx * 5 * u, oy = ny * 5 * u, mx = (x + x2) / 2 + ox, my = (y + y2) / 2 + oy; + if (mx < -10 || mx > W + 10 || my > H + 10 || hash(i * 0.7) < 0.3) continue; + ribbon(c, [[x + ox, y + oy], [mx + nx * 2 * u - (g.portrait ? 0 : 1) * u, my + (g.portrait ? 3 : -2) * u], [x2 + ox, y2 + oy]], TEX * 1.1, i, { tin: 1, tout: 1, amp: 0.3 }); + } + c.fillStyle = rgba(P.foam); c.fill(); + // the ridge, and in portrait the shore under it + c.beginPath(); + ribbon(c, rd.filter(p => p[0] > -20 && p[0] < W + 20), SIL, 21, { tin: 4, tout: 4, w0: 0.8, w1: 0.8 }); + if (g.portrait) ribbon(c, g.shore.filter(p => p[0] < W + 20), SIL * 0.9, 23, { tin: 30, tout: 4, w0: 0.3, w1: 0.8 }); + c.fillStyle = INK; c.fill(); + // daisies near the cottage and the tower, where the eye goes + const D = rng(9); + for (let i = 0; i < (g.portrait ? 6 : 11); i++) { + const x = lerp(T.x - T.h * 0.42, Math.min(W - 10, g.cot.x + g.cot.w * 1.6), D()), ry = s.ridgeY(x); + if (ry == null || Math.abs(x - T.x) < T.wB * 0.8 || Math.abs(x - g.cot.x) < g.cot.w * 0.6) { D(); continue; } + const y = ry + (8 + D() * (g.portrait ? 20 : 46)) * u, r = 2.2 * u; + for (let k = 0; k < 5; k++) { const a = k / 5 * TAU; dot(c, x + Math.cos(a) * r + REGX * 0.5, y + Math.sin(a) * r + REGY * 0.5, 1.5 * u, mul(hex('#FFFFFF'), P.paper)); } + dot(c, x + REGX * 0.5, y + REGY * 0.5, 1.4 * u, C.yolk); + } +} + +function drawCottage(s, c) { + const g = s.g, u = g.u, P = s.P, k = g.cot, w = k.w, h = k.h, x0 = k.x - w / 2, y0 = k.base - h; + const paper = mul(hex('#FFFFFF'), P.paper), night = smooth(0.45, 0.75, s.d); + // the chimney, a little crooked + const chx = x0 + w * 0.68, chw = w * 0.15, cht = y0 - h * 1.45; + const chim = [[chx, y0 - h * 0.2], [chx + 1.2 * u, cht], [chx + chw + 1.6 * u, cht], [chx + chw, y0 - h * 0.25]]; + print(c, chim, mul(C.chimney, P.paper)); + c.beginPath(); edges(c, chim, DET, 3, false); + ribbon(c, dens([[chx - 1.5 * u, cht + 3 * u], [chx + chw + 3 * u, cht + 2.5 * u]], 4), DET * 1.15, 4, { tin: 2, tout: 2 }); + c.fillStyle = INK; c.fill(); + // walls + const wall = [[x0, k.base], [x0, y0], [x0 + w, y0], [x0 + w, k.base]]; + print(c, wall, paper); + // the roof, inked solid as felt-pen fills are + const roof = [[x0 - w * 0.12, y0 + 1 * u], [x0 + w * 0.5, y0 - h * 0.78], [x0 + w * 1.12, y0 + 1 * u]]; + poly(c, roof); c.fillStyle = rgba(C.roof); c.fill(); + c.beginPath(); edges(c, roof, DET * 1.1, 6, true); edges(c, wall, SIL * 0.85, 8, false); c.fillStyle = INK; c.fill(); + // the window: four panes, lit at night + const wx = x0 + w * 0.14, wy = y0 + h * 0.24, ww = w * 0.27, wh = h * 0.36; + print(c, [[wx, wy], [wx + ww, wy], [wx + ww, wy + wh], [wx, wy + wh]], night > 0.5 ? P.beam : P.glass); + c.beginPath(); edges(c, [[wx, wy], [wx + ww, wy], [wx + ww, wy + wh], [wx, wy + wh]], DET * 0.85, 11, true, { ov: 1.2 }); + ribbon(c, [[wx + ww / 2, wy + 1], [wx + ww / 2, wy + wh - 1]], TEX, 12, { tin: 1, tout: 1, w0: 0.8, w1: 0.8 }); + ribbon(c, [[wx + 1, wy + wh / 2], [wx + ww - 1, wy + wh / 2]], TEX, 13, { tin: 1, tout: 1, w0: 0.8, w1: 0.8 }); + c.fillStyle = INK; c.fill(); + // the door (it opens for the keeper: see the states) + const dx = x0 + w * 0.56, dw = w * 0.24, dh = h * 0.66; + k.door = { x: dx, y: k.base - dh, w: dw, h: dh }; + print(c, [[dx, k.base], [dx, k.base - dh], [dx + dw, k.base - dh], [dx + dw, k.base]], mul(C.door, P.paper)); + c.beginPath(); edges(c, [[dx, k.base], [dx, k.base - dh], [dx + dw, k.base - dh], [dx + dw, k.base]], DET * 0.85, 14, false, { ov: 1 }); + c.fillStyle = INK; c.fill(); + dot(c, dx + dw * 0.78, k.base - dh * 0.48, 1.3 * u + 0.4, INK); +} + +/* the tower: white with curved stripes in the theme's colours, a door, two windows and a gallery */ +function drawTower(s, c) { + const g = s.g, u = g.u, P = s.P, T = g.lh, th = THEMES[s.theme]; + const paper = mul(hex('#FFFFFF'), P.paper); + const half = k => lerp(T.wB, T.wT, k) / 2, yAt = k => T.base - T.h * k; + const Lp = [], Rp = []; + for (let i = 0; i <= 20; i++) { const k = i / 20; Lp.push([T.x - half(k), yAt(k)]); Rp.push([T.x + half(k), yAt(k)]); } + const body = [...Lp, ...Rp.slice().reverse()]; + print(c, body, paper); + // stripes, seen from below: their edges curve up a little + const bands = 6, sag = T.wB * 0.09; + const edge = k => { const hw = half(k), out = []; for (let i = 0; i <= 12; i++) { const f = i / 12 * 2 - 1; out.push([T.x + f * hw, yAt(k) - sag * (1 - f * f)]); } return out; }; + let n = 0; + for (let j = 1; j < bands; j += 2) { + const k0 = j / bands, k1 = (j + 1) / bands; + const lo = edge(k0), hi = j + 1 >= bands ? [[T.x + half(1), yAt(1)], [T.x - half(1), yAt(1)]] : edge(k1).reverse(); + const col = hex(th.stripes[n++ % th.stripes.length]); + print(c, [...lo, ...hi], mul(col, P.paper)); + } + // windows and the door + const win = (k, w, h) => { const y = yAt(k); return [[T.x - w / 2, y - h / 2], [T.x + w / 2, y - h / 2], [T.x + w / 2, y + h / 2], [T.x - w / 2, y + h / 2]]; }; + const night = smooth(0.45, 0.75, s.d); + const glass = night > 0.5 ? P.beam : rgba(C.roof); + for (const k of [0.25, 0.58]) { const q = win(k, T.wB * 0.22, T.er * 0.75); poly(c, q); c.fillStyle = typeof glass === 'string' ? glass : rgba(glass); c.fill(); } + const dw = T.wB * 0.32, dh = T.wB * 0.5, dpts = [[T.x - dw / 2, T.base], ...ellipse(T.x, T.base - dh + dw / 2, dw / 2, dw / 2, 10, Math.PI, TAU).map(p => p), [T.x + dw / 2, T.base]]; + poly(c, dpts); c.fillStyle = rgba(C.roof); c.fill(); + // the outline: two sides and the base, then a few stripe edges and hatches on the shady side + c.beginPath(); + ribbon(c, dens([[T.x - T.wB / 2 - 1 * u, T.base + 3 * u], [T.x - T.wT / 2, T.top - 1 * u]], 6), SIL, 31, { tin: 6, tout: 4, w0: 0.7, w1: 0.6 }); + ribbon(c, dens([[T.x + T.wB / 2 + 0.5 * u, T.base + 2 * u], [T.x + T.wT / 2, T.top - 1 * u]], 6), SIL, 33, { tin: 6, tout: 4, w0: 0.7, w1: 0.6 }); + for (let j = 1; j < bands; j++) { + const e = edge(j / bands), cut = Math.round(e.length * (0.55 + hash(j) * 0.35)); + ribbon(c, j % 2 ? e.slice(0, cut) : e.slice(e.length - cut), TEX, 40 + j, { tin: 2, tout: 8, w0: 0.6, w1: 0.1 }); + } + for (let j = 0; j < bands; j++) { + const k = (j + 0.5) / bands, x = T.x + half(k) - 2.6 * u, y = yAt(k); + for (let i = 0; i < 3; i++) ribbon(c, [[x - i * 3 * u, y - T.h / bands * 0.32], [x - i * 3 * u - 0.6 * u, y + T.h / bands * 0.3 - i * 2 * u]], TEX * 0.85, j * 3 + i, { tin: 2, tout: 2, amp: 0.3 }); + } + for (const k of [0.25, 0.58]) edges(c, win(k, T.wB * 0.22, T.er * 0.75), TEX, 50 + k * 10, true, { ov: 0.6 }); + ribbon(c, dpts, DET * 0.9, 61, { tin: 2, tout: 2 }); + c.fillStyle = INK; c.fill(); + // the gallery: a dark deck the eye sits on + const gw = T.wT * 0.95, gy = T.top; + const deck = [[T.x - gw, gy + 1 * u], [T.x - gw * 1.05, gy - T.gal * 0.42], [T.x + gw * 1.05, gy - T.gal * 0.42], [T.x + gw, gy + 1 * u]]; + poly(c, deck); c.fillStyle = INK; c.fill(); +} + +/* the eye in the lantern: an iris the colour of the light, a pupil that follows the beam, a lid */ +function drawEye(s, c) { + const g = s.g, u = g.u, P = s.P, T = g.lh, L = g.lamp, er = T.er, B = s.beam; + const night = smooth(0.35, 0.8, s.d); + const rx = er * 1.32, ry = er * 1.08; + const eye = ellipse(L.x, L.y, rx, ry, 40); + print(c, eye, mix(mul(hex('#FFFFFF'), P.paper), P.beam, night * 0.25)); + // where it looks: along the beam, or straight at you when the light faces you + const look = clamp(B.len * 1.3) * (1 - B.flash), pa = B.a; + const px = L.x + Math.cos(pa) * rx * 0.42 * look, py = L.y + Math.sin(pa) * ry * 0.36 * look; + const ir = er * (0.62 + 0.12 * B.flash), pr = er * (0.3 - 0.08 * night + 0.1 * B.flash); + c.save(); poly(c, eye, true, REGX, REGY); c.clip(); + dot(c, px + REGX, py + REGY, ir, mix(P.beam, hex('#FFB52E'), 0.35 - 0.2 * night)); + c.restore(); + c.save(); poly(c, eye); c.clip(); + dot(c, px, py, pr, INK); + dot(c, px - pr * 0.45, py - pr * 0.5, pr * 0.38 + 0.4, '#FFFFFF'); + c.beginPath(); ribbon(c, ellipse(px, py, ir, ir, 22), TEX, 3, { tin: 1, tout: 1, w0: 1, w1: 1, amp: 0.3 }); c.fillStyle = INK; c.fill(); + // the lid: a little heavy by day, wide awake at night, shut when it blinks or winks + const bl = s.t - s.blink.at, wk = s.t - s.blink.wink; + let shut = lerp(0.2, 0.02, night); + if (bl < 0.18) shut = Math.max(shut, Math.sin(bl / 0.18 * Math.PI)); + if (wk < 0.6) shut = Math.max(shut, wk < 0.1 ? wk / 0.1 : wk < 0.45 ? 1 : 1 - (wk - 0.45) / 0.15); + if (s.reduced && wk >= 0.6) shut = lerp(0.2, 0.02, night); + const lidCol = rgba(mul(hex('#FFFFFF'), P.paper)); + if (shut > 0.86) { + poly(c, eye); c.fillStyle = lidCol; c.fill(); + c.beginPath(); + ribbon(c, ellipse(L.x, L.y - ry * 0.35, rx * 0.78, ry * 0.55, 16, 0.2 * Math.PI, 0.8 * Math.PI), DET, 5, { tin: 3, tout: 3 }); + for (const k of [0.32, 0.5, 0.68]) { const a = k * Math.PI, ex = L.x + Math.cos(a) * rx * 0.78, ey = L.y - ry * 0.35 + Math.sin(a) * ry * 0.55; ribbon(c, [[ex, ey], [ex + Math.cos(a) * er * 0.35, ey + Math.sin(a) * er * 0.35]], TEX, k * 9, { tin: 1, tout: 1 }); } + c.fillStyle = INK; c.fill(); + } else if (shut > 0.01) { + const ly = L.y - ry + shut * ry * 1.2, lid = []; + for (let i = 0; i <= 24; i++) { const a = Math.PI + i / 24 * Math.PI; lid.push([L.x + Math.cos(a) * (rx + 2), L.y + Math.sin(a) * (ry + 2)]); } + for (let i = 0; i <= 16; i++) { const f = 1 - i / 16, x = L.x - rx + f * 2 * rx; lid.push([x, ly + (1 - ((x - L.x) / rx) ** 2) * ry * 0.22 * shut]); } + poly(c, lid); c.fillStyle = lidCol; c.fill(); + c.beginPath(); ribbon(c, lid.slice(25), DET, 5, { tin: 3, tout: 3 }); c.fillStyle = INK; c.fill(); + } + c.restore(); + c.beginPath(); loop(c, eye, SIL * 0.85, 8); c.fillStyle = INK; c.fill(); + // the gallery's railing passes in front of the eye + const gw = T.wT * 0.95, top = T.top - T.gal * 0.42, rail = top - T.gal * 0.62; + c.beginPath(); + ribbon(c, dens([[L.x - gw * 1.08, rail], [L.x + gw * 1.08, rail]], 4), DET * 0.9, 9, { tin: 2, tout: 2, w0: 0.8, w1: 0.8 }); + for (let i = 0; i <= 6; i++) { const x = L.x - gw + i / 6 * gw * 2; ribbon(c, [[x, top], [x, rail]], TEX * 0.95, 70 + i, { tin: 1, tout: 1, w0: 0.9, w1: 0.9, amp: 0.2 }); } + c.fillStyle = INK; c.fill(); +} + +/* a knitted pom-pom hat: a ribbed brim, a long floppy cone in the theme's colour, a white bobble */ +function drawHat(s, c) { + const g = s.g, u = g.u, P = s.P, T = g.lh, L = g.lamp, er = T.er, th = THEMES[s.theme]; + const col = mul(hex(th.hat), P.paper), ry = er * 1.08; + const sway = s.reduced ? 0.15 : 0.15 + s.windTotal * 0.25 + Math.sin(s.t * 1.3) * 0.05; + const by = L.y - ry * 0.62, bw = er * 1.5, bh = er * 0.62; + const tip = [L.x + er * (0.5 + sway * 2.2), by - er * 3.1 + Math.abs(sway) * er * 0.6]; + const cone = curve([[L.x - bw * 0.92, by - bh * 0.6], [L.x - bw * 0.5, by - er * 1.9], [L.x + er * 0.1 + sway * er, by - er * 2.85], tip, [L.x + er * 0.65 + sway * er * 1.4, by - er * 2.2], [L.x + bw * 0.9, by - bh * 0.6]], 3); + print(c, cone, col); + // a white knitted stripe across the cone + c.save(); poly(c, cone, true, REGX, REGY); c.clip(); + const sy = by - er * 1.55; + c.beginPath(); c.moveTo(L.x - bw * 2, sy + er * 0.25); c.quadraticCurveTo(L.x, sy - er * 0.05, L.x + bw * 2, sy + er * 0.2); c.lineTo(L.x + bw * 2, sy - er * 0.25); c.quadraticCurveTo(L.x, sy - er * 0.55, L.x - bw * 2, sy - er * 0.25); c.closePath(); + c.fillStyle = rgba(mul(hex('#FFFFFF'), P.paper)); c.fill(); + c.restore(); + const brim = [[L.x - bw, by + bh * 0.45], [L.x - bw * 1.02, by - bh * 0.55], [L.x + bw * 1.02, by - bh * 0.6], [L.x + bw, by + bh * 0.5]]; + print(c, brim, mix(col, hex('#000000'), 0.12)); + c.beginPath(); + loop(c, cone, DET * 1.05, 14, { over: 0.05 }); + edges(c, brim, DET, 15, true, { ov: 1.2 }); + for (let i = 1; i < 7; i++) { const x = L.x - bw + i / 7 * bw * 2; ribbon(c, [[x, by - bh * 0.4], [x + 0.4 * u, by + bh * 0.32]], TEX * 0.8, 80 + i, { tin: 1, tout: 1, amp: 0.2 }); } + c.fillStyle = INK; c.fill(); + // the bobble: a scribbled ball of wool + const pr = er * 0.66, pb = []; + for (let i = 0; i < 40; i++) { const a = i / 40 * TAU, r = pr * (1 + 0.09 * Math.abs(Math.sin(a * 5.5 + 1))); pb.push([tip[0] + Math.cos(a) * r, tip[1] - pr * 0.6 + Math.sin(a) * r]); } + shape(c, pb, mul(hex('#FFFFFF'), P.paper), DET * 0.95, 16); + c.beginPath(); ribbon(c, ellipse(tip[0] + pr * 0.1, tip[1] - pr * 0.5, pr * 0.42, pr * 0.36, 8, 0.2, 1.9), TEX * 0.8, 90, { tin: 1, tout: 2 }); c.fillStyle = INK; c.fill(); + // a sparkle or two after changing into new pyjamas + const sp = s.t - s.sparkle; + if (sp < 1.2 && !s.reduced) { + c.beginPath(); + for (let i = 0; i < 6; i++) { + const a = -Math.PI / 2 + (i - 2.5) * 0.55, r1 = T.wB * (0.9 + sp * 1.4), r2 = r1 + 7 * u; + const cx = T.x, cy = T.top + T.h * 0.3; + ribbon(c, [[cx + Math.cos(a) * r1 * 1.4, cy + Math.sin(a) * r1 * 2], [cx + Math.cos(a) * r2 * 1.4, cy + Math.sin(a) * r2 * 2]], DET, i, { tin: 1, tout: 1 }); + } + c.fillStyle = rgba(P.ink, 1 - sp / 1.2); c.fill(); + } +} + +/* grass along the ridge, leaning with the wind and away from the pointer */ +function drawTufts(s, c) { + const g = s.g, u = g.u; + c.beginPath(); + for (const b of g.tufts) { + let a = s.reduced ? 0.12 : Math.sin(s.t * 1.6 + b.p) * 0.14 * (0.6 + s.windAmb) + s.windTotal * 0.36; + if (s.ptr.on && !s.reduced) { + const dx = b.x - s.ptr.x, dy = b.y - s.ptr.y, r = 95 * u, d2 = dx * dx + dy * dy; + if (d2 < r * r) a += Math.sign(dx || 1) * 1.05 * (1 - Math.sqrt(d2) / r); + } + a = clamp(a, -1.3, 1.3); + for (let k = 0; k < b.n + 1; k++) { + const da = (k - b.n / 2) * 0.42, l = b.l * (k === 1 ? 1.15 : 0.85), x = b.x + (k - b.n / 2) * 1.6 * u, y = b.y + 1.5 * u; + const tx = x + Math.sin(a + da) * l, ty = y - Math.cos(a + da) * l, w = 1.1 * g.pw; + c.moveTo(x - w, y); c.quadraticCurveTo(lerp(x, tx, 0.5) - w * 0.4, lerp(y, ty, 0.55), tx, ty); c.quadraticCurveTo(lerp(x, tx, 0.5) + w * 0.4, lerp(y, ty, 0.55), x + w, y); c.closePath(); + } + } + c.fillStyle = INK; c.fill(); +} + +function drawSmoke(s, c) { + if (s.reduced) return; + const g = s.g, u = g.u, k = g.cot, x0 = k.x - k.w / 2 + k.w * 0.68 + k.w * 0.075, y0 = k.base - k.h - k.h * 1.5; + c.beginPath(); + for (let i = 0; i < 3; i++) { + const a = (s.t * 0.22 + i / 3) % 1, sx = x0 + s.windTotal * 50 * u * a + Math.sin(a * 6 + i) * 3 * u, sy = y0 - 6 * u - a * 46 * u, r = (2.5 + a * 6) * u; + const sp = []; + for (let k2 = 0; k2 <= 16; k2++) { const an = k2 / 16 * TAU * 1.35 + i, rr = r * (0.25 + k2 / 16 * 0.75); sp.push([sx + Math.cos(an) * rr, sy + Math.sin(an) * rr]); } + ribbon(c, sp, TEX * (1 - a * 0.4), i, { tin: 3, tout: 6 }); + } + c.fillStyle = rgba(s.P.ink, 0.85); c.fill(); +} + +/* ---------- scene states: one dry gag per section, drawn where it's seen ---------- */ +function drawStates(s, c) { + const p = s.pres; + if (p.themes > 0.01) drawWashing(s, c, p.themes); + if (p.tiling > 0.01) drawHuts(s, c, p.tiling); + if (p.widgets > 0.01) drawDesk(s, c, p.widgets); + if (p.install > 0.01) drawDoorway(s, c, p.install); + if (p.edit > 0.01) drawEditKeeper(s, c, p.edit); +} +/* pops a prop in from the ground: scale with a little overshoot */ +const pop = p => { const t = clamp(p); return t < 1 ? 1 - Math.pow(1 - t, 3) * Math.cos(t * 4.2) : 1; }; + +function drawKeeper(s, c, x, y, sc, pose, flip, walk) { + const u = s.g.u * sc, P = s.P, f = flip ? -1 : 1; + const navy = mul(C.navy, P.paper), skin = mul(C.skin, P.paper), white = mul(hex('#FFFFFF'), P.paper); + c.save(); c.translate(x, y); c.scale(f, 1); + // legs + const st = Math.sin(walk) * 0.45; + c.beginPath(); + ribbon(c, [[-2 * u, -12 * u], [-2 * u + Math.sin(st) * 9 * u, 0]], DET * 1.5, 1, { tin: 1, tout: 1, w0: 1, w1: 1 }); + ribbon(c, [[2 * u, -12 * u], [2 * u - Math.sin(st) * 9 * u, 0]], DET * 1.5, 2, { tin: 1, tout: 1, w0: 1, w1: 1 }); + c.fillStyle = rgba(navy); c.fill(); + // boots + dot(c, -2 * u + Math.sin(st) * 9 * u + 1.5 * u, -0.5 * u, 2.2 * u, INK); dot(c, 2 * u - Math.sin(st) * 9 * u + 1.5 * u, -0.5 * u, 2.2 * u, INK); + // a striped jumper + const body = [[-5.5 * u, -11 * u], [-6 * u, -24 * u], [6 * u, -24 * u], [5.5 * u, -11 * u]]; + print(c, body, white); + c.save(); poly(c, body, true, REGX, REGY); c.clip(); + for (let i = 0; i < 3; i++) { c.fillStyle = rgba(navy); c.fillRect(-8 * u + REGX, (-22 + i * 4.2) * u + REGY, 16 * u, 2.1 * u); } + c.restore(); + c.beginPath(); edges(c, body, DET, 3, true, { ov: 0.6 }); + // arms + if (pose === 'carry') { + ribbon(c, [[-5 * u, -22 * u], [-7 * u, -30 * u], [-5 * u, -37 * u]], DET * 1.1, 4, { tin: 1, tout: 1 }); + ribbon(c, [[5 * u, -22 * u], [7 * u, -30 * u], [5 * u, -37 * u]], DET * 1.1, 5, { tin: 1, tout: 1 }); + } else if (pose === 'wave') { + const wv = s.reduced ? 0 : Math.sin(s.t * 7) * 0.5; + ribbon(c, [[-5 * u, -22 * u], [-7 * u, -15 * u]], DET * 1.1, 4, { tin: 1, tout: 1 }); + ribbon(c, [[5 * u, -22 * u], [10 * u, -28 * u], [10 * u + Math.sin(wv) * 7 * u, -36 * u]], DET * 1.1, 5, { tin: 1, tout: 1 }); + } else { + ribbon(c, [[-5 * u, -22 * u], [-6.5 * u - st * 4 * u, -14 * u]], DET * 1.1, 4, { tin: 1, tout: 1 }); + ribbon(c, [[5 * u, -22 * u], [6.5 * u + st * 4 * u, -14 * u]], DET * 1.1, 5, { tin: 1, tout: 1 }); + } + c.fillStyle = INK; c.fill(); + // head, white beard, cap with a peak + const head = ellipse(0.5 * u, -29 * u, 5 * u, 5.4 * u, 20); + print(c, head, skin); + const beard = [[-4.4 * u, -29 * u], [-3 * u, -23.5 * u], [1 * u, -22.4 * u], [5.2 * u, -24.5 * u], [5.6 * u, -28.5 * u]]; + print(c, curve(beard, 2), white); + c.beginPath(); loop(c, head, DET, 6, { over: 0.04 }); ribbon(c, curve(beard, 2), TEX, 7, { tin: 1, tout: 1 }); c.fillStyle = INK; c.fill(); + dot(c, 2.6 * u, -30 * u, 0.9 * u + 0.3, INK); + dot(c, 5.6 * u, -28.6 * u, 1.3 * u, mix(skin, hex('#E0786A'), 0.5)); + const cap = [[-5 * u, -31.5 * u], [-4.6 * u, -36 * u], [3 * u, -36.4 * u], [5.4 * u, -32 * u]]; + poly(c, cap); c.fillStyle = rgba(navy); c.fill(); + c.beginPath(); edges(c, cap, DET * 0.9, 8, false, { ov: 0.5 }); ribbon(c, [[3 * u, -32 * u], [9 * u, -31 * u]], DET * 1.2, 9, { tin: 1, tout: 2 }); c.fillStyle = INK; c.fill(); + c.restore(); +} + +function drawTile(s, c, x, y, u) { + const w = 34 * u, h = 15 * u, r = 5 * u, P = s.P; + const box = []; + for (const [cx, cy, a] of [[x + w / 2 - r, y - h / 2 + r, -Math.PI / 2], [x + w / 2 - r, y + h / 2 - r, 0], [x - w / 2 + r, y + h / 2 - r, Math.PI / 2], [x - w / 2 + r, y - h / 2 + r, Math.PI]]) + for (let i = 0; i <= 4; i++) { const an = a + i / 4 * Math.PI / 2; box.push([cx + Math.cos(an) * r, cy + Math.sin(an) * r]); } + shape(c, box, C.tile, DET, 4, { over: 0.05 }); + label(c, '10:30', x + REGX * 0.5, y + REGY * 0.5 + 0.5, 10 * u + 1.5, rgba(mul(hex('#FFFFFF'), P.paper)), 'center', 0); +} + +/* edit: the keeper wanders the ridge with a widget tile ("10:30") */ +function drawEditKeeper(s, c, p) { + const g = s.g, u = g.u, T = g.lh, k = g.cot, sc = g.portrait ? 1.5 : 1.55; + const t = s.t - s.since.edit; + const door = k.x - k.w * 0.5 + k.w * 0.68, xL = g.portrait ? g.W * 0.42 : g.W * 0.668, xR = T.x - T.wB * 1.6; + const v = 32 * u; + let x, flip; + const out = (door - xR) / v; + if (s.reduced) { x = lerp(xL, xR, 0.45); flip = true; } + else if (t < out) { x = door - t * v; flip = true; } + else { + const span = xR - xL, ph = ((t - out) * v) % (span * 2 + 120 * u); + if (ph < span) { x = xR - ph; flip = true; } + else if (ph < span + 60 * u) { x = xL; flip = false; } + else if (ph < span * 2 + 60 * u) { x = xL + (ph - span - 60 * u); flip = false; } + else { x = xR; flip = true; } + } + const ry = s.ridgeY(x) ?? k.base, walking = !s.reduced && t > 0; + c.globalAlpha = clamp(p * 1.5); + drawKeeper(s, c, x, ry + 2 * u, sc, 'carry', flip, walking ? s.t * 9 : 0); + drawTile(s, c, x + (flip ? -1 : 1) * 0.5 * u, ry + 2 * u - 42 * u * sc + Math.sin(s.reduced ? 0 : s.t * 9) * 0.6 * u, u * sc); + c.globalAlpha = 1; +} + +/* install: night, the cottage door open with warm light, the keeper waving */ +function drawDoorway(s, c, p) { + const g = s.g, u = g.u, P = s.P, k = g.cot, d = k.door; + if (!d) return; + const warm = hex('#FFD45C'); + c.globalAlpha = clamp(p * 1.4); + // light spilling onto the grass + const spill = [[d.x, k.base], [d.x + d.w, k.base], [d.x + d.w + 44 * u, k.base + 34 * u], [d.x - 34 * u, k.base + 34 * u]]; + poly(c, spill); c.fillStyle = rgba(mix(P.hill, warm, 0.62)); c.fill(); + poly(c, [[d.x, k.base], [d.x, d.y], [d.x + d.w, d.y], [d.x + d.w, k.base]]); c.fillStyle = rgba(warm); c.fill(); + // the door swung open against the wall + const door = [[d.x + d.w, k.base], [d.x + d.w, d.y], [d.x + d.w * 1.55, d.y + 2 * u], [d.x + d.w * 1.55, k.base - 1 * u]]; + print(c, door, mul(C.door, P.paper)); + c.beginPath(); edges(c, door, DET * 0.85, 3, true, { ov: 0.6 }); c.fillStyle = INK; c.fill(); + drawKeeper(s, c, d.x + d.w * 0.5, k.base, g.portrait ? 1.05 : 1.45, 'wave', false, 0); + c.globalAlpha = 1; +} + +/* widgets: a sheep at a tiny desk with a laptop, typing */ +function drawDesk(s, c, p) { + const g = s.g, P = s.P, sc = pop(p) * (g.portrait ? 1.75 : 2.1), u = g.u * sc; + const x = g.portrait ? g.W * 0.5 : g.W * 0.7, ry = s.ridgeY(x), y = g.portrait ? ry + (s.shoreY(x) - ry) * 0.66 : ry + g.H * 0.2; + if (sc < 0.05) return; + const typing = s.reduced ? 0 : s.t * 11, wool = mul(C.wool, P.paper), night = smooth(0.4, 0.8, s.d); + c.save(); c.translate(x, y); + // the sheep behind the desk, seen from the front + const bob = s.reduced ? 0 : Math.sin(s.t * 2.4) * 0.6 * u; + const body = []; + for (let i = 0; i < 36; i++) { const a = i / 36 * TAU, r = 1 + 0.12 * Math.abs(Math.sin(a * 5)); body.push([Math.cos(a) * 15 * u * r, -27 * u + Math.sin(a) * 12 * u * r]); } + shape(c, body, wool, DET, 3); + const face = ellipse(0, -36 * u + bob, 6 * u, 7.5 * u, 20); + poly(c, face); c.fillStyle = INK; c.fill(); + for (const sx of [-1, 1]) { const ear = ellipse(sx * 8.5 * u, -40 * u + bob, 4.4 * u, 1.9 * u, 12, 0, TAU, sx * 0.35); poly(c, ear); c.fillStyle = INK; c.fill(); } + const tuft = []; for (let i = 0; i < 14; i++) { const a = Math.PI + i / 13 * Math.PI, r = 1 + 0.2 * Math.abs(Math.sin(a * 4)); tuft.push([Math.cos(a) * 6.5 * u * r, -42 * u + bob + Math.sin(a) * 3.6 * u * r]); } + shape(c, tuft, wool, TEX, 5); + // eyes on the screen, lit by it at night + for (const sx of [-1, 1]) { dot(c, sx * 2.6 * u, -36.5 * u + bob, 1.6 * u, '#FFFFFF'); dot(c, sx * 2.6 * u, -35.8 * u + bob, 0.8 * u, INK); } + // the desk + const top = [[-24 * u, -14 * u], [24 * u, -14 * u], [24 * u, -10.5 * u], [-24 * u, -10.5 * u]]; + print(c, top, mul(C.wood, P.paper)); + c.beginPath(); edges(c, top, DET, 7, true, { ov: 1 }); + ribbon(c, [[-20 * u, -10.5 * u], [-21 * u, 0]], DET, 8, { tin: 1, tout: 1 }); ribbon(c, [[20 * u, -10.5 * u], [21 * u, 0]], DET, 9, { tin: 1, tout: 1 }); + c.fillStyle = INK; c.fill(); + // the laptop, seen from the back, with a lighthouse sticker; the screen's glow on the sheep + if (night > 0.05) { c.beginPath(); c.ellipse(0, -30 * u, 16 * u, 9 * u, 0, 0, TAU); c.fillStyle = rgba(hex('#BFE3FF'), 0.35 * night); c.fill(); } + const lid = [[-11 * u, -14.5 * u], [-12 * u, -29 * u], [12 * u, -29 * u], [11 * u, -14.5 * u]]; + print(c, lid, mul(C.steel, P.paper)); + c.beginPath(); edges(c, lid, DET, 11, true, { ov: 0.8 }); c.fillStyle = INK; c.fill(); + const st = [[-1.6 * u, -18 * u], [-1.1 * u, -25 * u], [1.1 * u, -25 * u], [1.6 * u, -18 * u]]; + print(c, st, mul(hex('#FFFFFF'), P.paper)); c.fillStyle = rgba(mul(C.chimney, P.paper)); c.fillRect(-1.4 * u + REGX, -22.5 * u + REGY, 2.8 * u, 1.6 * u); + // hooves on the keys, one and then the other + c.beginPath(); + for (const sx of [-1, 1]) { + const up = s.reduced ? 0 : Math.max(0, Math.sin(typing + (sx > 0 ? Math.PI : 0))) * 2.4 * u; + ribbon(c, [[sx * 10 * u, -22 * u], [sx * 13 * u, -17 * u - up], [sx * 11 * u, -15.5 * u - up]], DET * 1.3, 12 + sx, { tin: 1, tout: 1 }); + } + c.fillStyle = INK; c.fill(); + // tak tak + if (!s.reduced) { + const ph = (s.t * 1.3) % 1; + label(c, ph < 0.5 ? 'tak' : 'tak tak', 15 * u, -34 * u - ph * 6 * u, 8 * u + 2, rgba(s.d > 0.55 ? P.paper : P.ink, 1 - ph), 'left', -0.15); + } + // a mug of something + const mug = [[16 * u, -14 * u], [16.5 * u, -20 * u], [21 * u, -20 * u], [20.5 * u, -14 * u]]; + print(c, mug, mul(hex('#FF4A36'), P.paper)); c.beginPath(); edges(c, mug, TEX, 13, true, { ov: 0.6 }); ribbon(c, ellipse(21.5 * u, -17 * u, 2 * u, 2 * u, 8, -1.2, 1.2), TEX, 14, { tin: 1, tout: 1 }); c.fillStyle = INK; c.fill(); + c.restore(); +} + +/* tiling: four numbered beach huts; the focused workspace's hut is lit, its door open */ +function hutSpots(s) { + const g = s.g; + if (g.portrait) return [0.38, 0.54, 0.7, 0.86].map(fx => { const x = fx * g.W; return { x, y: g.top + (g.H - g.top) * 0.455 + (fx - 0.38) * g.H * 0.05 }; }); + return [0.625, 0.705, 0.785, 0.865].map(fx => ({ x: fx * g.W, y: g.top + (g.H - g.top) * 0.82 })); +} +function drawHuts(s, c, p) { + const g = s.g, P = s.P, spots = hutSpots(s), white = mul(hex('#FFFFFF'), P.paper); + const k0 = pop(p) * (g.portrait ? 1.3 : 1.75); + // the boardwalk they share + if (k0 > 0.05) { + const u = g.u * k0, a = spots[0], b = spots[3], walk = [[a.x - 30 * u, a.y], [b.x + 30 * u, b.y], [b.x + 30 * u, b.y + 4 * u], [a.x - 30 * u, a.y + 4 * u]]; + print(c, walk, mul(C.wood, P.paper)); + c.beginPath(); edges(c, walk, TEX * 1.2, 2, true, { ov: 1.2 }); + for (let x = a.x - 22 * u; x < b.x + 30 * u; x += 9 * u) ribbon(c, [[x, a.y + lerp(0, b.y - a.y, (x - a.x) / (b.x - a.x)) + 0.5 * u], [x, a.y + lerp(0, b.y - a.y, (x - a.x) / (b.x - a.x)) + 3.5 * u]], TEX * 0.8, x, { tin: 1, tout: 1, amp: 0.2 }); + c.fillStyle = INK; c.fill(); + } + spots.forEach((q, i) => { + const sc = pop(clamp(p * 1.6 - i * 0.15)) * (g.portrait ? 1.3 : 1.75), u = g.u * sc; + if (sc < 0.05) return; + const on = s.ws === i + 1, col = mul(C.huts[i], P.paper); + const w = 26 * u, h = 27 * u, x0 = q.x - w / 2, y0 = q.y - h; + const body = [[x0, q.y], [x0, y0], [x0 + w, y0], [x0 + w, q.y]]; + print(c, body, white); + c.save(); poly(c, body, true, REGX, REGY); c.clip(); c.fillStyle = rgba(col); + for (let k = 0; k < 4; k++) c.fillRect(x0 + REGX + k * w / 4, y0 + REGY - 1, w / 8, h + 2); + c.restore(); + const roof = [[x0 - 4 * u, y0 + 1 * u], [q.x, y0 - 14 * u], [x0 + w + 4 * u, y0 + 1 * u]]; + print(c, roof, col); + // the door: shut with its number painted on, or open on a warm room + const dw = w * 0.5, dh = h * 0.74, dx = q.x - dw / 2, dy = q.y - dh, door = [[dx, q.y], [dx, dy], [dx + dw, dy], [dx + dw, q.y]]; + const lit = mix(P.beam, hex('#FFB84A'), 0.3); + let num = [q.x, dy + dh * 0.45, 1]; + if (on) { + poly(c, door); c.fillStyle = rgba(lit); c.fill(); + const leaf = [[dx + dw, q.y], [dx + dw, dy], [dx + dw * 1.55, dy + 2.5 * u], [dx + dw * 1.55, q.y - 1.2 * u]]; + print(c, leaf, mix(col, hex('#000000'), 0.12)); + c.beginPath(); edges(c, leaf, TEX * 1.2, i + 3, true, { ov: 0.4 }); c.fillStyle = INK; c.fill(); + num = [dx + dw * 1.29, dy + dh * 0.45, 0.85]; + } else { + print(c, door, mix(col, hex('#000000'), 0.12)); + } + // the round window in the gable + dot(c, q.x + REGX, y0 - 5 * u + REGY, 3.4 * u, on ? lit : mix(P.sky, P.ink, 0.25 + 0.35 * smooth(0.4, 0.8, s.d))); + c.beginPath(); + edges(c, roof, DET, i * 7, true, { ov: 1 }); edges(c, body, DET, i * 7 + 2, false, { ov: 0.8 }); + edges(c, door, TEX * 1.2, i * 7 + 4, false, { ov: 0.4 }); + ribbon(c, ellipse(q.x, y0 - 5 * u, 3.4 * u, 3.4 * u, 12), TEX, i, { tin: 1, tout: 1, w0: 1, w1: 1 }); + c.fillStyle = INK; c.fill(); + label(c, String(i + 1), num[0] + 0.5 * u, num[1], 11 * u * num[2] + 2, rgba(white), 'center', 0); + }); +} + +/* themes: the washing line, with the other pyjamas drying */ +function drawWashing(s, c, p) { + const g = s.g, u = g.u, P = s.P, T = g.lh, sc = pop(p); + if (sc < 0.05) return; + const px = g.portrait ? g.W * 0.4 : g.W * 0.615, pry = s.ridgeY(px) ?? T.base; + const ph = (g.portrait ? 48 : 92) * u * sc, top = [px, pry - ph], end = [T.x - lerp(T.wB, T.wT, 0.3) / 2, T.base - T.h * 0.3]; + c.beginPath(); + ribbon(c, dens([[px, pry + 3 * u], top], 5), DET * 1.1, 1, { tin: 2, tout: 2 }); + ribbon(c, [[px - 6 * u, top[1] + 4 * u], [px, top[1] - 1 * u], [px + 6 * u, top[1] + 4 * u]], TEX, 2, { tin: 1, tout: 1 }); + const sag = 16 * u, line = []; + for (let i = 0; i <= 20; i++) { const f = i / 20; line.push([lerp(top[0], end[0], f), lerp(top[1], end[1], f) + Math.sin(f * Math.PI) * sag]); } + ribbon(c, line, TEX, 3, { tin: 2, tout: 2 }); + c.fillStyle = INK; c.fill(); + const others = Object.keys(THEMES).filter(k => k !== s.theme); + const wind = s.reduced ? 0.1 : s.windTotal; + others.forEach((id, i) => { + const f = (i + 0.7) / (others.length + 0.4), ax = lerp(top[0], end[0], f), ay = lerp(top[1], end[1], f) + Math.sin(f * Math.PI) * sag; + const k = sc * (g.portrait ? 1 : 1.45), w = 13 * u * k, h = 14 * u * k, sw = (s.reduced ? 0 : Math.sin(s.t * 2.2 + i) * 0.08) + wind * 0.12; + c.save(); c.translate(ax, ay); c.rotate(sw); + const shirt = [[-w / 2, 0], [-w * 0.85, h * 0.25], [-w * 0.62, h * 0.4], [-w * 0.42, h * 0.3], [-w * 0.42, h], [w * 0.42, h], [w * 0.42, h * 0.3], [w * 0.62, h * 0.4], [w * 0.85, h * 0.25], [w / 2, 0]]; + print(c, shirt, mul(hex('#FFFFFF'), P.paper)); + c.save(); poly(c, shirt, true, REGX, REGY); c.clip(); + const st = THEMES[id].stripes; + for (let j = 0; j < 3; j++) { c.fillStyle = rgba(mul(hex(st[j % st.length]), P.paper)); c.fillRect(-w + REGX, h * (0.18 + j * 0.3) + REGY, w * 2, h * 0.15); } + c.restore(); + c.beginPath(); edges(c, shirt, TEX, i * 3, true, { ov: 0.3 }); + ribbon(c, [[-w * 0.2, -2 * u], [-w * 0.2, 3 * u]], DET, i, { tin: 1, tout: 1, w0: 1, w1: 1 }); ribbon(c, [[w * 0.2, -2 * u], [w * 0.2, 3 * u]], DET, i + 1, { tin: 1, tout: 1, w0: 1, w1: 1 }); + c.fillStyle = INK; c.fill(); + c.restore(); + }); +} + +/* ---------- the hero's labels, each with a hand-drawn arrow ---------- */ +function arrow(c, pts, w, sd) { + const p = curve(pts, 4), n = p.length, [x1, y1] = p[n - 1], [x0, y0] = p[Math.max(0, n - 4)], a = Math.atan2(y1 - y0, x1 - x0), h = 8 * PW; + c.beginPath(); + ribbon(c, p, w, sd, { tin: 4, tout: 2, w0: 0.5, w1: 0.7 }); + ribbon(c, [[x1 + Math.cos(a + 2.6) * h, y1 + Math.sin(a + 2.6) * h], [x1, y1], [x1 + Math.cos(a - 2.5) * h, y1 + Math.sin(a - 2.5) * h]], w, sd + 1, { tin: 2, tout: 2, w0: 0.6, w1: 0.6 }); + c.fill(); +} +function drawLabels(s, c) { + const g = s.g, u = g.u, P = s.P, T = g.lh, night = smooth(0.5, 0.7, s.d); + const col = rgba(mix(P.ink, P.paper, night), s.pres.hero); + const size = Math.max(13, 17 * u); + c.fillStyle = col; + if (g.portrait) { + // the lighthouse, from the left of its lamp + const tx = T.x - T.wB * 1.9, ty = T.top + T.h * 0.3; + label(c, 'a lighthouse (stable)', tx, ty, size, col, 'right'); + c.fillStyle = col; arrow(c, [[tx + 4, ty - 1], [tx + 14, ty - 5], [T.x - T.wB * 0.62, T.top + T.h * 0.2]], TEX * 1.1, 8); + } else { + const tipX = T.x - T.wB * 0.62, tipY = T.top + T.h * 0.6, ax = tipX - 88 * u, ay = tipY + 38 * u; + label(c, 'a lighthouse (stable)', ax, ay, size, col, 'right'); + c.fillStyle = col; arrow(c, [[ax + 6 * u, ay - 3 * u], [ax + 42 * u, ay - 22 * u], [tipX - 6 * u, tipY]], TEX * 1.1, 8); + } + // the cloud with legs + const sc = g.sheepCloud, p = s.cloudXY(sc), w = sc.w; + const lx = p.x + w * 0.42 + (g.portrait ? 6 : 14 * u), ly = p.y + (g.portrait ? 12 : 22 * u); + c.font = `400 ${size}px ${HAND}`; + if (lx + 16 * u + c.measureText('a cloud (or a sheep)').width < (g.portrait ? g.lamp.x - T.er * 1.7 : T.x - T.er * 3)) { + label(c, 'a cloud (or a sheep)', lx + 16 * u, ly + 4 * u, size, col, 'left'); + c.fillStyle = col; arrow(c, [[lx + 12 * u, ly + 3 * u], [lx, ly + 2 * u], [p.x + w * 0.36, p.y + 6 * u]], TEX * 1.1, 9); + } + // the sea + const sea = seaLabelSpot(s); + if (sea) { + const wTxt = c.measureText ? (c.font = `400 ${size}px ${HAND}`, c.measureText('the sea (wet)').width) : 100; + label(c, 'the sea (wet)', sea.x, sea.y, size, rgba(P.paper, s.pres.hero), 'left'); + c.fillStyle = rgba(P.paper, s.pres.hero); arrow(c, [[sea.x + wTxt * 0.62, sea.y + 11 * u], [sea.x + wTxt * 0.5, sea.y + 22 * u], [sea.x + wTxt * 0.3, sea.y + 30 * u]], TEX * 1.1, 10); + } +} +function seaLabelSpot(s) { + const g = s.g, u = g.u, wTxt = 112 * u + 10; + if (g.portrait) { const y = g.horizon + (g.H - g.horizon) * 0.55; return { x: g.W * 0.3, y }; } + // in the sea, right of the notes or below them, clear of the key + for (const fy of [0.07, 0.14, 0.3, 0.55, 0.7, 0.85]) { + const y = g.horizon + (g.H - g.horizon) * fy, right = s.hillLeftX(y + 26 * u) - 10 * u; + let x0 = 30 * u + 22 * u; + if (y < g.note.y1) x0 = Math.max(x0, g.note.x1 + 16 * u); + if (g.key && y + 30 * u > g.key.y0) x0 = Math.max(x0, g.key.x1 + 24 * u); + if (right - x0 > wTxt) return { x: x0 + Math.min(20 * u, (right - x0 - wTxt) * 0.4), y: y + 8 * u }; + } + return null; +} + +/* ---------- surprises ---------- */ +function drawObj(s, c, o) { + const g = s.g, P = s.P, k = g.u * o.sc, lit = o.lit * s.beamI(), sq = s.squash(o), t = s.reduced ? 0 : s.t; + if (o.dying != null) c.globalAlpha = clamp(1 - (s.t - o.dying) / 0.7); + c.save(); c.translate(o.x, o.y); + if (lit > 0.15) { + // a startled "!", in the pen + const jy = -40 * k - Math.abs(Math.sin(t * 8)) * 3 * k; + c.globalAlpha *= clamp(lit * 1.6); + c.beginPath(); ribbon(c, [[2.6 * k, jy - 15 * k], [2 * k, jy - 6 * k], [1.6 * k, jy - 1 * k]], DET * 1.35, o.seed, { tin: 1, tout: 4, w0: 1, w1: 0.3 }); c.fillStyle = INK; c.fill(); + dot(c, 1.3 * k, jy + 4 * k, 1.7 * PW, INK); + c.globalAlpha = o.dying != null ? clamp(1 - (s.t - o.dying) / 0.7) : 1; + } + c.scale((o.flip ? -1 : 1) * (1 + sq * 0.6), 1 - sq); + const tint = base => mix(mul(base, P.paper), P.beam, clamp(lit) * 0.75); + switch (o.kind) { + case 'sheep': drawSheep(c, k, tint(C.wool), o, t); break; + case 'cow': drawCow(c, k, tint(hex('#FFFFFF')), P, o, t); break; + case 'gull': drawGull(c, k, tint(hex('#FFFFFF')), o, t); break; + case 'duck': drawDuck(c, k, tint(C.duck), P, o, t); break; + case 'monster': drawMonster(c, k, tint(C.monster), P, o, t); break; + } + c.restore(); + c.globalAlpha = 1; +} +function drawSheep(c, u, wool, o, t) { + const fall = o.state === 'fall', step = Math.abs(o.vx) > 2 ? Math.sin(t * 14 + o.seed) * 2 * u : 0; + c.beginPath(); + [-8, -3, 5, 10].forEach((lx, i) => ribbon(c, [[lx * u, -10 * u], [lx * u + (fall ? 3 * u : (i % 2 ? step : -step)), 0]], DET * 1.05, i, { tin: 1, tout: 1, w0: 0.9, w1: 1 })); + c.fillStyle = INK; c.fill(); + const body = []; + for (let i = 0; i < 40; i++) { const a = i / 40 * TAU, r = 1 + 0.13 * Math.abs(Math.sin(a * 5 + o.seed)); body.push([Math.cos(a) * 14 * u * r, -16 * u + Math.sin(a) * 9.5 * u * r]); } + shape(c, body, wool, DET, o.seed % 7); + // tail puff, black face, an ear, an eye + const head = ellipse(15 * u, -19 * u, 5 * u, 6.4 * u, 18, 0, TAU, -0.35); + poly(c, head); c.fillStyle = INK; c.fill(); + const ear = ellipse(11.5 * u, -23.5 * u, 3.6 * u, 1.6 * u, 10, 0, TAU, 0.5); + poly(c, ear); c.fill(); + const tuft = []; for (let i = 0; i < 10; i++) { const a = Math.PI * 1.05 + i / 9 * Math.PI * 0.9; tuft.push([13.5 * u + Math.cos(a) * 5 * u, -24 * u + Math.sin(a) * 2.6 * u]); } + c.beginPath(); ribbon(c, tuft, TEX, 3, { tin: 1, tout: 1, color: rgba(wool) }); c.fillStyle = rgba(wool); c.fill(); + dot(c, 16.6 * u, -20.5 * u, 1.5 * u, '#FFFFFF'); dot(c, 17.1 * u, -20.3 * u, 0.75 * u, INK); +} +function drawCow(c, u, white, P, o, t) { + const step = Math.abs(o.vx) > 2 ? Math.sin(t * 9 + o.seed) * 2 * u : 0; + c.beginPath(); + [-12, -7, 8, 13].forEach((lx, i) => ribbon(c, [[lx * u, -11 * u], [lx * u + (i % 2 ? step : -step), 0]], DET * 1.15, i, { tin: 1, tout: 1, w0: 0.9, w1: 1 })); + ribbon(c, [[-17 * u, -20 * u], [-21 * u, -14 * u], [-20 * u, -7 * u]], TEX, 5, { tin: 1, tout: 1 }); + c.fillStyle = INK; c.fill(); + dot(c, -20 * u, -6 * u, 2 * u, INK); + const body = curve([[-17 * u, -22 * u], [-6 * u, -26.5 * u], [10 * u, -25.5 * u], [17 * u, -20 * u], [15 * u, -11 * u], [0, -10 * u], [-16 * u, -11 * u], [-18.5 * u, -16 * u]], 3, true); + print(c, body, white); + // patches, inside the body + c.save(); poly(c, body, true, REGX, REGY); c.clip(); + const R = rng(o.seed + 3); + for (let i = 0; i < 3; i++) { const px = (-11 + i * 9 + R() * 4) * u, py = (-21 + R() * 8) * u; poly(c, ellipse(px, py, (3.5 + R() * 3) * u, (2.6 + R() * 2) * u, 12, 0, TAU, R())); c.fillStyle = INK; c.fill(); } + c.restore(); + c.beginPath(); loop(c, body, DET, 3); c.fillStyle = INK; c.fill(); + // head, muzzle, horns, an ear + const head = curve([[15 * u, -29 * u], [21 * u, -31 * u], [24.5 * u, -26 * u], [24.5 * u, -20 * u], [18 * u, -19.5 * u], [15 * u, -24 * u]], 2, true); + shape(c, head, white, DET, 4); + const muzzle = ellipse(23 * u, -21 * u, 4 * u, 3 * u, 14); + shape(c, muzzle, mul(C.pink, P.paper), TEX * 1.2, 2); + dot(c, 22 * u, -21 * u, 0.7 * u, INK); dot(c, 24.6 * u, -21.2 * u, 0.7 * u, INK); + dot(c, 20.2 * u, -26.6 * u, 1.05 * u, INK); + c.beginPath(); + ribbon(c, [[16.5 * u, -29.5 * u], [15 * u, -33.5 * u], [16.5 * u, -36 * u]], TEX * 1.3, 1, { tin: 1, tout: 2 }); + ribbon(c, [[21 * u, -31 * u], [22.5 * u, -34.5 * u], [21.5 * u, -37 * u]], TEX * 1.3, 2, { tin: 1, tout: 2 }); + c.fillStyle = INK; c.fill(); + poly(c, ellipse(13.6 * u, -27.5 * u, 3 * u, 1.5 * u, 10, 0, TAU, 0.4)); c.fillStyle = INK; c.fill(); +} +function drawGull(c, u, white, o, t) { + const fl = o.state === 'hover' ? 2 * u : Math.sin(t * 9 + o.seed) * 6 * u; + c.beginPath(); + ribbon(c, curve([[-1 * u, -4 * u], [-8 * u, -11 * u - fl * 0.4], [-14 * u, -10 * u - fl], [-20 * u, -6 * u - fl]], 2), DET * 1.15, 3, { tin: 2, tout: 6, w0: 1, w1: 0.2 }); + ribbon(c, curve([[2 * u, -4 * u], [8 * u, -11 * u - fl * 0.4], [14 * u, -10 * u - fl], [20 * u, -6 * u - fl]], 2), DET * 1.15, 4, { tin: 2, tout: 6, w0: 1, w1: 0.2 }); + c.fillStyle = INK; c.fill(); + shape(c, ellipse(0, -2.5 * u, 7.5 * u, 3.6 * u, 18), white, DET * 0.9, 1); + dot(c, 5 * u, -4 * u, 3 * u + REGX * 0.2, white); + poly(c, [[7 * u, -4 * u], [12 * u, -3 * u], [7 * u, -1.8 * u]]); c.fillStyle = rgba(C.beak); c.fill(); + dot(c, 5.6 * u, -4.6 * u, 0.8 * u, INK); +} +function drawDuck(c, u, yellow, P, o, t) { + const body = curve([[-12 * u, -8 * u], [-14 * u, -13 * u], [-8 * u, -10 * u], [2 * u, -10.5 * u], [10 * u, -8 * u], [11 * u, -3 * u], [4 * u, -0.5 * u], [-8 * u, -0.5 * u]], 2, true); + shape(c, body, yellow, DET, 1); + const head = ellipse(6.5 * u, -15 * u, 6 * u, 5.6 * u, 22); + shape(c, head, yellow, DET, 2); + const beak = [[11.5 * u, -15.5 * u], [17.5 * u, -14 * u], [16 * u, -12 * u], [11.5 * u, -12.2 * u]]; + print(c, beak, C.beak); + c.beginPath(); loop(c, dens(beak, 2, true), TEX * 1.2, 5); ribbon(c, ellipse(-1 * u, -6 * u, 6 * u, 3 * u, 10, 0.2, 2.2), TEX, 6, { tin: 1, tout: 2 }); c.fillStyle = INK; c.fill(); + dot(c, 8 * u, -16.5 * u, 1.35 * u, INK); dot(c, 7.6 * u, -17 * u, 0.45 * u, '#FFFFFF'); + // a little water at the waterline + c.beginPath(); ribbon(c, [[-13 * u, 0.5 * u], [-4 * u, -1.2 * u], [5 * u, 0.6 * u], [13 * u, -0.6 * u]], TEX, 7, { tin: 2, tout: 2 }); c.fillStyle = rgba(P.foam); c.fill(); +} +function drawMonster(c, u, pink, P, o, t) { + const sw = Math.sin(t * 1.6 + o.seed); + for (let i = 0; i < 3; i++) { + const hx = (-30 + i * 19) * u, hh = (8 + 2.5 * Math.sin(t * 2 + i * 1.3)) * u * (0.85 + 0.15 * sw); + const hump = ellipse(hx, 1 * u, 8.5 * u, hh, 14, Math.PI, TAU); + print(c, [...hump, [hx + 8.5 * u, 1 * u]], pink); + c.beginPath(); ribbon(c, hump, DET, i, { tin: 2, tout: 2 }); c.fillStyle = INK; c.fill(); + c.beginPath(); ribbon(c, [[hx - 11 * u, 1.5 * u], [hx, 0], [hx + 11 * u, 1.5 * u]], TEX, i + 4, { tin: 2, tout: 2 }); c.fillStyle = rgba(P.foam); c.fill(); + } + const neck = curve([[20 * u, 1 * u], [22 * u, -10 * u], [27 * u, -20 * u], [34 * u, -25 * u], [42 * u, -26 * u], [48 * u, -21 * u], [46 * u, -16 * u], [38 * u, -16 * u], [33 * u, -11 * u], [31 * u, 1 * u]], 2); + print(c, neck, pink); + c.beginPath(); ribbon(c, neck, DET, 7, { tin: 2, tout: 2 }); c.fillStyle = INK; c.fill(); + c.beginPath(); ribbon(c, [[16 * u, 1.5 * u], [26 * u, 0], [36 * u, 1.5 * u]], TEX, 8, { tin: 2, tout: 2 }); c.fillStyle = rgba(P.foam); c.fill(); + dot(c, 41 * u, -21.5 * u, 2.6 * u, '#FFFFFF'); + c.beginPath(); ribbon(c, ellipse(41 * u, -21.5 * u, 2.6 * u, 2.6 * u, 10), TEX, 9, { tin: 1, tout: 1, w0: 1, w1: 1 }); c.fillStyle = INK; c.fill(); + dot(c, 41.8 * u, -21.3 * u, 1.1 * u, INK); + c.beginPath(); ribbon(c, [[45 * u, -18 * u], [47 * u, -17.6 * u]], TEX, 10, { tin: 1, tout: 1 }); c.fill(); + // its little top hat, with a red band + const hat = [[36 * u, -25.5 * u], [37 * u, -35 * u], [45 * u, -35.5 * u], [45.5 * u, -26 * u]]; + poly(c, hat); c.fillStyle = INK; c.fill(); + c.fillStyle = rgba(mul(C.chimney, P.paper)); c.fillRect(36.6 * u, -29.5 * u, 8.6 * u, 2 * u); + c.beginPath(); ribbon(c, [[33 * u, -25.8 * u], [49 * u, -26.4 * u]], DET * 1.2, 2, { tin: 1, tout: 1, w0: 1, w1: 1 }); c.fillStyle = INK; c.fill(); +} + +window.Hill = { Scene, themes: Object.keys(THEMES), states: STATES }; +})(); diff --git a/site/style.css b/site/style.css index df47a672..5e780b98 100644 --- a/site/style.css +++ b/site/style.css @@ -1,7 +1,16 @@ +/* stablebar.app: Lighthouse Hill in absurd ink. Paper notes taped over a felt-pen drawing + (scene.js), with the real bar running along the top. */ + +@font-face { + font-family: "Shantell Sans"; + src: url(fonts/shantell-sans-display.woff2) format("woff2"); + font-weight: 800; + font-display: swap; +} @font-face { - font-family: "Host Grotesk"; - src: url(fonts/host-grotesk.woff2) format("woff2"); - font-weight: 300 800; + font-family: "Shantell Sans Hand"; + src: url(fonts/shantell-sans-hand.woff2) format("woff2"); + font-weight: 600; font-display: swap; } @font-face { @@ -10,151 +19,101 @@ font-weight: 200 800; font-display: swap; } +/* Fallbacks scaled to take the same room as the web fonts, so the swap doesn't move the page */ +@font-face { font-family: "Shantell Fallback"; src: local("Arial Bold"), local("Arial-BoldMT"); font-weight: 800; size-adjust: 117%; ascent-override: 87.2%; descent-override: 27.3%; line-gap-override: 0%; } +@font-face { font-family: "Hand Fallback"; src: local("Arial Bold"), local("Arial-BoldMT"); font-weight: 600; size-adjust: 112%; ascent-override: 91%; descent-override: 28.6%; line-gap-override: 0%; } +@font-face { font-family: "Atkinson Fallback"; src: local("Arial"), local("ArialMT"); font-weight: 100 549; size-adjust: 98.6%; ascent-override: 99.8%; descent-override: 32%; line-gap-override: 0%; } +@font-face { font-family: "Atkinson Fallback"; src: local("Arial Bold"), local("Arial-BoldMT"); font-weight: 550 900; size-adjust: 98.6%; ascent-override: 99.8%; descent-override: 32%; line-gap-override: 0%; } :root { - color-scheme: dark; - --ground: #15110d; - --text: #efe6da; - --text-2: #b8ac9d; - --text-3: #a09484; - --line: #efe6da2e; - --line-2: #efe6da66; - --backdrop: url(images/setup-amber-light.webp); - --backdrop-2: url(images/setup-amber-dark.webp); - --dim: linear-gradient(color-mix(in srgb, var(--ground) 40%, transparent) 0 0); - --win-bg: #1e1d1c; - --win-fg: #f5f5f7; - --win-ring: #000; - --win-edge: #ffffff17; - --card-bg: #2a2928; - --card-edge: #ffffff12; - --card-hover: #ffffff40; - --sys-blue: #0a84ff; - --shadow-1: #000; - --shadow-2: #000c; - --code-bg: #100e0c; - --code-fg: #d9cfc2; - --code-dim: #a09484; + color-scheme: light; + /* the page: white paper notes at every hour, a felt pen, yellow tape */ + --paper: #ffffff; + --text: #141414; + --text-2: #3b3b3b; + --pen: #111111; + --tape: #ffe04ab8; + --hi: #ffe04a; + --red: #d03a28; + --link: #2e58d8; + --ring: #2e58d8; + --sky: #86cbf2; + --sea: #2e58d8; + --screen: #86cbf2; + --strip: #ffffff; + /* the bar keeps the site's amber Download */ --amber: #f5821f; --amber-ink: #1b1007; - --ring: #f5821f; - --ease: cubic-bezier(.22, 1, .36, 1); - --gutter: 20px; - --bar-h: 40px; - --desk-r: 0px; - --display: "Host Grotesk", "Helvetica Neue", sans-serif; - --body: "Atkinson Hyperlegible Next", "Helvetica Neue", sans-serif; - --mac: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; - --mono: ui-monospace, "SF Mono", Menlo, monospace; -} -:root[data-mode="light"] { - color-scheme: light; - --ground: #f5f0e8; - --text: #1d1813; - --text-2: #4f463d; - --text-3: #60564b; - --line: #1d181329; - --line-2: #1d18137a; + /* the app's windows: Add here, the right-click menu, the Store */ --win-bg: #f4f2ef; --win-fg: #1d1d1f; --win-ring: #00000026; --win-edge: #ffffff99; - --card-bg: #ffffff; - --card-edge: #0000001a; - --card-hover: #00000033; --sys-blue: #0068da; - --shadow-1: #4a2d1240; - --shadow-2: #4a2d1229; - --code-bg: #fffaf2; - --code-fg: #3b3229; - --code-dim: #6f6559; - --ring: #a3470a; + --blue-fill: #0068da; + --shadow-1: #1b2a5240; + --shadow-2: #1b2a5233; + --ease: cubic-bezier(.22, 1, .36, 1); + --bar-h: 40px; + --display: "Shantell Sans", "Shantell Fallback", "Chalkboard SE", sans-serif; + --hand: "Shantell Sans Hand", "Hand Fallback", "Chalkboard SE", sans-serif; + --body: "Atkinson Hyperlegible Next", "Atkinson Fallback", "Helvetica Neue", sans-serif; + --mac: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; + --mono: ui-monospace, "SF Mono", Menlo, monospace; + /* felt-pen pieces: the washing line, a rule, and the highlighter */ + --string: url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 400 104%27 preserveAspectRatio=%27none%27%3E%3Cpath d=%27M0 9 L10 9.9 L20 9.3 L30 9.6 L40 10.8 L50 10.5 L60 10.2 L70 11.4 L80 11.5 L90 10.9 L100 11.7 L110 12.3 L120 11.5 L130 11.8 L140 12.8 L150 12.1 L160 11.9 L170 12.9 L180 12.7 L190 11.9 L200 12.7 L210 13 L220 12 L230 12.3 L240 13 L250 12.1 L260 11.7 L270 12.6 L280 12.1 L290 11.2 L300 11.9 L310 11.9 L320 10.7 L330 10.9 L340 11.4 L350 10.2 L360 9.9 L370 10.5 L380 9.7 L390 8.8 L400 9.3%27 fill=%27none%27 stroke=%27%23111%27 stroke-width=%272%27 stroke-linecap=%27round%27 vector-effect=%27non-scaling-stroke%27/%3E%3C/svg%3E"); + --rule: url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27160%27 height=%2710%27%3E%3Cpath d=%27M0 5 L2.5 5.7 L5 5.9 L7.5 5.9 L10 6 L12.5 6 L15 5.9 L17.5 5.9 L20 5.8 L22.5 5.7 L25 5.6 L27.5 5.6 L30 5.6 L32.5 5.6 L35 5.7 L37.5 5.8 L40 6 L42.5 6.1 L45 6.3 L47.5 6.4 L50 6.5 L52.5 6.6 L55 6.6 L57.5 6.6 L60 6.5 L62.5 6.3 L65 6.1 L67.5 5.9 L70 5.6 L72.5 5.3 L75 5 L77.5 4.7 L80 4.5 L82.5 4.3 L85 4.1 L87.5 4.1 L90 4 L92.5 4 L95 4.1 L97.5 4.1 L100 4.2 L102.5 4.3 L105 4.4 L107.5 4.4 L110 4.4 L112.5 4.4 L115 4.3 L117.5 4.2 L120 4 L122.5 3.9 L125 3.7 L127.5 3.6 L130 3.5 L132.5 3.4 L135 3.4 L137.5 3.4 L140 3.5 L142.5 3.7 L145 3.9 L147.5 4.1 L150 4.4 L152.5 4.7 L155 5 L157.5 5.3 L160 5.5%27 fill=%27none%27 stroke=%27%23111%27 stroke-width=%272.4%27 stroke-linecap=%27round%27/%3E%3C/svg%3E"); + --rule-night: url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27160%27 height=%2710%27%3E%3Cpath d=%27M0 5 L2.5 5.7 L5 5.9 L7.5 5.9 L10 6 L12.5 6 L15 5.9 L17.5 5.9 L20 5.8 L22.5 5.7 L25 5.6 L27.5 5.6 L30 5.6 L32.5 5.6 L35 5.7 L37.5 5.8 L40 6 L42.5 6.1 L45 6.3 L47.5 6.4 L50 6.5 L52.5 6.6 L55 6.6 L57.5 6.6 L60 6.5 L62.5 6.3 L65 6.1 L67.5 5.9 L70 5.6 L72.5 5.3 L75 5 L77.5 4.7 L80 4.5 L82.5 4.3 L85 4.1 L87.5 4.1 L90 4 L92.5 4 L95 4.1 L97.5 4.1 L100 4.2 L102.5 4.3 L105 4.4 L107.5 4.4 L110 4.4 L112.5 4.4 L115 4.3 L117.5 4.2 L120 4 L122.5 3.9 L125 3.7 L127.5 3.6 L130 3.5 L132.5 3.4 L135 3.4 L137.5 3.4 L140 3.5 L142.5 3.7 L145 3.9 L147.5 4.1 L150 4.4 L152.5 4.7 L155 5 L157.5 5.3 L160 5.5%27 fill=%27none%27 stroke=%27%23f2eeff%27 stroke-width=%272.4%27 stroke-linecap=%27round%27/%3E%3C/svg%3E"); + --highlight: url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 100 20%27 preserveAspectRatio=%27none%27%3E%3Cpath d=%27M2 4.5 9 2.6 50 2 92 1.6 98 3.4 96.4 7 99 10.6 97.6 15.2 99.2 18.4 60 18.6 8 19 1 17.4 3 13.2 .8 9.4Z%27 fill=%27%23ffe04a%27/%3E%3C/svg%3E"); +} +:root[data-mode="dark"] { + --sky: #232662; + --sea: #14184a; + --screen: #2f3374; + --strip: #1c1e4e; + --win-bg: #1e1d1c; + --win-fg: #f5f5f7; + --win-ring: #000; + --win-edge: #ffffff17; + --sys-blue: #0a84ff; + --shadow-1: #000; + --shadow-2: #000c; } * { box-sizing: border-box; margin: 0; padding: 0; } html { - background: var(--ground); color: var(--text); + background: var(--sky); color: var(--text); -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; + scroll-padding-top: calc(var(--bar-h) + 24px); } -body { font: 400 16px/1.55 var(--body); overflow-x: hidden; } -a { color: inherit; text-decoration: none; } +body { font: 400 18px/1.55 var(--body); overflow-x: hidden; touch-action: pan-y pinch-zoom; } +a { color: inherit; } button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; } -:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; } -:where(.brand, .nav a, .more-link, footer a, .skip):focus-visible { border-radius: 4px; } -::selection { background: color-mix(in srgb, var(--amber) 40%, transparent); } +:focus-visible { outline: 2.5px solid var(--ring); outline-offset: 3px; border-radius: 4px; } +::selection { background: var(--hi); color: #111; } +p { text-wrap: pretty; } +h1, h2, h3 { text-wrap: balance; } +.num { font-variant-numeric: tabular-nums; } +.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } .skip { - position: absolute; top: 10px; left: var(--gutter); z-index: 100; padding: 10px 14px; border-radius: 8px; - background: var(--text); color: var(--ground); font: 600 14px/1 var(--display); - translate: 0 calc(-100% - 20px); + position: fixed; top: 8px; left: 10px; z-index: 100; padding: 10px 14px; border-radius: 10px; + background: var(--paper); color: var(--text); box-shadow: 0 0 0 2.4px var(--pen); + font: 600 16px/1 var(--hand); text-decoration: none; translate: 0 calc(-100% - 20px); } .skip:focus-visible { translate: none; } -.page { max-width: calc(1800px + var(--gutter) * 2); margin: 0 auto; padding: 0 var(--gutter); } -.num { font-variant-numeric: tabular-nums; } -.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } -p { text-wrap: pretty; } -h1, h2 { text-wrap: balance; } - -/* ---------- Top ---------- */ -.top { display: flex; align-items: center; justify-content: space-between; height: 60px; } -.brand { display: flex; align-items: center; gap: 10px; font: 600 16px/1 var(--display); letter-spacing: -.01em; } -.brand img { width: 26px; height: 26px; } -.nav { display: flex; gap: 18px; font-size: 14px; color: var(--text-2); } -.nav a { padding: 10px 0; } -.nav a:hover { color: var(--text); } -.nav .gh { display: none; } -.nav { align-items: center; } -.mode { display: grid; place-items: center; width: 36px; height: 36px; margin: -8px -10px -8px -4px; border-radius: 50%; color: var(--text-2); } -.mode:hover { color: var(--text); background: var(--line); } -.mode svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; } -.mode .sun, [data-mode="light"] .mode .moon { display: none; } -[data-mode="light"] .mode .sun { display: block; } - -/* ---------- Hero ---------- */ -.hero { padding: 36px 0 34px; } -h1 { - font: 600 clamp(38px, 10.4vw, 62px)/1.02 var(--display); letter-spacing: -.035em; - max-width: 12ch; -} -h1 .wide { display: none; } -.lede { margin-top: 18px; font-size: 18px; line-height: 1.5; color: var(--text-2); max-width: 36ch; } -.get { margin-top: 28px; display: flex; flex-wrap: wrap; align-items: center; gap: 14px 18px; } -.cta { - display: inline-flex; align-items: center; gap: 10px; height: 46px; padding: 0 18px 0 16px; - background: var(--amber); color: var(--amber-ink); border-radius: 9px; - font: 600 15.5px/1 var(--display); letter-spacing: -.005em; - transition: background .15s; -} -.cta:hover { background: #ff9233; } -.cta svg { width: 15px; height: 18px; fill: currentColor; } -.cta .ver { margin-left: -2px; font-weight: 500; font-variant-numeric: tabular-nums; opacity: .75; } -.req { font-size: 13.5px; line-height: 1.45; color: var(--text-3); } -.brew { margin-top: 14px; } -.req code { font: 400 .92em/1 var(--mono); color: var(--text); white-space: nowrap; } - -/* ---------- The desk: the product, at real scale ---------- */ -.desk { - position: relative; margin: 34px calc(var(--gutter) * -1) 0; height: 260px; - isolation: isolate; z-index: 10; -} -.wall { - position: absolute; inset: 0; z-index: -1; overflow: hidden; border-radius: var(--desk-r); background: #f08a24; - -webkit-mask-image: radial-gradient(64% 84% at 78% 0%, #0000009e 0%, #00000075 40%, #0000001f 74%, transparent 100%), linear-gradient(#000 55%, transparent); - -webkit-mask-composite: source-in; - mask-image: radial-gradient(64% 84% at 78% 0%, #0000009e 0%, #00000075 40%, #0000001f 74%, transparent 100%), linear-gradient(#000 55%, transparent); - mask-composite: intersect; -} -.wall::before, .wall::after { content: ""; position: absolute; inset: -60% -10%; background: var(--backdrop) center / cover; scale: 1.4; } -.wall::after { background-image: var(--backdrop-2); opacity: .5; } -@media (prefers-reduced-motion: no-preference) { - .wall::before { animation: grow 44s ease-in-out infinite alternate, turn 69s ease-in-out infinite alternate, wander 57s ease-in-out infinite alternate; } - .wall::after { animation: grow 44s ease-in-out -22s infinite alternate, turn 69s ease-in-out -35s infinite alternate, wander 57s ease-in-out -28s infinite alternate, fade 25s ease-in-out infinite alternate; } + +/* The drawing lives behind the page. Until it paints, or without it, the page sits on its sky. */ +.scene { + position: fixed; left: 0; top: 0; z-index: 0; width: 100%; height: 100vh; height: 100lvh; + background: linear-gradient(var(--sky) 0 61%, var(--sea) 61%); } -@keyframes grow { from { scale: 1.32; } to { scale: 1.48; } } -@keyframes turn { from { rotate: -12deg; } to { rotate: 12deg; } } -@keyframes wander { from { translate: -6% -6%; } to { translate: 6% 6%; } } -@keyframes fade { from { opacity: 0; } to { opacity: 1; } } +.scene canvas { display: block; } +main, .site-foot { position: relative; z-index: 1; } +main { overflow-x: clip; } + +/* ---------- The bar, drawn as the app draws it ---------- */ /* Theme tokens, from each theme's config.json in Resources/themes */ -[data-theme] { --strip-bg: transparent; --strip-edge: none; --strip-radius: 0px; --strip-m: 0px; --w-bg: #242629ee; --w-border: #ffffff1f; --w-shadow: #00000040; --w-glow: none; --w-radius: 14px; --tile-h: 28px; --fg: #f5f5f7; --muted: #a1a1a6; --accent: #0a84ff; --accent-text: #ffffff; --ws-off: #f5f5f7; --ws-ind: #0a84ff; --ws-on: #ffffff; --ws-r: 10px; --pop-bg: #242629ee; --pop-border: #ffffff1f; --pop-radius: 14px; --raised: #36383c; --track: #f5f5f726; --bar-font: 400 13px/1 var(--mac); --pop-font: 400 13px/16px var(--mac); } +[data-theme] { --rest-h: 40px; --strip-bg: transparent; --strip-edge: none; --strip-radius: 0px; --strip-m: 0px; --w-bg: #242629ee; --w-border: #ffffff1f; --w-shadow: #00000040; --w-glow: none; --w-radius: 14px; --tile-h: 28px; --fg: #f5f5f7; --muted: #a1a1a6; --accent: #0a84ff; --accent-text: #ffffff; --ws-off: #f5f5f7; --ws-ind: #0a84ff; --ws-on: #ffffff; --ws-r: 10px; --pop-bg: #242629ee; --pop-border: #ffffff1f; --pop-radius: 14px; --raised: #36383c; --track: #f5f5f726; --bar-font: 400 13px/1 var(--mac); --pop-font: 400 13px/16px var(--mac); } [data-theme] { --tray-tint: color-mix(in srgb, var(--fg) 7%, transparent); --tray-line: color-mix(in srgb, var(--fg) 12%, transparent); --tray-backing: transparent; } [data-theme="built-in"] { --tray-backing: #2b2b2b; } [data-mode="light"] [data-theme="built-in"] { --tray-backing: #f7f7f7; } @@ -165,42 +124,42 @@ h1 .wide { display: none; } [data-mode="light"] [data-theme="gruvbox"] { --strip-bg: linear-gradient(180deg, #fbf1c7ee 0%, #fbf1c7ee 79%, #cc241d 79%, #cc241d 86%, #d65d0e 86%, #d65d0e 93%, #d79921 93%, #d79921 100%); --w-bg: transparent; --w-border: transparent; --w-shadow: transparent; --fg: #3c3836; --muted: #7c6f64; --accent: #af3a03; --accent-text: #fbf1c7; --ws-off: #7c6f64; --ws-ind: transparent; --ws-on: #af3a03; --pop-bg: #fbf1c7ee; --pop-border: #3c38361f; --raised: #ebdbb2; --track: #d5c4a1; } [data-theme="leman"] { --strip-bg: #0f1d21ee; --strip-edge: inset 0 0 0 1px #dcebe81f; --strip-radius: 14px; --strip-m: 6px; --tile-h: 22px; --w-bg: transparent; --w-border: transparent; --w-shadow: transparent; --w-radius: 10px; --fg: #dcebe8; --muted: #8fb0ad; --accent: #5cc5b8; --accent-text: #0f1d21; --ws-off: #8fb0ad; --ws-ind: #5cc5b829; --ws-on: #5cc5b8; --ws-r: 7px; --pop-bg: #0f1d21ee; --pop-border: #dcebe81f; --pop-radius: 10px; --raised: #1d3238; --track: #dcebe826; --bar-font: 400 13px/1 var(--mac); } [data-mode="light"] [data-theme="leman"] { --strip-bg: #edf3f2ee; --strip-edge: inset 0 0 0 1px #12343b1f; --w-bg: transparent; --w-border: transparent; --w-shadow: transparent; --fg: #12343b; --muted: #4d6b70; --accent: #0e7c7b; --accent-text: #ffffff; --ws-off: #4d6b70; --ws-ind: #0e7c7b1f; --ws-on: #0e7c7b; --pop-bg: #edf3f2ee; --pop-border: #12343b1f; --track: #12343b26; } -[data-theme="nord"] { --bar-h: 30px; --strip-bg: #2e3440ee; --w-bg: transparent; --w-border: transparent; --w-shadow: transparent; --w-radius: 5px; --fg: #eceff4; --muted: #a3acbd; --accent: #88c0d0; --accent-text: #2e3440; --ws-off: #a3acbd; --ws-ind: #88c0d0; --ws-on: #eceff4; --ws-r: 1.5px; --pop-bg: #2e3440ee; --pop-border: #eceff41f; --pop-radius: 8px; --raised: #3b4252; --track: #4c566a; --bar-font: 500 12px/1 "Avenir Next", var(--mac); --pop-font: 500 12px/16px "Avenir Next", var(--mac); } +[data-theme="nord"] { --rest-h: 30px; --bar-h: 30px; --strip-bg: #2e3440ee; --w-bg: transparent; --w-border: transparent; --w-shadow: transparent; --w-radius: 5px; --fg: #eceff4; --muted: #a3acbd; --accent: #88c0d0; --accent-text: #2e3440; --ws-off: #a3acbd; --ws-ind: #88c0d0; --ws-on: #eceff4; --ws-r: 1.5px; --pop-bg: #2e3440ee; --pop-border: #eceff41f; --pop-radius: 8px; --raised: #3b4252; --track: #4c566a; --bar-font: 500 12px/1 "Avenir Next", var(--mac); --pop-font: 500 12px/16px "Avenir Next", var(--mac); } [data-mode="light"] [data-theme="nord"] { --strip-bg: #eceff4ee; --w-bg: transparent; --w-border: transparent; --w-shadow: transparent; --fg: #2e3440; --muted: #4c566a; --accent: #5e81ac; --accent-text: #eceff4; --ws-off: #4c566a; --ws-ind: #5e81ac; --ws-on: #2e3440; --pop-bg: #eceff4ee; --pop-border: #2e34401f; --raised: #d8dee9; --track: #d8dee9; } [data-theme="notch"] { --strip-bg: #000000; --w-bg: transparent; --w-border: transparent; --w-shadow: transparent; --w-radius: 6px; --muted: #8e8e93; --accent: #64d2ff; --accent-text: #000000; --ws-off: #8e8e93; --ws-ind: transparent; --ws-on: #f5f5f7; --ws-r: 0px; --pop-bg: #000000; --pop-border: #ffffff1a; --pop-radius: 6px; --raised: #1c1c1e; --track: #ffffff26; --bar-font: 500 13px/1 var(--mac); --pop-font: 500 13px/16px var(--mac); } [data-mode="light"] [data-theme="notch"] { --w-bg: transparent; --w-border: transparent; --w-shadow: transparent; --fg: #f5f5f7; --muted: #8e8e93; --accent: #64d2ff; --ws-off: #8e8e93; --ws-ind: transparent; --pop-bg: #000000; --pop-border: #ffffff1a; --raised: #1c1c1e; --track: #ffffff26; } [data-theme="phosphor"] { --tile-h: 27px; --strip-bg: #110d06ee; --w-bg: transparent; --w-border: transparent; --w-shadow: transparent; --w-glow: drop-shadow(0 0 4px #ffb00080); --w-radius: 0px; --fg: #ffb000; --muted: #b8800c; --accent: #ffcc5c; --accent-text: #110d06; --ws-off: #b8800c; --ws-ind: #ffb000; --ws-on: #110d06; --ws-r: 0px; --pop-bg: #110d06ee; --pop-border: #ffb00059; --pop-radius: 0px; --raised: #2b2009; --track: #ffb00033; --bar-font: 400 12px/1 "Monaco", var(--mono); --pop-font: 400 12px/15px "Monaco", var(--mono); } [data-mode="light"] [data-theme="phosphor"] { --strip-bg: #f6f1e6ee; --w-bg: transparent; --w-border: transparent; --w-shadow: transparent; --w-glow: none; --fg: #5c3a00; --muted: #87683b; --accent: #a86400; --accent-text: #f6f1e6; --ws-off: #87683b; --ws-ind: #5c3a00; --ws-on: #f6f1e6; --pop-bg: #f6f1e6ee; --pop-border: #5c3a0040; --raised: #ebe2cf; --track: #5c3a0026; } -/* The dock holds the bar and its popups. It sits at the top of the desk, - stays at the top of the window once the desk reaches it, and becomes - the page's header once the desk has scrolled away. */ -.dock { position: absolute; top: 0; left: 0; right: 0; height: var(--bar-h); z-index: 30; } -.dock.docked { position: fixed; z-index: 50; --desk-r: 0px; } +/* The dock holds the bar and its popups at the top of the window, for the whole page. + Once the page scrolls, a strip of paper slides in behind it so text never runs under its widgets. */ +.dock { position: fixed; top: 0; left: 0; right: 0; z-index: 50; height: var(--bar-h); } +[data-mode="dark"] .dock { color-scheme: dark; --ring: #ffe04a; } .dock::before { - content: ""; position: absolute; inset: 0; z-index: -1; - background: var(--ground); box-shadow: 0 1px 0 var(--line); - opacity: 0; transition: opacity .3s; + content: ""; position: absolute; inset: 0 0 -7px; z-index: -1; pointer-events: none; + background: var(--rule) left bottom / 160px 10px repeat-x, linear-gradient(var(--strip) 0 0) top / 100% calc(100% - 5px) no-repeat; + opacity: 0; translate: 0 -6px; transition: opacity .25s, translate .3s var(--ease); } -.dock.header::before { inset: 0 -100vw; opacity: 1; } +[data-mode="dark"] .dock::before { background-image: var(--rule-night), linear-gradient(var(--strip) 0 0); } +.dock.scrolled::before { opacity: 1; translate: none; } .bar { position: absolute; top: 0; left: 0; right: 0; height: calc(var(--bar-h) - 2 * var(--strip-m)); - margin: 0 var(--strip-m); padding: 0 12px; margin-top: var(--strip-m); + margin: var(--strip-m) var(--strip-m) 0; padding: 0 12px; display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--fg); font: var(--bar-font); -webkit-font-smoothing: auto; - background: var(--strip-bg); box-shadow: var(--strip-edge); - border-radius: max(var(--strip-radius), var(--desk-r)) max(var(--strip-radius), var(--desk-r)) var(--strip-radius) var(--strip-radius); + background: var(--strip-bg); box-shadow: var(--strip-edge); border-radius: var(--strip-radius); transition: background .25s, border-radius .25s, margin .25s var(--ease), height .25s var(--ease); } .bar .g { display: flex; align-items: center; gap: 6px; min-width: 0; } .w { flex: none; height: var(--tile-h); display: flex; align-items: center; gap: 7px; padding: 0 10px; white-space: nowrap; background: var(--w-bg); box-shadow: inset 0 0 0 1px var(--w-border), 0 1px 3px var(--w-shadow); border-radius: var(--w-radius); - filter: var(--w-glow); cursor: default; user-select: none; transition: background .15s, border-radius .25s; + filter: var(--w-glow); color: var(--fg); font: var(--bar-font); text-decoration: none; cursor: default; user-select: none; + transition: background .15s, border-radius .25s; } -.w[data-pop], .w.app, .w.more { cursor: pointer; } -.w[data-pop]:hover, .w.more:hover { background: var(--raised); } +.w[data-pop], .w.app, .w.more, .w.moon { cursor: pointer; } +.w[data-pop]:hover, .w.more:hover, .w.moon:hover { background: var(--raised); } .w.over, .w[hidden] { display: none !important; } .w svg { width: 15px; height: 15px; flex: none; } .w.apple { padding: 0 9px; font-size: 15px; } @@ -220,6 +179,7 @@ h1 .wide { display: none; } .menus { display: grid; grid-template-columns: 0fr; transition: grid-template-columns .3s var(--ease); } .menus > span { overflow: hidden; display: flex; gap: 14px; } .menus > span > :first-child { margin-left: 6px; } +.menus a { text-decoration: none; } .app.expand .menus { grid-template-columns: 1fr; } .w.more { padding: 0 9px; letter-spacing: .08em; } /* Sound: hovering it opens a volume slider before the icon, as on the right of the real bar */ @@ -233,6 +193,11 @@ h1 .wide { display: none; } .snd input::-webkit-slider-thumb { appearance: none; width: 13px; height: 13px; border-radius: 50%; background: #fff; box-shadow: 0 0 0 .5px #0000004d, 0 1px 2px #00000059; } .snd input::-moz-range-thumb { width: 13px; height: 13px; border: 0; border-radius: 50%; background: #fff; box-shadow: 0 0 0 .5px #0000004d, 0 1px 2px #00000059; } .snd .pct { min-width: 4ch; text-align: right; } +/* The night switch: a script widget with an icon and an action, as the app can draw one */ +.w.moon { padding: 0 9px; } +.w.moon svg { width: 16px; height: 16px; } +.moon .on, .moon[aria-pressed="true"] .off { display: none; } +.moon[aria-pressed="true"] .on { display: block; } /* Now playing: artwork, the track and an equalizer, as on the real bar */ .art { width: 16px; height: 16px; flex: none; border-radius: 3px; background: linear-gradient(150deg, #241a5c 0%, #6b2a8c 42%, #e0566a 72%, #f6a04d 100%); } @@ -287,20 +252,21 @@ h1 .wide { display: none; } .lane.fl { mask-image: linear-gradient(90deg, transparent, #000 18px); } .lane.fr { mask-image: linear-gradient(90deg, #000 calc(100% - 18px), transparent); } .lane.fl.fr { mask-image: linear-gradient(90deg, transparent, #000 18px, #000 calc(100% - 18px), transparent); } -.tile { position: relative; flex: none; display: flex; isolation: isolate; border-radius: 5px; cursor: grab; touch-action: none; user-select: none; } +.tile { position: relative; flex: none; display: flex; isolation: isolate; border-radius: 5px; user-select: none; } +.lane .tile { cursor: grab; touch-action: none; } .tile::before { content: ""; position: absolute; inset: -2px; z-index: -1; border-radius: 5px; background: var(--fg); opacity: 0; transition: opacity .1s; } -.tile:hover::before { opacity: .08; } +.lane .tile:hover::before { opacity: .08; } .tile:focus-visible { outline-offset: 2px; } .tile[hidden] { display: none; } .tile.lifted { opacity: 0; } -.tile .w { cursor: grab; } +.lane .tile .w { cursor: grab; } .minus { position: absolute; left: -5px; top: -5px; z-index: 2; display: grid; place-items: center; width: 14px; height: 14px; border-radius: 50%; background: #6e6e73; transition: opacity .15s; } .minus::before { content: ""; width: 7px; height: 1.8px; border-radius: 1px; background: #fff; } .add { flex: none; display: grid; place-items: center; width: 22px; height: 22px; border-radius: 5px; color: var(--fg); border: 1.5px dashed color-mix(in srgb, var(--fg) 55%, transparent); transition: opacity .15s; } .add svg { width: 12px; height: 12px; } -.add.on { background: var(--sys-blue); border-color: var(--sys-blue); color: #fff; } +.add.on { background: var(--blue-fill); border-color: var(--blue-fill); color: #fff; } .chip { position: absolute; left: 5px; top: -5px; z-index: 3; padding: 1px 4px; border-radius: 99px; font: 700 9px/1.25 var(--mac); color: var(--fg); background: linear-gradient(var(--tray-tint) 0 0) var(--pop-bg); box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--fg) 15%, transparent); opacity: 0; transition: opacity .15s; pointer-events: none; } -.g.target .chip { background: var(--sys-blue); color: #fff; } +.g.target .chip { background: var(--blue-fill); color: #fff; } .dragging .chip { opacity: 1; } .dragging .minus { opacity: 0; pointer-events: none; } .dragging .g.target .add { opacity: 0; } @@ -313,24 +279,22 @@ h1 .wide { display: none; } .gauge i { height: 5px; border-radius: 3px; background: linear-gradient(currentColor 0 0) 0 / var(--p) 100% no-repeat, var(--track); } .gauge i + i { height: 3px; } -/* The panel below the bar: Edit Bar in Place, then a hint, Undo and Done while editing */ +/* Below the bar while editing: a hint, Undo and Done */ .edit-ctl { - position: absolute; top: calc(100% + 12px); left: calc(var(--strip-m) + 6px); z-index: 5; - display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 12px; white-space: nowrap; + position: absolute; top: calc(100% + 12px); left: 50%; z-index: 5; translate: -50% 0; + display: none; align-items: center; gap: 12px; padding: 12px; border-radius: 12px; white-space: nowrap; color: var(--win-fg); font: 400 13px/1 var(--mac); background: color-mix(in srgb, var(--win-bg) 84%, transparent); -webkit-backdrop-filter: blur(24px) saturate(1.5); backdrop-filter: blur(24px) saturate(1.5); box-shadow: 0 0 0 .5px var(--win-ring), inset 0 0 0 1px var(--win-edge), 0 14px 34px -14px var(--shadow-2); } -.edit-ctl button { height: 22px; padding: 0 8px; border-radius: 5px; background: color-mix(in srgb, var(--win-fg) 12%, transparent); } -[data-mode="light"] .edit-ctl button { background: color-mix(in srgb, var(--win-fg) 7%, transparent); } +.editing .edit-ctl { display: flex; } +.adding .edit-ctl { display: none; } +.edit-ctl button { height: 22px; padding: 0 8px; border-radius: 5px; background: color-mix(in srgb, var(--win-fg) 7%, transparent); } +[data-mode="dark"] .edit-ctl button { background: color-mix(in srgb, var(--win-fg) 12%, transparent); } .edit-ctl button:hover { background: color-mix(in srgb, var(--win-fg) 18%, transparent); } -.edit-ctl .done, [data-mode="light"] .edit-ctl .done { background: var(--sys-blue); color: #fff; } +.edit-ctl .done, [data-mode="dark"] .edit-ctl .done { background: var(--blue-fill); color: #fff; } .edit-ctl button:disabled { opacity: .45; cursor: default; background: color-mix(in srgb, var(--win-fg) 12%, transparent); } .edit-ctl .hint { font-size: 11.5px; color: color-mix(in srgb, var(--win-fg) 62%, transparent); } -.editing .edit-ctl { left: 50%; translate: -50% 0; } -.edit-ctl .editing-hint, .edit-ctl .b, .editing .edit-ctl :is(.start, .idle) { display: none; } -.editing .edit-ctl :is(.editing-hint, .b) { display: inline-block; } -.header .edit-ctl, .adding .edit-ctl, .docked:not(.editing) .edit-ctl { display: none; } /* Add here: built-in widgets and the Store's packages, then a package's review */ .addpop { @@ -340,29 +304,29 @@ h1 .wide { display: none; } } .addpop.review { width: 400px; } .addpop::before { content: ""; position: absolute; top: -6px; left: var(--ax, 24px); width: 12px; height: 12px; rotate: 45deg; background: var(--win-bg); border-radius: 2px 0 0; box-shadow: -1px -1px 0 -.5px var(--win-edge); } -.addpop .t { margin-bottom: 10px; font: 700 10px/1 var(--mac); color: color-mix(in srgb, var(--win-fg) 55%, transparent); letter-spacing: .02em; } +.addpop .t { margin-bottom: 10px; font: 700 10px/1 var(--mac); color: color-mix(in srgb, var(--win-fg) 68%, transparent); letter-spacing: .02em; } .addpop .find { width: 100%; height: 26px; padding: 0 9px; border: 0; border-radius: 6px; background: color-mix(in srgb, var(--win-fg) 8%, transparent); color: inherit; font: 400 13px/1 var(--mac); } .addpop .find:focus-visible { outline: 3px solid color-mix(in srgb, var(--sys-blue) 55%, transparent); outline-offset: 0; } -.addpop .find::placeholder { color: color-mix(in srgb, var(--win-fg) 50%, transparent); } +.addpop .find::placeholder { color: color-mix(in srgb, var(--win-fg) 62%, transparent); } .addpop .list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; max-height: 296px; margin-top: 10px; overflow-y: auto; overscroll-behavior: contain; } .addpop .ch { display: flex; align-items: center; gap: 7px; height: 44px; min-width: 0; padding: 0 8px; border-radius: 6px; text-align: left; } .addpop .ch:hover, .addpop .ch:focus-visible { background: color-mix(in srgb, var(--sys-blue) 18%, transparent); outline: none; } .addpop .ch .ic { width: 15px; height: 15px; flex: none; margin: 0 1.5px; } .addpop .ch span { min-width: 0; } .addpop .ch b { display: block; font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.addpop .ch small { display: block; margin-top: 1px; font-size: 10px; color: color-mix(in srgb, var(--win-fg) 55%, transparent); } +.addpop .ch small { display: block; margin-top: 1px; font-size: 10px; color: color-mix(in srgb, var(--win-fg) 68%, transparent); } .addpop .ch .from-store { width: 11px; height: 11px; flex: none; margin-left: auto; } -.addpop .none { padding: 10px 2px 2px; color: color-mix(in srgb, var(--win-fg) 55%, transparent); } +.addpop .none { padding: 10px 2px 2px; color: color-mix(in srgb, var(--win-fg) 68%, transparent); } .addpop .back { height: 22px; padding: 0 8px; border-radius: 5px; background: color-mix(in srgb, var(--win-fg) 12%, transparent); } .addpop h4 { margin-top: 12px; font: 600 15px/1.25 var(--mac); } .addpop .d { margin-top: 4px; color: color-mix(in srgb, var(--win-fg) 78%, transparent); } -.addpop .m { margin-top: 4px; color: color-mix(in srgb, var(--win-fg) 55%, transparent); } +.addpop .m { margin-top: 4px; color: color-mix(in srgb, var(--win-fg) 68%, transparent); } .addpop .can { list-style: none; margin-top: 12px; padding: 2px 10px; border-radius: 8px; background: color-mix(in srgb, var(--win-fg) 5%, transparent); } .addpop .can li { display: flex; align-items: center; gap: 8px; padding: 7px 0; } .addpop .can li + li { border-top: 1px solid color-mix(in srgb, var(--win-fg) 8%, transparent); } .addpop .can svg { width: 14px; height: 14px; flex: none; color: color-mix(in srgb, var(--win-fg) 60%, transparent); } -.addpop .foot { display: flex; align-items: center; gap: 12px; margin-top: 12px; color: color-mix(in srgb, var(--win-fg) 55%, transparent); font-size: 11px; } -.addpop .go { flex: none; margin-left: auto; height: 26px; padding: 0 12px; border-radius: 6px; background: var(--sys-blue); color: #fff; font-size: 13px; } +.addpop .foot { display: flex; align-items: center; gap: 12px; margin-top: 12px; color: color-mix(in srgb, var(--win-fg) 68%, transparent); font-size: 11px; } +.addpop .go { flex: none; margin-left: auto; height: 26px; padding: 0 12px; border-radius: 6px; background: var(--blue-fill); color: #fff; font-size: 13px; } /* The bar's context menu, in the system's style */ .ctx { @@ -372,12 +336,12 @@ h1 .wide { display: none; } box-shadow: 0 0 0 .5px var(--win-ring), inset 0 0 0 1px var(--win-edge), 0 12px 30px -10px var(--shadow-2); } .ctx button { display: block; width: 100%; padding: 5px 10px; border-radius: 5px; text-align: left; } -.ctx button:hover, .ctx button:focus-visible { background: var(--sys-blue); color: #fff; outline: none; } +.ctx button:hover, .ctx button:focus-visible { background: var(--blue-fill); color: #fff; outline: none; } .ctx hr { height: 1px; margin: 5px 10px; border: 0; background: color-mix(in srgb, var(--win-fg) 14%, transparent); } /* Popups and the More widgets menu */ .pop { - position: absolute; z-index: 6; width: 300px; padding: 12px 14px 11px; + position: absolute; z-index: 6; width: 300px; background: var(--pop-bg); color: var(--fg); box-shadow: inset 0 0 0 1px var(--pop-border), 0 24px 60px -24px var(--shadow-2); border-radius: var(--pop-radius); font: var(--pop-font); -webkit-font-smoothing: auto; opacity: 0; visibility: hidden; transform: translateY(-4px); @@ -399,19 +363,9 @@ h1 .wide { display: none; } .pop .meter i { display: block; height: 100%; border-radius: 2px; background: var(--accent); } .pop .graph { display: block; width: 100%; height: 44px; margin: 2px 0 4px; } .pop .graph polyline { fill: none; stroke: var(--accent); stroke-width: 1.6; vector-effect: non-scaling-stroke; } -.ctl { display: grid; grid-template-columns: 58px 1fr; align-items: center; gap: 8px; padding: 5px 0; } -.ctl input[type=range] { width: 100%; accent-color: var(--accent); } -.check { display: flex; align-items: center; gap: 8px; padding: 5px 0; cursor: pointer; } -.check input { accent-color: var(--accent); width: 14px; height: 14px; } -.seg { display: flex; background: var(--track); border-radius: 7px; padding: 2px; } -.seg button { flex: 1; padding: 4px 0; border-radius: 5px; font: inherit; } -.seg button.on { background: var(--raised); } -.search { width: 100%; margin-top: 8px; padding: 6px 9px; border-radius: 7px; border: 0; background: var(--track); color: var(--fg); font: inherit; } -.search::placeholder { color: var(--muted); } -.apps div { padding: 4px 0; } .menu { width: 250px; padding: 6px; } .menu p { color: var(--muted); padding: 6px 8px 4px; font: 500 11.5px/1 var(--mac); } -.menu button, .menu a { display: flex; width: 100%; align-items: center; gap: 9px; padding: 6px 8px; border-radius: 6px; text-align: left; font: inherit; } +.menu button, .menu a { display: flex; width: 100%; align-items: center; gap: 9px; padding: 6px 8px; border-radius: 6px; text-align: left; font: inherit; text-decoration: none; } .menu button:hover, .menu a:hover { background: var(--accent); color: var(--accent-text); } .pane[data-id="site"] { width: 200px; } .np-pop .src { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; color: var(--muted); font-size: 12px; } @@ -437,291 +391,388 @@ h1 .wide { display: none; } .menu button svg { width: 15px; height: 15px; flex: none; } .menu button.over-off { display: none; } -/* Windows. On the desk, drag them by the title bar. */ -.layer { - position: absolute; z-index: 4; - transform: translate3d(var(--dx, 0px), var(--dy, 0px), 0) rotate(var(--tilt, 0deg)); -} -.win { - border-radius: 12px; background: var(--win-bg); color: var(--win-fg); font: 400 13px/1.3 var(--mac); - box-shadow: 0 0 0 .5px var(--win-ring), inset 0 0 0 1px var(--win-edge), 0 40px 90px -30px var(--shadow-1), 0 12px 30px -16px var(--shadow-2); - transition: box-shadow .2s; -} -.win .tb { position: relative; display: flex; align-items: center; gap: 14px; padding: 15px 16px 10px; } -.win .tb .title { position: absolute; left: 0; right: 0; text-align: center; color: var(--text-3); font-size: 12px; pointer-events: none; } -.layer .tb { cursor: grab; touch-action: none; user-select: none; } -.layer.dragging .tb { cursor: grabbing; } -.layer.dragging .win { box-shadow: 0 0 0 .5px var(--win-ring), inset 0 0 0 1px var(--win-edge), 0 60px 120px -30px var(--shadow-1), 0 20px 44px -16px var(--shadow-2); } -.tl { display: flex; gap: 8px; } -.tl i { width: 12px; height: 12px; border-radius: 50%; background: #ff5f57; } -.tl i:nth-child(2) { background: #febc2e; } .tl i:nth-child(3) { background: #28c840; } -.theme-layer { left: 16px; bottom: 16px; width: min(358px, calc(100% - 32px)); } -.theme-win .tb b { font-weight: 600; } -.theme-win .tb .save { margin-left: auto; color: var(--sys-blue); font-size: 12.5px; white-space: nowrap; } -.cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; max-height: 206px; overflow-y: auto; overscroll-behavior: contain; padding: 2px 14px 14px; } -.card { min-width: 0; overflow: hidden; padding: 5px 5px 7px; border-radius: 9px; background: var(--card-bg); box-shadow: inset 0 0 0 1px var(--card-edge); text-align: left; transition: box-shadow .15s; } -.card:hover { box-shadow: inset 0 0 0 1px var(--card-hover); } -.card.on { box-shadow: inset 0 0 0 2px var(--sys-blue); } -.card .shot { position: relative; height: 40px; border-radius: 5px; overflow: hidden; background: var(--dim), var(--backdrop) center / cover; } -.card .nm { display: flex; justify-content: space-between; align-items: center; margin: 6px 3px 0; font-size: 12px; font-weight: 600; } -.card .nm svg { width: 12px; height: 12px; stroke: var(--sys-blue); fill: none; stroke-width: 2.2; opacity: 0; } -.card.on .nm svg { opacity: 1; } -.term-layer { display: none; } -.win.term { --win-bg: #100e0c; --win-fg: #d9cfc2; --win-edge: #ffffff17; --text-3: #a09484; } -.term pre { padding: 2px 8px 14px; font: 400 12.5px/1.5 var(--mono); } -.term button { display: block; width: 100%; text-align: left; padding: 3px 8px; border-radius: 5px; font: inherit; white-space: pre; position: relative; } -.term button::after { content: "Run ↵"; content: "Run ↵" / ""; position: absolute; right: 8px; color: var(--amber); font-family: var(--body); font-size: 11.5px; opacity: 0; transition: opacity .15s; } -.term button:hover { background: #f5821f14; } -.term button:hover::after, .term button.ran::after { opacity: 1; } -.term button.ran::after { content: "✓"; content: "✓" / ""; } -.term .p, .term .out { color: var(--text-3); } -.term .out { padding: 0 8px 4px; } - -/* Small sample bars in the theme cards and the theme list */ +/* ---------- Paper ---------- */ + +/* A note: white paper with an irregular corner, a felt-pen line printed a little off it, and tape. + main.js draws each line as an SVG (.pen) in three states at the note's size, and the CSS steps between them. */ +.note { + --rot: -.6deg; --ox: -5px; --oy: -3px; + position: relative; isolation: isolate; background: var(--paper); color: var(--text); + padding: 32px 34px 30px; border-radius: 16px 24px 14px 28px / 24px 14px 28px 16px; rotate: var(--rot); +} +.note::after { + content: ""; position: absolute; top: -14px; left: var(--tape-x, 50%); width: var(--tape-w, 118px); height: 30px; z-index: 1; + translate: -50% 0; rotate: var(--tape-r, -3deg); background: var(--tape); pointer-events: none; + clip-path: polygon(0 6%, 4% 0, 8% 8%, 13% 2%, 18% 7%, 50% 3%, 82% 6%, 88% 0, 93% 7%, 97% 1%, 100% 8%, 99% 92%, 95% 100%, 90% 93%, 85% 100%, 50% 96%, 15% 99%, 10% 92%, 5% 100%, 1% 94%); +} +/* The pen line before main.js draws it, or without it */ +:where(.note, .cta, .tag, .keys button)::before { + content: ""; position: absolute; z-index: -1; pointer-events: none; + inset: var(--oy) calc(-1 * var(--ox)) calc(-1 * var(--oy)) var(--ox); + border: 2.4px solid var(--pen); border-radius: inherit; +} +.inked::before { content: none; } +.inked > .pen { + position: absolute; z-index: -1; left: calc(var(--ox) - 8px); top: calc(var(--oy) - 8px); width: calc(100% + 16px); height: calc(100% + 16px); + margin: 0 !important; overflow: visible; pointer-events: none; fill: var(--pen); +} +.pen path { visibility: hidden; } +.pen .p0 { visibility: visible; } +/* About seven states a second, the same everywhere; at rest after a few quiet seconds */ +@media (prefers-reduced-motion: no-preference) { + .pen path { animation: boil .429s steps(1, end) infinite; } + .pen .p1 { animation-delay: -.286s; } + .pen .p2 { animation-delay: -.143s; } + .idle .pen path { animation-play-state: paused; } +} +@keyframes boil { 0% { visibility: visible; } 33.333%, 100% { visibility: hidden; } } + +/* Type on paper */ +h1, h2 { font-family: var(--display); font-weight: 800; font-variation-settings: "INFM" 70, "BNCE" 25; letter-spacing: -.01em; color: var(--text); } +h1 { font-size: clamp(40px, 4.2vw, 62px); line-height: 1.04; rotate: -1deg; } +h2 { font-size: clamp(30px, 3vw, 44px); line-height: 1.06; } +h3 { font: 800 22px/1.2 var(--display); font-variation-settings: "INFM" 70, "BNCE" 25; } +.note > * + * { margin-top: 14px; } +.note h2 + p { margin-top: 12px; } +.note a:not(.cta) { color: var(--link); text-decoration-thickness: 2px; text-underline-offset: 3px; text-decoration-color: color-mix(in srgb, var(--link) 40%, transparent); } +.note a:not(.cta):hover { text-decoration-color: currentColor; } +.note strong { font-weight: 700; } +.lede { font-size: 1.15em; line-height: 1.45; } +.req { font-size: 15px; line-height: 1.45; color: var(--text-2); } +.links { display: flex; flex-wrap: wrap; gap: 4px 20px; font-size: 16px; } +.cap { + width: fit-content; max-width: 100%; color: var(--red); rotate: -2deg; transform-origin: left center; + font: 600 18px/1.3 var(--hand); font-variation-settings: "INFM" 100, "BNCE" 60; +} +.cap small { font-size: .78em; color: var(--text); } +.note .cap { margin-top: 22px; } + +/* Words the beam crosses get a highlighter swipe; at rest the headline has none */ +.lumen { position: relative; z-index: 0; } +.lumen::before { + content: ""; position: absolute; inset: 18% -.08em 4% -.1em; z-index: -1; + background: var(--highlight) 0 0 / 100% 100% no-repeat; clip-path: inset(0 100% 0 0); + transition: clip-path .28s var(--ease); +} +.lumen.lit::before { clip-path: inset(0); } + +/* Download: Shantell on the lamp's yellow, in a pen line */ +.get { display: flex; flex-wrap: wrap; align-items: center; gap: 14px 22px; } +.note .get { margin-top: 24px; } +.cta { + --ox: -4px; --oy: -3px; + position: relative; isolation: isolate; display: inline-flex; align-items: center; gap: 10px; flex: none; + min-height: 54px; padding: 12px 26px 12px 22px; border-radius: 999px; background: var(--hi); color: #111; + font: 800 20px/1 var(--display); font-variation-settings: "INFM" 70, "BNCE" 25; text-decoration: none; white-space: nowrap; + transition: rotate .2s var(--ease), background-color .2s; +} +.cta:hover { background: #ffb547; rotate: -2deg; } +.cta svg { width: 16px; height: 19px; flex: none; fill: currentColor; } +.cta .ver { margin-left: -2px; opacity: .72; font-variant-numeric: tabular-nums; } + +/* ---------- The hero: the headline note and the key ---------- */ +.hero { + --left: 5vw; + position: relative; min-height: 100vh; min-height: 100svh; display: flex; flex-direction: column; align-items: flex-start; gap: 22px; + padding: calc(var(--bar-h) + clamp(26px, 6.5vh, 70px)) 0 clamp(18px, 3.4vh, 34px) var(--left); +} +.hero-note { width: min(46vw, 620px); --rot: -.7deg; --tape-x: 38%; padding: 34px 38px 26px; } +.hero-note .lede { margin-top: 16px; } +.hero-note .get { margin-top: 22px; } +.hero-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 18px; } +.note .hero-foot { margin-top: 18px; } +.quiet { display: flex; flex-wrap: wrap; gap: 4px 18px; font-size: 15.5px; } +.hero-foot .cap { margin-top: 0; } + +.key { --rot: .8deg; --tape-x: 16%; --tape-w: 66px; --tape-r: 6deg; margin-top: auto; width: min(452px, 46vw); padding: 14px 18px 12px; border-radius: 12px 18px 10px 20px / 18px 10px 20px 12px; } +.js:not(.has-scene) .key { visibility: hidden; } +.key-t, .key-or { font: 600 16px/1.25 var(--hand); font-variation-settings: "INFM" 100, "BNCE" 60; } +.key .touch { display: none; } +@media (pointer: coarse) { .key .mouse { display: none; } .key .touch { display: inline; } } +.keys { display: flex; flex-wrap: wrap; gap: 8px; } +.note .keys { margin-top: 9px; } +.note .key-or { margin-top: 7px; } +.keys button { + --ox: -3px; --oy: -2px; + position: relative; isolation: isolate; display: inline-flex; align-items: center; gap: 4px; height: 36px; padding: 0 12px 0 6px; + border-radius: 999px; background: var(--paper); font: 600 15px/1 var(--hand); font-variation-settings: "INFM" 100, "BNCE" 60; + transition: background-color .15s, rotate .2s var(--ease); +} +.keys button:hover { background: #fff6c2; rotate: -2deg; } +.keys button[aria-pressed="true"] { background: var(--hi); } +.keys svg { width: 26px; height: 20px; flex: none; } + +/* ---------- Sections: each a different piece of paper, in the left part of the window ---------- */ +.sec { display: flex; align-items: center; min-height: 100vh; min-height: 100svh; padding: 14vh 0 14vh var(--left, 5vw); } +.sec .note { width: min(44vw, 620px); } +.edit { --left: 7vw; } +.themes { --left: 4.5vw; } +.widgets { --left: 6vw; } +.tiling { --left: 7.5vw; } +.install { --left: 5vw; } +/* Narrow landscape screens: wider notes so the Download button stays on its paper, still clear of the lighthouse at 0.775 × width */ +@media (min-width: 700px) and (max-width: 1023.98px) { + .hero-note, .sec .note { width: min(58vw, 620px); } + .hero-note { padding-left: 30px; padding-right: 30px; } + .key { width: min(452px, 58vw); } +} + +/* Edit: a taped note with a picture of the bar in edit mode */ +.taped { --rot: .7deg; --tape-x: 9%; --tape-r: -32deg; --tape-w: 104px; } +.taped::after { top: -6px; } +.taped .tape2 + h2 { margin-top: 0; } +.taped .tape2 { position: absolute; top: -4px; right: -12px; width: 96px; height: 28px; rotate: 34deg; background: var(--tape); clip-path: polygon(0 6%, 5% 0, 10% 8%, 50% 3%, 90% 6%, 95% 0, 100% 8%, 99% 92%, 94% 100%, 50% 96%, 6% 100%, 1% 92%); pointer-events: none; z-index: 1; } +.screen { position: relative; border-radius: 13px; background: var(--screen); box-shadow: 0 0 0 2.2px var(--pen); } +.fig-edit { display: block; margin-top: 22px !important; padding: 40px 12px 16px; color: var(--fg); font: var(--bar-font); -webkit-font-smoothing: auto; container-type: inline-size; } +.fig-edit .trays { display: flex; flex-wrap: wrap; gap: 8px; } +.fig-edit .tray { position: relative; display: flex; align-items: center; gap: 6px; min-height: 40px; padding: 6px 7px; border-radius: 9px; background: linear-gradient(var(--tray-tint) 0 0) var(--tray-backing); box-shadow: inset 0 0 0 .5px var(--tray-line); } +.fig-edit .tray.center { flex: 1; justify-content: center; min-width: 48px; } +.fig-edit .tile { cursor: default; } +.fig-edit .w { cursor: default; } +.fig-edit .w.ws { gap: 2px; padding: 0 4px; } +.fig-edit .w.ws .wsi { height: 22px; cursor: default; } +.fig-edit .w.ws .wsi.on { background: var(--ws-ind); } +.fig-edit .arrow { position: absolute; left: var(--ax, 196px); top: 6px; width: var(--aw, 150px); height: 36px; color: var(--pen); pointer-events: none; } +[data-mode="dark"] .fig-edit .arrow { color: #fff; } +.fig-edit .drag-note { position: absolute; left: calc(var(--ax, 196px) + var(--aw, 150px) * .42); top: 2px; font: 600 15px/1 var(--hand); color: var(--pen); rotate: -4deg; } +[data-mode="dark"] .fig-edit .drag-note { color: #fff; } +@container (max-width: 470px) { .fig-edit .arrow, .fig-edit .drag-note { display: none; } .fig-edit { padding-top: 16px; } } +.try { + position: relative; display: inline-flex; align-items: center; gap: 8px; margin-top: 20px !important; padding: 9px 16px 10px; + border-radius: 10px 14px 12px 9px; background: var(--paper); box-shadow: 0 0 0 2.2px var(--pen), 3px 3px 0 2.2px var(--pen); + font: 600 17px/1.2 var(--hand); font-variation-settings: "INFM" 100, "BNCE" 60; transition: translate .15s, box-shadow .15s; +} +.try::after { content: "↑"; font-family: var(--body); font-weight: 700; } +.try:hover { background: #fff6c2; } +.try:active { translate: 2px 2px; box-shadow: 0 0 0 2.2px var(--pen), 1px 1px 0 2.2px var(--pen); } + +/* Themes: seven tags pegged on a washing line, each a miniature bar in its theme */ +.line-note { --rot: -.9deg; } +.line-note::after { display: none; } +.line { + display: grid; grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); grid-auto-rows: 112px; column-gap: 12px; + margin: 18px -12px 0 !important; padding: 0 12px; background: var(--string) 0 0 / 100% 112px repeat-y; +} +.tag { + --ox: -3px; --oy: -2px; --r: -2deg; + position: relative; isolation: isolate; align-self: start; display: flex; flex-direction: column; gap: 7px; + margin-top: 18px; padding: 8px 8px 9px; border-radius: 6px 9px 7px 10px; background: var(--paper); text-align: left; + rotate: var(--r); transform-origin: 50% -8px; transition: rotate .35s var(--ease), background-color .15s; +} +.tag:nth-child(3n + 2) { --r: 1.8deg; } +.tag:nth-child(3n) { --r: -.5deg; } +.tag:hover { --r: 0deg; } +.tag[aria-pressed="true"] { background: var(--hi); } +/* a wooden peg */ +.tag::after { + content: ""; position: absolute; top: -19px; left: 50%; width: 10px; height: 24px; translate: -50% 0; rotate: 4deg; + border-radius: 3px 3px 4px 4px; background: linear-gradient(90deg, #d79b5f 0 45%, #b8783f 45% 55%, #d79b5f 55%); box-shadow: 0 0 0 1.8px var(--pen); +} +.tag .shot { position: relative; height: 40px; overflow: hidden; border-radius: 5px; background: var(--screen); box-shadow: 0 0 0 1.6px var(--pen); } +.tag .nm { display: flex; align-items: center; justify-content: space-between; gap: 6px; font: 600 16px/1 var(--hand); font-variation-settings: "INFM" 100, "BNCE" 60; } +.tag[aria-pressed="true"] .nm::after { content: "✓"; font-family: var(--body); font-weight: 700; } + +/* Small sample bars in the theme tags: three workspaces, Finder, battery and clock, as in Settings' theme cards */ .mini { - display: flex; align-items: center; justify-content: space-between; gap: 6px; height: 100%; - font: var(--bar-font); color: var(--fg); -webkit-font-smoothing: auto; padding: 0 calc(5px + var(--strip-m) * .5); + position: absolute; inset: 0; display: flex; align-items: center; justify-content: space-between; gap: 6px; + width: 200%; height: 200%; padding: 0 calc(5px + var(--strip-m) * .5); transform: scale(.5); transform-origin: 0 0; + font: var(--bar-font); color: var(--fg); -webkit-font-smoothing: auto; } -.mini .strip { position: absolute; inset: 3px calc(3px + var(--strip-m) * .4); background: var(--strip-bg); border-radius: var(--strip-radius); box-shadow: var(--strip-edge); } +.mini .strip { position: absolute; inset: 6px calc(6px + var(--strip-m) * .4); background: var(--strip-bg); border-radius: var(--strip-radius); box-shadow: var(--strip-edge); } .mini .g { display: flex; gap: 4px; position: relative; } -.mini .t { position: relative; filter: var(--w-glow); background: var(--w-bg); box-shadow: inset 0 0 0 1px var(--w-border), 0 1px 3px var(--w-shadow); border-radius: var(--w-radius); padding: 0 7px; height: calc(var(--tile-h) * 22 / 28); display: flex; align-items: center; gap: 5px; white-space: nowrap; } -.mini .wsi { padding: 0 6px; border-radius: calc(var(--ws-r) * 22 / 28); cursor: default; } +.mini .t { position: relative; filter: var(--w-glow); background: var(--w-bg); box-shadow: inset 0 0 0 1px var(--w-border), 0 1px 3px var(--w-shadow); border-radius: var(--w-radius); padding: 0 7px; height: var(--tile-h); display: flex; align-items: center; gap: 5px; white-space: nowrap; } +.mini .wsi { padding: 0 6px; cursor: inherit; } .ws-under .mini .wsi.on::after { bottom: 1px; } -.card .mini { transform: scale(.52); transform-origin: left center; width: 192%; } - -/* ---------- Sections ---------- */ -.stop { position: relative; padding: 72px 0 20px; } -.stop.first { padding-top: 96px; } -.stop h2 { margin-bottom: 26px; font: 600 clamp(30px, 7.6vw, 48px)/1.04 var(--display); letter-spacing: -.03em; } -.body p { max-width: 52ch; color: var(--text-2); } -.body p + p { margin-top: 12px; } -.body p strong { color: var(--text); font-weight: 500; } -.more-link { display: inline-block; margin-top: 16px; font-size: 14.5px; color: var(--text); border-bottom: 1px solid var(--line-2); padding-bottom: 2px; } -.more-link:hover { border-color: var(--text); } -.more-link.try { margin: 14px 0 6px; } -.try + p { margin-top: 16px; } -.fig { margin-top: 40px; position: relative; } - -/* Configure all of it: two groups of specs */ -.specs h3 { margin-bottom: 4px; font: 500 13.5px/1.3 var(--body); color: var(--text-3); } -.specs h3 + ul { margin-bottom: 32px; } -.specs ul { list-style: none; border-bottom: 1px solid var(--line); } -.specs li { padding: 11px 0; border-top: 1px solid var(--line); } -.specs b { display: block; font: 600 15px/1.3 var(--display); } -.specs span { font-size: 13.5px; line-height: 1.4; color: var(--text-3); } - -/* Popups */ -.pops { position: relative; display: grid; gap: 14px; } -.pops .pop { position: relative; opacity: 1; visibility: visible; transform: none; width: auto; max-width: 320px; } - -/* Themes: every included theme as a card that restyles the bar */ -.board { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } -.board .card { padding: 6px 6px 9px; background: none; box-shadow: inset 0 0 0 1px var(--line); } -.board .card:hover { box-shadow: inset 0 0 0 1px var(--line-2); } -.board .card.on { box-shadow: inset 0 0 0 2px var(--ring); } -.board .card .nm { margin: 8px 3px 0; font: 600 14px/1.3 var(--display); } -.board .card .nm svg { stroke: var(--ring); } -.board .store { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 14px; color: var(--text-2); font-size: 14px; } -.board .store:hover { color: var(--text); } -.board .store svg { width: 14px; height: 14px; } - -/* Scripts */ -.code { border-radius: 12px; background: var(--code-bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; } -.code .fn { display: flex; justify-content: space-between; padding: 10px 14px; border-bottom: 1px solid var(--line); font: 500 12px/1.2 var(--mono); color: var(--text-3); } -.code pre { padding: 12px 14px 14px; font: 400 12.5px/1.6 var(--mono); color: var(--code-fg); overflow-x: auto; } -.code pre:focus-visible { outline-offset: -2px; border-radius: 0 0 12px 12px; } -.code .c { color: var(--code-dim); } -.code + .code { margin-top: 12px; } -.result { position: relative; margin: -18px 0 0 auto; width: max-content; z-index: 2; display: flex; gap: 6px; padding: 6px; border-radius: 12px; background: var(--dim), var(--backdrop) center / cover; box-shadow: inset 0 0 0 1px var(--line), 0 20px 50px -20px var(--shadow-1); } + +/* Scripts and the Store: a page torn from a notebook, ruled, with a red margin */ +.notebook { --rot: .5deg; --ox: -5px; --oy: -3px; background: none; padding: 46px 34px 34px 70px; line-height: 28px; } +.notebook::after { + content: ""; inset: 0; top: 0; left: 0; width: auto; height: auto; translate: none; rotate: none; z-index: -2; + border-radius: 0 0 14px 22px / 0 0 22px 14px; clip-path: var(--torn, none); + background: + linear-gradient(90deg, transparent 48px, #e8786b 48px 50px, transparent 50px), + repeating-linear-gradient(transparent 0 27px, #b7d2ef 27px 28px) 0 17px / 100% 28px, + var(--paper); +} +.notebook > * + * { margin-top: 14px; } +.notebook h2 { line-height: 1.1; } +.notebook .script { margin-top: 18px; } +.code { border-radius: 10px; background: #fbfaf6; box-shadow: 0 0 0 2px var(--pen); overflow: hidden; } +.code .fn { display: flex; justify-content: space-between; padding: 7px 14px; border-bottom: 2px solid var(--pen); font: 500 12.5px/1.2 var(--mono); color: #4a4a4a; background: #fff2a8; } +.code pre { padding: 12px 14px 14px; font: 400 13px/1.6 var(--mono); color: #1d1d1f; overflow-x: auto; } +.code pre:focus-visible { outline-offset: -3px; border-radius: 0 0 10px 10px; } +.code .c { color: #6b6b6b; } +.result { display: flex; gap: 6px; width: max-content; max-width: 100%; margin: -14px 18px 0 auto; padding: 7px; } .result .w { font: var(--bar-font); -webkit-font-smoothing: auto; color: var(--fg); } -.result .w.new { box-shadow: inset 0 0 0 1px var(--w-border), 0 0 0 2px var(--amber); } +.result .w.new { box-shadow: inset 0 0 0 1px var(--w-border), 0 0 0 2.5px var(--amber); } +.store { margin-top: 30px !important; } +.store > * + * { margin-top: 12px; } +.store-fig { position: relative; margin-top: 18px !important; rotate: -.6deg; } /* The Store: Settings → Packages, as the app draws it */ -.body code { font: 400 .92em/1 var(--mono); color: var(--text); } -.store-win .tb { gap: 16px; padding: 12px 14px; border-bottom: 1px solid color-mix(in srgb, var(--win-fg) 9%, transparent); } -.tabs, .kinds { display: flex; padding: 2px; border-radius: 7px; background: color-mix(in srgb, var(--win-fg) 8%, transparent); font-size: 12.5px; } -.tabs span, .kinds span { padding: 4px 10px; border-radius: 5px; color: color-mix(in srgb, var(--win-fg) 80%, transparent); } -.tabs .on, .kinds .on { background: color-mix(in srgb, var(--win-fg) 16%, transparent); color: var(--win-fg); } -.store-win .find { flex: 1; min-width: 0; max-width: 220px; padding: 5px 10px; border-radius: 7px; background: color-mix(in srgb, var(--win-fg) 6%, transparent); color: color-mix(in srgb, var(--win-fg) 50%, transparent); font-size: 12.5px; white-space: nowrap; overflow: hidden; } -.kinds { width: max-content; margin: 14px 14px 0; } -.pkgs { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 10px; padding: 14px; } -.pkg { display: flex; flex-direction: column; min-width: 0; padding: 10px; border-radius: 10px; background: color-mix(in srgb, var(--win-fg) 4%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--win-fg) 8%, transparent); } -.pkg .pv { display: grid; place-items: center; height: 58px; border-radius: 7px; background: var(--dim), var(--backdrop) center / cover; font: var(--bar-font); -webkit-font-smoothing: auto; color: var(--fg); overflow: hidden; } +.win { border-radius: 12px; background: var(--win-bg); color: var(--win-fg); font: 400 13px/1.3 var(--mac); box-shadow: 0 0 0 .5px var(--win-ring), inset 0 0 0 1px var(--win-edge), 0 0 0 2.4px var(--pen), 6px 8px 0 0 #1111111f; } +[data-mode="dark"] .store-fig { color-scheme: dark; } +.win .tb { position: relative; display: flex; align-items: center; gap: 14px; padding: 11px 14px; } +.tl { display: flex; gap: 8px; } +.tl i { width: 12px; height: 12px; border-radius: 50%; background: #ff5f57; } +.tl i:nth-child(2) { background: #febc2e; } .tl i:nth-child(3) { background: #28c840; } +.store-win .tb { gap: 14px; border-bottom: 1px solid color-mix(in srgb, var(--win-fg) 9%, transparent); } +.tabs { display: flex; padding: 2px; border-radius: 7px; background: color-mix(in srgb, var(--win-fg) 8%, transparent); font-size: 12.5px; } +.tabs span { padding: 4px 10px; border-radius: 5px; color: color-mix(in srgb, var(--win-fg) 80%, transparent); } +.tabs .on { background: color-mix(in srgb, var(--win-fg) 16%, transparent); color: var(--win-fg); } +.store-win .find { flex: 1; min-width: 0; max-width: 200px; padding: 5px 10px; border-radius: 7px; background: color-mix(in srgb, var(--win-fg) 6%, transparent); color: color-mix(in srgb, var(--win-fg) 50%, transparent); font-size: 12.5px; white-space: nowrap; overflow: hidden; } +.pkgs { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 9px; padding: 12px; line-height: 1.3; } +.pkg { display: flex; flex-direction: column; min-width: 0; padding: 9px; border-radius: 10px; background: color-mix(in srgb, var(--win-fg) 4%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--win-fg) 8%, transparent); } +.pkg .pv { display: grid; place-items: center; height: 50px; border-radius: 7px; background: var(--screen); font: var(--bar-font); -webkit-font-smoothing: auto; color: var(--fg); overflow: hidden; } .pkg .pv .w { height: 26px; } -.pkg b { margin-top: 10px; font-weight: 600; font-size: 13.5px; } -.pkg p { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-top: 3px; font-size: 12px; line-height: 1.35; color: color-mix(in srgb, var(--win-fg) 62%, transparent); } -.pkg .by { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: auto; padding-top: 10px; font-size: 11.5px; color: color-mix(in srgb, var(--win-fg) 55%, transparent); } +.pkg b { margin-top: 9px; font-weight: 600; font-size: 13px; } +.pkg p { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-top: 3px; font-size: 11.5px; line-height: 1.35; color: color-mix(in srgb, var(--win-fg) 66%, transparent); } +.pkg .by { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-top: auto; padding-top: 9px; font-size: 11px; color: color-mix(in srgb, var(--win-fg) 60%, transparent); } .pkg .by > span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.pkg .pkg-get { height: 24px; padding: 0 12px; border-radius: 6px; background: color-mix(in srgb, var(--win-fg) 12%, transparent); color: var(--win-fg); font-size: 12.5px; } +.pkg .pkg-get { flex: none; height: 24px; padding: 0 12px; border-radius: 6px; background: color-mix(in srgb, var(--win-fg) 12%, transparent); color: var(--win-fg); font-size: 12.5px; } .pkg .pkg-get:hover { background: color-mix(in srgb, var(--win-fg) 18%, transparent); } .pkg .state { display: flex; align-items: center; gap: 5px; white-space: nowrap; } .pkg .state svg { width: 11px; height: 11px; fill: none; stroke: currentColor; stroke-width: 1.8; } .store-win { position: relative; } .store-win::after { content: ""; position: absolute; inset: 0; border-radius: inherit; background: #00000059; opacity: 0; visibility: hidden; transition: opacity .2s, visibility 0s .2s; } .store-win.sheet::after { opacity: 1; visibility: visible; transition: opacity .2s; } -.pkg-review { z-index: 3; top: 56px; left: 50%; translate: -50% 0; width: min(400px, calc(100% - 24px)); } +.addpop.pkg-review { z-index: 3; top: 52px; left: 50%; translate: -50% 0; width: min(400px, calc(100% - 24px)); line-height: 1.3; } .pkg-review::before { content: none; } -/* Displays: two screens, each with its own bar */ -.setup { display: grid; gap: 16px; margin: 40px calc(var(--gutter) * -1) 0; } -.cap { - position: relative; overflow: hidden; height: 300px; border-radius: 12px; - background: linear-gradient(transparent 45%, #000000a6), var(--dim), var(--backdrop) 50% 40% / cover; - box-shadow: 0 0 0 .5px var(--win-ring), inset 0 0 0 1px var(--win-edge), 0 40px 90px -30px var(--shadow-1); -} -.cap .bar { transition: none; } -.cap .w { cursor: default; } -.cap .pop { right: 150px; top: calc(var(--bar-h) - var(--strip-m) + 6px); width: 250px; } -.cap figcaption { position: absolute; left: 16px; right: 16px; bottom: 14px; font-size: 13.5px; line-height: 1.4; color: #fff; } -.cap figcaption b { font-weight: 600; font-family: var(--display); } -.cap.monitor figcaption { text-align: right; } -.cap.monitor .gl { display: none; } -.cap.monitor .bar { justify-content: flex-end; } -.cap.laptop { height: 220px; border-radius: 14px 14px 6px 6px; background-image: linear-gradient(transparent 45%, #000000a6), var(--dim), var(--backdrop-2); } -.cap.laptop .bar { padding: 0 10px; } -.notch { position: absolute; top: 0; left: 50%; width: 96px; height: 34px; translate: -50% 0; background: #000; border-radius: 0 0 11px 11px; z-index: 1; } - -/* ---------- Install ---------- */ -.install { position: relative; margin-top: 40px; padding: 88px 0 96px; isolation: isolate; } -.install h2 { font: 600 clamp(40px, 11vw, 88px)/.98 var(--display); letter-spacing: -.04em; max-width: 11ch; } -.install .lede { max-width: 40ch; } -.install .cta { height: 54px; padding: 0 24px 0 20px; font-size: 17px; border-radius: 11px; } -.install .cta svg { width: 17px; height: 20px; } - -/* The disk image window, on setup's backdrop: drag StableBar onto Applications */ -.desktop { position: relative; isolation: isolate; display: grid; place-items: center; margin: 48px calc(var(--gutter) * -1) 0; padding: 40px 20px; } -.desktop .wall { -webkit-mask-image: linear-gradient(#0009 0 0); mask-image: linear-gradient(#0009 0 0); } -.dmg { width: 100%; max-width: 520px; } -.dmg .stage { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; padding: 24px 18px 18px; } -.slot { justify-self: center; display: grid; justify-items: center; gap: 8px; font-size: 12.5px; } -.slot .ico { width: 84px; height: 84px; display: grid; place-items: center; } -.app-ico { width: 84px; height: 84px; touch-action: none; cursor: grab; user-select: none; -webkit-user-drag: none; position: relative; z-index: 2; transition: transform .45s var(--ease), opacity .3s; } -.app-ico.dragging { cursor: grabbing; transition: none; } -.app-ico.in { opacity: 0; } -.dmg .arrow { display: grid; justify-items: center; gap: 8px; color: var(--text-3); font: 400 12px/1 var(--mono); } -.dmg .arrow svg { width: 72px; height: 14px; stroke: var(--amber); fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } -.folder { width: 80px; height: 64px; transition: transform .25s var(--ease); } -.folder.over { transform: scale(1.08); } -.dmg .foot { padding: 0 18px 16px; text-align: center; color: var(--text-3); font-size: 12.5px; } - -footer { padding: 22px 0 40px; border-top: 1px solid var(--line); display: flex; flex-wrap: wrap; gap: 8px 22px; font-size: 13.5px; color: var(--text-3); } -footer span { flex-basis: 100%; } -footer a:hover { color: var(--text); } - -/* ---------- Privacy Policy and Terms ---------- */ -.legal { max-width: 68ch; padding: 36px 0 64px; } -.legal h1 { font-size: clamp(34px, 8vw, 48px); max-width: none; } -.legal .updated { margin-top: 10px; font-size: 14px; color: var(--text-3); } -.legal h2 { margin-top: 36px; font: 600 22px/1.2 var(--display); letter-spacing: -.01em; } -.legal p, .legal ul { margin-top: 12px; color: var(--text-2); } -.legal ul { padding-left: 1.2em; } -.legal li + li { margin-top: 6px; } -.legal strong { font-weight: 600; color: var(--text); } -.legal a { text-decoration: underline; text-underline-offset: 3px; } -.legal a:hover { color: var(--text); } -.legal code { font: 14px var(--mono); } - -/* Arrival, then sections easing in as they scroll into view */ -@media (prefers-reduced-motion: no-preference) { - .js .hero h1, .js .hero .lede, .js .hero .get { opacity: 0; translate: 0 12px; } - .js .wall { opacity: 0; } - .js #bar .g > .w:not(.dl) { opacity: 0; translate: 0 -6px; } - .js .layer { opacity: 0; translate: 0 28px; } - .arrived .hero h1, .arrived .hero .lede, .arrived .hero .get { opacity: 1; translate: none; transition: opacity .8s var(--ease) var(--d, 0s), translate 1s var(--ease) var(--d, 0s); } - .arrived .hero .lede { --d: .08s; } - .arrived .hero .get { --d: .16s; } - .arrived .wall { opacity: 1; transition: opacity 1.4s var(--ease) .2s; } - .arrived #bar .g > .w:not(.dl) { opacity: 1; translate: none; transition: background .15s, border-radius .25s, opacity .5s var(--ease) calc(.4s + var(--i) * 35ms), translate .7s var(--ease) calc(.4s + var(--i) * 35ms); } - .arrived .layer { opacity: 1; translate: none; transition: opacity .8s var(--ease) var(--d), translate 1.1s var(--ease) var(--d); } - .arrived .theme-layer { --d: .75s; } - .arrived .term-layer { --d: .9s; } - .js .edit-ctl { opacity: 0; } - .arrived .edit-ctl { opacity: 1; transition: opacity .6s var(--ease) 1s; } - - .js .reveal { opacity: 0; translate: 0 8px; transition: opacity .6s var(--ease), translate .8s var(--ease); } - .js .reveal.in { opacity: 1; translate: none; } -} - -/* Phones: keep the header bar to the essentials */ -@media (max-width: 719px) { - .pops .pop { width: calc(100% - 48px); } - .pops .p2 { margin-left: 24px; } - .pops .p3 { margin-left: 48px; } +/* Tiling: a postcard, with the workspace widget large as its picture */ +.postcard { + --rot: -1.1deg; --tape-x: 88%; --tape-r: 28deg; --tape-w: 90px; + display: grid; grid-template-columns: minmax(0, 1fr) minmax(130px, 34%); column-gap: 26px; + padding: 30px 30px 24px; border-radius: 8px 12px 9px 14px / 12px 8px 14px 9px; +} +.postcard > * + * { margin-top: 0; } +.pc-msg > * + * { margin-top: 12px; } +.pc-addr { position: relative; display: flex; flex-direction: column; gap: 10px; padding-left: 22px; } +.pc-addr::before { content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 2.2px; border-radius: 2px; background: var(--pen); rotate: .8deg; } +.stamp { + align-self: flex-end; display: grid; place-items: center; width: 62px; height: 74px; margin-bottom: 12px; rotate: 3deg; + background: #9fd6f5; + -webkit-mask: radial-gradient(circle at 50% 50%, #000 2.6px, transparent 2.8px) -4px -4px / 8px 8px, linear-gradient(#000 0 0) center / calc(100% - 8px) calc(100% - 8px) no-repeat; + mask: radial-gradient(circle at 50% 50%, #000 2.6px, transparent 2.8px) -4px -4px / 8px 8px, linear-gradient(#000 0 0) center / calc(100% - 8px) calc(100% - 8px) no-repeat; +} +.stamp svg { width: 40px; height: 48px; } +.postmark { position: absolute; top: -6px; right: 34px; width: 74px; height: 50px; rotate: -12deg; color: #3a3a46; opacity: .75; pointer-events: none; } +.pc-addr .to { font: 600 15px/1 var(--hand); color: var(--red); } +.pc-addr .addr { padding: 0 2px 4px; border-bottom: 2px dotted #9aa3b5; font: 600 19px/1.1 var(--hand); font-variation-settings: "INFM" 100, "BNCE" 60; } +.pc-ws { grid-column: 1 / -1; display: flex; justify-content: center; margin-top: 22px; padding: 22px 12px; } +.ws-big { + display: flex; align-items: center; gap: 3px; height: 34px; padding: 0 5px; zoom: 1.7; + background: var(--w-bg); box-shadow: inset 0 0 0 1px var(--w-border), 0 1px 3px var(--w-shadow); border-radius: var(--w-radius); + color: var(--fg); font: var(--bar-font); -webkit-font-smoothing: auto; +} +.ws-big .wsi { height: 26px; } +.pc-foot { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 10px 20px; margin-top: 18px !important; } +.note .pc-foot .cap { margin-top: 0; } + +/* Install: a recipe card, numbered */ +.recipe { + --rot: .9deg; --tape-x: 50%; --tape-r: 2deg; + padding: 30px 34px 30px; border-radius: 6px 10px 8px 12px / 10px 6px 12px 8px; +} +.recipe h2 { padding-bottom: 12px; border-bottom: 2.4px solid #e8786b; } +.steps { list-style: none; counter-reset: step; background: repeating-linear-gradient(transparent 0 33px, #c3d8f0 33px 35px) 0 0 / 100% 35px; } +.note .steps { margin-top: 18px; } +.steps li { position: relative; counter-increment: step; padding-left: 44px; line-height: 35px; } +.steps li::before { + content: counter(step); position: absolute; left: 0; top: 2px; width: 30px; height: 30px; display: grid; place-items: center; + border-radius: 54% 46% 50% 48% / 48% 54% 46% 52%; box-shadow: 0 0 0 2.2px var(--pen); background: var(--paper); + font: 600 16px/1 var(--hand); color: var(--red); +} +.steps li:nth-child(2n)::before { rotate: -8deg; } +.brew { font-size: 15.5px; color: var(--text-2); } +.brew code { display: inline-block; margin-top: 4px; padding: 3px 8px; border-radius: 6px; background: #fbfaf6; box-shadow: 0 0 0 1.6px var(--pen); font: 400 14px/1.4 var(--mono); color: #1d1d1f; white-space: nowrap; } + +/* ---------- Footer: a strip of paper with a pen line along its top ---------- */ +.site-foot { display: grid; gap: 12px; margin-top: 8vh; padding: 30px 5vw 44px; background: var(--paper); color: var(--text); font-size: 15.5px; line-height: 1.5; } +.site-foot::before { content: ""; position: absolute; left: 0; right: 0; top: -5px; height: 10px; background: var(--rule) 0 0 / 160px 10px repeat-x; } +.site-foot p { max-width: 70ch; } +.site-foot nav { display: flex; flex-wrap: wrap; gap: 6px 20px; } +.site-foot a { color: var(--link); text-decoration-thickness: 2px; text-underline-offset: 3px; text-decoration-color: color-mix(in srgb, var(--link) 40%, transparent); } + +/* ---------- Portrait: phones and portrait tablets. The scene's rule: narrower than 700 px, or taller than 1.1 × wide ---------- */ +@media (max-width: 699.98px), (max-aspect-ratio: 10/11) { + .hero, .sec { --left: var(--m); padding-left: var(--m); padding-right: var(--m); align-items: stretch; } + :root { --m: clamp(14px, 4vw, 32px); } + .hero { padding-top: calc(var(--bar-h) + 24vh); padding-top: calc(var(--bar-h) + 24lvh); padding-bottom: 0; min-height: 0; } + .hero-note, .sec .note { width: auto; max-width: 680px; } + .key { width: auto; max-width: 680px; margin-top: 28px; } + .sec { display: block; min-height: 0; padding-top: 34vh; padding-bottom: 6vh; } + .install { padding-bottom: 12vh; } + h1 { font-size: clamp(34px, 9.4vw, 58px); } + .site-foot { padding-left: var(--m); padding-right: var(--m); } +} +@media (max-width: 719.98px) { + body { font-size: 17px; } + .note { padding: 26px 22px 24px; } + .hero-note { padding: 28px 22px 24px; } + .hero-note .lede { margin-top: 14px; } + .note .get { margin-top: 20px; } + .cta { min-height: 50px; padding: 10px 22px 10px 18px; font-size: 18px; } + h2 { font-size: 30px; } + .notebook { padding: 40px 18px 26px 52px; } + .notebook::after { background: linear-gradient(90deg, transparent 36px, #e8786b 36px 38px, transparent 38px), repeating-linear-gradient(transparent 0 27px, #b7d2ef 27px 28px) 0 17px / 100% 28px, var(--paper); } + .result { margin-right: 8px; } + .postcard { grid-template-columns: 1fr; } + .pc-addr { flex-direction: row; flex-wrap: wrap; align-items: baseline; gap: 6px 14px; margin-top: 18px !important; padding: 16px 70px 0 0; } + .pc-addr::before { left: 0; right: 0; top: 0; bottom: auto; width: auto; height: 2.2px; } + .stamp { position: absolute; right: 0; top: 14px; width: 52px; height: 62px; margin: 0; } + .stamp svg { width: 32px; height: 40px; } + .postmark { top: 12px; right: 30px; width: 60px; height: 42px; } + .ws-big { zoom: 1.25; } + .ws-big .ai { display: none; } .code pre { font-size: 11.5px; } + .brew code { white-space: normal; } + .taped .tape2 { right: 4px; } + .try, .edit-ctl { display: none !important; } + .pkg:nth-child(n + 5) { display: none; } + .store-win .find { display: none; } + .line { grid-template-columns: repeat(2, minmax(0, 1fr)); } #bar .ws .ai, #bar [data-key="app"], #bar [data-key="meeting"], #bar [data-key="system"], #bar [data-key="sound"], #bar [data-key="battery"], #bar .np .track, #bar .clock .day, - .header [data-key="clock"], .site .menus, .cap.laptop .app, .cap .meeting { display: none; } - .cap .pop { right: 12px; } - .edit-ctl, .try { display: none !important; } - .store-win .find, .pkg:nth-child(n+5) { display: none; } - .try + p { margin-top: 12px; } - .theme-layer { width: min(296px, calc(100% - 32px)); } - .theme-win .tb { gap: 10px; padding: 11px 12px 8px; } - .theme-win .tl { gap: 6px; } - .theme-win .tl i { width: 10px; height: 10px; } - .cards { gap: 6px; max-height: 100px; padding: 2px 10px 10px; } - .cards .card { padding: 4px 4px 5px; border-radius: 7px; } - .cards .shot { height: 30px; } - .cards .mini { transform: scale(.42); width: 238%; } - .cards .nm { margin-top: 4px; font-size: 11px; } -} - -/* ---------- Wider screens ---------- */ -@media (min-width: 720px) { - :root { --gutter: 32px; } - .nav .gh { display: inline; } - .desk { height: 640px; margin-left: 0; margin-right: 0; --desk-r: 16px; } - .desktop { margin-left: 0; margin-right: 0; padding: 64px 40px; --desk-r: 16px; } - .term-layer { display: block; right: 3%; top: 120px; width: 390px; } - .theme-layer { left: 3%; width: 470px; bottom: 24px; } - .cards { grid-template-columns: repeat(3, minmax(0, 1fr)); } - .pops { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; } - .setup { margin-left: 0; margin-right: 0; } - .cap.laptop { height: 260px; } - .notch { width: 132px; } - .install { padding-top: 120px; padding-bottom: 128px; } + .header [data-key="clock"], .site .menus { display: none; } +} +@media (max-width: 380px) { + .keys button { padding-right: 11px; font-size: 14.5px; } + .cta { font-size: 17px; padding-left: 16px; padding-right: 18px; } } @media (min-width: 1080px) { - :root { --gutter: 48px; } - h1 .wide { display: inline; } - h1 { max-width: none; font-size: clamp(62px, 5.6vw, 84px); } .header .w.ws { display: flex; } - .hero { padding: 64px 0 0; } - .lede { font-size: 19px; } - .hero .lede { max-width: none; } - .desk { height: 680px; margin-top: 56px; } - .theme-layer { left: 5%; width: 520px; bottom: 40px; } - .term-layer { right: 4%; top: 300px; width: 410px; } - .stop { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: 28px; padding-top: 160px; } - .stop.first { padding-top: 120px; } - .stop h2 { grid-column: 1 / -1; margin-bottom: 36px; } - .body { grid-column: 1 / 5; } - .fig { grid-column: 6 / -1; margin-top: 0; } - .specs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 28px; } - .specs h3 + ul { margin-bottom: 0; } - .pops { display: block; max-width: 715px; height: 470px; margin-left: auto; } - .pops .pop { position: absolute; width: 290px; } - .pops .p1 { left: 0; top: 0; z-index: 0; box-shadow: inset 0 0 0 1px var(--pop-border), 0 10px 30px -18px var(--shadow-2); } - .pops .p2 { left: min(26%, 190px); top: 150px; z-index: 1; } - .pops .p3 { left: min(58%, 425px); top: 40px; z-index: 2; box-shadow: inset 0 0 0 1px var(--pop-border), 0 40px 90px -30px var(--shadow-1), 0 16px 40px -20px var(--shadow-2); } - .flip .body { grid-column: 9 / -1; grid-row: 2; } - .flip .fig { grid-column: 1 / 8; grid-row: 2; } - .wide .fig { grid-column: 1 / -1; margin-top: 56px; } - .board { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; } - .board .card .shot { height: 50px; } - .board .card .mini { transform: scale(.75); width: 133.33%; } - .scripts .code:first-child { margin-right: 16%; } - .scripts .code + .code { position: relative; margin: -12px 0 0 16%; } - .result { margin: -22px 8% 0 auto; } - .setup { grid-column: 1 / -1; justify-self: end; display: block; position: relative; width: min(100%, 1280px); height: 600px; margin-top: 64px; } - .cap { position: absolute; } - .cap.monitor { right: 0; top: 0; width: min(940px, 72%); height: 500px; } - .cap.laptop { left: 0; bottom: 0; width: min(620px, 46%); height: 340px; z-index: 2; } - .cap.monitor .gl { display: flex; } - .cap.monitor .bar { justify-content: space-between; } - .install { padding: 160px 0 150px; display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: 28px; align-items: end; } - .install .copy { grid-column: 1 / 7; } - .desktop { grid-column: 7 / -1; margin-top: 0; } } + +/* ---------- Privacy Policy and Terms: one sheet of paper on the sky, no drawing ---------- */ +.doc .page { + position: relative; isolation: isolate; max-width: 820px; margin: 40px auto 56px; padding: 6px 48px 8px; + background: var(--paper); color: var(--text); border-radius: 14px 22px 12px 26px / 22px 12px 26px 14px; +} +.doc .page::before { content: ""; position: absolute; inset: -4px 6px 4px -6px; z-index: -1; border: 2.6px solid var(--pen); border-radius: inherit; pointer-events: none; } +.doc .page::after { content: ""; position: absolute; top: -15px; left: 50%; width: 130px; height: 30px; translate: -50% 0; rotate: -2deg; background: var(--tape); } +.top { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 76px; padding: 10px 0; background: var(--rule) left bottom / 160px 10px repeat-x; } +.brand { display: flex; align-items: center; gap: 10px; font: 800 22px/1 var(--display); text-decoration: none; } +.brand img { width: 30px; height: 30px; } +.nav { display: flex; flex-wrap: wrap; gap: 4px 20px; font: 600 17px/1 var(--hand); } +.nav a { padding: 8px 0; color: var(--link); text-decoration: none; } +.nav a:hover { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 4px; } +.legal { padding: 34px 0 40px; } +.legal h1 { rotate: none; font-size: clamp(36px, 7vw, 54px); line-height: 1.05; } +.legal .updated { margin-top: 10px; font: 600 16px/1.3 var(--hand); color: var(--red); } +.legal h2 { margin-top: 38px; font-size: 27px; line-height: 1.15; } +.legal p, .legal ul { margin-top: 12px; } +.legal ul { padding-left: 1.25em; } +.legal li + li { margin-top: 8px; } +.legal strong { font-weight: 700; } +.legal a { color: var(--link); text-decoration-thickness: 2px; text-underline-offset: 3px; text-decoration-color: color-mix(in srgb, var(--link) 40%, transparent); } +.legal code { font: 15px var(--mono); } +.doc footer { display: flex; flex-wrap: wrap; gap: 6px 22px; padding: 22px 0 28px; background: var(--rule) left top / 160px 10px repeat-x; font-size: 15.5px; } +.doc footer a { color: var(--link); text-decoration-thickness: 2px; text-underline-offset: 3px; text-decoration-color: color-mix(in srgb, var(--link) 40%, transparent); } +@media (max-width: 719.98px) { + .doc .page { margin: 26px 12px 36px; padding: 4px 20px 6px; } + .doc { font-size: 17px; } +} + @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 0s !important; transition-delay: 0s !important; } } diff --git a/site/terms.html b/site/terms.html index a4bccef6..06522ba7 100644 --- a/site/terms.html +++ b/site/terms.html @@ -6,14 +6,16 @@ Terms of Service · StableBar - + - + +
StableBar From 6fb8db060e81823043c14bb3c84fdd18e1bba37d Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Fri, 30 Oct 2026 09:55:02 +0100 Subject: [PATCH 02/46] docs: keep the website's bar steady and readable when scrolling The night switch showed the same crescent outlined by day and filled at night, which looks alike at 16 points. It now shows a sun by day and a moon at night, as the old site did. Scrolling past the headline slid the bar into the header in several visible steps: a widget dropped into More widgets, Download grew from nothing and the menus opened late. It now fades out, swaps in one step and fades back in, and stays the header until the headline is well back in view, so small scrolls no longer flip it back and forth. Once scrolled, a white strip slid in behind the bar, where its near-white tiles disappeared. The strip now takes the colour of the sky it covers, so the bar reads as it does over the drawing, by day and by night. --- site/index.html | 4 ++-- site/main.js | 55 +++++++++++++++++++++++++++++++++++++------------ site/scene.js | 2 ++ site/style.css | 20 ++++++++++-------- 4 files changed, 57 insertions(+), 24 deletions(-) diff --git a/site/index.html b/site/index.html index 534064c9..a622fac5 100644 --- a/site/index.html +++ b/site/index.html @@ -62,7 +62,7 @@ - + @@ -134,7 +134,7 @@
Battery 82%
Thu 22 Oct  09:41
Download - +
diff --git a/site/main.js b/site/main.js index 1c4e7200..b6c0348e 100644 --- a/site/main.js +++ b/site/main.js @@ -202,25 +202,44 @@ volume.addEventListener("input", () => { volume.style.setProperty("--v", `${volume.value}%`); }); -/* Once the page scrolls, a strip of paper slides in behind the bar. Once the headline has gone +/* Once the page scrolls, a strip of sky slides in behind the bar. Once the headline has gone under it, the bar becomes the page's header. While the bar is being edited it stays the demo. */ -function dockState() { - dock.classList.toggle("scrolled", scrollY > 2); - const header = !editing && heroNote.getBoundingClientRect().bottom < restHeight() + 56; - if (header === dock.classList.contains("header")) return; +let swapAt = 0; +function wantHeader() { + const line = restHeight() + 56, bottom = heroNote.getBoundingClientRect().bottom; + // once it's the header it stays so until the headline is well back in view, so small scrolls don't flip it + return !editing && bottom < (dock.classList.contains("header") ? line + 120 : line); +} +function setHeader(header) { dock.classList.add("measure"); dock.classList.toggle("header", header); fit(); - dock.classList.toggle("header", !header); dock.offsetWidth; dock.classList.remove("measure"); - dock.classList.toggle("header", header); if (openId) open(openId); } -/* The bar measures itself again once the header's menus and Download have finished opening or closing */ -bar.addEventListener("transitionend", e => { - if (e.propertyName === "grid-template-columns" && e.target.matches(".site .menus, .w.dl")) fit(); -}); +/* Scrolling swaps the bar's contents in one step behind a short fade, as macOS swaps an app's menus, + instead of letting widgets slide over one another. Editing, resizing and loading swap at once. */ +function dockState(fade = false) { + dock.classList.toggle("scrolled", scrollY > 2); + if (swapAt && fade) return; + swapAt = 0; + bar.classList.remove("swap"); + const header = wantHeader(); + if (header === dock.classList.contains("header")) return; + if (!fade || reduce.matches) return setHeader(header); + // fade out, then swap on the first frame after the fade (frames, not a timer, so a busy page can't hold it) + bar.classList.add("swap"); + const at = swapAt = performance.now() + 130; + const step = now => { + if (swapAt !== at) return; + if (now < at) return requestAnimationFrame(step); + swapAt = 0; + setHeader(wantHeader()); + bar.classList.remove("swap"); + }; + requestAnimationFrame(step); +} /* Edit Bar in Place: the bar becomes its own editor, as in the app. Each position gets a tray, widgets keep their look, − takes one off, + adds one where it sits, and Undo and Done wait below. */ @@ -836,8 +855,18 @@ try { root.classList.toggle("has-scene", Boolean(scene)); if (scene) { + /* The strip behind the bar takes the colour of the sky it covers, so the bar reads over it as it does over the drawing */ + let lastSky = ""; + const lum = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255).map(c => c <= .03928 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4).reduce((a, c, i) => a + c * [.2126, .7152, .0722][i], 0); + const skyStrip = s => { + const sky = s.sky?.(); + if (!sky || sky === lastSky) return; + lastSky = sky; + dock.style.setProperty("--strip", sky); + dock.dataset.sky = lum(sky) < .18 ? "dark" : "light"; + }; scene.onFrame = s => { - try { lightWords(s); } catch (err) { + try { lightWords(s); skyStrip(s); } catch (err) { if (!sceneFailed) { sceneFailed = true; console.warn("The drawing failed:", err); } } }; @@ -989,7 +1018,7 @@ addEventListener("scroll", () => { queued = true; requestAnimationFrame(() => { queued = false; - dockState(); + dockState(true); updateDusk(); lumRects = null; }); diff --git a/site/scene.js b/site/scene.js index a3de7dbd..68fc4c05 100644 --- a/site/scene.js +++ b/site/scene.js @@ -272,6 +272,8 @@ class Scene { } beamI() { return Math.max(0.3 + 0.7 * smooth(0.22, 0.72, this.d), this.pres.install); } lamp() { return { x: this.g.lamp.x + this.ox, y: this.g.lamp.y + this.oy }; } + // the colour of the sky at the top of the drawing right now, as #rrggbb + sky() { const c = this.P?.sky; return c ? '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('') : null; } /* ----- input, in client coordinates ----- */ pointer(x, y, kind = 'mouse') { diff --git a/site/style.css b/site/style.css index 5e780b98..8948d3d3 100644 --- a/site/style.css +++ b/site/style.css @@ -40,7 +40,7 @@ --sky: #86cbf2; --sea: #2e58d8; --screen: #86cbf2; - --strip: #ffffff; + --strip: var(--sky); /* the bar keeps the site's amber Download */ --amber: #f5821f; --amber-ink: #1b1007; @@ -70,7 +70,6 @@ --sky: #232662; --sea: #14184a; --screen: #2f3374; - --strip: #1c1e4e; --win-bg: #1e1d1c; --win-fg: #f5f5f7; --win-ring: #000; @@ -132,7 +131,7 @@ main { overflow-x: clip; } [data-mode="light"] [data-theme="phosphor"] { --strip-bg: #f6f1e6ee; --w-bg: transparent; --w-border: transparent; --w-shadow: transparent; --w-glow: none; --fg: #5c3a00; --muted: #87683b; --accent: #a86400; --accent-text: #f6f1e6; --ws-off: #87683b; --ws-ind: #5c3a00; --ws-on: #f6f1e6; --pop-bg: #f6f1e6ee; --pop-border: #5c3a0040; --raised: #ebe2cf; --track: #5c3a0026; } /* The dock holds the bar and its popups at the top of the window, for the whole page. - Once the page scrolls, a strip of paper slides in behind it so text never runs under its widgets. */ + Once the page scrolls, a strip of sky slides in behind it so text never runs under its widgets. */ .dock { position: fixed; top: 0; left: 0; right: 0; z-index: 50; height: var(--bar-h); } [data-mode="dark"] .dock { color-scheme: dark; --ring: #ffe04a; } .dock::before { @@ -140,7 +139,9 @@ main { overflow-x: clip; } background: var(--rule) left bottom / 160px 10px repeat-x, linear-gradient(var(--strip) 0 0) top / 100% calc(100% - 5px) no-repeat; opacity: 0; translate: 0 -6px; transition: opacity .25s, translate .3s var(--ease); } -[data-mode="dark"] .dock::before { background-image: var(--rule-night), linear-gradient(var(--strip) 0 0); } +/* The strip is the colour of the sky behind it (set by main.js); its pen line turns light when the sky is dark */ +[data-mode="dark"] .dock::before, .dock[data-sky="dark"]::before { background-image: var(--rule-night), linear-gradient(var(--strip) 0 0); } +.dock[data-sky="light"]::before { background-image: var(--rule), linear-gradient(var(--strip) 0 0); } .dock.scrolled::before { opacity: 1; translate: none; } .bar { @@ -152,6 +153,9 @@ main { overflow-x: clip; } transition: background .25s, border-radius .25s, margin .25s var(--ease), height .25s var(--ease); } .bar .g { display: flex; align-items: center; gap: 6px; min-width: 0; } +/* Becoming the header: the widgets fade out, swap in one step and fade back in (see dockState in main.js) */ +.bar .g { transition: opacity .13s ease; } +.bar.swap .g { opacity: 0; } .w { flex: none; height: var(--tile-h); display: flex; align-items: center; gap: 7px; padding: 0 10px; white-space: nowrap; background: var(--w-bg); box-shadow: inset 0 0 0 1px var(--w-border), 0 1px 3px var(--w-shadow); border-radius: var(--w-radius); @@ -223,17 +227,15 @@ main { overflow-x: clip; } .header .w.site { display: flex; } .site .menus a { color: var(--fg); } .site .menus a:hover { color: var(--accent); } -.header .site .menus { grid-template-columns: 1fr; transition-delay: .1s; } -@starting-style { .header .site .menus { grid-template-columns: 0fr; } } +.header .site .menus { grid-template-columns: 1fr; } .w.dl { padding: 0; display: grid; grid-template-columns: 0fr; margin-left: -6px; opacity: 0; visibility: hidden; background: var(--amber); box-shadow: none; color: var(--amber-ink); font-weight: 600; - transition: grid-template-columns .4s var(--ease), margin .4s var(--ease), opacity .3s, visibility 0s .4s; } -.w.dl > span { overflow: hidden; display: flex; align-items: center; gap: 6px; padding: 0; transition: padding .4s var(--ease); } +.w.dl > span { overflow: hidden; display: flex; align-items: center; gap: 6px; padding: 0; } .w.dl svg { width: 16px; height: 16px; } .w.dl:hover { background: #ff9233; } -.header .w.dl { grid-template-columns: 1fr; margin-left: 0; opacity: 1; visibility: visible; transition-delay: 0s; } +.header .w.dl { grid-template-columns: 1fr; margin-left: 0; opacity: 1; visibility: visible; } .header .w.dl > span { padding: 0 11px 0 8px; } .measure * { transition: none !important; } From 8805a9b57de0d564a68afd1b860d89c4046b8770 Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Fri, 30 Oct 2026 09:59:42 +0100 Subject: [PATCH 03/46] docs: draw the website's header Download like the page's button The Download widget that joins the bar once it becomes the header still used the old site's amber and the system font. It now matches the page's Download button at bar size: the highlighter yellow, the display handwriting, a felt-pen outline printed a little off it that boils with the others, and the same tilt on hover. --- site/index.html | 2 +- site/main.js | 6 +++--- site/style.css | 20 ++++++++++++-------- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/site/index.html b/site/index.html index a622fac5..a78e4a4a 100644 --- a/site/index.html +++ b/site/index.html @@ -59,7 +59,7 @@ - + diff --git a/site/main.js b/site/main.js index b6c0348e..5f812d64 100644 --- a/site/main.js +++ b/site/main.js @@ -908,7 +908,7 @@ if (scene) { /* ---------- The pen: every note, Download button, tag and key button gets a felt-pen outline, drawn at its size in three slightly different states that the CSS steps through (the boil). Inline SVG paths rather than images, so an outline never counts as the page's largest paint. ---------- */ -const INK = ".note, .cta, .tag, .keys button"; +const INK = ".note, .cta, .tag, .keys button, .w.dl"; const hash = n => { const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); }; const noise = x => { const i = Math.floor(x), f = x - i, u = f * f * (3 - 2 * f); return (hash(i) + (hash(i + 1) - hash(i)) * u) * 2 - 1; }; const r1 = v => Math.round(v * 10) / 10; @@ -976,11 +976,11 @@ function ink(el, seed) { const W = el.offsetWidth, H = el.offsetHeight; if (!W || !H || el.dataset.inkSize === `${W}x${H}`) return; el.dataset.inkSize = `${W}x${H}`; - const shape = el.dataset.ink === "torn" ? "torn" : el.matches(".cta, .keys button") ? "pill" : "rect"; + const shape = el.dataset.ink === "torn" ? "torn" : el.matches(".cta, .keys button, .w.dl") ? "pill" : "rect"; const cs = getComputedStyle(el); const radii = shape === "pill" ? [H / 2, H / 2, H / 2, H / 2] : [cs.borderTopLeftRadius, cs.borderTopRightRadius, cs.borderBottomRightRadius, cs.borderBottomLeftRadius].map(v => parseFloat(v) || 0); - const weight = el.matches(".note") ? 2.9 : 2.5; + const weight = el.matches(".note") ? 2.9 : el.matches(".w.dl") ? 2.1 : 2.5; let svg = el.querySelector(":scope > .pen"); if (!svg) { svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); diff --git a/site/style.css b/site/style.css index 8948d3d3..4822af93 100644 --- a/site/style.css +++ b/site/style.css @@ -41,9 +41,8 @@ --sea: #2e58d8; --screen: #86cbf2; --strip: var(--sky); - /* the bar keeps the site's amber Download */ + /* the ring around the widget a script makes */ --amber: #f5821f; - --amber-ink: #1b1007; /* the app's windows: Add here, the right-click menu, the Store */ --win-bg: #f4f2ef; --win-fg: #1d1d1f; @@ -228,15 +227,20 @@ main { overflow-x: clip; } .site .menus a { color: var(--fg); } .site .menus a:hover { color: var(--accent); } .header .site .menus { grid-template-columns: 1fr; } +/* The header's Download is the page's own button at bar size: the highlighter yellow, the display hand + and a felt-pen outline printed a little off it (drawn by main.js, as on the page's other buttons) */ .w.dl { - padding: 0; display: grid; grid-template-columns: 0fr; margin-left: -6px; opacity: 0; visibility: hidden; - background: var(--amber); box-shadow: none; color: var(--amber-ink); font-weight: 600; + --ox: -2px; --oy: -2px; + position: relative; isolation: isolate; padding: 0; display: grid; grid-template-columns: 0fr; margin-left: -6px; opacity: 0; visibility: hidden; + border-radius: 999px; background: var(--hi); box-shadow: none; filter: none; color: #111; + font: 800 13.5px/1 var(--display); font-variation-settings: "INFM" 70, "BNCE" 25; letter-spacing: .01em; + transition: rotate .2s var(--ease), background-color .2s; } .w.dl > span { overflow: hidden; display: flex; align-items: center; gap: 6px; padding: 0; } -.w.dl svg { width: 16px; height: 16px; } -.w.dl:hover { background: #ff9233; } -.header .w.dl { grid-template-columns: 1fr; margin-left: 0; opacity: 1; visibility: visible; } -.header .w.dl > span { padding: 0 11px 0 8px; } +.w.dl svg:not(.pen) { width: 16px; height: 16px; } +.w.dl:hover { background: #ffb547; rotate: -2deg; } +.header .w.dl { grid-template-columns: 1fr; margin-left: 2px; opacity: 1; visibility: visible; } +.header .w.dl > span { padding: 0 12px 0 8px; } .measure * { transition: none !important; } /* The center position shows only when a widget sits there */ From c6a65c4975dc5c0a88d28be3d0511d41f38e6c6b Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Fri, 30 Oct 2026 10:58:57 +0100 Subject: [PATCH 04/46] docs: let the website's lighthouse play with the bar Three small games between the drawing and the demo bar, all decorative: - Keepy-uppy: a click on the lighthouse, or the key's new Play ball, sends its pom-pom up at the bar. Widgets hop when it hits them, a workspace it hits gets focused, the pointer aims the next bounce, and the rally ends with a splash in the sea and a new pom-pom. - Pointing at a widget lights it from below and hangs a paper tag under it, such as "the keeper is late" under the calendar. Keyboard focus shows the same tag. - Left alone for a while, once per visit, the lighthouse nudges a widget aside and chuckles, then puts it back, whistling, when the visitor moves. None of them changes the bar for good or runs while it's being edited. Under reduced motion only the tags remain. The scene's idle timers now also start when the page loads quickly. --- site/index.html | 3 + site/main.js | 171 ++++++++++++++++++- site/scene.js | 444 +++++++++++++++++++++++++++++++++++++++++++++--- site/style.css | 30 +++- 4 files changed, 611 insertions(+), 37 deletions(-) diff --git a/site/index.html b/site/index.html index a78e4a4a..2a421f08 100644 --- a/site/index.html +++ b/site/index.html @@ -104,6 +104,7 @@ + @@ -198,6 +199,7 @@ +

Drag it, or use the Left and Right arrow keys, to move it. Delete removes it.

@@ -225,6 +227,7 @@

A native, +

Or click the hill or the sea.Or tap the hill or the sea.

diff --git a/site/main.js b/site/main.js index 5f812d64..9efd8dcd 100644 --- a/site/main.js +++ b/site/main.js @@ -89,7 +89,7 @@ function fit() { const widgets = [...bar.querySelectorAll("[data-pri]")].filter(w => w.dataset.pri !== "never"); widgets.forEach(w => w.classList.remove("over")); more.hidden = true; - if (editing) { fades(); return; } + if (editing) { fades(); sendBar(); return; } const order = widgets.map((w, i) => ({ w, p: Number(w.dataset.pri), i })).sort((a, b) => a.p - b.p || b.i - a.i).map(o => o.w); const shown = order.filter(w => w.offsetWidth > 0); for (const w of shown) { @@ -108,8 +108,32 @@ function fit() { } place(); slide(); + sendBar(); } +/* The bar's visible widgets, for the lighthouse's ball game (scene.setBar): each workspace number is + its own target, and the ball bounces on the line under them: the widgets' bottom edge, the bar's + when it has a strip, or the strip's pen line once the page has scrolled. Measured when the bar + changes (fit, the header swap, resizing, themes, scrolling past the top), never every frame. */ +function barTargets() { + if (editing) return []; + // from the theme's tokens rather than the bar's box, which may still be easing into a new theme + const cs = getComputedStyle(bar), strip = cs.getPropertyValue("--strip-bg").trim() !== "transparent"; + const line = dock.classList.contains("scrolled") ? restHeight() + 3.5 : strip ? restHeight() - (parseFloat(cs.getPropertyValue("--strip-m")) || 0) : null; + const out = []; + const add = (key, el) => { + const r = el.getBoundingClientRect(); + if (r.width > 3 && r.right > 0 && r.left < innerWidth) out.push({ key, x0: r.left, x1: r.right, y: line ?? r.bottom }); + }; + for (const w of bar.querySelectorAll(":scope > .g > .w[data-key]")) { + if (w.hidden || w.classList.contains("over") || getComputedStyle(w).visibility === "hidden") continue; + if (w.dataset.key === "ws") for (const b of w.querySelectorAll(".wsi")) add(`ws:${b.dataset.ws}`, b); + else add(w.dataset.key, w); + } + return out; +} +function sendBar() { if (scene) S("setBar", barTargets()); } + /* Popups open below their widget, or from More widgets when the widget is hidden */ function anchorFor(id) { if (id === "more") return more; @@ -133,6 +157,7 @@ const popButton = w => w.matches("button") ? w : w.querySelector(":scope > butto /* Opening another widget's popup morphs the open one into it, as the app does */ function open(id) { if (id !== openId && anchorFor(id).hidden) return; + hideTour(); const from = openId; openId = openId === id ? null : id; pop.classList.toggle("morph", Boolean(from && openId)); @@ -201,6 +226,8 @@ volume.addEventListener("input", () => { sound.querySelector(".pct").textContent = `${volume.value}%`; volume.style.setProperty("--v", `${volume.value}%`); }); +/* Those two change the widgets' widths, and a new theme the bar's height: the ball game measures the bar again once they settle */ +bar.addEventListener("transitionend", e => { if ((e.propertyName === "grid-template-columns" || e.target === bar && e.propertyName === "height") && !editing) sendBar(); }); /* Once the page scrolls, a strip of sky slides in behind the bar. Once the headline has gone under it, the bar becomes the page's header. While the bar is being edited it stays the demo. */ @@ -211,6 +238,7 @@ function wantHeader() { return !editing && bottom < (dock.classList.contains("header") ? line + 120 : line); } function setHeader(header) { + hideTour(); dock.classList.add("measure"); dock.classList.toggle("header", header); fit(); @@ -221,7 +249,10 @@ function setHeader(header) { /* Scrolling swaps the bar's contents in one step behind a short fade, as macOS swaps an app's menus, instead of letting widgets slide over one another. Editing, resizing and loading swap at once. */ function dockState(fade = false) { - dock.classList.toggle("scrolled", scrollY > 2); + if (dock.classList.contains("scrolled") !== scrollY > 2) { + dock.classList.toggle("scrolled", scrollY > 2); + sendBar(); + } if (swapAt && fade) return; swapAt = 0; bar.classList.remove("swap"); @@ -287,6 +318,7 @@ function beginEditing(from) { if (openId) open(openId); closeMenu(); opener = from || (bar.contains(document.activeElement) ? document.activeElement : null); + hideTour(); editing = true; dockState(); past = []; @@ -866,7 +898,7 @@ if (scene) { dock.dataset.sky = lum(sky) < .18 ? "dark" : "light"; }; scene.onFrame = s => { - try { lightWords(s); skyStrip(s); } catch (err) { + try { lightWords(s); skyStrip(s); if (lit && s.playing) hideTour(); } catch (err) { if (!sceneFailed) { sceneFailed = true; console.warn("The drawing failed:", err); } } }; @@ -901,14 +933,136 @@ if (scene) { S("tap", e.clientX, e.clientY); }); - /* The key's buttons drop the same surprises, for keyboards and screen readers too */ + /* The key's buttons drop the same surprises, for keyboards and screen readers too; Play ball starts the game */ for (const b of document.querySelectorAll("[data-drop]")) b.addEventListener("click", () => S("surprise", b.dataset.drop)); + for (const b of document.querySelectorAll("[data-play]")) b.addEventListener("click", () => { hideTour(); S("play"); }); + scene.onBar = onBar; +} + +/* ---------- The lighthouse plays with the bar ---------- + The ball hits a widget and it hops; a workspace number also takes the focus, as a click would, which lights + its beach hut. The bored lighthouse shoves one widget aside and puts it back. Nothing else changes: + Download never navigates, the night switch never toggles, and every hop is gone in half a second. */ +const widgetFor = key => key.startsWith("ws:") ? bar.querySelector(`#gl .wsi[data-ws="${key.slice(3)}"]`) : bar.querySelector(`:scope > .g > .w[data-key="${CSS.escape(key)}"]`); +let shove = null; +function unshove(quick) { + const s = shove; + if (!s) return; + shove = null; + if (!reduce.matches && s.el.isConnected) s.el.animate([{ transform: s.to }, { transform: "none" }], { duration: quick ? 240 : 760, easing: quick ? "cubic-bezier(.3, 0, .2, 1)" : "cubic-bezier(.45, 0, .25, 1)" }).finished.then(() => { if (shove?.el !== s.el) s.el.style.zIndex = ""; }, () => {}); + else s.el.style.zIndex = ""; + s.a.cancel(); +} +function onBar(event, key, info = {}) { + if (event === "release") return unshove(info.caught); + const el = !editing && widgetFor(key); + if (!el) return; + if (event === "bump") { + if (key.startsWith("ws:")) focusWorkspace(Number(key.slice(3))); + if (!reduce.matches) el.animate([ + { transform: "none" }, + { transform: "translateY(-6px) rotate(-3deg)", offset: .26, easing: "ease-in" }, + { transform: "translateY(1px) scale(1.04, .94)", offset: .52 }, + { transform: "translateY(-1.5px) rotate(.6deg)", offset: .74 }, + { transform: "none" }, + ], { duration: 520, easing: "ease-out" }); + } else if (event === "nudge" && !reduce.matches) { + unshove(true); + const dx = info.dx || 14, to = `translateX(${dx}px) rotate(${Math.sign(dx) * 3}deg)`; + el.style.zIndex = "3"; + shove = { el, to, a: el.animate([{ transform: "none" }, { transform: to }], { duration: 620, easing: "cubic-bezier(.3, 1.5, .5, 1)", fill: "forwards" }) }; + } +} + +/* The lamp-lit tour, for pointers that hover: the widget under the pointer takes the lamp's glow (the beam + already follows the pointer) and a paper tag hangs under it on a string. Keyboard focus shows it too. + It waits while the ball is in play, and never shows while editing, dragging or a popup is open. */ +const tag = document.getElementById("lamp-tag"); +const tagPaper = tag.querySelector(".lt-p"), tagText = tag.querySelector(".lt-t"), tagString = [...tag.querySelectorAll(".lt-s path")]; +const TAGS = { + ws: "four little rooms", apple: "still there", app: "Finder’s, for now", site: "this website, dressed as an app", + np: "the lighthouse hums along", meeting: "the keeper is late", system: "all calm", sound: "shh, the sheep are asleep", + battery: "the lamp runs on oil", clock: "always on time, unlike the keeper", dl: "the only serious button", + moon: "the lamp’s favourite", more: "the ones that didn’t fit", +}; +const canHover = matchMedia("(hover: hover)"); +let lit = null, tourShow = 0, tourHide = 0; +const tourWidget = el => { const w = el?.closest?.(".w[data-key]"); return w && bar.contains(w) && !w.closest(".lane") && TAGS[w.dataset.key] ? w : null; }; +const tourOK = () => scene && !editing && !openId && !drag && !adding && ctx.hidden && !scene.playing && !bar.classList.contains("swap"); +function showTour(w) { + clearTimeout(tourShow); + clearTimeout(tourHide); + if (!tourOK()) return; + const again = Boolean(lit); + lit?.classList.remove("lamp"); + lit = w; + w.classList.add("lamp"); + tagText.textContent = TAGS[w.dataset.key]; + tagPaper.style.setProperty("--r", `${(w.dataset.key.length * 7 % 5 - 2) * .7}deg`); + tag.hidden = false; + placeTag(); + if (!reduce.matches) tag.animate(again + ? [{ transform: "rotate(7deg)" }, { transform: "rotate(-3deg)", offset: .45 }, { transform: "none" }] + : [{ transform: "rotate(-16deg)", opacity: 0 }, { transform: "rotate(10deg)", opacity: 1, offset: .28 }, { transform: "rotate(-5deg)", offset: .52 }, { transform: "rotate(2deg)", offset: .76 }, { transform: "none" }], + { duration: again ? 420 : 850, easing: "ease-out" }); +} +/* The tag hangs from the middle of the widget's bottom edge; the paper keeps inside the window and the + string goes through a hole above the widget, wherever the paper ends up */ +function placeTag() { + const r = lit.getBoundingClientRect(), cx = r.left + r.width / 2, len = 13, pw = tagPaper.offsetWidth; + const left = clamp(cx - pw / 2, 8, innerWidth - pw - 8) - cx, hole = clamp(-left, 14, pw - 14); + tag.style.left = `${cx}px`; + tag.style.top = `${r.bottom - 1}px`; + tagPaper.style.left = `${left}px`; + tagPaper.style.top = `${len}px`; + tagPaper.style.setProperty("--hole", `${hole}px`); + const hx = left + hole, hy = len + 7.5; + for (const p of tagString) p.setAttribute("d", `M0 -1C${hx * .2 + 1.8} ${hy * .4} ${hx * .75 - 1.4} ${hy * .62} ${hx} ${hy}`); +} +function hideTour() { + clearTimeout(tourShow); + clearTimeout(tourHide); + if (!lit) return; + lit.classList.remove("lamp"); + lit = null; + tag.hidden = true; +} +bar.addEventListener("pointerover", e => { + if (e.pointerType === "touch" || !canHover.matches) return; + const w = tourWidget(e.target); + if (!w) return; + clearTimeout(tourHide); + if (w === lit) return; + clearTimeout(tourShow); + if (lit) showTour(w); + else tourShow = setTimeout(() => showTour(w), 240); +}); +bar.addEventListener("pointerout", e => { + const w = tourWidget(e.target); + if (!w || w.contains(e.relatedTarget)) return; + clearTimeout(tourShow); + if (w === lit) tourHide = setTimeout(hideTour, 140); +}); +bar.addEventListener("focusin", e => { const w = tourWidget(e.target); if (w && e.target.matches(":focus-visible")) showTour(w); }); +bar.addEventListener("focusout", e => { if (lit && !lit.contains(e.relatedTarget) && !lit.matches(":hover")) hideTour(); }); + +/* The bored lighthouse: once a visit, after a quiet spell with no pointer, scroll or key, and nothing open. + The scene checks the rest (motion allowed, the lamp in view, no game on); any input catches it. */ +let boredTimer = 0, boredDone = false; +function quietSpell() { + clearTimeout(boredTimer); + if (!scene || boredDone) return; + boredTimer = setTimeout(function check() { + const calm = !editing && !openId && !adding && ctx.hidden && !reviewing && !lit && !document.hidden && !reduce.matches; + if (calm && S("bored")) boredDone = true; + else boredTimer = setTimeout(check, 4000); + }, 12000); } /* ---------- The pen: every note, Download button, tag and key button gets a felt-pen outline, drawn at its size in three slightly different states that the CSS steps through (the boil). Inline SVG paths rather than images, so an outline never counts as the page's largest paint. ---------- */ -const INK = ".note, .cta, .tag, .keys button, .w.dl"; +const INK = ".note, .cta, .tag, .keys button, .w.dl, .lt-p"; const hash = n => { const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); }; const noise = x => { const i = Math.floor(x), f = x - i, u = f * f * (3 - 2 * f); return (hash(i) + (hash(i + 1) - hash(i)) * u) * 2 - 1; }; const r1 = v => Math.round(v * 10) / 10; @@ -980,7 +1134,7 @@ function ink(el, seed) { const cs = getComputedStyle(el); const radii = shape === "pill" ? [H / 2, H / 2, H / 2, H / 2] : [cs.borderTopLeftRadius, cs.borderTopRightRadius, cs.borderBottomRightRadius, cs.borderBottomLeftRadius].map(v => parseFloat(v) || 0); - const weight = el.matches(".note") ? 2.9 : el.matches(".w.dl") ? 2.1 : 2.5; + const weight = el.matches(".note") ? 2.9 : el.matches(".w.dl, .lt-p") ? 2.1 : 2.5; let svg = el.querySelector(":scope > .pen"); if (!svg) { svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); @@ -999,7 +1153,7 @@ const pen = new ResizeObserver(entries => { for (const e of entries) ink(e.targe for (const el of inked) pen.observe(el); /* The boil rests after a few quiet seconds, like the drawing */ -let idleTimer = 0, lastWake = 0; +let idleTimer = 0, lastWake = -1e9; function wake() { const now = performance.now(); if (now - lastWake < 250) return; @@ -1007,6 +1161,8 @@ function wake() { root.classList.remove("idle"); clearTimeout(idleTimer); idleTimer = setTimeout(() => root.classList.add("idle"), 6000); + S("poke"); + quietSpell(); } for (const type of ["pointermove", "pointerdown", "keydown", "scroll", "wheel", "touchstart"]) addEventListener(type, wake, { passive: true }); wake(); @@ -1024,6 +1180,7 @@ addEventListener("scroll", () => { }); }, { passive: true }); addEventListener("resize", () => { + hideTour(); fit(); dockState(); measureMarks(); diff --git a/site/scene.js b/site/scene.js index 68fc4c05..5868f5d7 100644 --- a/site/scene.js +++ b/site/scene.js @@ -221,6 +221,11 @@ class Scene { this.sprites = new Map(); this.fontsV = 0; this.layoutV = 0; this.sparkle = -9; this.rebuilds = 0; this.onFrame = null; + // the ball game and the bored trick (see "the lighthouse plays with the bar") + this.bar = []; this.ball = null; this.bore = null; this.look = null; + this.doodles = []; this.splashes = []; + this.hatKick = -9; this.regrowAt = -9; + this.onBar = null; this.times = []; this.awake = 0; this.kick = 0; this.raf = 0; this.timer = 0; this.last = null; this.mq = matchMedia('(prefers-reduced-motion: reduce)'); @@ -231,7 +236,7 @@ class Scene { this.measure(true); this.ro = new ResizeObserver(() => { if (this.measure()) this.wake(); }); this.ro.observe(host); - this.onVis = () => { if (document.hidden) this.sleep(); else { this.last = null; this.wake(); } }; + this.onVis = () => { if (document.hidden) { this.endBall(); this.endBore(); this.sleep(); } else { this.last = null; this.wake(); } }; document.addEventListener('visibilitychange', this.onVis); const fontsIn = () => { this.fontsV++; this.wake(); }; if (document.fonts) { @@ -291,17 +296,23 @@ class Scene { this.ripples.push({ x, y, t0: this.t, a: Math.min(1, sp / 2400) * 0.9 }); } if (this.reduced) this.aim(x, y); + this.poke(); this.wake(); } release() { this.ptr.on = false; this.wake(); } tap(x, y) { x -= this.ox; y -= this.oy; + const T = this.g.lh; + this.poke(); + // the lighthouse plays ball instead of catching things (and winks when it can't) + if (y >= this.top && Math.abs(x - T.x) < T.wB * 0.75 && y > T.hatTop - T.er * 1.6 && y < T.base) { + if (!this.play()) this.blink.wink = this.t; + this.wake(); + return false; + } this.aim(x, y); this.wake(); - const g = this.g, T = g.lh; if (y < this.top) return false; - // the lighthouse winks instead of catching things - if (Math.abs(x - T.x) < T.wB * 0.75 && y > T.hatTop && y < T.base) { this.blink.wink = this.t; return false; } this.spawn(x, y); return true; } @@ -326,6 +337,234 @@ class Scene { return a; } + /* ----- the lighthouse plays with the bar ----- + Keepy-uppy: the pom-pom leaves the hat, bounces off the line under a widget, comes back and is + headed up again, three to six times, then misses and drops into the sea. The bored trick: once, + when nobody has done anything for a while (the page decides), the beam shoves a widget aside. + The page sends the bar's widgets, animates them on onBar(event, key, info) and reads s.playing. */ + get playing() { return Boolean(this.ball); } + // the bar's visible widgets, in client coordinates: { key, x0, x1, y }, y the line the ball bounces on + setBar(list) { + this.bar = (Array.isArray(list) ? list : []).filter(t => t && t.x1 > t.x0) + .map(t => ({ key: String(t.key), x0: t.x0 - this.ox, x1: t.x1 - this.ox, y: t.y - this.oy })); + if (!this.bar.length) { this.endBall(); this.endBore(); } + this.wake(); + } + play() { + if (this.reduced || this.ball || document.hidden) return false; + const h = hatGeom(this), pr = h.pr; + if (!this.bar.some(t => t.y + pr < h.bob[1] - pr) || !this.seen(this.g.lamp.x, this.g.lamp.y)) return false; + this.endBore(); + this.ball = { x: h.bob[0], y: h.bob[1], vx: 0, vy: 0, n: 0, max: 3 + Math.floor(Math.random() * 4), key: null, last: null, spin: 0, sq: -9 }; + this.regrowAt = -9; + this.serve(); + this.wake(); + return !!this.ball; + } + // any input: catches the bored trick in the act + poke() { + const b = this.bore; + if (!b || (b.phase !== 'look' && b.phase !== 'push')) return; + if (b.phase === 'push') this.emit('release', b.key, { caught: true }); + b.phase = 'caught'; b.at = this.t; b.notes = 0; + this.wake(); + } + // the bored trick, now (the page calls it after a quiet spell; also a hook for testing). True if it started. + bored() { + if (this.reduced || this.ball || this.bore || document.hidden) return false; + const L = this.g.lamp, mid = t => (t.x0 + t.x1) / 2; + const near = this.bar.filter(t => /^(clock|meeting|battery)$/.test(t.key)).sort((a, b) => Math.abs(mid(a) - L.x) - Math.abs(mid(b) - L.x))[0]; + if (!near || !this.seen(L.x, L.y)) return false; + const x = mid(near); + this.bore = { key: near.key, x, y: Math.max(4, this.top * 0.5), side: x < L.x ? -1 : 1, phase: 'look', at: this.t, notes: 0 }; + this.wake(); + return true; + } + emit(event, key, info = {}) { + try { this.onBar?.(event, key, info); } catch (err) { if (!this.onBarFailed) { this.onBarFailed = true; console.warn('onBar failed:', err); } } + } + // the next widget: the one nearest the pointer when it's up near the bar, otherwise another one, never the same twice + pick() { + const b = this.ball, g = this.g, h = hatGeom(this), L = g.lamp, mid = t => (t.x0 + t.x1) / 2; + const reach = this.bar.filter(t => t.y + h.pr < h.bob[1] - h.pr && t.x1 > 0 && t.x0 < g.W); + if (!reach.length) return null; + if (this.ptr.on && this.ptr.y < Math.max(g.top + 60 * g.u, g.H * 0.34)) { + const px = this.ptr.x, gap = t => px < t.x0 ? t.x0 - px : px > t.x1 ? px - t.x1 : 0; + return reach.slice().sort((p, q) => gap(p) - gap(q) || Math.abs(mid(p) - px) - Math.abs(mid(q) - px))[0]; + } + const other = reach.filter(t => t.key !== b.last); + const list = other.length ? other : reach, w = list.map(t => 1 / (1 + Math.abs(mid(t) - L.x) / (500 * g.u + 200))); + let r = Math.random() * w.reduce((a, v) => a + v, 0); + for (let i = 0; i < list.length; i++) if ((r -= w[i]) <= 0) return list[i]; + return list[list.length - 1]; + } + // up from the hat to the next widget + serve() { + const b = this.ball, tg = this.pick(); + if (!tg) return this.endBall(); + const pr = this.g.lh.er * 0.66, span = tg.x1 - tg.x0; + b.key = tg.key; + this.leg('up', clamp((tg.x0 + tg.x1) / 2 + (Math.random() - 0.5) * span * 0.36, pr, this.g.W - pr), tg.y + pr); + } + /* One flight, along a parabola: 'up' slows down and still rises when it arrives (the bounce), + 'down' and 'miss' speed up as they fall back, 'fall' drops freely into the sea */ + leg(kind, bx, by, v0) { + const b = this.ball, R = by - b.y, T = clamp(0.4 + Math.abs(bx - b.x) / 1600, 0.42, 1); + const L = { kind, t0: this.t, ax: b.x, ay: b.y, bx, by, T }; + if (kind === 'up') { const k = 0.3; L.v0 = 2 * R / (T * (1 + k)); L.g = (k - 1) * L.v0 / T; } + else if (kind === 'fall') { + const g = 1300 * this.g.u, v = Math.max(0, v0 || 0); + L.v0 = v; L.g = g; L.T = (-v + Math.sqrt(v * v + 2 * g * Math.max(1, R))) / g; + } else { L.v0 = 0.3 * R / T; L.g = 2 * (R - L.v0 * T) / (T * T); } + b.leg = L; + } + legAt(L, k) { return [L.ax + (L.bx - L.ax) * k / L.T, L.ay + L.v0 * k + 0.5 * L.g * k * k]; } + updateBall(dt) { + const b = this.ball; + if (!b) return; + const L = b.leg, k = Math.min(this.t - L.t0, L.T); + [b.x, b.y] = this.legAt(L, k); + b.vx = (L.bx - L.ax) / L.T; b.vy = L.v0 + L.g * k; + b.spin += dt * b.vx / (this.g.lh.er * 0.66) * 0.5; + if (this.t - L.t0 < L.T) return; + if (L.kind === 'up') this.hit(); + else if (L.kind === 'down') this.header(); + else if (L.kind === 'miss') this.fall(); + else this.splash(); + } + // the ball meets the line under a widget: the widget hops, "boink!" + hit() { + const b = this.ball, g = this.g, u = g.u, pr = g.lh.er * 0.66; + const tg = this.bar.find(t => t.key === b.key && b.x > t.x0 - pr && b.x < t.x1 + pr) || this.bar.find(t => b.x > t.x0 - 2 && b.x < t.x1 + 2); + b.sq = this.t; b.n++; + if (tg) { + b.last = tg.key; + this.emit('bump', tg.key); + // "boink!" beside it on the far side from the lighthouse, out of the way of the ball coming back; + // against the window's edge, under it instead + const side = b.x < g.lamp.x ? -1 : 1; + if (side < 0 ? b.x < 90 * u + 20 : b.x > g.W - 90 * u - 20) this.doodle('boink', clamp(b.x, 34 * u + 12, g.W - 34 * u - 12), b.y + pr + 14 * u + 4); + else this.doodle('boink', b.x + side * (pr + 4 * u), b.y + pr * 0.15, { align: side < 0 ? 'right' : 'left' }); + } + const h = hatGeom(this); + // the last one comes back just wide of the hat, on the sea side, clear of the gallery, and keeps going + if (b.n >= b.max) this.leg('miss', Math.min(h.bob[0] - h.pr * 2.7, g.lamp.x - g.lh.er * 1.7 - h.pr), h.bob[1] + h.pr * 0.9); + else this.leg('down', h.bob[0], h.bob[1]); + } + // back on the hat: a header, "pok", and up again + header() { + const b = this.ball, u = this.g.u, pr = this.g.lh.er * 0.66, from = b.vx > 0 ? -1 : 1; + if (!this.seen(this.g.lamp.x, this.g.lamp.y)) return this.endBall(); + this.hatKick = this.t; b.sq = this.t; + this.doodle('pok', b.x + from * (pr + 3 * u), b.y + pr * 0.3, { align: from < 0 ? 'right' : 'left', life: 0.7 }); + this.serve(); + } + // missed: the hat lunges too late, and the ball falls past the lighthouse into the sea + fall() { + const b = this.ball; + this.hatKick = this.t; + const S = b.splash = this.splashSpot(); + this.leg('fall', S.x, S.y, b.vy); + // when the sea there is under the page, "plop" where the ball was last seen + if (!S.seen) { + let last = null; + for (let i = 1; i <= 14; i++) { const p = this.legAt(b.leg, b.leg.T * i / 14); if (!this.seen(p[0], p[1])) break; last = p; } + S.edge = last; + } + } + splash() { + const b = this.ball, S = b.splash, u = this.g.u; + if (S.seen) { + this.ripples.push({ x: S.x, y: S.y, t0: this.t, a: 1 }); + this.splashes.push({ x: S.x, y: S.y, t0: this.t }); + this.doodle('plop', S.x + 12 * u, S.y - 26 * u, { life: 1.1 }); + } else if (S.edge) this.doodle('plop', S.edge[0] + 8 * u, S.edge[1] - 6 * u, { life: 1.1 }); + this.look = { x: S.edge ? S.edge[0] : S.x, y: S.edge ? S.edge[1] : S.y, until: this.t + 1.2 }; + this.ball = null; + this.regrowAt = this.t + 0.6; + } + // a spot in the sea near the lighthouse, on its sea side, that can be seen (a few DOM checks, once a game) + splashSpot() { + const g = this.g, u = g.u, L = g.lamp, near = g.horizon + (g.H - g.horizon) * 0.24, out = []; + // and clear of "the sea (wet)" while the hero's labels show + const lab = this.pres.hero > 0.01 && seaLabelSpot(this); + const onLabel = (x, y) => lab && x > lab.x - 40 * u && x < lab.x + 170 * u && y > lab.y - 50 * u && y < lab.y + 45 * u; + // not right on the horizon, where a splash is too small to read: a little way out, close to the hill + for (const fy of [0.12, 0.2, 0.3, 0.42, 0.55, 0.72, 0.88]) { + const y = g.horizon + (g.H - g.horizon) * fy; + for (const [a, z] of this.seaSpans(y)) + for (let x = Math.min(z, g.W) - 30 * u; x > Math.max(a, 0) + 18 * u; x -= 34 * u) + if (x < L.x - g.lh.wB * 1.6 && !onLabel(x, y)) out.push({ x, y, d: Math.hypot(x - g.lh.x, (y - near) * 0.8) }); + } + out.sort((p, q) => p.d - q.d); + let tries = 0; + for (const p of out) { + if (tries++ > 14) break; + if (this.seen(p.x, p.y) && this.seen(p.x, p.y - 30 * u)) return { x: p.x, y: p.y, seen: true }; + } + const p = out[0] || { x: L.x - 160 * u, y: g.H - 12 }; + return { x: p.x, y: p.y, seen: false }; + } + // the game ends quietly (editing, the page hidden, the lighthouse covered): the pom-pom is simply back + endBall() { + if (!this.ball) return; + this.ball = null; + this.regrowAt = this.reduced || document.hidden ? -9 : this.t; + } + endBore() { + const b = this.bore; + if (!b) return; + if (b.phase === 'push') this.emit('release', b.key, { caught: true }); + this.bore = null; + } + updateBore() { + const b = this.bore; + if (!b) return; + const a = this.t - b.at, g = this.g, u = g.u, L = g.lamp, er = g.lh.er; + if (b.phase === 'look' && a > 1.3) { + b.phase = 'push'; b.at = this.t; + this.emit('nudge', b.key, { dx: b.side * 14 }); + this.doodle('hehe', L.x - b.side * (er * 1.32 + 12 * u + 3), L.y + er * 0.25, { align: b.side > 0 ? 'right' : 'left', life: 2.4 }); + } else if (b.phase === 'push' && a > 6) { + b.phase = 'back'; b.at = this.t; + this.emit('release', b.key, { caught: false }); + } else if (b.phase === 'caught') { + // whistling innocently, the notes rising on the side it looks away to + if (b.notes < 3 && a > 0.45 + b.notes * 0.48) { + this.doodle('note', L.x - b.side * (er * 1.5 + (4 + b.notes * 3) * u), L.y - er * (0.9 + b.notes * 0.15), { life: 1.5, flip: b.notes % 2 }); + b.notes++; + } + if (a > 2.5) this.bore = null; + } else if (b.phase === 'back' && a > 1.6) this.bore = null; + } + // the bored trick holds the light on its widget + beamLock() { + const b = this.bore; + if (!b || !((b.phase === 'look' && this.t - b.at > 0.35) || b.phase === 'push')) return null; + return Math.atan2(b.y - this.g.lamp.y, b.x - this.g.lamp.x); + } + /* What the eye is up to: where it looks (a, m), and squint, wide, a heavier lid or a happy shut eye */ + mood() { + const L = this.g.lamp, b = this.bore; + if (this.ball) return { a: Math.atan2(this.ball.y - L.y, this.ball.x - L.x), m: 1 }; + if (this.look && this.t < this.look.until) return { a: Math.atan2(this.look.y - L.y, this.look.x - L.x), m: 1 }; + if (!b) return null; + const a = this.t - b.at, up = Math.atan2(b.y - L.y, b.x - L.x); + if (b.phase === 'look') return { a: up, m: smooth(0, 0.45, a), squint: smooth(0.3, 0.85, a) }; + if (b.phase === 'push') return { a: up, m: 1, squint: 1 }; + if (b.phase === 'caught') { + if (a < 0.4) return { a: up, m: 1, wide: smooth(0, 0.07, a) }; + return { a: -Math.PI / 2 - b.side * 0.9, m: 0.85, shut: 0.36 * smooth(0.4, 0.7, a), wide: 1 - smooth(0.4, 0.62, a) }; + } + return { a: up, m: 1 - smooth(0, 0.5, a), squint: 1 - smooth(0, 0.3, a), happy: a > 0.25 && a < 1.05 ? 1 : 0 }; + } + // 0 while the pom-pom is in play, growing back after it + bobbleK() { return this.ball || this.t < this.regrowAt ? 0 : pop((this.t - this.regrowAt) / 0.5); } + // a word or a note drawn by the pen for a moment; light over the sea, dark over the sky by day + doodle(kind, x, y, o = {}) { + this.doodles.push({ kind, x, y, t0: this.t, life: o.life ?? 0.95, align: o.align ?? 'center', sea: this.isSea(x, y + 4 * this.g.u), flip: o.flip ? -1 : 1, seed: Math.random() * 10 }); + } + /* ----- size and layout ----- */ measure(first) { const r = this.host.getBoundingClientRect(); @@ -434,9 +673,11 @@ class Scene { } // where the page's notes sit (a guess, for placing labels and surprises) g.note = portrait ? { x0: 0, x1: W, y0: top + Hs * 0.24, y1: H } : { x0: 0, x1: W * 0.05 + Math.min(W * 0.46, 620) + 12, y0: top, y1: H * 0.84 }; - g.key = portrait ? null : { x0: 0, x1: W * 0.05 + 440, y0: H - 160, y1: H }; + g.key = portrait ? null : { x0: 0, x1: W * 0.05 + Math.min(W * 0.46, 562) + 12, y0: H - 160, y1: H }; this.layoutV++; for (const o of this.objs) this.settle(o); + // the bar's widgets moved with it: a game or a trick in progress ends quietly + this.endBall(); this.endBore(); } seaSpans(y) { const spans = [], W = this.W; @@ -582,6 +823,9 @@ class Scene { } this.objs = this.objs.filter(o => !o.dead); this.ripples = []; + this.endBall(); this.endBore(); + this.regrowAt = this.hatKick = -9; + this.doodles = []; this.splashes = []; this.look = null; } settle(o) { // after a resize, put land animals back on the ground and sea animals back in the water @@ -614,6 +858,7 @@ class Scene { if (this.d !== (this.dEff ?? this.dTarget) || (this.state === 'install') !== (this.pres.install > 0.999)) return true; for (const k of STATES) { const p = this.pres[k]; if (p > 0.001 && p < 0.999) return true; } if (this.ripples.length) return true; + if (this.ball || this.bore || this.doodles.length || this.splashes.length || this.t - this.hatKick < 0.7 || this.t - this.regrowAt < 0.9) return true; for (const o of this.objs) if (o.state === 'fall' || (o.state === 'fly' && !this.reduced) || o.dying != null || (o.land > 0 && this.t - o.land < 0.4)) return true; return this.beam.steer > 0.01 && this.beam.steer < 0.99 && !this.reduced; } @@ -669,15 +914,20 @@ class Scene { this.updateBeam(dt); this.ripples = this.ripples.filter(r => this.t - r.t0 < 2.2); this.updateObjs(dt); + this.updateBall(dt); + this.updateBore(); + this.doodles = this.doodles.filter(d => this.t - d.t0 < d.life); + this.splashes = this.splashes.filter(p => this.t - p.t0 < 0.7); } updateBeam(dt) { const b = this.beam, L = this.g.lamp, now = performance.now() / 1000, rm = this.reduced; - const quiet = this.ptr.kind === 'touch' ? 3.2 : 5; - const steering = this.ptr.on && now - this.ptr.last < quiet; + const quiet = this.ptr.kind === 'touch' ? 3.2 : 5, lock = this.beamLock(); + const steering = lock != null || (this.ptr.on && now - this.ptr.last < quiet); b.steer = rm ? (steering || b.steer > 0.5 ? 1 : 0) : lerp(b.steer, steering ? 1 : 0, Math.min(1, dt * (steering ? 4 : 0.7))); if (!rm) b.phi += dt * TAU / 15; const c = Math.cos(b.phi), sn = Math.sin(b.phi); - if (this.ptr.on && steering) { + if (lock != null) b.sa += angDiff(lock, b.sa) * Math.min(1, dt * 3.2); + else if (this.ptr.on && steering) { const want = this.clampAim(Math.atan2(this.ptr.y - L.y, this.ptr.x - L.x)); b.sa = rm ? want : b.sa + angDiff(want, b.sa) * Math.min(1, dt * 5); } @@ -843,6 +1093,9 @@ function drawScene(s, c) { const objs = s.objs.slice().sort((a, b) => a.y - b.y); for (const o of objs) drawObj(s, c, o); if (s.pres.hero > 0.01) drawLabels(s, c); + drawSplashes(s, c); + drawBall(s, c); + drawDoodles(s, c); } /* the lower sky, a lighter band with a soft wave at its top, and the horizon */ @@ -1211,17 +1464,19 @@ function drawTower(s, c) { poly(c, deck); c.fillStyle = INK; c.fill(); } -/* the eye in the lantern: an iris the colour of the light, a pupil that follows the beam, a lid */ +/* the eye in the lantern: an iris the colour of the light, a pupil that follows the beam (or the ball), a lid. + It also squints when up to something, goes wide when caught, and shuts happily when it got away with it. */ function drawEye(s, c) { const g = s.g, u = g.u, P = s.P, T = g.lh, L = g.lamp, er = T.er, B = s.beam; - const night = smooth(0.35, 0.8, s.d); - const rx = er * 1.32, ry = er * 1.08; + const night = smooth(0.35, 0.8, s.d), md = s.mood() || {}, wide = md.wide || 0, sq = md.squint || 0; + const rx = er * 1.32 * (1 + 0.13 * wide), ry = er * 1.08 * (1 + 0.16 * wide); const eye = ellipse(L.x, L.y, rx, ry, 40); print(c, eye, mix(mul(hex('#FFFFFF'), P.paper), P.beam, night * 0.25)); - // where it looks: along the beam, or straight at you when the light faces you - const look = clamp(B.len * 1.3) * (1 - B.flash), pa = B.a; + // where it looks: along the beam, or straight at you when the light faces you, or wherever it's busy + let look = clamp(B.len * 1.3) * (1 - B.flash), pa = B.a; + if (md.a != null) { pa = md.a; look = md.m; } const px = L.x + Math.cos(pa) * rx * 0.42 * look, py = L.y + Math.sin(pa) * ry * 0.36 * look; - const ir = er * (0.62 + 0.12 * B.flash), pr = er * (0.3 - 0.08 * night + 0.1 * B.flash); + const ir = er * (0.62 + 0.12 * B.flash) * (1 - 0.2 * wide), pr = er * (0.3 - 0.08 * night + 0.1 * B.flash) * (1 - 0.42 * wide); c.save(); poly(c, eye, true, REGX, REGY); c.clip(); dot(c, px + REGX, py + REGY, ir, mix(P.beam, hex('#FFB52E'), 0.35 - 0.2 * night)); c.restore(); @@ -1231,12 +1486,18 @@ function drawEye(s, c) { c.beginPath(); ribbon(c, ellipse(px, py, ir, ir, 22), TEX, 3, { tin: 1, tout: 1, w0: 1, w1: 1, amp: 0.3 }); c.fillStyle = INK; c.fill(); // the lid: a little heavy by day, wide awake at night, shut when it blinks or winks const bl = s.t - s.blink.at, wk = s.t - s.blink.wink; - let shut = lerp(0.2, 0.02, night); - if (bl < 0.18) shut = Math.max(shut, Math.sin(bl / 0.18 * Math.PI)); + let shut = lerp(lerp(0.2, 0.02, night), 0, wide); + if (md.shut) shut = Math.max(shut, md.shut); + shut = Math.max(shut, 0.5 * sq); + if (bl < 0.18 && !s.bore) shut = Math.max(shut, Math.sin(bl / 0.18 * Math.PI)); if (wk < 0.6) shut = Math.max(shut, wk < 0.1 ? wk / 0.1 : wk < 0.45 ? 1 : 1 - (wk - 0.45) / 0.15); if (s.reduced && wk >= 0.6) shut = lerp(0.2, 0.02, night); const lidCol = rgba(mul(hex('#FFFFFF'), P.paper)); - if (shut > 0.86) { + if (md.happy) { + // shut, and pleased with itself: the lid line curves up + poly(c, eye); c.fillStyle = lidCol; c.fill(); + c.beginPath(); ribbon(c, ellipse(L.x, L.y + ry * 0.38, rx * 0.72, ry * 0.62, 16, 1.18 * Math.PI, 1.82 * Math.PI), DET, 5, { tin: 3, tout: 3 }); c.fillStyle = INK; c.fill(); + } else if (shut > 0.86) { poly(c, eye); c.fillStyle = lidCol; c.fill(); c.beginPath(); ribbon(c, ellipse(L.x, L.y - ry * 0.35, rx * 0.78, ry * 0.55, 16, 0.2 * Math.PI, 0.8 * Math.PI), DET, 5, { tin: 3, tout: 3 }); @@ -1249,8 +1510,25 @@ function drawEye(s, c) { poly(c, lid); c.fillStyle = lidCol; c.fill(); c.beginPath(); ribbon(c, lid.slice(25), DET, 5, { tin: 3, tout: 3 }); c.fillStyle = INK; c.fill(); } + if (sq > 0.01 && !md.happy) { + // squinting: the lower lid comes up too + const ly = L.y + ry - sq * ry * 0.62, lid = []; + for (let i = 0; i <= 24; i++) { const a = i / 24 * Math.PI; lid.push([L.x + Math.cos(a) * (rx + 2), L.y + Math.sin(a) * (ry + 2)]); } + for (let i = 0; i <= 16; i++) { const x = L.x - rx + i / 16 * 2 * rx; lid.push([x, ly - (1 - ((x - L.x) / rx) ** 2) * ry * 0.16 * sq]); } + poly(c, lid); c.fillStyle = lidCol; c.fill(); + c.beginPath(); ribbon(c, lid.slice(25), TEX * 1.2, 6, { tin: 3, tout: 3 }); c.fillStyle = INK; c.fill(); + } c.restore(); c.beginPath(); loop(c, eye, SIL * 0.85, 8); c.fillStyle = INK; c.fill(); + // caught: a start, in a few short strokes either side + if (wide > 0.05) { + c.beginPath(); + for (const sx of [-1, 1]) for (const k of [-0.42, 0, 0.42]) { + const a = (sx < 0 ? Math.PI : 0) + sx * k, r0 = rx * 1.22, r1 = r0 + er * 0.55 * wide; + ribbon(c, [[L.x + Math.cos(a) * r0, L.y + Math.sin(a) * r0 * 0.9], [L.x + Math.cos(a) * r1, L.y + Math.sin(a) * r1 * 0.9]], TEX * 1.1, 20 + k * 7 + sx, { tin: 1, tout: 2 }); + } + c.fillStyle = INK; c.fill(); + } // the gallery's railing passes in front of the eye const gw = T.wT * 0.95, top = T.top - T.gal * 0.42, rail = top - T.gal * 0.62; c.beginPath(); @@ -1259,13 +1537,30 @@ function drawEye(s, c) { c.fillStyle = INK; c.fill(); } +/* The hat right now: where its brim sits and where the cone's tip and the bobble are. It lifts and + flops when it heads the ball (the ball game reads the bobble's place from here too). */ +function hatGeom(s) { + const g = s.g, L = g.lamp, er = g.lh.er, ry = er * 1.08, ka = s.t - s.hatKick, kicked = !s.reduced && ka >= 0 && ka < 0.7; + const lift = kicked && ka < 0.3 ? Math.sin(ka / 0.3 * Math.PI) * er * 0.34 : 0; + const flop = kicked ? Math.sin(ka * 19) * Math.exp(-ka * 6) * 0.34 : 0; + const sway = s.reduced ? 0.15 : 0.15 + s.windTotal * 0.25 + Math.sin(s.t * 1.3) * 0.05 + flop; + const by = L.y - ry * 0.62 - lift, pr = er * 0.66; + const tip = [L.x + er * (0.5 + sway * 2.2), by - er * 3.1 + Math.abs(sway) * er * 0.6]; + return { by, bw: er * 1.5, bh: er * 0.62, sway, tip, pr, bob: [tip[0], tip[1] - pr * 0.6] }; +} +/* a pom-pom: a scribbled ball of wool, turning with `spin` */ +function bobble(c, x, y, pr, col, spin = 0) { + const pb = []; + for (let i = 0; i < 40; i++) { const a = i / 40 * TAU, r = pr * (1 + 0.09 * Math.abs(Math.sin((a - spin) * 5.5 + 1))); pb.push([x + Math.cos(a) * r, y + Math.sin(a) * r]); } + shape(c, pb, col, DET * 0.95, 16); + const cs = Math.cos(spin), sn = Math.sin(spin), ox = pr * 0.1, oy = pr * 0.1; + c.beginPath(); ribbon(c, ellipse(x + ox * cs - oy * sn, y + ox * sn + oy * cs, pr * 0.42, pr * 0.36, 8, 0.2 + spin, 1.9 + spin), TEX * 0.8, 90, { tin: 1, tout: 2 }); c.fillStyle = INK; c.fill(); +} + /* a knitted pom-pom hat: a ribbed brim, a long floppy cone in the theme's colour, a white bobble */ function drawHat(s, c) { const g = s.g, u = g.u, P = s.P, T = g.lh, L = g.lamp, er = T.er, th = THEMES[s.theme]; - const col = mul(hex(th.hat), P.paper), ry = er * 1.08; - const sway = s.reduced ? 0.15 : 0.15 + s.windTotal * 0.25 + Math.sin(s.t * 1.3) * 0.05; - const by = L.y - ry * 0.62, bw = er * 1.5, bh = er * 0.62; - const tip = [L.x + er * (0.5 + sway * 2.2), by - er * 3.1 + Math.abs(sway) * er * 0.6]; + const col = mul(hex(th.hat), P.paper), h = hatGeom(s), { by, bw, bh, sway, tip, pr } = h; const cone = curve([[L.x - bw * 0.92, by - bh * 0.6], [L.x - bw * 0.5, by - er * 1.9], [L.x + er * 0.1 + sway * er, by - er * 2.85], tip, [L.x + er * 0.65 + sway * er * 1.4, by - er * 2.2], [L.x + bw * 0.9, by - bh * 0.6]], 3); print(c, cone, col); // a white knitted stripe across the cone @@ -1281,11 +1576,21 @@ function drawHat(s, c) { edges(c, brim, DET, 15, true, { ov: 1.2 }); for (let i = 1; i < 7; i++) { const x = L.x - bw + i / 7 * bw * 2; ribbon(c, [[x, by - bh * 0.4], [x + 0.4 * u, by + bh * 0.32]], TEX * 0.8, 80 + i, { tin: 1, tout: 1, amp: 0.2 }); } c.fillStyle = INK; c.fill(); - // the bobble: a scribbled ball of wool - const pr = er * 0.66, pb = []; - for (let i = 0; i < 40; i++) { const a = i / 40 * TAU, r = pr * (1 + 0.09 * Math.abs(Math.sin(a * 5.5 + 1))); pb.push([tip[0] + Math.cos(a) * r, tip[1] - pr * 0.6 + Math.sin(a) * r]); } - shape(c, pb, mul(hex('#FFFFFF'), P.paper), DET * 0.95, 16); - c.beginPath(); ribbon(c, ellipse(tip[0] + pr * 0.1, tip[1] - pr * 0.5, pr * 0.42, pr * 0.36, 8, 0.2, 1.9), TEX * 0.8, 90, { tin: 1, tout: 2 }); c.fillStyle = INK; c.fill(); + // the bobble, unless it's in play; then a few loose ends of wool, until a new one grows back with a pop + const k = s.bobbleK(); + if (k < 0.999) { + c.beginPath(); + for (const a of [-2.3, -1.6, -0.9]) ribbon(c, [[tip[0], tip[1]], [tip[0] + Math.cos(a) * pr * 0.55, tip[1] + Math.sin(a) * pr * 0.55]], TEX, a * 3, { tin: 1, tout: 2 }); + c.fillStyle = INK; c.fill(); + } + if (k > 0.02) bobble(c, tip[0], tip[1] - pr * 0.6 * k, pr * k, mul(hex('#FFFFFF'), P.paper)); + const ra = s.t - s.regrowAt; + if (ra > 0.18 && ra < 0.75 && !s.reduced) { + const f = (ra - 0.18) / 0.57, cx = tip[0], cy = tip[1] - pr * 0.6; + c.beginPath(); + for (let i = 0; i < 6; i++) { const a = -Math.PI / 2 + (i - 2.5) * 0.62, r0 = pr * (1.35 + f * 0.9), r1 = r0 + pr * 0.55 * (1 - f * 0.6); ribbon(c, [[cx + Math.cos(a) * r0, cy + Math.sin(a) * r0], [cx + Math.cos(a) * r1, cy + Math.sin(a) * r1]], TEX, i, { tin: 1, tout: 1 }); } + c.fillStyle = rgba(P.ink, 1 - f); c.fill(); + } // a sparkle or two after changing into new pyjamas const sp = s.t - s.sparkle; if (sp < 1.2 && !s.reduced) { @@ -1746,5 +2051,92 @@ function drawMonster(c, u, pink, P, o, t) { c.beginPath(); ribbon(c, [[33 * u, -25.8 * u], [49 * u, -26.4 * u]], DET * 1.2, 2, { tin: 1, tout: 1, w0: 1, w1: 1 }); c.fillStyle = INK; c.fill(); } +/* ---------- the lighthouse plays with the bar: the ball, its doodles and the splash ---------- */ +function drawBall(s, c) { + const b = s.ball; + if (!b) return; + const g = s.g, u = g.u, pr = g.lh.er * 0.66, v = Math.hypot(b.vx, b.vy); + // a couple of speed lines behind it when it's going + if (v > 240 * u && !s.reduced) { + const ax = -b.vx / v, ay = -b.vy / v, nx = -ay, ny = ax, l = pr * clamp(v / (700 * u), 0.6, 1.4); + c.beginPath(); + for (const k of [-0.5, 0.42]) { + const x0 = b.x + ax * pr * 1.4 + nx * pr * k, y0 = b.y + ay * pr * 1.4 + ny * pr * k; + ribbon(c, [[x0, y0], [x0 + ax * l * (1 - k * 0.3), y0 + ay * l * (1 - k * 0.3)]], TEX, 30 + k * 5, { tin: 1, tout: 4 }); + } + c.fillStyle = INK; c.fill(); + } + // squashed for a moment by a hit or a header + const q = s.t - b.sq, sq = q < 0.16 ? Math.sin(q / 0.16 * Math.PI) * 0.24 : 0; + c.save(); c.translate(b.x, b.y); c.scale(1 + sq, 1 - sq); + bobble(c, 0, 0, pr, mul(hex('#FFFFFF'), s.P.paper), b.spin); + c.restore(); +} + +/* a quaver, drawn: a tilted head, a stem and a flag */ +function quaver(c, x, y, sz, col, flip) { + c.beginPath(); c.ellipse(x, y, sz * 0.62, sz * 0.44, -0.45, 0, TAU); c.fillStyle = col; c.fill(); + c.beginPath(); + ribbon(c, [[x + sz * 0.52, y - sz * 0.1], [x + sz * 0.56, y - sz * 2.1]], TEX * 1.2, 3, { tin: 1, tout: 1, w0: 0.9, w1: 0.9 }); + ribbon(c, [[x + sz * 0.56, y - sz * 2.1], [x + sz * (0.56 + 0.6 * flip), y - sz * 1.62], [x + sz * (0.56 + 0.45 * flip), y - sz * 1.0]], TEX * 1.1, 4, { tin: 1, tout: 3 }); + c.fillStyle = col; c.fill(); +} + +/* "boink!", "pok", "plop", "hehe" and the whistled notes: they pop in, drift a little and fade */ +function drawDoodles(s, c) { + if (!s.doodles.length) return; + const g = s.g, u = g.u, P = s.P, ink = rgba(mix(P.ink, P.paper, smooth(0.5, 0.7, s.d))); + for (const d of s.doodles) { + const a = s.t - d.t0, k = pop(a / 0.24), f = 1 - smooth(d.life - 0.3, d.life, a), col = d.sea ? rgba(P.paper) : ink; + if (f <= 0 || k <= 0.01) continue; + c.save(); c.globalAlpha = f; + if (d.kind === 'note') { + const sz = 4.2 * u + 1.6; + quaver(c, d.x + Math.sin(a * 4 + d.seed) * 5 * u, d.y - a * 36 * u, sz * k, col, d.flip); + c.restore(); continue; + } + const size = Math.max(d.kind === 'pok' ? 12 : 13.5, (d.kind === 'pok' ? 12 : d.kind === 'boink' ? 16 : 15) * u + 4), dir = d.align === 'right' ? -1 : 1; + const shake = d.kind === 'hehe' ? (Math.floor(a * 9) % 2 ? -1 : 1) * 1.3 * u : 0; + c.translate(d.x, d.y - a * (d.kind === 'plop' ? 12 : 8) * u + shake); c.scale(k, k); + const text = d.kind === 'boink' ? 'boink!' : d.kind; + label(c, text, 0, 0, size, col, d.align, d.kind === 'boink' ? -0.09 * dir : d.kind === 'pok' ? 0.12 * dir : -0.06); + c.beginPath(); + if (d.kind === 'boink') { + // the knock, in three short strokes toward the widget + for (const i of [0, 1, 2]) { const an = -Math.PI / 2 - dir * (0.25 + i * 0.42), r0 = size * 0.32, r1 = size * 0.72; ribbon(c, [[Math.cos(an) * r0, -size * 0.3 + Math.sin(an) * r0], [Math.cos(an) * r1, -size * 0.3 + Math.sin(an) * r1]], TEX * 1.1, i + 7, { tin: 1, tout: 2 }); } + } else if (d.kind === 'plop') { + // two drops over the word + for (const i of [0, 1]) { const x = size * (0.45 + i * 0.85), y = -size * (0.9 + i * 0.2); ribbon(c, ellipse(x, y, size * 0.13, size * 0.18, 10, -0.6, Math.PI * 1.6), TEX, i + 3, { tin: 1, tout: 1 }); } + } + c.fillStyle = col; c.fill(); + c.restore(); + } +} + +/* where the ball lands: a crown of water and a few drops thrown up and out, in foam with the pen round them */ +function drawSplashes(s, c) { + if (!s.splashes.length) return; + const g = s.g, u = g.u, P = s.P, foam = rgba(P.foam); + for (const p of s.splashes) { + const a = s.t - p.t0, k = clamp(a / 0.7), e = easeOut(k); + c.globalAlpha = 1 - smooth(0.6, 1, k); + for (const [w, col] of [[DET * 2.5, INK], [DET * 1.25, foam]]) { + c.beginPath(); + for (let i = 0; i < 5; i++) { + const an = -Math.PI / 2 + (i - 2) * 0.46, r0 = 6 * u, r1 = r0 + (8 + 20 * e) * u * (1 - k * 0.55); + ribbon(c, [[p.x + Math.cos(an) * r0 * 1.5, p.y + Math.sin(an) * r0 * 0.5], [p.x + Math.cos(an) * r1 * 1.5, p.y + Math.sin(an) * r1]], w, i, { tin: 2, tout: 3, amp: 0.4 }); + } + c.fillStyle = col; c.fill(); + } + for (let i = 0; i < 4; i++) { + const dx = (i - 1.5) * (10 + 22 * e) * u, dy = -(30 + i % 2 * 10) * u * e + 70 * u * k * k, x = p.x + dx, y = p.y + dy, r = (2.8 - 1.4 * k) * u + 0.8; + if (y > p.y + 2) continue; + dot(c, x, y, r, foam); + c.beginPath(); ribbon(c, ellipse(x, y, r + 0.6, r + 0.6, 10), TEX * 0.8, i, { tin: 1, tout: 1, w0: 1, w1: 1 }); c.fillStyle = INK; c.fill(); + } + c.globalAlpha = 1; + } +} + window.Hill = { Scene, themes: Object.keys(THEMES), states: STATES }; })(); diff --git a/site/style.css b/site/style.css index 4822af93..0e2c6f64 100644 --- a/site/style.css +++ b/site/style.css @@ -159,7 +159,7 @@ main { overflow-x: clip; } flex: none; height: var(--tile-h); display: flex; align-items: center; gap: 7px; padding: 0 10px; white-space: nowrap; background: var(--w-bg); box-shadow: inset 0 0 0 1px var(--w-border), 0 1px 3px var(--w-shadow); border-radius: var(--w-radius); filter: var(--w-glow); color: var(--fg); font: var(--bar-font); text-decoration: none; cursor: default; user-select: none; - transition: background .15s, border-radius .25s; + transition: background .15s, border-radius .25s, box-shadow .2s; } .w[data-pop], .w.app, .w.more, .w.moon { cursor: pointer; } .w[data-pop]:hover, .w.more:hover, .w.moon:hover { background: var(--raised); } @@ -243,6 +243,26 @@ main { overflow-x: clip; } .header .w.dl > span { padding: 0 12px 0 8px; } .measure * { transition: none !important; } +/* The lamp-lit tour: the widget under the pointer glows as if the lamp lit it from below, + and a paper tag hangs under it on a doodled string (placed by main.js) */ +#bar .w.lamp { background-image: linear-gradient(0deg, #ffe04a80, #ffe04a00 82%); box-shadow: inset 0 0 0 1px var(--w-border), 0 0 0 1.5px #ffd43bb3, 0 6px 14px -2px #ffd43bf0; } +#bar .w.dl.lamp { background-image: none; box-shadow: 0 6px 14px -2px #ffd43bf0; } +.lamp-tag { position: absolute; left: 0; top: 0; z-index: 4; width: 0; height: 0; pointer-events: none; transform-origin: 0 0; } +.lamp-tag[hidden] { display: none; } +.lt-s { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: visible; } +.lt-s path { fill: none; stroke-linecap: round; } +.lt-s .h { stroke: #fff; stroke-width: 4.4; } +.lt-s .k { stroke: var(--pen); stroke-width: 1.9; } +.lt-p { + --ox: -3px; --oy: -2px; + position: absolute; left: 0; top: 0; isolation: isolate; display: block; padding: 12px 12px 7px; white-space: nowrap; + background: var(--paper); color: var(--text); border-radius: 7px 10px 6px 11px / 10px 6px 11px 7px; + font: 600 15px/1.15 var(--hand); font-variation-settings: "INFM" 100, "BNCE" 60; + rotate: var(--r, -1deg); transform-origin: var(--hole, 50%) 7px; +} +/* the punched hole the string goes through, the sky showing in it */ +.lt-p::after { content: ""; position: absolute; left: var(--hole, 50%); top: 4px; width: 7px; height: 7px; margin-left: -3.5px; border-radius: 50%; background: var(--strip); box-shadow: 0 0 0 1.6px var(--pen); } + /* The center position shows only when a widget sits there */ .dock:not(.editing) #gc:not(:has(> .w:not([hidden]))) { display: none; } @@ -412,7 +432,7 @@ main { overflow-x: clip; } clip-path: polygon(0 6%, 4% 0, 8% 8%, 13% 2%, 18% 7%, 50% 3%, 82% 6%, 88% 0, 93% 7%, 97% 1%, 100% 8%, 99% 92%, 95% 100%, 90% 93%, 85% 100%, 50% 96%, 15% 99%, 10% 92%, 5% 100%, 1% 94%); } /* The pen line before main.js draws it, or without it */ -:where(.note, .cta, .tag, .keys button)::before { +:where(.note, .cta, .tag, .keys button, .lt-p)::before { content: ""; position: absolute; z-index: -1; pointer-events: none; inset: var(--oy) calc(-1 * var(--ox)) calc(-1 * var(--oy)) var(--ox); border: 2.4px solid var(--pen); border-radius: inherit; @@ -490,7 +510,7 @@ h3 { font: 800 22px/1.2 var(--display); font-variation-settings: "INFM" 70, "BNC .quiet { display: flex; flex-wrap: wrap; gap: 4px 18px; font-size: 15.5px; } .hero-foot .cap { margin-top: 0; } -.key { --rot: .8deg; --tape-x: 16%; --tape-w: 66px; --tape-r: 6deg; margin-top: auto; width: min(452px, 46vw); padding: 14px 18px 12px; border-radius: 12px 18px 10px 20px / 18px 10px 20px 12px; } +.key { --rot: .8deg; --tape-x: 16%; --tape-w: 66px; --tape-r: 6deg; margin-top: auto; width: fit-content; max-width: min(562px, 46vw); padding: 14px 18px 12px; border-radius: 12px 18px 10px 20px / 18px 10px 20px 12px; } .js:not(.has-scene) .key { visibility: hidden; } .key-t, .key-or { font: 600 16px/1.25 var(--hand); font-variation-settings: "INFM" 100, "BNCE" 60; } .key .touch { display: none; } @@ -507,6 +527,8 @@ h3 { font: 800 22px/1.2 var(--display); font-variation-settings: "INFM" 70, "BNC .keys button:hover { background: #fff6c2; rotate: -2deg; } .keys button[aria-pressed="true"] { background: var(--hi); } .keys svg { width: 26px; height: 20px; flex: none; } +/* The ball game moves; without motion it isn't offered */ +@media (prefers-reduced-motion: reduce) { .keys [data-play] { display: none; } } /* ---------- Sections: each a different piece of paper, in the left part of the window ---------- */ .sec { display: flex; align-items: center; min-height: 100vh; min-height: 100svh; padding: 14vh 0 14vh var(--left, 5vw); } @@ -520,7 +542,7 @@ h3 { font: 800 22px/1.2 var(--display); font-variation-settings: "INFM" 70, "BNC @media (min-width: 700px) and (max-width: 1023.98px) { .hero-note, .sec .note { width: min(58vw, 620px); } .hero-note { padding-left: 30px; padding-right: 30px; } - .key { width: min(452px, 58vw); } + .key { max-width: min(562px, 58vw); } } /* Edit: a taped note with a picture of the bar in edit mode */ From ffab236ae6444f267ad30f815b90209567874672 Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Fri, 30 Oct 2026 12:00:26 +0100 Subject: [PATCH 05/46] docs: bring the hill on the website to life The drawing no longer starts empty: a few sheep, a cow and a duck are already there where the notes leave the hill and the sea in view. Grass sprouts here and there on the hill and leans away from the pointer; sheep and cows wander over in turns, munch it until it's gone and look pleased. The section's little scenes (the desk, the huts, the keeper) are drawn over the animals, and animals don't stay in their way. A white box with a cloud's puffs for a lid drifts across the sky with the clouds, labelled "a sheep (in a box)" while it's in clear sky. In the beam two eyes look out of its air holes, and a click makes it hop and say "baa!". A click in the sky above the sea now drops a duck that falls into the water; sea monsters only surface where the water itself is clicked. --- site/scene.js | 666 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 611 insertions(+), 55 deletions(-) diff --git a/site/scene.js b/site/scene.js index 5868f5d7..4deec7dd 100644 --- a/site/scene.js +++ b/site/scene.js @@ -120,7 +120,31 @@ const C = { monster: hex('#FF8FC0'), chimney: hex('#E8452F'), roof: hex('#2B2B36'), door: hex('#2E58D8'), skin: hex('#F6C9A6'), navy: hex('#25305E'), wood: hex('#C98B4A'), steel: hex('#D8DCE6'), moon: hex('#FFE89A'), tile: hex('#1D1D24'), huts: ['#FF4A36', '#2E58D8', '#FFC21A', '#36A852'].map(hex), + sprout: hex('#2E9B3B'), }; +// animals that live on the hill (not riding a cloud): they wander and graze +const grazer = o => (o.kind === 'sheep' || o.kind === 'cow') && !o.ride; +/* The sheep's box, in its own units (times boxK): the front face's half size, the depth seen past it, and + three air holes punched in the front, the first two with eyes behind them */ +const BOX = { hw: 21, hh: 13, dx: 9, dy: -7, holes: [[-11, -1.5], [0.5, -3.5], [11.5, -0.5]] }; +// its lid: a cloud's row of puffs sitting on the box, following its top back to the far corner (box units) +const LID = (() => { + const bumps = [[-18, -12, 10], [-6, -18, 13], [8, -19.5, 13], [21, -18, 11], [30, -17, 7.5]], top = []; + for (let i = 0; i <= 72; i++) { + const x = -28.5 + 66.5 * i / 72; + let y = -9.5; + for (const [bx, by, r] of bumps) { const d = x - bx; if (Math.abs(d) < r) y = Math.min(y, by - Math.sqrt(r * r - d * d)); } + top.push([x, y]); + } + return top; +})(); +const LID_TOP = Math.min(...LID.map(p => p[1])), LID_X0 = LID[0][0], LID_X1 = LID[LID.length - 1][0]; +const BOX_SP = 6.5; // its drift, in the clouds' units +// a rect [x0, y0, x1, y1] against a rect, or a disc [x, y, r] +const overlaps = (a, q) => q.length === 3 + ? Math.hypot(clamp(q[0], a[0], a[2]) - q[0], clamp(q[1], a[1], a[3]) - q[1]) < q[2] + : a[0] < q[2] && a[2] > q[0] && a[1] < q[3] && a[3] > q[1]; +const boxK = s => Math.max(s.g.u, 0.72) * 1.12; const HAND = '"Shantell Sans Hand", "Shantell Sans", "Chalkboard SE", sans-serif'; /* ---------- the pen ---------- @@ -196,6 +220,8 @@ function label(c, text, x, y, size, col, align = 'left', rot = -0.035) { /* ---------- the scene: geometry, state, input and time ---------- */ const IDLE_AFTER = 4000, IDLE_FPS = 14, BOIL_FPS = 7, MAX_OBJS = 36; +// the grass on the hill: at most this many tufts, a new one every 6 to 12 s while there are fewer +const MEADOW_MAX = 6, SPROUT_MIN = 6, SPROUT_MAX = 12; class Scene { constructor(host, opts = {}) { @@ -226,6 +252,9 @@ class Scene { this.doodles = []; this.splashes = []; this.hatKick = -9; this.regrowAt = -9; this.onBar = null; + // a lived-in hill: the animals already there, the grass they eat, and a sheep in a box up in the sky + this.flocked = false; this.meadow = []; this.sproutAt = 0; + this.box = { v: null, cl: null, side: null, lastSide: null, la: 0, seenAt: -9, hop: -9, peek: -9, lit: 0, open: false, openAt: -9, blinkAt: -9, nextBlink: 0 }; this.times = []; this.awake = 0; this.kick = 0; this.raf = 0; this.timer = 0; this.last = null; this.mq = matchMedia('(prefers-reduced-motion: reduce)'); @@ -250,7 +279,11 @@ class Scene { setDusk(d) { this.dTarget = clamp(+d || 0); if (this.reduced) this.d = this.dTarget; this.wake(); } setState(name) { if (!STATES.includes(name)) return; - if (name !== this.state) { this.state = name; this.since[name] = this.t; } + if (name !== this.state) { + this.state = name; this.since[name] = this.t; + // a new little scene may stand where an animal rests: resting ones look around again shortly + for (const o of this.objs) if (grazer(o) && o.act === 'idle') o.until = Math.min(o.until, this.t + 0.6 + Math.random() * 1.2); + } this.wake(); } setTheme(id) { @@ -310,6 +343,13 @@ class Scene { this.wake(); return false; } + // the sheep in its box says baa (and the light turns to see who's there); nothing drops + if (this.boxHit(x, y)) { + this.aim(x, y); + this.baa(); + this.wake(); + return false; + } this.aim(x, y); this.wake(); if (y < this.top) return false; @@ -562,7 +602,7 @@ class Scene { bobbleK() { return this.ball || this.t < this.regrowAt ? 0 : pop((this.t - this.regrowAt) / 0.5); } // a word or a note drawn by the pen for a moment; light over the sea, dark over the sky by day doodle(kind, x, y, o = {}) { - this.doodles.push({ kind, x, y, t0: this.t, life: o.life ?? 0.95, align: o.align ?? 'center', sea: this.isSea(x, y + 4 * this.g.u), flip: o.flip ? -1 : 1, seed: Math.random() * 10 }); + this.doodles.push({ kind, x, y, t0: this.t, life: o.life ?? 0.95, align: o.align ?? 'center', sea: this.isSea(x, y + 4 * this.g.u), flip: o.flip ? -1 : 1, seed: Math.random() * 10, text: o.text, calm: Boolean(o.calm) }); } /* ----- size and layout ----- */ @@ -676,6 +716,15 @@ class Scene { g.key = portrait ? null : { x0: 0, x1: W * 0.05 + Math.min(W * 0.46, 562) + 12, y0: H - 160, y1: H }; this.layoutV++; for (const o of this.objs) this.settle(o); + // the grass stays on the ground at the same depth, or goes; meals and walks start over + for (const m of this.meadow) { + const ry = this.ridgeY(m.x); + if (ry == null || ry + m.depth > Math.min(this.shoreY(m.x) - 10 * u, H - 12 * u)) m.gone = true; + else m.y = ry + m.depth; + m.by = null; + } + this.meadow = this.meadow.filter(m => !m.gone); + for (const o of this.objs) if (grazer(o) && o.act) { o.tuft = null; o.act = 'idle'; o.until = this.t + 1; } // the bar's widgets moved with it: a game or a trick in progress ends quietly this.endBall(); this.endBore(); } @@ -756,16 +805,23 @@ class Scene { } // no land in sight (a phone, with the notes over the hill): it lands on a cloud if (kind === 'sheep' || kind === 'cow') { - const riders = cl => this.objs.filter(o => o.ride === cl && o.dying == null).length + R() * 0.5; - for (const cl of [...g.clouds, g.sheepCloud].sort((a, b) => riders(a) - riders(b))) { - const p = this.cloudXY(cl); - if (p.x > 40 * u && p.x < W - 40 * u && Math.hypot(p.x - g.lamp.x, p.y - g.lamp.y) > T.er * 5 && this.seen(p.x, p.y - cl.w * 0.2)) return { x: p.x, y: p.y, cloud: cl }; - } + const cl = this.cloudSpot(); + if (cl) { const p = this.cloudXY(cl); return { x: p.x, y: p.y, cloud: cl }; } } // nothing visible: drop it where it belongs anyway, falling from the sky const p = tries[0] || { x: W * 0.8, y: H * 0.8 }; return { ...p, fromSky: true }; } + // a cloud that shows, the one with the fewest riders first, for a sheep or a cow with no land in sight + cloudSpot() { + const g = this.g, u = g.u, R = Math.random, T = g.lh; + const riders = cl => this.objs.filter(o => o.ride === cl && o.dying == null).length + R() * 0.5; + for (const cl of [...g.clouds, g.sheepCloud].sort((a, b) => riders(a) - riders(b))) { + const p = this.cloudXY(cl); + if (p.x > 40 * u && p.x < g.W - 40 * u && Math.hypot(p.x - g.lamp.x, p.y - g.lamp.y) > T.er * 5 && this.seen(p.x, p.y - cl.w * 0.2)) return cl; + } + return null; + } spawn(x, y, kind, settled, fromSky) { const g = this.g, u = g.u, R = Math.random; @@ -775,7 +831,7 @@ class Scene { if (land) o.kind = R() < 0.6 ? 'sheep' : 'cow'; else if (sea) o.kind = R() < 0.55 ? 'duck' : 'monster'; else if (y < lerp(g.top, g.horizon, 0.55) && R() < 0.45) o.kind = 'gull'; - else o.kind = this.ridgeY(x) != null && y < this.shoreY(x) && x > (g.portrait ? 0 : this.hillLeftX(g.horizon) - 20 * u) ? (R() < 0.6 ? 'sheep' : 'cow') : (R() < 0.6 ? 'duck' : 'monster'); + else o.kind = this.ridgeY(x) != null && y < this.shoreY(x) && x > (g.portrait ? 0 : this.hillLeftX(g.horizon) - 20 * u) ? (R() < 0.6 ? 'sheep' : 'cow') : 'duck'; // from the sky, only ducks: monsters surface where the water is clicked } const still = settled || this.reduced; if (o.kind === 'gull') { @@ -793,7 +849,7 @@ class Scene { } else { o.ty = sea ? y : g.horizon + (g.H - g.horizon) * (0.16 + R() * 0.55); if (!sea) { const sp = this.seaSpans(o.ty); const s = sp.find(q => q[0] <= x && x <= q[1]) || sp[0]; if (s) o.x = clamp(x, s[0] + 24 * u, s[1] - 30 * u); } - if (o.kind === 'monster' || still || sea && !fromSky) { o.y = o.ty; o.state = o.kind === 'monster' ? 'swim' : 'float'; if (!this.reduced) this.ripples.push({ x: o.x, y: o.ty, t0: this.t, a: 0.8 }); } + if (o.kind === 'monster' || still || sea && !fromSky) { o.y = o.ty; o.state = o.kind === 'monster' ? 'swim' : 'float'; if (!this.reduced && !settled) this.ripples.push({ x: o.x, y: o.ty, t0: this.t, a: 0.8 }); } else { o.state = 'fall'; if (fromSky) o.y = Math.min(o.ty - 120 * u, g.top - 20); } o.sc = this.depthScale(o.ty); } @@ -802,11 +858,11 @@ class Scene { if (live.length > MAX_OBJS) live[0].dying = this.t; return o; } - ride(kind, cl) { + ride(kind, cl, settled) { const g = this.g, R = Math.random, p = this.cloudXY(cl); const o = { kind, ride: cl, dx: (R() - 0.5) * cl.w * 0.3, x: p.x, y: 0, vx: 0, vy: 0, t0: this.t, flip: R() < 0.5, seed: (R() * 1e4) | 0, lit: 0, sc: 0.85 * (g.portrait ? 1.1 : 1) }; o.x = p.x + o.dx; o.ty = this.rideY(o); - if (this.reduced) { o.state = 'ride'; o.y = o.ty; o.land = -9; } else { o.state = 'fall'; o.y = g.top - 30; } + if (this.reduced || settled) { o.state = 'ride'; o.y = o.ty; o.land = -9; } else { o.state = 'fall'; o.y = g.top - 30; } this.objs.push(o); const live = this.objs.filter(q => q.dying == null); if (live.length > MAX_OBJS) live[0].dying = this.t; @@ -820,8 +876,14 @@ class Scene { else if (o.state === 'fall') { o.y = o.ty; o.vy = 0; o.state = o.ride ? 'ride' : o.kind === 'duck' ? 'float' : 'land'; } o.land = -9; if (o.dying != null) o.dead = true; + // no one eats: heads up, meals over + if (grazer(o)) { this.letGo(o); o.act = 'idle'; o.head = 0; o.vx = o.vd = 0; o.joy = null; } } this.objs = this.objs.filter(o => !o.dead); + // the grass stands still: grown, with whatever is left of it + for (const m of this.meadow) { m.t0 = -99; m.blades = m.blades.filter(b => b.cut == null); m.left = m.blades.length; } + this.meadow = this.meadow.filter(m => m.left > 0); + this.box.hop = this.box.peek = -9; this.ripples = []; this.endBall(); this.endBore(); this.regrowAt = this.hatKick = -9; @@ -858,7 +920,8 @@ class Scene { if (this.d !== (this.dEff ?? this.dTarget) || (this.state === 'install') !== (this.pres.install > 0.999)) return true; for (const k of STATES) { const p = this.pres[k]; if (p > 0.001 && p < 0.999) return true; } if (this.ripples.length) return true; - if (this.ball || this.bore || this.doodles.length || this.splashes.length || this.t - this.hatKick < 0.7 || this.t - this.regrowAt < 0.9) return true; + // (the grazing and its "nom"s are slow: they carry on at the idle rate) + if (this.ball || this.bore || this.doodles.some(d => !d.calm) || this.splashes.length || this.t - this.hatKick < 0.7 || this.t - this.regrowAt < 0.9 || this.t - this.box.hop < 0.8) return true; for (const o of this.objs) if (o.state === 'fall' || (o.state === 'fly' && !this.reduced) || o.dying != null || (o.land > 0 && this.t - o.land < 0.4)) return true; return this.beam.steer > 0.01 && this.beam.steer < 0.99 && !this.reduced; } @@ -894,7 +957,7 @@ class Scene { this.windTotal = this.windAmb + this.wind; if (!rm) this.drift += dt * (1 + this.windTotal * 2.4); // clouds lean away from the pointer - for (const cl of [...g.clouds, g.sheepCloud]) { + for (const cl of [...g.clouds, g.sheepCloud, ...(this.box.cl ? [this.box.cl] : [])]) { let tx = 0, ty = 0; if (this.ptr.on && !rm) { const p = this.cloudXY(cl, true), dx = p.x - this.ptr.x, dy = p.y - this.ptr.y, r = 170 * u, dd = Math.hypot(dx, dy); @@ -913,7 +976,10 @@ class Scene { if (!rm && this.t > this.blink.next) { this.blink.at = this.t; this.blink.next = this.t + 3 + Math.random() * 5; } this.updateBeam(dt); this.ripples = this.ripples.filter(r => this.t - r.t0 < 2.2); + if (!this.flocked) this.flock(); this.updateObjs(dt); + this.updateMeadow(); + this.updateBox(dt); this.updateBall(dt); this.updateBore(); this.doodles = this.doodles.filter(d => this.t - d.t0 < d.life); @@ -959,13 +1025,7 @@ class Scene { } else if (rm) { // reduced motion: everything holds still where it appeared } else if (o.state === 'land') { - const dx = o.x - this.ptr.x, dy = o.y - this.ptr.y, near = this.ptr.on && o.kind === 'sheep' && dx * dx + dy * dy < (110 * u) ** 2; - const stroll = Math.sin(this.t * 0.35 + o.seed) > 0.8 ? (o.flip ? -1 : 1) * (o.kind === 'cow' ? 7 : 12) * u : 0; - o.vx = lerp(o.vx, near ? Math.sign(dx || 1) * 70 * u : stroll, Math.min(1, dt * 3)); - if (Math.abs(o.vx) > 3 * u) o.flip = o.vx < 0; - const nx = o.x + o.vx * dt, ry = this.ridgeY(nx); - if (ry == null || ry + o.depth > Math.min(g.H - 8 * u, this.shoreY(nx) - 12 * u) || nx > g.W - 12 * u || Math.abs(nx - g.lh.x) < g.lh.wB * 0.6) o.vx = 0; - else { o.x = nx; o.y = ry + o.depth; } + this.live(o, dt); } else if (o.state === 'float') { o.x += this.windTotal * 6 * u * dt; o.y = o.ty + Math.sin(this.t * 2.2 + o.seed) * 1.8 * u; @@ -981,8 +1041,320 @@ class Scene { o.lit = rm ? this.inBeam(o.x + this.ox, o.y - 12 * u * o.sc + this.oy) : lerp(o.lit, this.inBeam(o.x + this.ox, o.y - 12 * u * o.sc + this.oy), Math.min(1, dt * 8)); if (o.dying != null && (rm || this.t - o.dying > 0.7)) o.dead = true; } + for (const o of this.objs) if (o.dead || o.dying != null) this.letGo(o); this.objs = this.objs.filter(o => !o.dead); } + + /* ----- a lived-in hill ----- + A small flock is there from the start, settled where it's seen. Tufts of grass sprout here and there on + the visible hill; a sheep or a cow that isn't busy walks over to the nearest free one (across the slope as + much as along it), lowers its head and eats it blade by blade, looks pleased, and goes back to idling and + wandering. Sheep still run from the pointer, mid-meal or not. All of it is slow, so it runs at the idle rate. */ + flock() { + this.flocked = true; + const g = this.g, u = g.u, R = Math.random; + if (g.portrait) { + // phones: one or two, on whatever land or cloud shows in the first screen + for (const kind of ['sheep', R() < 0.4 ? 'cow' : 'sheep']) { + const p = this.landSpot({ gap: 50 * u }); + if (p) this.spawn(p.x, p.y, kind, true); + else { const cl = this.cloudSpot(); if (cl && !this.objs.some(o => o.ride === cl)) this.ride(kind, cl, true); } + } + } else { + for (const kind of g.W >= 1200 ? ['sheep', 'cow', 'sheep', 'sheep'] : ['sheep', 'cow', 'sheep']) { + const p = this.landSpot({ gap: 64 * u, spread: true, check: 4, snug: true }); + if (p) this.spawn(p.x, p.y, kind, true); + } + const d = this.seaSpot(); + if (d) this.spawn(d.x, d.y, 'duck', true); + } + // grass already there: one a short walk from somebody (the first meal comes soon), the rest spread out + const first = this.objs.find(grazer); + for (let i = 0, n = 3 + (R() < 0.5 ? 1 : 0); i < n; i++) this.sprout(true, i === 0 && first && !this.reduced ? first : null); + this.sproutAt = this.t + 4 + R() * 4; + // not everyone is hungry at once: the one nearest some grass first, soon, and the others in turn + const near = o => Math.min(1e9, ...this.meadow.map(m => Math.hypot(m.x - o.x, (m.y - o.y) * 1.5))); + this.objs.filter(grazer).sort((a, b) => near(a) - near(b)).forEach((o, k) => { + o.hungry = this.t + (k ? 6 + k * (6 + R() * 5) : 0); o.act = 'idle'; o.until = this.t + (k ? 1 + R() * 3 : 1); o.head = 0; o.vd = 0; + }); + } + /* A visible spot on the hill for an animal or a tuft, clear of the lighthouse, the cottage, the key and the + section's props, at least `gap` from the others (the farthest of a few when `spread`), or near `near`. + A few DOM checks at most; null when none shows. */ + landSpot({ gap = 0, spread = false, near = null, reach = 0, min = 0, check = 3, snug = false, clear = false } = {}) { + const g = this.g, u = g.u, R = Math.random, W = g.W, H = g.H, out = []; + const others = [...this.objs.filter(o => grazer(o) && o.dying == null && o !== near), ...this.meadow]; + for (let i = 0; i < 40; i++) { + const x = near ? near.x + (R() * 2 - 1) * reach : (g.portrait ? lerp(0.3, 0.97, R()) : lerp(0.5, 0.97, R())) * W; + const ry = this.ridgeY(x); + if (ry == null || x > W - 22 * u) continue; + const room = Math.min(this.shoreY(x) - 16 * u, H - 24 * u, ry + H * 0.36) - ry; + if (room < 12 * u) continue; + const y = near ? clamp(near.y + (R() * 2 - 1) * reach * 0.45, ry + 6 * u, ry + room) : ry + lerp(8 * u, room, R() ** 0.85); + if (near && Math.hypot(x - near.x, y - near.y) < min) continue; + // snug: well inside the hill, not on its steep edge over the sea or down at the window's bottom + if (snug && (y > H - 84 * u || !this.isLand(x - 46 * u, y) || !this.isLand(x - 46 * u, y + 16 * u))) continue; + if (!this.open(x, y)) continue; + // clear: not hidden behind an animal's body, nor under its feet + if (clear && others.some(o => !o.blades && Math.abs(o.x - x) < 32 * u * o.sc && y > o.y - 36 * u * o.sc && y < o.y + 12 * u)) continue; + let d = 1e9; + for (const o of others) d = Math.min(d, Math.hypot(o.x - x, (o.y - y) * 1.6)); + if (d < gap) continue; + out.push({ x, y, d }); + } + // spread out, but not into the corners: far enough from the others, then near the middle of the pasture + if (spread) { const mid = g.portrait ? 0.7 * W : 0.79 * W, sc = p => Math.min(p.d, 170 * u) - Math.abs(p.x - mid) * 0.3; out.sort((a, b) => sc(b) - sc(a)); } + // seen: its middle, and its ends too unless it's only a walk's goal + for (let i = 0; i < Math.min(check, out.length); i++) { + const p = out[i]; + if (this.seen(p.x, p.y - 6 * u) && this.seen(p.x, p.y - 24 * u) && (near || (this.seen(p.x - 20 * u, p.y - 8 * u) && this.seen(p.x + 20 * u, p.y - 8 * u)))) return p; + } + return null; + } + // nothing of the scene's own in the way: the lighthouse, the cottage, the key, the huts, the desk + open(x, y) { + const g = this.g, u = g.u, T = g.lh, k = g.cot; + if (g.key && x > g.key.x0 && x < g.key.x1 && y > g.key.y0 - 30 * u) return false; + if (Math.abs(x - T.x) < T.wB * 0.9 + 20 * u && y < T.base + 42 * u) return false; + if (Math.abs(x - k.x) < k.w * 0.8 + 18 * u && y < k.base + 32 * u) return false; + if (this.state === 'tiling' || this.pres.tiling > 0.01) for (const q of hutSpots(this)) if (Math.abs(x - q.x) < 42 * u && y > q.y - 74 * u && y < q.y + 24 * u) return false; + if (this.state === 'widgets' || this.pres.widgets > 0.01) { const d = deskSpot(this); if (Math.abs(x - d.x) < 66 * u && y > d.y - 110 * u && y < d.y + 24 * u) return false; } + return true; + } + // a visible spot on the sea for the duck, clear of "the sea (wet)" + seaSpot() { + const u = this.g.u, lab = seaLabelSpot(this); + for (let i = 0; i < 4; i++) { + const p = this.spot('duck'); + if (p.fromSky) return null; + if (!lab || !(p.x > lab.x - 46 * u && p.x < lab.x + 160 * u && p.y > lab.y - 34 * u && p.y < lab.y + 54 * u)) return p; + } + return null; + } + // a tuft of grass where it's seen (near `near` if it can); `grown` for the ones already there when the page opens + sprout(grown, near) { + const u = this.g.u, R = Math.random; + const p = (near && this.landSpot({ near, reach: 110 * u, min: 55 * u, gap: 44 * u, snug: true, clear: true, check: 3 })) + || this.landSpot({ gap: 48 * u, snug: true, clear: true, spread: grown }); + if (!p) return null; + const sc = this.depthScale(p.y) * 0.85, n = 5 + ((R() * 3) | 0), blades = []; + for (let i = 0; i < n; i++) { + const f = i / (n - 1) - 0.5; + blades.push({ + dx: (f * 13 + (R() - 0.5) * 1.8) * u * sc, a: f * 1.05 + (R() - 0.5) * 0.22, flex: 0.7 + R() * 0.5, + l: (14 + R() * 7) * (1 - Math.abs(f) * 0.5) * u * sc, d: Math.abs(f) * 1.1 + R() * 0.3, cut: null, + }); + } + const m = { x: p.x, y: p.y, depth: p.y - this.ridgeY(p.x), sc, blades, left: n, lastCut: -9, by: null, t0: grown || this.reduced ? -99 : this.t, ph: R() * TAU }; + this.meadow.push(m); + return m; + } + updateMeadow() { + if (!this.flocked) return; + const t = this.t, R = Math.random; + // the last blade gone: the tuft goes, and whoever ate it looks pleased + for (const m of this.meadow) { + if (m.left > 0 || t - m.lastCut < 0.45) continue; + m.gone = true; + const o = m.by; + if (o) { o.tuft = null; o.act = 'happy'; o.joy = t; o.until = t + 1.3; o.hungry = t + 30 + R() * 30; } + } + this.meadow = this.meadow.filter(m => !m.gone); + // a new one now and then, only while the page shows (the scene's time stands still while it's hidden) + if (this.reduced || t < this.sproutAt) return; + const m = this.meadow.length < MEADOW_MAX ? this.sprout(false) : null; + this.sproutAt = t + (m || this.meadow.length >= MEADOW_MAX ? lerp(SPROUT_MIN, SPROUT_MAX, R()) : 3); + } + letGo(o) { if (o.tuft) { if (o.tuft.by === o) o.tuft.by = null; o.tuft = null; } } + // one animal's day: idle, wander, walk to a tuft and eat it, look pleased; run from the pointer (sheep) + live(o, dt) { + const g = this.g, u = g.u, t = this.t, R = Math.random, cow = o.kind === 'cow'; + if (o.act == null) { o.act = 'idle'; o.until = t + 0.5 + R() * 2.5; o.hungry ??= t + 3 + R() * 10; o.head = 0; o.vd = 0; o.bites = 0; } + o.vd ??= 0; + const dx = o.x - this.ptr.x, dy = o.y - this.ptr.y; + const scared = this.ptr.on && !cow && o.dying == null && dx * dx + dy * dy < (110 * u) ** 2; + let wx = 0, wd = 0, head = 0; + if (scared) { + this.letGo(o); + o.act = 'flee'; o.until = t + 0.5 + R() * 0.8; + wx = Math.sign(dx || 1) * 70 * u; wd = clamp(dy / (110 * u), -1, 1) * 26 * u; + } else { + if ((o.act === 'flee' || o.act === 'happy') && t > o.until) { o.act = 'idle'; o.until = t + 1.5 + R() * 3.5; } + if (o.act === 'idle' && t > o.until) this.decide(o); + if (o.act === 'walk' || o.act === 'go') { + const ex = o.tx - o.x, ed = o.td - o.depth, dist = Math.hypot(ex, ed); + if (o.act === 'go' && dist < 2.5 * u && o.tuft) { o.act = 'eat'; o.bite = t + 0.8; o.flip = o.tuft.x < o.x; } + else if (dist < 2.5 * u || t > o.until || (o.act === 'go' && !o.tuft)) { this.letGo(o); o.act = 'idle'; o.until = t + 2 + R() * 4; } + else { + const v = (o.act === 'go' ? 19 : 13) * u * (cow ? 0.65 : 1) * Math.min(1, dist / (6 * u)); + wx = ex / dist * v; wd = ed / dist * v; + } + } + if (o.act === 'eat') { + const m = o.tuft; + if (!m) { o.act = 'idle'; o.until = t + 1 + R() * 2; } + else { + head = 1; + o.flip = m.x < o.x; + if (m.left > 0 && t > o.bite && o.head > 0.85) this.bite(o, m); + } + } + } + o.head = lerp(o.head || 0, head, Math.min(1, dt * 4)); + // walk, in x and in depth (across the slope), as far as the hill allows + const k = Math.min(1, dt * 3); + o.vx = lerp(o.vx, wx, k); o.vd = lerp(o.vd, wd, k); + if (o.act !== 'eat' && Math.abs(o.vx) > 2.5 * u) o.flip = o.vx < 0; + const roomAt = (x, ry) => Math.min(g.H - 10 * u, this.shoreY(x) - 12 * u) - ry; + const nx = o.x + o.vx * dt, ny = this.ridgeY(nx); + if (ny == null || roomAt(nx, ny) < 3 * u || nx > g.W - 12 * u || (Math.abs(nx - g.lh.x) < g.lh.wB * 0.6 && o.depth < 30 * u)) o.vx = 0; + else o.x = nx; + const ry = this.ridgeY(o.x); + if (ry == null) return; + o.depth = clamp(o.depth + o.vd * dt, 3 * u, Math.max(3 * u, roomAt(o.x, ry))); + o.y = o.ty = ry + o.depth; + o.sc = this.depthScale(o.y); + } + // what next: grass if hungry and there's some free nearby, otherwise a short walk or a rest + decide(o) { + const t = this.t, R = Math.random, u = this.g.u; + // standing where one of the section's little scenes now shows: out of its way first + if (!this.open(o.x, o.y - 6 * u)) { + const p = this.landSpot({ near: o, reach: 170 * u, min: 40 * u, gap: 34 * u, check: 1 }); + if (p) { o.act = 'walk'; o.tx = p.x; o.td = p.y - this.ridgeY(p.x); o.until = t + 14; return; } + } + if (t > o.hungry && this.feed(o)) return; + if (R() < 0.5) { + const p = this.landSpot({ near: o, reach: 100 * u, min: 30 * u, gap: 34 * u, check: 1 }); + if (p) { o.act = 'walk'; o.tx = p.x; o.td = p.y - this.ridgeY(p.x); o.until = t + 14; return; } + } + o.act = 'idle'; o.until = t + 2.5 + R() * 4.5; + if (t > o.hungry) o.hungry = t + 3 + R() * 4; // nothing to eat in reach: look again in a bit + } + // the nearest free tuft in reach (grown, nobody else at it): walk over to stand with the mouth at it + feed(o) { + const g = this.g, u = g.u, reach = (o.kind === 'cow' ? 21 : 19) * u; + // they graze in turns: a few at the grass at once, at most, so the hill stays calm + const herd = this.objs.filter(q => grazer(q) && q.dying == null), busy = herd.filter(q => q.tuft).length; + if (busy >= Math.min(3, Math.max(1, Math.round(herd.length * 0.4)))) return false; + let best = null, bd = 320 * u; + for (const m of this.meadow) { + if (m.by || m.left <= 0 || this.t - m.t0 < 2.2) continue; + const d = Math.hypot(m.x - o.x, (m.y - o.y) * 1.5); + if (d < bd) { bd = d; best = m; } + } + if (!best) return false; + const sc = this.depthScale(best.y); + for (const f of best.x >= o.x ? [1, -1] : [-1, 1]) { + const x = best.x - f * reach * sc, ry = this.ridgeY(x); + if (ry == null || x > g.W - 14 * u) continue; + const d = best.y - ry, room = Math.min(g.H - 10 * u, this.shoreY(x) - 12 * u) - ry; + if (d < 3 * u || d > room || (Math.abs(x - g.lh.x) < g.lh.wB * 0.6 && d < 30 * u)) continue; + // not where another animal already stands + if (this.objs.some(q => q !== o && grazer(q) && Math.abs(q.x - x) < 30 * u * sc && Math.abs(q.y - best.y) < 14 * u * sc)) continue; + best.by = o; o.tuft = best; o.act = 'go'; o.tx = x; o.td = d; o.until = this.t + 45; + return true; + } + return false; + } + // a blade gone, the one nearest the mouth; every other bite, a "nom" + bite(o, m) { + const t = this.t, u = this.g.u, f = o.flip ? -1 : 1, mx = o.x + f * 20 * u * o.sc; + let pick = null; + for (const b of m.blades) if (b.cut == null && (!pick || Math.abs(m.x + b.dx - mx) < Math.abs(m.x + pick.dx - mx))) pick = b; + if (pick) { pick.cut = t; m.left--; m.lastCut = t; } + o.bite = t + (o.kind === 'cow' ? 1.3 : 1) + Math.random() * 0.35; + if ((o.bites = (o.bites || 0) + 1) % 2 === 1) { + const words = o.kind === 'cow' ? ['crunch', 'munch', 'crunch'] : ['nom', 'nom nom', 'munch']; + this.doodle('munch', o.x + f * 23 * u * o.sc, o.y - 31 * u * o.sc, { text: words[((o.bites - 1) / 2 | 0) % 3], align: f > 0 ? 'left' : 'right', life: 1.15, calm: true }); + } + } + + /* ----- the sheep in a box ----- + A white box with a cloud's puffs for a lid drifts across the sky with the clouds, at a height of its own. + When the light finds it, two eyes look out through its air holes (glinting at night); a click or a tap + makes it hop and say baa. It isn't a surprise: clicking it drops nothing. Its label shows while it's in + clear sky. */ + placeBox() { + const g = this.g, u = g.u, W = g.W, b = this.box, k = boxK(this), T = g.lh, L = g.lamp, sc = g.sheepCloud; + b.v = `${this.layoutV}|${this.fontsV}`; + const size = b.size = Math.max(13, 17 * u), cw = textW(this, 'a cloud (or a sheep)', size); + b.tw = textW(this, 'a sheep (in a box)', size); + // where the box and its label don't show (rects, and discs as [x, y, r]): the bar, the notes (a guess; the + // DOM has the last word), near the horizon, the lighthouse, its halo and labels, the cloud with legs and its + // label (the sun and the moon, while they're up, are added as they move) + const noteX = W < 1024 ? W * 0.05 + Math.min(W * 0.58, 620) + 16 : g.note.x1 + 4; + b.hard = [ + [0, 0, W, g.top + 8], g.portrait ? [0, g.note.y0 - 10, W, g.H] : [0, 0, noteX, g.H], [-1e4, g.horizon - 26 * u, 2e4, 2e4], + [L.x, L.y, T.er * 4.9], [T.x - T.wB * 0.8, T.hatTop, T.x + T.wB * 0.8, T.base], + [sc.x0 - sc.w * 0.66, sc.y - sc.w * 0.5, sc.x1 + sc.w * 0.66, sc.y + 18 * u], + [sc.x0 + sc.w * 0.3, sc.y - 6 * u, sc.x1 + sc.w * 0.42 + (g.portrait ? 6 : 14 * u) + 16 * u + cw + 6, sc.y + (g.portrait ? 12 : 22 * u) + 4 * u + size * 0.8], + ...lighthouseLabelRects(this, textW(this, 'a lighthouse (stable)', size), size), + ]; + // it drifts with the clouds, at a height of its own, as far as can be from theirs + const sky = g.horizon - g.top, lo = g.top + sky * (g.portrait ? 0.12 : 0.18), hi = g.top + sky * (g.portrait ? 0.34 : 0.46); + const others = [...g.clouds.map(cl => cl.y), sc.y]; + let y = lo, far = -1; + for (let cy = lo; cy <= hi; cy += 3 * u) { const d = Math.min(...others.map(o => Math.abs(o - cy))); if (d > far) { far = d; y = cy; } } + // first seen in open sky between the headline and the lighthouse; after a resize, where it was + const frac = b.frac ?? (g.portrait ? 0.5 : 0.62); + b.cl = { x0: frac * W + 180 * u - this.drift * BOX_SP * u, y, w: (2 * BOX.hw + BOX.dx) * k, sp: BOX_SP, seed: 91, ox: b.cl?.ox ?? 0, oy: b.cl?.oy ?? 0 }; + b.seenAt = -9; + } + // the box right now: where it drifts, its tilt, and (unless `still`, as for its label) the hop with its squash + boxAt(still) { + const b = this.box, u = this.g.u, k = boxK(this), rm = this.reduced, t = this.t, q = this.cloudXY(b.cl); + let y = q.y + (rm ? 0 : Math.sin(t * 0.7 + 1) * 3 * u), rot = rm ? 0 : Math.sin(t * 0.5) * 0.035, sq = 0; + const a = still ? -1 : t - b.hop; + if (!rm && a >= 0 && a < 0.75) { + if (a < 0.08) sq = Math.sin(a / 0.08 * Math.PI) * 0.1; + else if (a < 0.52) { const f = (a - 0.08) / 0.44; y -= Math.sin(f * Math.PI) * 18 * k; rot -= Math.sin(f * Math.PI) * 0.14; } + else sq = Math.sin((a - 0.52) / 0.23 * Math.PI) * 0.14; + } + return { x: q.x, y, rot, sq, k }; + } + boxHit(x, y) { + if (this.box.v == null || !this.box.cl) return false; + const p = this.boxAt(), k = p.k, pad = this.ptr.kind === 'touch' ? 14 : 7; + return x > p.x + LID_X0 * k - pad && x < p.x + LID_X1 * k + pad && y > p.y + LID_TOP * k - pad && y < p.y + BOX.hh * k + pad; + } + // a click or a tap: it hops (not without motion) and says so + baa() { + const b = this.box, p = this.boxAt(), k = p.k; + if (!this.reduced) b.hop = this.t; + b.peek = this.t; + // beside it, toward the lighthouse, where the hop doesn't cover it + this.doodle('baa', p.x + (BOX.hw + BOX.dx + 6) * k, p.y + (BOX.dy - BOX.hh + 1) * k, { text: 'baa!', align: 'left', life: 1.4 }); + } + updateBox(dt) { + const b = this.box, rm = this.reduced, t = this.t; + if (b.v !== `${this.layoutV}|${this.fontsV}`) this.placeBox(); + const p = this.boxAt(), lit = this.inBeam(p.x + this.ox, p.y + this.oy); + b.frac = p.x / this.g.W; + b.lit = rm ? lit : lerp(b.lit, lit, Math.min(1, dt * 8)); + // the eyes come to the holes while the light is on it (and for a moment after a baa), blinking now and then + const want = b.lit > 0.4 || t - b.peek < 1.8; + if (want !== b.open) { b.open = want; b.openAt = t; b.nextBlink = t + 1.4 + Math.random() * 2; } + if (b.open && !rm && t > b.nextBlink) { b.blinkAt = t; b.nextBlink = t + 1.8 + Math.random() * 2.6; } + // its label, while it's in clear sky: looked at a few times a second, faded in and out + if (t - b.seenAt > 0.3 || rm) { b.seenAt = t; b.side = this.pres.hero > 0.01 ? this.boxSide(this.boxAt(true)) : null; if (b.side) b.lastSide = b.side; } + b.la = rm ? (b.side ? 1 : 0) : lerp(b.la, b.side ? 1 : 0, Math.min(1, dt * 3)); + } + // the side of the box its label fits on right now (below, left or right), or null: the box and the label in the + // window, clear of what's in the way, and seen + boxSide(p) { + const g = this.g, u = g.u, W = g.W, k = p.k, sp = this.sunPos(), mp = this.moonPos(), moon = 25 * u * 1.15, z = 26 * u, hard = [...this.box.hard]; + if (sp.a > 0.1) hard.push([sp.x, sp.y, 38 * u * 1.2]); + if (mp.a > 0.1) hard.push([mp.x, mp.y, moon], [mp.x + moon, mp.y - moon - z * 0.6, mp.x + moon + z, mp.y - moon * 0.2]); + const r = [p.x + LID_X0 * k, p.y + LID_TOP * k, p.x + LID_X1 * k, p.y + BOX.hh * k]; + if (r[0] < 6 || r[2] > W - 6 || hard.some(q => overlaps(r, q)) || !this.seen(p.x, p.y)) return null; + for (const side of ['below', 'left', 'right']) { + const q = boxLabel(p.x, p.y, k, u, this.box.size, this.box.tw, side); + if (q.x0 < 6 || q.x1 > W - 6 || hard.some(h => overlaps([q.x0, q.y0, q.x1, q.y1], h))) continue; + if (this.seen((q.x0 + q.x1) / 2, (q.y0 + q.y1) / 2)) return side; + } + return null; + } cloudXY(cl, raw) { const g = this.g; if (cl === g.sheepCloud) return { x: cl.x + (raw ? 0 : cl.ox), y: cl.y + (raw ? 0 : cl.oy) }; @@ -1079,6 +1451,7 @@ function drawScene(s, c) { drawMoon(s, c); for (let i = 0; i < g.clouds.length; i++) drawCloud(s, c, g.clouds[i], 'cloud' + i); drawCloud(s, c, g.sheepCloud, 'sheepcloud', true); + drawBox(s, c); drawIsle(s, c); const T = g.lh, x0 = g.portrait ? g.ridge[0][0] - 8 : Math.max(0, g.ridge[0][0] - 10); const y0 = Math.min(T.top - T.gal * 1.2, g.cot.base - g.cot.h * 4.2) - 10 * u; @@ -1089,9 +1462,11 @@ function drawScene(s, c) { drawSmoke(s, c); drawEye(s, c); drawHat(s, c); + // animals and the grass on the hill, back to front (a tuft in front of the one eating it) + const items = [...s.objs.map(o => [o.y, o]), ...s.meadow.map(m => [m.y + 0.5, m])].sort((a, b) => a[0] - b[0]); + for (const [, it] of items) it.blades ? drawMeadow(s, c, it) : drawObj(s, c, it); + // the section's little scenes over them: animals pass behind the desk, the huts and the keeper drawStates(s, c); - const objs = s.objs.slice().sort((a, b) => a.y - b.y); - for (const o of objs) drawObj(s, c, o); if (s.pres.hero > 0.01) drawLabels(s, c); drawSplashes(s, c); drawBall(s, c); @@ -1212,6 +1587,58 @@ function drawCloud(s, c, cl, name, legs) { }); } +/* the sheep's box, white and soft like the clouds it drifts with: a box below, a cloud's puffs for a lid and three + air holes in front; while the light is on it, eyes in two of them that look at the lamp, blink, and glint at night */ +function drawBox(s, c) { + const b = s.box; + if (b.v == null || !b.cl) return; + const g = s.g, P = s.P, p = s.boxAt(), k = p.k, t = s.t, rm = s.reduced, { hw, hh, dx, dy } = BOX; + if (p.x < -(hw + dx) * k * 1.8 || p.x > g.W + hw * k * 1.8) return; + const night = smooth(0.5, 0.9, s.d); + c.save(); c.translate(p.x, p.y + hh * k); c.rotate(p.rot); c.scale(1 + p.sq * 0.6, 1 - p.sq); c.translate(0, -hh * k); + const K = q => q.map(([x, y]) => [x * k, y * k]); + const lid = K(LID); + print(c, K([[hw, -hh], [hw + dx, -hh + dy], [hw + dx, hh + dy], [hw, hh]]), mix(P.cloud, P.sky, 0.2)); + print(c, K([[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]]), mix(P.cloud, P.sky, 0.07)); + print(c, lid, P.cloud); + // the holes, dark; eyes in two of them while the light is on the box (or just after a baa) + let open = b.open ? (rm ? 1 : smooth(0, 0.2, t - b.openAt)) : (rm ? 0 : 1 - smooth(0, 0.14, t - b.openAt)); + if (b.open && !rm && t - b.blinkAt < 0.16) open *= Math.abs(Math.cos((t - b.blinkAt) / 0.16 * Math.PI)); + const L = s.g.lamp, look = Math.atan2(L.y - p.y, L.x - p.x), r = 3.4 * k; + BOX.holes.forEach(([hx, hy], i) => { + const x = hx * k, y = hy * k; + dot(c, x, y, r, mix(P.ink, P.sky, 0.15)); + if (i > 1 || open < 0.04) return; + const ew = r * 0.8, eh = r * 0.8 * open; + c.beginPath(); c.ellipse(x, y + (1 - open) * r * 0.3, ew, Math.max(0.3, eh), 0, 0, TAU); c.fillStyle = rgba(mix(hex('#FFFFFF'), P.beam, 0.4 * night)); c.fill(); + c.save(); c.clip(); + const px = x + Math.cos(look) * r * 0.32, py = y + Math.sin(look) * r * 0.28; + dot(c, px, py, r * 0.44, INK); + dot(c, px - r * 0.16, py - r * 0.2, r * 0.15 + 0.25, '#FFFFFF'); + c.restore(); + }); + // the pen: the box below the puffs, the puffs' edge, the corner where front meets side lighter, a ring round + // each hole, and a curl of puff on the lid as on the clouds + c.beginPath(); + edges(c, K([[-hw, -9.5], [-hw, hh], [hw, hh], [hw + dx, hh + dy], [hw + dx, -9.5]]), DET * 1.05, 3, false, { ov: 1.2 }); + ribbon(c, lid, DET, 5, { tin: 2, tout: 2 }); + edges(c, K([[hw, -9.5], [hw, hh]]), TEX * 1.25, 9, false, { ov: 0.8 }); + BOX.holes.forEach(([hx, hy], i) => ribbon(c, ellipse(hx * k, hy * k, r, r, 12, i), TEX, 30 + i, { tin: 1, tout: 1, w0: 1, w1: 1, amp: 0.2 })); + ribbon(c, ellipse(-7 * k, -23 * k, 7.5 * k, 6 * k, 10, Math.PI * 1.05, Math.PI * 1.75), TEX, 3, { tin: 2, tout: 3 }); + ribbon(c, ellipse(18 * k, -22 * k, 5.5 * k, 4.4 * k, 10, Math.PI * 1.1, Math.PI * 1.7), TEX, 7, { tin: 2, tout: 3 }); + c.fillStyle = INK; c.fill(); + // and at night the eyes glint, a little star at each + if (open > 0.3 && night > 0.05) { + c.beginPath(); + for (let i = 0; i < 2; i++) { + const [hx, hy] = BOX.holes[i], x = hx * k + r * 0.95, y = hy * k - r * 1.05, l = (1.4 + 1.2 * night) * k * open; + for (const an of [0.2, 0.2 + Math.PI / 2]) ribbon(c, [[x - Math.cos(an) * l, y - Math.sin(an) * l], [x + Math.cos(an) * l, y + Math.sin(an) * l]], TEX * 0.9, i * 2 + an, { tin: 1, tout: 1, w0: 0.5, w1: 0.5, amp: 0.1 }); + } + c.fillStyle = rgba(P.beam, clamp(night * open * 1.3)); c.fill(); + } + c.restore(); +} + /* the competition: a much smaller lighthouse on a far island */ function drawIsle(s, c) { const g = s.g, u = g.u, P = s.P, I = g.isle, hz = g.horizon; @@ -1624,6 +2051,63 @@ function drawTufts(s, c) { c.fillStyle = INK; c.fill(); } +/* a tuft of fresh grass on the hill: blades that rise out of the ground one after another, lean with the wind + and away from the pointer, part for an animal walking through, and go one by one when something eats them */ +function drawMeadow(s, c, m) { + const g = s.g, u = g.u, P = s.P, t = s.t, rm = s.reduced, age = t - m.t0; + // a section's props stand in front of the grass (the huts, the desk) + let alpha = 1; + if (s.pres.tiling > 0.01) for (const q of hutSpots(s)) if (Math.abs(m.x - q.x) < 34 * u && m.y > q.y - 64 * u && m.y < q.y + 8 * u) alpha = Math.min(alpha, 1 - s.pres.tiling); + if (s.pres.widgets > 0.01) { const d = deskSpot(s); if (Math.abs(m.x - d.x) < 56 * u && m.y > d.y - 100 * u && m.y < d.y + 8 * u) alpha = Math.min(alpha, 1 - s.pres.widgets); } + if (alpha <= 0.01) return; + // the lean: the wind and its gusts, away from the pointer, apart for a passer-by, toward the mouth eating it + let a = rm ? 0.12 : Math.sin(t * 1.6 + m.ph) * 0.14 * (0.6 + s.windAmb) + s.windTotal * 0.36; + if (!rm) { + if (s.ptr.on) { const dx = m.x - s.ptr.x, dy = m.y - 6 * u - s.ptr.y, r = 95 * u, d2 = dx * dx + dy * dy; if (d2 < r * r) a += Math.sign(dx || 1) * 1.05 * (1 - Math.sqrt(d2) / r); } + for (const o of s.objs) { + if (!grazer(o) || o === m.by || o.state !== 'land') continue; + const dx = m.x - o.x, w = 19 * u * o.sc; + if (Math.abs(dx) < w && Math.abs(m.y - o.y) < 9 * u * o.sc) a += Math.sign(dx || 1) * 0.95 * (1 - Math.abs(dx) / w); + } + const e = m.by; + if (e && e.act === 'eat') a += (e.flip ? 1 : -1) * (0.2 + 0.16 * Math.sin(t * 9 + e.seed)) * e.head; + } + const blades = []; + for (const b of m.blades) { + const gk = rm ? 1 : pop((age - b.d) / 0.75), ck = b.cut == null ? 1 : 1 - clamp((t - b.cut) / 0.4) ** 2; + if (gk <= 0.02 || ck <= 0.03) continue; + const L = b.l * gk * ck, th = clamp(b.a + a * b.flex, -1.45, 1.45), bx = m.x + b.dx, by = m.y + 1.2 * u; + const mx = bx + Math.sin(th * 0.4 + b.a * 0.3) * L * 0.55, my = by - Math.cos(th * 0.4) * L * 0.55, tx = bx + Math.sin(th) * L, ty = by - Math.cos(th) * L; + const pts = []; + for (let i = 0; i <= 6; i++) { const q = i / 6, r = 1 - q; pts.push([r * r * bx + 2 * r * q * mx + q * q * tx, r * r * by + 2 * r * q * my + q * q * ty]); } + blades.push({ pts, w: (3.9 + 0.6 * ck) * u * m.sc, L, gk: clamp(gk) }); + } + if (!blades.length) return; + c.save(); c.globalAlpha = alpha; + // the green, printed off the line + const night = smooth(0.4, 0.9, s.d); + c.beginPath(); + for (const { pts, w } of blades) { + const l = [], r = [], n = pts.length - 1; + for (let i = 0; i <= n; i++) { + const p0 = pts[Math.max(0, i - 1)], p1 = pts[Math.min(n, i + 1)], dx = p1[0] - p0[0], dy = p1[1] - p0[1], dl = Math.hypot(dx, dy) || 1, hw = w * (1 - i / n) * 0.5 + 0.25; + l.push([pts[i][0] - dy / dl * hw + REGX, pts[i][1] + dx / dl * hw + REGY]); r.push([pts[i][0] + dy / dl * hw + REGX, pts[i][1] - dx / dl * hw + REGY]); + } + const ring = [...l, ...r.reverse()]; + c.moveTo(ring[0][0], ring[0][1]); + for (const p of ring) c.lineTo(p[0], p[1]); + c.closePath(); + } + c.fillStyle = rgba(mix(P.hill2, C.sprout, 0.85 * (1 - night))); c.fill(); + // the pen: one stroke a blade, and a little ground under the tuft that grows and goes with it + c.beginPath(); + blades.forEach(({ pts, L }, i) => ribbon(c, pts, TEX * (L > 6 * u ? 0.8 : 0.65), m.ph * 3 + i, { tin: 1, tout: 6, w0: 0.9, w1: 0.12, amp: 0.25 })); + const gw = 9 * u * m.sc * Math.min(1, Math.max(...blades.map(b => b.gk)) * 1.4) * Math.max(0.4, blades.length / m.blades.length); + ribbon(c, [[m.x - gw, m.y + 2.2 * u], [m.x - gw * 0.3, m.y + 1 * u], [m.x + gw * 0.4, m.y + 1.2 * u], [m.x + gw, m.y + 2.4 * u]], TEX, m.ph, { tin: 2, tout: 3, amp: 0.3 }); + c.fillStyle = INK; c.fill(); + c.restore(); +} + function drawSmoke(s, c) { if (s.reduced) return; const g = s.g, u = g.u, k = g.cot, x0 = k.x - k.w / 2 + k.w * 0.68 + k.w * 0.075, y0 = k.base - k.h - k.h * 1.5; @@ -1749,7 +2233,7 @@ function drawDoorway(s, c, p) { /* widgets: a sheep at a tiny desk with a laptop, typing */ function drawDesk(s, c, p) { const g = s.g, P = s.P, sc = pop(p) * (g.portrait ? 1.75 : 2.1), u = g.u * sc; - const x = g.portrait ? g.W * 0.5 : g.W * 0.7, ry = s.ridgeY(x), y = g.portrait ? ry + (s.shoreY(x) - ry) * 0.66 : ry + g.H * 0.2; + const { x, y } = deskSpot(s); if (sc < 0.05) return; const typing = s.reduced ? 0 : s.t * 11, wool = mul(C.wool, P.paper), night = smooth(0.4, 0.8, s.d); c.save(); c.translate(x, y); @@ -1912,6 +2396,14 @@ function drawLabels(s, c) { label(c, 'a cloud (or a sheep)', lx + 16 * u, ly + 4 * u, size, col, 'left'); c.fillStyle = col; arrow(c, [[lx + 12 * u, ly + 3 * u], [lx, ly + 2 * u], [p.x + w * 0.36, p.y + 6 * u]], TEX * 1.1, 9); } + // the sheep in its box, where placeBox found room for the label + const bx = s.box; + if (bx.lastSide && bx.la > 0.01 && bx.cl) { + const p = s.boxAt(true), q = boxLabel(p.x, p.y, p.k, u, size, bx.tw, bx.lastSide), lc = rgba(mix(P.ink, P.paper, night), s.pres.hero * bx.la); + label(c, 'a sheep (in a box)', q.tx, q.ty, size, lc, q.align); + c.fillStyle = lc; arrow(c, q.arrow, TEX * 1.1, 11); + c.fillStyle = col; + } // the sea const sea = seaLabelSpot(s); if (sea) { @@ -1920,6 +2412,37 @@ function drawLabels(s, c) { c.fillStyle = rgba(P.paper, s.pres.hero); arrow(c, [[sea.x + wTxt * 0.62, sea.y + 11 * u], [sea.x + wTxt * 0.5, sea.y + 22 * u], [sea.x + wTxt * 0.3, sea.y + 30 * u]], TEX * 1.1, 10); } } +function textW(s, text, size) { const c = s.ctx; c.save(); c.font = `400 ${size}px ${HAND}`; const w = c.measureText(text).width; c.restore(); return w; } +// what "a lighthouse (stable)" and its arrow cover (as drawLabels places them), for keeping out of their way +function lighthouseLabelRects(s, w, size) { + const g = s.g, u = g.u, T = g.lh, h = size * 0.75; + if (g.portrait) { + const tx = T.x - T.wB * 1.9, ty = T.top + T.h * 0.3; + return [[tx - w - 4, ty - h, tx + 4, ty + h], [tx, T.top + T.h * 0.2 - 6, T.x - T.wB * 0.62 + 4, ty + 4]]; + } + const tipX = T.x - T.wB * 0.62, tipY = T.top + T.h * 0.6, ax = tipX - 88 * u, ay = tipY + 38 * u; + return [[ax - w - 4, ay - h, ax + 4, ay + h], [ax, tipY - 6, tipX + 4, ay]]; +} +/* "a sheep (in a box)" for a box at (x, y): below it, or beside it, with an arrow back to the box; + the text's anchor and alignment, the arrow, and the rect the two take */ +function boxLabel(x, y, k, u, size, tw, side) { + const h = size * 0.75, R = (BOX.hw + BOX.dx) * k, Lx = BOX.hw * k, B = BOX.hh * k; + if (side === 'below') { + const tx = x - Lx * 0.35, ty = y + B + 12 * u + size * 0.55; + return { tx, ty, align: 'left', arrow: [[tx + 3 * u, ty - h * 1.05], [tx - 3 * u, ty - h * 1.6], [x - Lx * 0.5, y + B + 3 * u]], x0: Math.min(tx, x - Lx) - 4, y0: y + B + 2, x1: tx + tw + 4, y1: ty + h }; + } + if (side === 'right') { + const tx = x + R + 22 * u, ty = y + 9 * u; + return { tx, ty, align: 'left', arrow: [[tx - 3 * u, ty - 1 * u], [tx - 11 * u, ty - 2 * u], [x + R + 4 * u, y + 1 * u]], x0: x + R + 2, y0: ty - h, x1: tx + tw + 4, y1: ty + h }; + } + const tx = x - Lx - 20 * u, ty = y + 9 * u; + return { tx, ty, align: 'right', arrow: [[tx + 3 * u, ty - 1 * u], [tx + 11 * u, ty - 2 * u], [x - Lx - 4 * u, y + 1 * u]], x0: tx - tw - 4, y0: ty - h, x1: x - Lx - 2, y1: ty + h }; +} +// where the widgets section's sheep sits at its desk +function deskSpot(s) { + const g = s.g, x = g.portrait ? g.W * 0.5 : g.W * 0.7, ry = s.ridgeY(x); + return { x, y: g.portrait ? ry + (s.shoreY(x) - ry) * 0.66 : ry + g.H * 0.2 }; +} function seaLabelSpot(s) { const g = s.g, u = g.u, wTxt = 112 * u + 10; if (g.portrait) { const y = g.horizon + (g.H - g.horizon) * 0.55; return { x: g.W * 0.3, y }; } @@ -1938,7 +2461,9 @@ function seaLabelSpot(s) { function drawObj(s, c, o) { const g = s.g, P = s.P, k = g.u * o.sc, lit = o.lit * s.beamI(), sq = s.squash(o), t = s.reduced ? 0 : s.t; if (o.dying != null) c.globalAlpha = clamp(1 - (s.t - o.dying) / 0.7); - c.save(); c.translate(o.x, o.y); + // a sheep pleased with its meal hops, twice + const ja = t - (o.joy ?? -99), hop = o.kind === 'sheep' && ja >= 0 && ja < 0.8 ? Math.abs(Math.sin(ja / 0.4 * Math.PI)) * 6 * k * (1 - ja / 0.8) : 0; + c.save(); c.translate(o.x, o.y - hop); if (lit > 0.15) { // a startled "!", in the pen const jy = -40 * k - Math.abs(Math.sin(t * 8)) * 3 * k; @@ -1959,30 +2484,53 @@ function drawObj(s, c, o) { c.restore(); c.globalAlpha = 1; } +/* a grazer's head: up, or lowered into the grass and chewing; `px, py` is where the neck turns, `to` where it + goes when the head is down. Draws the head with `draw`, in the head-up coordinates. */ +function head(c, u, o, t, px, py, to, turn, draw) { + const hd = o.head || 0, chew = o.act === 'eat' ? Math.sin(t * 9 + o.seed) * hd : 0; + c.save(); + c.translate(lerp(px, to[0], hd) * u, (lerp(py, to[1], hd) + chew * 0.55) * u); + c.rotate(hd * turn + chew * 0.07); + c.translate(-px * u, -py * u); + draw(chew); + c.restore(); +} +// pleased with a meal: a short while after it, eyes shut happily and the tail wags +const joyAt = (o, t) => { const a = t - (o.joy ?? -99); return a >= 0 && a < 1.3 ? a : -1; }; function drawSheep(c, u, wool, o, t) { - const fall = o.state === 'fall', step = Math.abs(o.vx) > 2 ? Math.sin(t * 14 + o.seed) * 2 * u : 0; + const fall = o.state === 'fall', step = Math.hypot(o.vx, o.vd || 0) > 2 ? Math.sin(t * 14 + o.seed) * 2 * u : 0; c.beginPath(); [-8, -3, 5, 10].forEach((lx, i) => ribbon(c, [[lx * u, -10 * u], [lx * u + (fall ? 3 * u : (i % 2 ? step : -step)), 0]], DET * 1.05, i, { tin: 1, tout: 1, w0: 0.9, w1: 1 })); c.fillStyle = INK; c.fill(); const body = []; for (let i = 0; i < 40; i++) { const a = i / 40 * TAU, r = 1 + 0.13 * Math.abs(Math.sin(a * 5 + o.seed)); body.push([Math.cos(a) * 14 * u * r, -16 * u + Math.sin(a) * 9.5 * u * r]); } shape(c, body, wool, DET, o.seed % 7); - // tail puff, black face, an ear, an eye - const head = ellipse(15 * u, -19 * u, 5 * u, 6.4 * u, 18, 0, TAU, -0.35); - poly(c, head); c.fillStyle = INK; c.fill(); - const ear = ellipse(11.5 * u, -23.5 * u, 3.6 * u, 1.6 * u, 10, 0, TAU, 0.5); - poly(c, ear); c.fill(); - const tuft = []; for (let i = 0; i < 10; i++) { const a = Math.PI * 1.05 + i / 9 * Math.PI * 0.9; tuft.push([13.5 * u + Math.cos(a) * 5 * u, -24 * u + Math.sin(a) * 2.6 * u]); } - c.beginPath(); ribbon(c, tuft, TEX, 3, { tin: 1, tout: 1, color: rgba(wool) }); c.fillStyle = rgba(wool); c.fill(); - dot(c, 16.6 * u, -20.5 * u, 1.5 * u, '#FFFFFF'); dot(c, 17.1 * u, -20.3 * u, 0.75 * u, INK); + // a stubby tail, wagging when pleased + const ja = joyAt(o, t); + c.save(); c.translate(-14 * u, -19 * u); c.rotate(-0.45 + (ja >= 0 ? Math.sin(ja * 24) * 0.6 * (1 - ja / 1.3) : 0)); + shape(c, ellipse(-2.4 * u, 0, 3 * u, 2.2 * u, 12), wool, TEX * 1.15, 5, { over: 0.05 }); + c.restore(); + // black face, an ear, a tuft of wool on top, an eye + head(c, u, o, t, 15, -19, [19, -7.5], 1.05, chew => { + poly(c, ellipse(15 * u, -19 * u, 5 * u, 6.4 * u, 18, 0, TAU, -0.35)); c.fillStyle = INK; c.fill(); + poly(c, ellipse(11.5 * u, -23.5 * u, 3.6 * u, 1.6 * u, 10, 0, TAU, 0.5 + chew * 0.25)); c.fill(); + const tuft = []; for (let i = 0; i < 10; i++) { const a = Math.PI * 1.05 + i / 9 * Math.PI * 0.9; tuft.push([13.5 * u + Math.cos(a) * 5 * u, -24 * u + Math.sin(a) * 2.6 * u]); } + c.beginPath(); ribbon(c, tuft, TEX, 3, { tin: 1, tout: 1 }); c.fillStyle = rgba(wool); c.fill(); + if (ja >= 0) { c.beginPath(); ribbon(c, ellipse(16.6 * u, -19.6 * u, 1.7 * u, 1.3 * u, 8, Math.PI * 1.1, Math.PI * 1.9), TEX, 4, { tin: 1, tout: 1, w0: 0.9, w1: 0.9, amp: 0.1 }); c.fillStyle = '#FFFFFF'; c.fill(); } + else { dot(c, 16.6 * u, -20.5 * u, 1.5 * u, '#FFFFFF'); dot(c, 17.1 * u, -20.3 * u, 0.75 * u, INK); } + }); } function drawCow(c, u, white, P, o, t) { - const step = Math.abs(o.vx) > 2 ? Math.sin(t * 9 + o.seed) * 2 * u : 0; + const step = Math.hypot(o.vx, o.vd || 0) > 2 ? Math.sin(t * 9 + o.seed) * 2 * u : 0; c.beginPath(); [-12, -7, 8, 13].forEach((lx, i) => ribbon(c, [[lx * u, -11 * u], [lx * u + (i % 2 ? step : -step), 0]], DET * 1.15, i, { tin: 1, tout: 1, w0: 0.9, w1: 1 })); - ribbon(c, [[-17 * u, -20 * u], [-21 * u, -14 * u], [-20 * u, -7 * u]], TEX, 5, { tin: 1, tout: 1 }); c.fillStyle = INK; c.fill(); + // the tail swishes now and then, and wags when pleased + const ja = joyAt(o, t), wag = ja >= 0 ? Math.sin(ja * 16) * 0.5 * (1 - ja / 1.3) : Math.sin(t * 1.3 + o.seed) * 0.12; + c.save(); c.translate(-17 * u, -20 * u); c.rotate(wag); c.translate(17 * u, 20 * u); + c.beginPath(); ribbon(c, [[-17 * u, -20 * u], [-21 * u, -14 * u], [-20 * u, -7 * u]], TEX, 5, { tin: 1, tout: 1 }); c.fillStyle = INK; c.fill(); dot(c, -20 * u, -6 * u, 2 * u, INK); + c.restore(); const body = curve([[-17 * u, -22 * u], [-6 * u, -26.5 * u], [10 * u, -25.5 * u], [17 * u, -20 * u], [15 * u, -11 * u], [0, -10 * u], [-16 * u, -11 * u], [-18.5 * u, -16 * u]], 3, true); print(c, body, white); // patches, inside the body @@ -1991,18 +2539,21 @@ function drawCow(c, u, white, P, o, t) { for (let i = 0; i < 3; i++) { const px = (-11 + i * 9 + R() * 4) * u, py = (-21 + R() * 8) * u; poly(c, ellipse(px, py, (3.5 + R() * 3) * u, (2.6 + R() * 2) * u, 12, 0, TAU, R())); c.fillStyle = INK; c.fill(); } c.restore(); c.beginPath(); loop(c, body, DET, 3); c.fillStyle = INK; c.fill(); - // head, muzzle, horns, an ear - const head = curve([[15 * u, -29 * u], [21 * u, -31 * u], [24.5 * u, -26 * u], [24.5 * u, -20 * u], [18 * u, -19.5 * u], [15 * u, -24 * u]], 2, true); - shape(c, head, white, DET, 4); - const muzzle = ellipse(23 * u, -21 * u, 4 * u, 3 * u, 14); - shape(c, muzzle, mul(C.pink, P.paper), TEX * 1.2, 2); - dot(c, 22 * u, -21 * u, 0.7 * u, INK); dot(c, 24.6 * u, -21.2 * u, 0.7 * u, INK); - dot(c, 20.2 * u, -26.6 * u, 1.05 * u, INK); - c.beginPath(); - ribbon(c, [[16.5 * u, -29.5 * u], [15 * u, -33.5 * u], [16.5 * u, -36 * u]], TEX * 1.3, 1, { tin: 1, tout: 2 }); - ribbon(c, [[21 * u, -31 * u], [22.5 * u, -34.5 * u], [21.5 * u, -37 * u]], TEX * 1.3, 2, { tin: 1, tout: 2 }); - c.fillStyle = INK; c.fill(); - poly(c, ellipse(13.6 * u, -27.5 * u, 3 * u, 1.5 * u, 10, 0, TAU, 0.4)); c.fillStyle = INK; c.fill(); + // head, muzzle (it chews), horns, an ear + head(c, u, o, t, 15, -24, [19, -13], 0.95, chew => { + const face = curve([[15 * u, -29 * u], [21 * u, -31 * u], [24.5 * u, -26 * u], [24.5 * u, -20 * u], [18 * u, -19.5 * u], [15 * u, -24 * u]], 2, true); + shape(c, face, white, DET, 4); + const my = (-21 + Math.max(0, chew) * 0.7) * u; + shape(c, ellipse(23 * u, my, 4 * u, 3 * u, 14), mul(C.pink, P.paper), TEX * 1.2, 2); + dot(c, 22 * u, my, 0.7 * u, INK); dot(c, 24.6 * u, my - 0.2 * u, 0.7 * u, INK); + if (ja >= 0) { c.beginPath(); ribbon(c, ellipse(20.2 * u, -25.8 * u, 1.5 * u, 1.1 * u, 8, Math.PI * 1.1, Math.PI * 1.9), TEX, 6, { tin: 1, tout: 1, amp: 0.1 }); c.fillStyle = INK; c.fill(); } + else dot(c, 20.2 * u, -26.6 * u, 1.05 * u, INK); + c.beginPath(); + ribbon(c, [[16.5 * u, -29.5 * u], [15 * u, -33.5 * u], [16.5 * u, -36 * u]], TEX * 1.3, 1, { tin: 1, tout: 2 }); + ribbon(c, [[21 * u, -31 * u], [22.5 * u, -34.5 * u], [21.5 * u, -37 * u]], TEX * 1.3, 2, { tin: 1, tout: 2 }); + c.fillStyle = INK; c.fill(); + poly(c, ellipse(13.6 * u, -27.5 * u, 3 * u, 1.5 * u, 10, 0, TAU, 0.4 + chew * 0.2)); c.fillStyle = INK; c.fill(); + }); } function drawGull(c, u, white, o, t) { const fl = o.state === 'hover' ? 2 * u : Math.sin(t * 9 + o.seed) * 6 * u; @@ -2082,12 +2633,13 @@ function quaver(c, x, y, sz, col, flip) { c.fillStyle = col; c.fill(); } -/* "boink!", "pok", "plop", "hehe" and the whistled notes: they pop in, drift a little and fade */ +/* "boink!", "pok", "plop", "hehe", the whistled notes, the grazers' "nom"s and the box's "baa!": they pop in, + drift a little and fade (without motion they just show, and go) */ function drawDoodles(s, c) { if (!s.doodles.length) return; - const g = s.g, u = g.u, P = s.P, ink = rgba(mix(P.ink, P.paper, smooth(0.5, 0.7, s.d))); + const g = s.g, u = g.u, P = s.P, rm = s.reduced, ink = rgba(mix(P.ink, P.paper, smooth(0.5, 0.7, s.d))); for (const d of s.doodles) { - const a = s.t - d.t0, k = pop(a / 0.24), f = 1 - smooth(d.life - 0.3, d.life, a), col = d.sea ? rgba(P.paper) : ink; + const a = s.t - d.t0, k = rm ? 1 : pop(a / 0.24), f = 1 - smooth(d.life - 0.3, d.life, a), col = d.sea ? rgba(P.paper) : ink; if (f <= 0 || k <= 0.01) continue; c.save(); c.globalAlpha = f; if (d.kind === 'note') { @@ -2095,15 +2647,19 @@ function drawDoodles(s, c) { quaver(c, d.x + Math.sin(a * 4 + d.seed) * 5 * u, d.y - a * 36 * u, sz * k, col, d.flip); c.restore(); continue; } - const size = Math.max(d.kind === 'pok' ? 12 : 13.5, (d.kind === 'pok' ? 12 : d.kind === 'boink' ? 16 : 15) * u + 4), dir = d.align === 'right' ? -1 : 1; - const shake = d.kind === 'hehe' ? (Math.floor(a * 9) % 2 ? -1 : 1) * 1.3 * u : 0; - c.translate(d.x, d.y - a * (d.kind === 'plop' ? 12 : 8) * u + shake); c.scale(k, k); - const text = d.kind === 'boink' ? 'boink!' : d.kind; - label(c, text, 0, 0, size, col, d.align, d.kind === 'boink' ? -0.09 * dir : d.kind === 'pok' ? 0.12 * dir : -0.06); + const munch = d.kind === 'munch', dir = d.align === 'right' ? -1 : 1; + const size = munch ? Math.max(11, 12 * u + 2.5) : Math.max(d.kind === 'pok' ? 12 : 13.5, (d.kind === 'pok' ? 12 : d.kind === 'boink' ? 16 : 15) * u + 4); + const shake = d.kind === 'hehe' && !rm ? (Math.floor(a * 9) % 2 ? -1 : 1) * 1.3 * u : 0; + c.translate(d.x, d.y - (rm ? 0 : a * (d.kind === 'plop' ? 12 : munch ? 7 : 8) * u) + shake); c.scale(k, k); + const text = d.text || (d.kind === 'boink' ? 'boink!' : d.kind); + label(c, text, 0, 0, size, col, d.align, d.kind === 'boink' ? -0.09 * dir : d.kind === 'pok' ? 0.12 * dir : munch ? -0.1 * dir : d.kind === 'baa' ? 0.1 * dir : -0.06); c.beginPath(); if (d.kind === 'boink') { // the knock, in three short strokes toward the widget for (const i of [0, 1, 2]) { const an = -Math.PI / 2 - dir * (0.25 + i * 0.42), r0 = size * 0.32, r1 = size * 0.72; ribbon(c, [[Math.cos(an) * r0, -size * 0.3 + Math.sin(an) * r0], [Math.cos(an) * r1, -size * 0.3 + Math.sin(an) * r1]], TEX * 1.1, i + 7, { tin: 1, tout: 2 }); } + } else if (d.kind === 'baa') { + // two short strokes, from the box toward the word + for (const i of [0, 1]) { const an = Math.PI * (i ? 0.1 : -0.12), x0 = -dir * size * 0.6, r = size * 0.42; ribbon(c, [[x0 - dir * Math.cos(an) * r * 0.2, -size * 0.1 + Math.sin(an) * r * 0.6 + i * size * 0.38 - size * 0.12], [x0 + dir * Math.cos(an) * r * 0.55, -size * 0.1 + i * size * 0.38 - size * 0.12]], TEX * 1.1, i + 5, { tin: 1, tout: 2 }); } } else if (d.kind === 'plop') { // two drops over the word for (const i of [0, 1]) { const x = size * (0.45 + i * 0.85), y = -size * (0.9 + i * 0.2); ribbon(c, ellipse(x, y, size * 0.13, size * 0.18, 10, -0.6, Math.PI * 1.6), TEX, i + 3, { tin: 1, tout: 1 }); } From 0afe1486339a10690c6d819355531d9da10446e9 Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Fri, 30 Oct 2026 12:01:56 +0100 Subject: [PATCH 06/46] docs: draw the website's sheep box as a plain box in cloud colours The box drifting with the clouds keeps its own shape again, without the puffs on its lid, and takes the clouds' colours: the top as white as they are, the front with a touch of sky and the side in their shade, changing with them through the evening. --- site/scene.js | 41 +++++++++++++---------------------------- 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/site/scene.js b/site/scene.js index 4deec7dd..b5d1e2d8 100644 --- a/site/scene.js +++ b/site/scene.js @@ -127,18 +127,6 @@ const grazer = o => (o.kind === 'sheep' || o.kind === 'cow') && !o.ride; /* The sheep's box, in its own units (times boxK): the front face's half size, the depth seen past it, and three air holes punched in the front, the first two with eyes behind them */ const BOX = { hw: 21, hh: 13, dx: 9, dy: -7, holes: [[-11, -1.5], [0.5, -3.5], [11.5, -0.5]] }; -// its lid: a cloud's row of puffs sitting on the box, following its top back to the far corner (box units) -const LID = (() => { - const bumps = [[-18, -12, 10], [-6, -18, 13], [8, -19.5, 13], [21, -18, 11], [30, -17, 7.5]], top = []; - for (let i = 0; i <= 72; i++) { - const x = -28.5 + 66.5 * i / 72; - let y = -9.5; - for (const [bx, by, r] of bumps) { const d = x - bx; if (Math.abs(d) < r) y = Math.min(y, by - Math.sqrt(r * r - d * d)); } - top.push([x, y]); - } - return top; -})(); -const LID_TOP = Math.min(...LID.map(p => p[1])), LID_X0 = LID[0][0], LID_X1 = LID[LID.length - 1][0]; const BOX_SP = 6.5; // its drift, in the clouds' units // a rect [x0, y0, x1, y1] against a rect, or a disc [x, y, r] const overlaps = (a, q) => q.length === 3 @@ -1271,7 +1259,7 @@ class Scene { } /* ----- the sheep in a box ----- - A white box with a cloud's puffs for a lid drifts across the sky with the clouds, at a height of its own. + A box in the clouds' colours drifts across the sky with them, at a height of its own. When the light finds it, two eyes look out through its air holes (glinting at night); a click or a tap makes it hop and say baa. It isn't a surprise: clicking it drops nothing. Its label shows while it's in clear sky. */ @@ -1316,7 +1304,7 @@ class Scene { boxHit(x, y) { if (this.box.v == null || !this.box.cl) return false; const p = this.boxAt(), k = p.k, pad = this.ptr.kind === 'touch' ? 14 : 7; - return x > p.x + LID_X0 * k - pad && x < p.x + LID_X1 * k + pad && y > p.y + LID_TOP * k - pad && y < p.y + BOX.hh * k + pad; + return x > p.x - BOX.hw * k - pad && x < p.x + (BOX.hw + BOX.dx) * k + pad && y > p.y + (BOX.dy - BOX.hh) * k - pad && y < p.y + BOX.hh * k + pad; } // a click or a tap: it hops (not without motion) and says so baa() { @@ -1346,7 +1334,7 @@ class Scene { const g = this.g, u = g.u, W = g.W, k = p.k, sp = this.sunPos(), mp = this.moonPos(), moon = 25 * u * 1.15, z = 26 * u, hard = [...this.box.hard]; if (sp.a > 0.1) hard.push([sp.x, sp.y, 38 * u * 1.2]); if (mp.a > 0.1) hard.push([mp.x, mp.y, moon], [mp.x + moon, mp.y - moon - z * 0.6, mp.x + moon + z, mp.y - moon * 0.2]); - const r = [p.x + LID_X0 * k, p.y + LID_TOP * k, p.x + LID_X1 * k, p.y + BOX.hh * k]; + const r = [p.x - BOX.hw * k - 3, p.y + (BOX.dy - BOX.hh) * k - 3, p.x + (BOX.hw + BOX.dx) * k + 3, p.y + BOX.hh * k + 3]; if (r[0] < 6 || r[2] > W - 6 || hard.some(q => overlaps(r, q)) || !this.seen(p.x, p.y)) return null; for (const side of ['below', 'left', 'right']) { const q = boxLabel(p.x, p.y, k, u, this.box.size, this.box.tw, side); @@ -1587,8 +1575,8 @@ function drawCloud(s, c, cl, name, legs) { }); } -/* the sheep's box, white and soft like the clouds it drifts with: a box below, a cloud's puffs for a lid and three - air holes in front; while the light is on it, eyes in two of them that look at the lamp, blink, and glint at night */ +/* the sheep's box, in the colours of the clouds it drifts with: three faces and three air holes in front; while + the light is on it, eyes in two of them that look at the lamp, blink, and glint at night */ function drawBox(s, c) { const b = s.box; if (b.v == null || !b.cl) return; @@ -1597,10 +1585,10 @@ function drawBox(s, c) { const night = smooth(0.5, 0.9, s.d); c.save(); c.translate(p.x, p.y + hh * k); c.rotate(p.rot); c.scale(1 + p.sq * 0.6, 1 - p.sq); c.translate(0, -hh * k); const K = q => q.map(([x, y]) => [x * k, y * k]); - const lid = K(LID); - print(c, K([[hw, -hh], [hw + dx, -hh + dy], [hw + dx, hh + dy], [hw, hh]]), mix(P.cloud, P.sky, 0.2)); - print(c, K([[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]]), mix(P.cloud, P.sky, 0.07)); - print(c, lid, P.cloud); + // lit from above like the clouds: the top as white as they are, the front a touch of sky, the side in their shade + print(c, K([[hw, -hh], [hw + dx, -hh + dy], [hw + dx, hh + dy], [hw, hh]]), mix(P.cloud, P.sky, 0.24)); + print(c, K([[-hw, -hh], [-hw + dx, -hh + dy], [hw + dx, -hh + dy], [hw, -hh]]), P.cloud); + print(c, K([[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]]), mix(P.cloud, P.sky, 0.08)); // the holes, dark; eyes in two of them while the light is on the box (or just after a baa) let open = b.open ? (rm ? 1 : smooth(0, 0.2, t - b.openAt)) : (rm ? 0 : 1 - smooth(0, 0.14, t - b.openAt)); if (b.open && !rm && t - b.blinkAt < 0.16) open *= Math.abs(Math.cos((t - b.blinkAt) / 0.16 * Math.PI)); @@ -1617,15 +1605,12 @@ function drawBox(s, c) { dot(c, px - r * 0.16, py - r * 0.2, r * 0.15 + 0.25, '#FFFFFF'); c.restore(); }); - // the pen: the box below the puffs, the puffs' edge, the corner where front meets side lighter, a ring round - // each hole, and a curl of puff on the lid as on the clouds + // the pen: the outline in one weight, the edges inside lighter, and a ring round each hole c.beginPath(); - edges(c, K([[-hw, -9.5], [-hw, hh], [hw, hh], [hw + dx, hh + dy], [hw + dx, -9.5]]), DET * 1.05, 3, false, { ov: 1.2 }); - ribbon(c, lid, DET, 5, { tin: 2, tout: 2 }); - edges(c, K([[hw, -9.5], [hw, hh]]), TEX * 1.25, 9, false, { ov: 0.8 }); + edges(c, K([[-hw, hh], [-hw, -hh], [-hw + dx, -hh + dy], [hw + dx, -hh + dy], [hw + dx, hh + dy], [hw, hh]]), DET * 1.05, 3, true, { ov: 1.5 }); + edges(c, K([[-hw, -hh], [hw, -hh], [hw, hh]]), TEX * 1.25, 9, false, { ov: 0.8 }); + edges(c, K([[hw, -hh], [hw + dx, -hh + dy]]), TEX * 1.25, 12, false, { ov: 0.6 }); BOX.holes.forEach(([hx, hy], i) => ribbon(c, ellipse(hx * k, hy * k, r, r, 12, i), TEX, 30 + i, { tin: 1, tout: 1, w0: 1, w1: 1, amp: 0.2 })); - ribbon(c, ellipse(-7 * k, -23 * k, 7.5 * k, 6 * k, 10, Math.PI * 1.05, Math.PI * 1.75), TEX, 3, { tin: 2, tout: 3 }); - ribbon(c, ellipse(18 * k, -22 * k, 5.5 * k, 4.4 * k, 10, Math.PI * 1.1, Math.PI * 1.7), TEX, 7, { tin: 2, tout: 3 }); c.fillStyle = INK; c.fill(); // and at night the eyes glint, a little star at each if (open > 0.3 && night > 0.05) { From 7ae479ab01bb3992746b83aaca3da1beed1e96e6 Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Fri, 30 Oct 2026 12:16:22 +0100 Subject: [PATCH 07/46] docs: start the website's sheep box at the far left of the sky The box sets off from the left edge of the sky when the page opens and drifts right with the clouds from there, instead of starting between the headline and the lighthouse. --- site/scene.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/scene.js b/site/scene.js index b5d1e2d8..1b2dfeec 100644 --- a/site/scene.js +++ b/site/scene.js @@ -1284,8 +1284,8 @@ class Scene { const others = [...g.clouds.map(cl => cl.y), sc.y]; let y = lo, far = -1; for (let cy = lo; cy <= hi; cy += 3 * u) { const d = Math.min(...others.map(o => Math.abs(o - cy))); if (d > far) { far = d; y = cy; } } - // first seen in open sky between the headline and the lighthouse; after a resize, where it was - const frac = b.frac ?? (g.portrait ? 0.5 : 0.62); + // it sets off from the far left of the sky; after a resize, it carries on from where it was + const frac = b.frac ?? (BOX.hw * k + 8) / W; b.cl = { x0: frac * W + 180 * u - this.drift * BOX_SP * u, y, w: (2 * BOX.hw + BOX.dx) * k, sp: BOX_SP, seed: 91, ox: b.cl?.ox ?? 0, oy: b.cl?.oy ?? 0 }; b.seenAt = -9; } From a66a37b9fe0e40edb81ace4feb7b5edd002915eb Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Fri, 30 Oct 2026 13:03:29 +0100 Subject: [PATCH 08/46] docs: open the website with the camera coming down to the bar On a visit's first page load the drawing starts up in the sky, with the bar in the middle of the screen and the headline waiting below. The camera then comes down to the hill while the bar flies up to its place, and the lighthouse sweeps its light along the bar, widget by widget. It plays once a session, only when the page opens at its top, and never under reduced motion; any scroll, key, click or tap jumps straight to the end. The headline is on screen from the first frame, so the page's largest paint isn't delayed. --- site/index.html | 2 ++ site/main.js | 46 ++++++++++++++++++++++++++++++++++++++++++++-- site/scene.js | 23 +++++++++++++++++++++-- site/style.css | 13 +++++++++++++ 4 files changed, 80 insertions(+), 4 deletions(-) diff --git a/site/index.html b/site/index.html index 2a421f08..82b1a830 100644 --- a/site/index.html +++ b/site/index.html @@ -45,6 +45,8 @@ const root = document.documentElement; root.dataset.mode = localStorage.getItem("mode") || (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"); root.classList.add("js"); +// the landing film: once a session, opening at the top of the page, with motion allowed (see land() in main.js) +try { if (!sessionStorage.getItem("landed") && !location.hash && !matchMedia("(prefers-reduced-motion: reduce)").matches) root.classList.add("intro"); } catch {} diff --git a/site/main.js b/site/main.js index 9efd8dcd..c29c2e0d 100644 --- a/site/main.js +++ b/site/main.js @@ -879,7 +879,7 @@ for (const b of nightSwitches) b.addEventListener("click", e => { measureMarks(); try { - if (window.Hill?.Scene) scene = new window.Hill.Scene(document.getElementById("scene"), { topInset: restHeight(), dusk: root.dataset.mode === "dark" ? 1 : hour().d }); + if (window.Hill?.Scene) scene = new window.Hill.Scene(document.getElementById("scene"), { topInset: restHeight(), dusk: root.dataset.mode === "dark" ? 1 : hour().d, held: root.classList.contains("intro") }); } catch (err) { console.warn("The drawing didn't start:", err); scene = null; @@ -898,7 +898,7 @@ if (scene) { dock.dataset.sky = lum(sky) < .18 ? "dark" : "light"; }; scene.onFrame = s => { - try { lightWords(s); skyStrip(s); if (lit && s.playing) hideTour(); } catch (err) { + try { if (landed) lightWords(s); sweepGlow(s); skyStrip(s); if (lit && s.playing) hideTour(); } catch (err) { if (!sceneFailed) { sceneFailed = true; console.warn("The drawing failed:", err); } } }; @@ -974,6 +974,48 @@ function onBar(event, key, info = {}) { } } +/* The landing: the camera starts up in the sky with the bar in the middle of it, comes down to the hill as the bar + flies up to its place, and the lighthouse sweeps its light along the bar. Once a session, opening at the top, with + motion allowed (the class comes from the page's head); any scroll, key, click or tap ends it at once. */ +let landed = !root.classList.contains("intro"), sweptUntil = 0, runTimer = 0, landTimer = 0; +const swept = new Map(); +const LAND_SKIP = ["wheel", "keydown", "pointerdown", "touchstart", "scroll"]; +function land(skip) { + if (landed) return; + landed = true; + clearTimeout(runTimer); + clearTimeout(landTimer); + for (const type of LAND_SKIP) removeEventListener(type, skipLanding, true); + if (skip) root.classList.add("intro-skip"); + root.classList.remove("intro", "intro-run"); + measureMarks(); + lumRects = null; + sendBar(); // the bar's widgets where they now stand, at the top, for the sweep and the ball game + S("hold", false); + if (skip) requestAnimationFrame(() => requestAnimationFrame(() => root.classList.remove("intro-skip"))); + else if (S("sweepBar", 1.1)) sweptUntil = performance.now() + 1800; +} +function skipLanding() { land(true); } +/* While the light sweeps the bar, each widget it passes glows for a moment */ +function sweepGlow(s) { + const now = performance.now(); + if (now < sweptUntil) for (const w of bar.querySelectorAll(".w[data-key]")) { + if (w.hidden || w.classList.contains("over") || !w.offsetWidth) continue; + const r = w.getBoundingClientRect(); + if (s.inBeam(r.left + r.width / 2, r.top + r.height / 2) > .45) { w.classList.add("swept"); swept.set(w, now + 420); } + } + for (const [w, until] of swept) if (now > until) { w.classList.remove("swept"); swept.delete(w); } +} +if (!landed) { + try { sessionStorage.setItem("landed", "1"); } catch {} + if (scrollY > 0 || document.hidden) land(true); + else { + for (const type of LAND_SKIP) addEventListener(type, skipLanding, { capture: true, passive: true }); + runTimer = setTimeout(() => root.classList.add("intro-run"), 380); + landTimer = setTimeout(() => land(false), 380 + 1500); + } +} + /* The lamp-lit tour, for pointers that hover: the widget under the pointer takes the lamp's glow (the beam already follows the pointer) and a paper tag hangs under it on a string. Keyboard focus shows it too. It waits while the ball is in play, and never shows while editing, dragging or a popup is open. */ diff --git a/site/scene.js b/site/scene.js index 1b2dfeec..86297203 100644 --- a/site/scene.js +++ b/site/scene.js @@ -242,6 +242,7 @@ class Scene { this.onBar = null; // a lived-in hill: the animals already there, the grass they eat, and a sheep in a box up in the sky this.flocked = false; this.meadow = []; this.sproutAt = 0; + this.held = !!this.o.held; this.sweep = null; this.box = { v: null, cl: null, side: null, lastSide: null, la: 0, seenAt: -9, hop: -9, peek: -9, lit: 0, open: false, openAt: -9, blinkAt: -9, nextBlink: 0 }; this.times = []; this.awake = 0; this.kick = 0; this.raf = 0; this.timer = 0; this.last = null; @@ -378,6 +379,17 @@ class Scene { if (!this.bar.length) { this.endBall(); this.endBore(); } this.wake(); } + // the landing: until the camera has come down, the flock and the grass wait (they're placed where they're seen) + hold(on) { this.held = !!on; if (!on) this.measure(); this.wake(); } + // the landing's last beat: the light swings to the bar's left end and sweeps along it to the right, once + sweepBar(dur = 1) { + const L = this.g.lamp, ts = this.bar; + if (this.reduced || !ts.length || document.hidden) return false; + const y = Math.min(...ts.map(t => t.y)) - 12 * this.g.u, x0 = Math.min(...ts.map(t => (t.x0 + t.x1) / 2)), x1 = Math.max(...ts.map(t => (t.x0 + t.x1) / 2)); + this.sweep = { t0: this.t, lead: 0.3, dur, tail: 0.25, a0: Math.atan2(y - L.y, x0 - L.x), a1: Math.atan2(y - L.y, x1 - L.x) }; + this.wake(); + return true; + } play() { if (this.reduced || this.ball || document.hidden) return false; const h = hatGeom(this), pr = h.pr; @@ -567,6 +579,12 @@ class Scene { } // the bored trick holds the light on its widget beamLock() { + const w = this.sweep; + if (w) { + const a = this.t - w.t0; + if (a < w.lead + w.dur + w.tail) return a < w.lead ? w.a0 : lerp(w.a0, w.a1, smooth(0, 1, (a - w.lead) / w.dur)); + this.sweep = null; + } const b = this.bore; if (!b || !((b.phase === 'look' && this.t - b.at > 0.35) || b.phase === 'push')) return null; return Math.atan2(b.y - this.g.lamp.y, b.x - this.g.lamp.x); @@ -905,6 +923,7 @@ class Scene { // reduced motion and nothing moving: the scene sleeps until the next call or input } busy() { + if (this.sweep) return true; if (this.d !== (this.dEff ?? this.dTarget) || (this.state === 'install') !== (this.pres.install > 0.999)) return true; for (const k of STATES) { const p = this.pres[k]; if (p > 0.001 && p < 0.999) return true; } if (this.ripples.length) return true; @@ -964,7 +983,7 @@ class Scene { if (!rm && this.t > this.blink.next) { this.blink.at = this.t; this.blink.next = this.t + 3 + Math.random() * 5; } this.updateBeam(dt); this.ripples = this.ripples.filter(r => this.t - r.t0 < 2.2); - if (!this.flocked) this.flock(); + if (!this.flocked && !this.held) this.flock(); this.updateObjs(dt); this.updateMeadow(); this.updateBox(dt); @@ -980,7 +999,7 @@ class Scene { b.steer = rm ? (steering || b.steer > 0.5 ? 1 : 0) : lerp(b.steer, steering ? 1 : 0, Math.min(1, dt * (steering ? 4 : 0.7))); if (!rm) b.phi += dt * TAU / 15; const c = Math.cos(b.phi), sn = Math.sin(b.phi); - if (lock != null) b.sa += angDiff(lock, b.sa) * Math.min(1, dt * 3.2); + if (lock != null) b.sa += angDiff(lock, b.sa) * Math.min(1, dt * (this.sweep ? 9 : 3.2)); else if (this.ptr.on && steering) { const want = this.clampAim(Math.atan2(this.ptr.y - L.y, this.ptr.x - L.x)); b.sa = rm ? want : b.sa + angDiff(want, b.sa) * Math.min(1, dt * 5); diff --git a/site/style.css b/site/style.css index 0e2c6f64..f5b3c64f 100644 --- a/site/style.css +++ b/site/style.css @@ -104,6 +104,17 @@ h1, h2, h3 { text-wrap: balance; } position: fixed; left: 0; top: 0; z-index: 0; width: 100%; height: 100vh; height: 100lvh; background: linear-gradient(var(--sky) 0 61%, var(--sea) 61%); } +/* The landing (see land() in main.js): the camera starts up in the sky, the bar in the middle of it and the + headline below, then comes down to the hill as the bar flies up to its place */ +.intro .scene { background: var(--sky); } +.intro .scene canvas { transform: translateY(72vh); } +.intro .hero { transform: translateY(64vh); } +.intro .dock { transform: translateY(calc(46vh - var(--bar-h) / 2)); } +.intro-run .scene canvas, .intro-run .hero { transform: none; transition: transform 1.4s cubic-bezier(.45, 0, .2, 1); } +.intro-run .dock { transform: none; transition: transform 1.25s cubic-bezier(.55, 0, .2, 1) .15s; } +.intro-skip .scene canvas, .intro-skip .hero, .intro-skip .dock { transition: none !important; } +/* on phones the headline starts higher, so its big words are on screen from the first frame */ +@media (max-width: 699.98px), (max-aspect-ratio: 10/11) { .intro .hero { transform: translateY(38vh); } } .scene canvas { display: block; } main, .site-foot { position: relative; z-index: 1; } main { overflow-x: clip; } @@ -247,6 +258,8 @@ main { overflow-x: clip; } and a paper tag hangs under it on a doodled string (placed by main.js) */ #bar .w.lamp { background-image: linear-gradient(0deg, #ffe04a80, #ffe04a00 82%); box-shadow: inset 0 0 0 1px var(--w-border), 0 0 0 1.5px #ffd43bb3, 0 6px 14px -2px #ffd43bf0; } #bar .w.dl.lamp { background-image: none; box-shadow: 0 6px 14px -2px #ffd43bf0; } +/* the landing's sweep: each widget glows as the light passes over it */ +#bar .w.swept { background-image: linear-gradient(0deg, #ffe04a80, #ffe04a00 82%); box-shadow: inset 0 0 0 1px var(--w-border), 0 0 0 1.5px #ffd43bb3, 0 6px 14px -2px #ffd43bf0; } .lamp-tag { position: absolute; left: 0; top: 0; z-index: 4; width: 0; height: 0; pointer-events: none; transform-origin: 0 0; } .lamp-tag[hidden] { display: none; } .lt-s { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: visible; } From c82f6160909da7ab9aaad9094d5835b1b20de373 Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Fri, 30 Oct 2026 13:08:05 +0100 Subject: [PATCH 09/46] docs: put the website's sheep box back on the grass The sheep's box no longer drifts in the sky in the clouds' colours. It's a cardboard box again, sitting on the grass where it's seen, with room for its label beside it, and now and then it wobbles as the sheep inside shifts. Animals walk round it and no grass grows under it; the eyes in its air holes, its hop and its "baa!" stay. --- site/scene.js | 133 +++++++++++++++++++++++++++----------------------- 1 file changed, 73 insertions(+), 60 deletions(-) diff --git a/site/scene.js b/site/scene.js index 86297203..7610af4b 100644 --- a/site/scene.js +++ b/site/scene.js @@ -120,14 +120,13 @@ const C = { monster: hex('#FF8FC0'), chimney: hex('#E8452F'), roof: hex('#2B2B36'), door: hex('#2E58D8'), skin: hex('#F6C9A6'), navy: hex('#25305E'), wood: hex('#C98B4A'), steel: hex('#D8DCE6'), moon: hex('#FFE89A'), tile: hex('#1D1D24'), huts: ['#FF4A36', '#2E58D8', '#FFC21A', '#36A852'].map(hex), - sprout: hex('#2E9B3B'), + kraft: hex('#CC9A5E'), kraftLit: hex('#E2B983'), kraftDark: hex('#A87442'), tape: hex('#FFE04A'), sprout: hex('#2E9B3B'), }; // animals that live on the hill (not riding a cloud): they wander and graze const grazer = o => (o.kind === 'sheep' || o.kind === 'cow') && !o.ride; /* The sheep's box, in its own units (times boxK): the front face's half size, the depth seen past it, and three air holes punched in the front, the first two with eyes behind them */ const BOX = { hw: 21, hh: 13, dx: 9, dy: -7, holes: [[-11, -1.5], [0.5, -3.5], [11.5, -0.5]] }; -const BOX_SP = 6.5; // its drift, in the clouds' units // a rect [x0, y0, x1, y1] against a rect, or a disc [x, y, r] const overlaps = (a, q) => q.length === 3 ? Math.hypot(clamp(q[0], a[0], a[2]) - q[0], clamp(q[1], a[1], a[3]) - q[1]) < q[2] @@ -243,7 +242,7 @@ class Scene { // a lived-in hill: the animals already there, the grass they eat, and a sheep in a box up in the sky this.flocked = false; this.meadow = []; this.sproutAt = 0; this.held = !!this.o.held; this.sweep = null; - this.box = { v: null, cl: null, side: null, lastSide: null, la: 0, seenAt: -9, hop: -9, peek: -9, lit: 0, open: false, openAt: -9, blinkAt: -9, nextBlink: 0 }; + this.box = { v: null, gx: null, gy: null, side: null, lastSide: null, la: 0, seenAt: -9, hop: -9, wob: -9, nextWob: 0, peek: -9, lit: 0, open: false, openAt: -9, blinkAt: -9, nextBlink: 0 }; this.times = []; this.awake = 0; this.kick = 0; this.raf = 0; this.timer = 0; this.last = null; this.mq = matchMedia('(prefers-reduced-motion: reduce)'); @@ -928,7 +927,7 @@ class Scene { for (const k of STATES) { const p = this.pres[k]; if (p > 0.001 && p < 0.999) return true; } if (this.ripples.length) return true; // (the grazing and its "nom"s are slow: they carry on at the idle rate) - if (this.ball || this.bore || this.doodles.some(d => !d.calm) || this.splashes.length || this.t - this.hatKick < 0.7 || this.t - this.regrowAt < 0.9 || this.t - this.box.hop < 0.8) return true; + if (this.ball || this.bore || this.doodles.some(d => !d.calm) || this.splashes.length || this.t - this.hatKick < 0.7 || this.t - this.regrowAt < 0.9 || this.t - this.box.hop < 0.8 || this.t - this.box.wob < 0.5) return true; for (const o of this.objs) if (o.state === 'fall' || (o.state === 'fly' && !this.reduced) || o.dying != null || (o.land > 0 && this.t - o.land < 0.4)) return true; return this.beam.steer > 0.01 && this.beam.steer < 0.99 && !this.reduced; } @@ -964,7 +963,7 @@ class Scene { this.windTotal = this.windAmb + this.wind; if (!rm) this.drift += dt * (1 + this.windTotal * 2.4); // clouds lean away from the pointer - for (const cl of [...g.clouds, g.sheepCloud, ...(this.box.cl ? [this.box.cl] : [])]) { + for (const cl of [...g.clouds, g.sheepCloud]) { let tx = 0, ty = 0; if (this.ptr.on && !rm) { const p = this.cloudXY(cl, true), dx = p.x - this.ptr.x, dy = p.y - this.ptr.y, r = 170 * u, dd = Math.hypot(dx, dy); @@ -1120,7 +1119,8 @@ class Scene { } // nothing of the scene's own in the way: the lighthouse, the cottage, the key, the huts, the desk open(x, y) { - const g = this.g, u = g.u, T = g.lh, k = g.cot; + const g = this.g, u = g.u, T = g.lh, k = g.cot, bx = this.box; + if (bx.gx != null && Math.abs(x - bx.gx) < 44 * u && y > bx.gy - 46 * u && y < bx.gy + 18 * u) return false; if (g.key && x > g.key.x0 && x < g.key.x1 && y > g.key.y0 - 30 * u) return false; if (Math.abs(x - T.x) < T.wB * 0.9 + 20 * u && y < T.base + 42 * u) return false; if (Math.abs(x - k.x) < k.w * 0.8 + 18 * u && y < k.base + 32 * u) return false; @@ -1278,50 +1278,54 @@ class Scene { } /* ----- the sheep in a box ----- - A box in the clouds' colours drifts across the sky with them, at a height of its own. - When the light finds it, two eyes look out through its air holes (glinting at night); a click or a tap - makes it hop and say baa. It isn't a surprise: clicking it drops nothing. Its label shows while it's in - clear sky. */ + A cardboard box with three air holes sits on the grass where it's seen, clear of the lighthouse, the cottage, + the little scenes and the animals. Now and then the sheep inside shifts and the box wobbles. When the light + finds it, two eyes look out through the holes (glinting at night); a click or a tap makes it hop and say baa. + It isn't a surprise: clicking it drops nothing. Its label shows in the first screen while it's in view. */ placeBox() { - const g = this.g, u = g.u, W = g.W, b = this.box, k = boxK(this), T = g.lh, L = g.lamp, sc = g.sheepCloud; + const g = this.g, u = g.u, W = g.W, b = this.box, T = g.lh, ck = g.cot; b.v = `${this.layoutV}|${this.fontsV}`; - const size = b.size = Math.max(13, 17 * u), cw = textW(this, 'a cloud (or a sheep)', size); - b.tw = textW(this, 'a sheep (in a box)', size); - // where the box and its label don't show (rects, and discs as [x, y, r]): the bar, the notes (a guess; the - // DOM has the last word), near the horizon, the lighthouse, its halo and labels, the cloud with legs and its - // label (the sun and the moon, while they're up, are added as they move) + b.size = Math.max(13, 17 * u); b.tw = textW(this, 'a sheep (in a box)', b.size); + // where its label mustn't go: the bar, the notes (a guess; the DOM has the last word), the key, the + // lighthouse and its label, the cottage const noteX = W < 1024 ? W * 0.05 + Math.min(W * 0.58, 620) + 16 : g.note.x1 + 4; b.hard = [ - [0, 0, W, g.top + 8], g.portrait ? [0, g.note.y0 - 10, W, g.H] : [0, 0, noteX, g.H], [-1e4, g.horizon - 26 * u, 2e4, 2e4], - [L.x, L.y, T.er * 4.9], [T.x - T.wB * 0.8, T.hatTop, T.x + T.wB * 0.8, T.base], - [sc.x0 - sc.w * 0.66, sc.y - sc.w * 0.5, sc.x1 + sc.w * 0.66, sc.y + 18 * u], - [sc.x0 + sc.w * 0.3, sc.y - 6 * u, sc.x1 + sc.w * 0.42 + (g.portrait ? 6 : 14 * u) + 16 * u + cw + 6, sc.y + (g.portrait ? 12 : 22 * u) + 4 * u + size * 0.8], - ...lighthouseLabelRects(this, textW(this, 'a lighthouse (stable)', size), size), + [0, 0, W, g.top + 8], g.portrait ? [0, g.note.y0 - 10, W, g.H] : [0, 0, noteX, g.H], + [T.x - T.wB * 0.9, T.hatTop, T.x + T.wB * 0.9, T.base + 4 * u], [ck.x - ck.w * 0.7, ck.base - ck.h * 3, ck.x + ck.w * 0.7, ck.base + 4 * u], + ...lighthouseLabelRects(this, textW(this, 'a lighthouse (stable)', b.size), b.size), + ...(g.key ? [[g.key.x0, g.key.y0, g.key.x1, g.key.y1]] : []), ]; - // it drifts with the clouds, at a height of its own, as far as can be from theirs - const sky = g.horizon - g.top, lo = g.top + sky * (g.portrait ? 0.12 : 0.18), hi = g.top + sky * (g.portrait ? 0.34 : 0.46); - const others = [...g.clouds.map(cl => cl.y), sc.y]; - let y = lo, far = -1; - for (let cy = lo; cy <= hi; cy += 3 * u) { const d = Math.min(...others.map(o => Math.abs(o - cy))); if (d > far) { far = d; y = cy; } } - // it sets off from the far left of the sky; after a resize, it carries on from where it was - const frac = b.frac ?? (BOX.hw * k + 8) / W; - b.cl = { x0: frac * W + 180 * u - this.drift * BOX_SP * u, y, w: (2 * BOX.hw + BOX.dx) * k, sp: BOX_SP, seed: 91, ox: b.cl?.ox ?? 0, oy: b.cl?.oy ?? 0 }; - b.seenAt = -9; - } - // the box right now: where it drifts, its tilt, and (unless `still`, as for its label) the hop with its squash + // a spot on the grass where it's seen, away from the animals, the tufts and the window's edges, with room + // for its label on the grass beside it + b.gx = b.gy = null; + const k = boxK(this); + let first = null, pick = null; + for (let i = 0; i < 14 && !pick; i++) { + const p = this.landSpot({ gap: 56 * u, spread: i < 7, check: 3, snug: true, clear: true }); + if (!p) break; + first ||= p; + if (!g.portrait && (p.x < noteX + 60 * u || p.x > W * 0.9)) continue; + if (this.boxSide({ x: p.x, y: p.y - BOX.hh * k, k })) pick = p; + } + if (pick ||= first) { b.gx = pick.x; b.gy = pick.y; } + b.seenAt = -9; b.nextWob = this.t + 4 + Math.random() * 6; + } + // the box right now: standing on the grass, its wobble, and (unless `still`, as for its label) the hop with its squash boxAt(still) { - const b = this.box, u = this.g.u, k = boxK(this), rm = this.reduced, t = this.t, q = this.cloudXY(b.cl); - let y = q.y + (rm ? 0 : Math.sin(t * 0.7 + 1) * 3 * u), rot = rm ? 0 : Math.sin(t * 0.5) * 0.035, sq = 0; + const b = this.box, k = boxK(this), rm = this.reduced, t = this.t; + let y = b.gy - BOX.hh * k, rot = 0, sq = 0; + const w = t - b.wob; + if (!rm && w >= 0 && w < 0.5) rot = Math.sin(w / 0.5 * Math.PI * 3) * 0.05 * (1 - w / 0.5); const a = still ? -1 : t - b.hop; if (!rm && a >= 0 && a < 0.75) { if (a < 0.08) sq = Math.sin(a / 0.08 * Math.PI) * 0.1; else if (a < 0.52) { const f = (a - 0.08) / 0.44; y -= Math.sin(f * Math.PI) * 18 * k; rot -= Math.sin(f * Math.PI) * 0.14; } else sq = Math.sin((a - 0.52) / 0.23 * Math.PI) * 0.14; } - return { x: q.x, y, rot, sq, k }; + return { x: b.gx, y, rot, sq, k }; } boxHit(x, y) { - if (this.box.v == null || !this.box.cl) return false; + if (this.box.gx == null) return false; const p = this.boxAt(), k = p.k, pad = this.ptr.kind === 'touch' ? 14 : 7; return x > p.x - BOX.hw * k - pad && x < p.x + (BOX.hw + BOX.dx) * k + pad && y > p.y + (BOX.dy - BOX.hh) * k - pad && y < p.y + BOX.hh * k + pad; } @@ -1335,29 +1339,31 @@ class Scene { } updateBox(dt) { const b = this.box, rm = this.reduced, t = this.t; + if (this.held) return; if (b.v !== `${this.layoutV}|${this.fontsV}`) this.placeBox(); + if (b.gx == null) return; const p = this.boxAt(), lit = this.inBeam(p.x + this.ox, p.y + this.oy); - b.frac = p.x / this.g.W; b.lit = rm ? lit : lerp(b.lit, lit, Math.min(1, dt * 8)); // the eyes come to the holes while the light is on it (and for a moment after a baa), blinking now and then const want = b.lit > 0.4 || t - b.peek < 1.8; if (want !== b.open) { b.open = want; b.openAt = t; b.nextBlink = t + 1.4 + Math.random() * 2; } if (b.open && !rm && t > b.nextBlink) { b.blinkAt = t; b.nextBlink = t + 1.8 + Math.random() * 2.6; } - // its label, while it's in clear sky: looked at a few times a second, faded in and out + // now and then the sheep inside shifts + if (!rm && t > b.nextWob) { b.wob = t; b.nextWob = t + 7 + Math.random() * 9; } + // its label, in the first screen while it's in view: looked at a few times a second, faded in and out if (t - b.seenAt > 0.3 || rm) { b.seenAt = t; b.side = this.pres.hero > 0.01 ? this.boxSide(this.boxAt(true)) : null; if (b.side) b.lastSide = b.side; } b.la = rm ? (b.side ? 1 : 0) : lerp(b.la, b.side ? 1 : 0, Math.min(1, dt * 3)); } - // the side of the box its label fits on right now (below, left or right), or null: the box and the label in the - // window, clear of what's in the way, and seen + // the side of the box its label fits on right now (right, into the pasture, first; then left or below), or null: + // the box and the label in the window, on the grass, clear of what's in the way, and seen boxSide(p) { - const g = this.g, u = g.u, W = g.W, k = p.k, sp = this.sunPos(), mp = this.moonPos(), moon = 25 * u * 1.15, z = 26 * u, hard = [...this.box.hard]; - if (sp.a > 0.1) hard.push([sp.x, sp.y, 38 * u * 1.2]); - if (mp.a > 0.1) hard.push([mp.x, mp.y, moon], [mp.x + moon, mp.y - moon - z * 0.6, mp.x + moon + z, mp.y - moon * 0.2]); + const g = this.g, u = g.u, W = g.W, k = p.k, hard = this.box.hard; const r = [p.x - BOX.hw * k - 3, p.y + (BOX.dy - BOX.hh) * k - 3, p.x + (BOX.hw + BOX.dx) * k + 3, p.y + BOX.hh * k + 3]; if (r[0] < 6 || r[2] > W - 6 || hard.some(q => overlaps(r, q)) || !this.seen(p.x, p.y)) return null; - for (const side of ['below', 'left', 'right']) { + for (const side of ['right', 'left', 'below']) { const q = boxLabel(p.x, p.y, k, u, this.box.size, this.box.tw, side); - if (q.x0 < 6 || q.x1 > W - 6 || hard.some(h => overlaps([q.x0, q.y0, q.x1, q.y1], h))) continue; + if (q.x0 < 6 || q.x1 > W - 6 || q.y1 > g.H - 6 || hard.some(h => overlaps([q.x0, q.y0, q.x1, q.y1], h))) continue; + if (!this.isLand(q.x0 + 4, (q.y0 + q.y1) / 2) || !this.isLand(q.x1 - 4, (q.y0 + q.y1) / 2)) continue; if (this.seen((q.x0 + q.x1) / 2, (q.y0 + q.y1) / 2)) return side; } return null; @@ -1458,7 +1464,6 @@ function drawScene(s, c) { drawMoon(s, c); for (let i = 0; i < g.clouds.length; i++) drawCloud(s, c, g.clouds[i], 'cloud' + i); drawCloud(s, c, g.sheepCloud, 'sheepcloud', true); - drawBox(s, c); drawIsle(s, c); const T = g.lh, x0 = g.portrait ? g.ridge[0][0] - 8 : Math.max(0, g.ridge[0][0] - 10); const y0 = Math.min(T.top - T.gal * 1.2, g.cot.base - g.cot.h * 4.2) - 10 * u; @@ -1470,8 +1475,8 @@ function drawScene(s, c) { drawEye(s, c); drawHat(s, c); // animals and the grass on the hill, back to front (a tuft in front of the one eating it) - const items = [...s.objs.map(o => [o.y, o]), ...s.meadow.map(m => [m.y + 0.5, m])].sort((a, b) => a[0] - b[0]); - for (const [, it] of items) it.blades ? drawMeadow(s, c, it) : drawObj(s, c, it); + const items = [...s.objs.map(o => [o.y, o]), ...s.meadow.map(m => [m.y + 0.5, m]), ...(s.box.gx != null ? [[s.box.gy, s.box]] : [])].sort((a, b) => a[0] - b[0]); + for (const [, it] of items) it === s.box ? drawBox(s, c) : it.blades ? drawMeadow(s, c, it) : drawObj(s, c, it); // the section's little scenes over them: animals pass behind the desk, the huts and the keeper drawStates(s, c); if (s.pres.hero > 0.01) drawLabels(s, c); @@ -1594,27 +1599,30 @@ function drawCloud(s, c, cl, name, legs) { }); } -/* the sheep's box, in the colours of the clouds it drifts with: three faces and three air holes in front; while - the light is on it, eyes in two of them that look at the lamp, blink, and glint at night */ +/* the sheep's box: kraft cardboard, a strip of tape over the flaps, three air holes; while the light is on + it, eyes in two of them that look at the lamp, blink, and glint at night */ function drawBox(s, c) { const b = s.box; - if (b.v == null || !b.cl) return; + if (b.gx == null) return; const g = s.g, P = s.P, p = s.boxAt(), k = p.k, t = s.t, rm = s.reduced, { hw, hh, dx, dy } = BOX; - if (p.x < -(hw + dx) * k * 1.8 || p.x > g.W + hw * k * 1.8) return; - const night = smooth(0.5, 0.9, s.d); + const night = smooth(0.5, 0.9, s.d), card = col => mix(mul(col, P.paper), P.sky, 0.2 * night); c.save(); c.translate(p.x, p.y + hh * k); c.rotate(p.rot); c.scale(1 + p.sq * 0.6, 1 - p.sq); c.translate(0, -hh * k); const K = q => q.map(([x, y]) => [x * k, y * k]); - // lit from above like the clouds: the top as white as they are, the front a touch of sky, the side in their shade - print(c, K([[hw, -hh], [hw + dx, -hh + dy], [hw + dx, hh + dy], [hw, hh]]), mix(P.cloud, P.sky, 0.24)); - print(c, K([[-hw, -hh], [-hw + dx, -hh + dy], [hw + dx, -hh + dy], [hw, -hh]]), P.cloud); - print(c, K([[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]]), mix(P.cloud, P.sky, 0.08)); + const front = K([[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]]); + const top = K([[-hw, -hh], [-hw + dx, -hh + dy], [hw + dx, -hh + dy], [hw, -hh]]); + const side = K([[hw, -hh], [hw + dx, -hh + dy], [hw + dx, hh + dy], [hw, hh]]); + print(c, side, card(C.kraftDark)); print(c, top, card(C.kraftLit)); print(c, front, card(C.kraft)); + // tape over the seam between the flaps, front to back, folded a little way down the front + c.globalAlpha = 0.8; + print(c, K([[-3.4, -hh], [-3.4 + dx, -hh + dy], [3.4 + dx, -hh + dy], [3.4, -hh], [3.6, -hh + 6.5], [0.4, -hh + 5.8], [-3.6, -hh + 6.8]]), card(C.tape)); + c.globalAlpha = 1; // the holes, dark; eyes in two of them while the light is on the box (or just after a baa) let open = b.open ? (rm ? 1 : smooth(0, 0.2, t - b.openAt)) : (rm ? 0 : 1 - smooth(0, 0.14, t - b.openAt)); if (b.open && !rm && t - b.blinkAt < 0.16) open *= Math.abs(Math.cos((t - b.blinkAt) / 0.16 * Math.PI)); const L = s.g.lamp, look = Math.atan2(L.y - p.y, L.x - p.x), r = 3.4 * k; BOX.holes.forEach(([hx, hy], i) => { const x = hx * k, y = hy * k; - dot(c, x, y, r, mix(P.ink, P.sky, 0.15)); + dot(c, x, y, r, mix(P.ink, C.kraftDark, 0.18)); if (i > 1 || open < 0.04) return; const ew = r * 0.8, eh = r * 0.8 * open; c.beginPath(); c.ellipse(x, y + (1 - open) * r * 0.3, ew, Math.max(0.3, eh), 0, 0, TAU); c.fillStyle = rgba(mix(hex('#FFFFFF'), P.beam, 0.4 * night)); c.fill(); @@ -1624,12 +1632,17 @@ function drawBox(s, c) { dot(c, px - r * 0.16, py - r * 0.2, r * 0.15 + 0.25, '#FFFFFF'); c.restore(); }); - // the pen: the outline in one weight, the edges inside lighter, and a ring round each hole + // the pen: the outline in one weight, the edges inside lighter, a ring round each hole, and this side up c.beginPath(); edges(c, K([[-hw, hh], [-hw, -hh], [-hw + dx, -hh + dy], [hw + dx, -hh + dy], [hw + dx, hh + dy], [hw, hh]]), DET * 1.05, 3, true, { ov: 1.5 }); edges(c, K([[-hw, -hh], [hw, -hh], [hw, hh]]), TEX * 1.25, 9, false, { ov: 0.8 }); edges(c, K([[hw, -hh], [hw + dx, -hh + dy]]), TEX * 1.25, 12, false, { ov: 0.6 }); BOX.holes.forEach(([hx, hy], i) => ribbon(c, ellipse(hx * k, hy * k, r, r, 12, i), TEX, 30 + i, { tin: 1, tout: 1, w0: 1, w1: 1, amp: 0.2 })); + for (const ax of [hw + dx * 0.32, hw + dx * 0.7]) { + const x = ax * k, y0 = (dy * (ax - hw) / dx + 6) * k, y1 = y0 - 7 * k; + ribbon(c, [[x, y0], [x, y1]], TEX * 0.9, ax, { tin: 1, tout: 1, w0: 0.8, w1: 0.8, amp: 0.1 }); + ribbon(c, [[x - 1.7 * k, y1 + 2.4 * k], [x, y1], [x + 1.7 * k, y1 + 2.4 * k]], TEX * 0.9, ax + 1, { tin: 1, tout: 1, w0: 0.8, w1: 0.8, amp: 0.1 }); + } c.fillStyle = INK; c.fill(); // and at night the eyes glint, a little star at each if (open > 0.3 && night > 0.05) { @@ -2402,7 +2415,7 @@ function drawLabels(s, c) { } // the sheep in its box, where placeBox found room for the label const bx = s.box; - if (bx.lastSide && bx.la > 0.01 && bx.cl) { + if (bx.lastSide && bx.la > 0.01 && bx.gx != null) { const p = s.boxAt(true), q = boxLabel(p.x, p.y, p.k, u, size, bx.tw, bx.lastSide), lc = rgba(mix(P.ink, P.paper, night), s.pres.hero * bx.la); label(c, 'a sheep (in a box)', q.tx, q.ty, size, lc, q.align); c.fillStyle = lc; arrow(c, q.arrow, TEX * 1.1, 11); From 8bea9e86469320d27558bfcd8089936472d1613f Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Fri, 30 Oct 2026 13:11:33 +0100 Subject: [PATCH 10/46] docs: let the website's opening film replay with ?intro The opening plays once a browser tab, only from the top of the page. Adding ?intro to the address plays it again on every load, so it can be watched and reviewed; reduced motion still turns it off. --- site/index.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/site/index.html b/site/index.html index 82b1a830..ecebc2e9 100644 --- a/site/index.html +++ b/site/index.html @@ -45,8 +45,9 @@ const root = document.documentElement; root.dataset.mode = localStorage.getItem("mode") || (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"); root.classList.add("js"); -// the landing film: once a session, opening at the top of the page, with motion allowed (see land() in main.js) -try { if (!sessionStorage.getItem("landed") && !location.hash && !matchMedia("(prefers-reduced-motion: reduce)").matches) root.classList.add("intro"); } catch {} +// the landing film: once a session, opening at the top of the page, with motion allowed; ?intro plays it again +// (see land() in main.js) +try { if (!matchMedia("(prefers-reduced-motion: reduce)").matches && (new URLSearchParams(location.search).has("intro") || (!sessionStorage.getItem("landed") && !location.hash))) root.classList.add("intro"); } catch {} From c021796e02a197a145fdd3075fda1f700cd6ef32 Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Fri, 30 Oct 2026 13:24:56 +0100 Subject: [PATCH 11/46] docs: open the website on a title card that expands into the bar The opening no longer starts on an empty sky. The whole drawing is there from the first frame, with a title card over it: StableBar in big letters, the bar large and centred under it, and a line about it under that. After a moment the card's bar stretches across the window and rises into the real bar at the top, the card drifts away, the notes come in and the lighthouse sweeps its light along the bar. On phones the card sits low over the sea, its title on two lines. The drawing's own labels wait until the card has gone, and the title stays the page's first large paint. --- site/index.html | 12 +++++++++++ site/main.js | 46 ++++++++++++++++++++++++++++++--------- site/scene.js | 4 +++- site/style.css | 57 +++++++++++++++++++++++++++++++++++++++---------- 4 files changed, 97 insertions(+), 22 deletions(-) diff --git a/site/index.html b/site/index.html index ecebc2e9..56b4ebfe 100644 --- a/site/index.html +++ b/site/index.html @@ -113,6 +113,18 @@ +