diff --git a/site/src/iso.css b/site/src/iso.css index 9f363b3..501a05e 100644 --- a/site/src/iso.css +++ b/site/src/iso.css @@ -11,3 +11,22 @@ .face.right { fill: color-mix(in oklab, var(--tone), black 20%); } + +/* Each block drops one step and lands on its beat. */ +.grow .block { + animation: land 0.2s cubic-bezier(0.2, 0.8, 0.3, 1) both; + animation-delay: calc(var(--land) - 0.2s); +} + +@keyframes land { + from { + transform: translateY(-28px); + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .grow .block { + animation: none; + } +} diff --git a/site/src/main.ts b/site/src/main.ts index d7ec522..99e3117 100644 --- a/site/src/main.ts +++ b/site/src/main.ts @@ -1,15 +1,30 @@ import "./style.css"; import "./iso.css"; import { block, stage } from "./iso.ts"; +import { model } from "./model.ts"; + +/** 112 BPM, the film's tempo; blocks land on sixteenths. */ +const BEAT = 60 / 112; +const STEP = BEAT / 4; const app = document.querySelector("#app"); if (!app) throw new Error("index.html has no #app"); -app.append( - stage([ - block({ x: 0, y: 0, w: 12, d: 12, h: 0.5, tone: "#e9e9e6" }), - block({ x: 2, y: 2, z: 0.5, w: 3, d: 3, h: 4, tone: "#f4f4f2" }), - block({ x: 7, y: 2, z: 0.5, w: 3, d: 3, h: 2, tone: "#f4f4f2" }), - block({ x: 2, y: 7, z: 0.5, w: 3, d: 3, h: 1, tone: "#3d7fe5" }), - ]), -); +const blocks: SVGGElement[] = []; +let beat = 1; +for (const part of model) { + part.blocks.forEach((spec, i) => { + const g = block(spec); + g.style.setProperty("--land", `${(beat * BEAT + i * STEP).toFixed(3)}s`); + blocks.push(g); + }); + beat += Math.ceil(part.blocks.length / 4); +} + +const title = document.createElement("h1"); +title.textContent = "Ely Office"; +const model3d = stage(blocks); +model3d.classList.add("grow"); +model3d.setAttribute("role", "img"); +model3d.setAttribute("aria-label", model.slice(1, -1).map((p) => p.name).join(", ")); +app.append(title, model3d); diff --git a/site/src/model.ts b/site/src/model.ts new file mode 100644 index 0000000..8f64cd7 --- /dev/null +++ b/site/src/model.ts @@ -0,0 +1,61 @@ +// The desk model: six apps, each its own object. +import type { Block } from "./iso.ts"; + +const PAPER = "#f4f4f2"; +const DESK = "#e6e6e2"; +/** The one accent: roads and the selected cell. */ +export const ACCENT = "#3d7fe5"; +const TOP = 0.6; + +/** One part of the model, grown together. */ +export interface Part { + name: string; + blocks: Required[]; +} + +const on = (x: number, y: number, w: number, d: number, h: number, z = 0, tone = PAPER): Required => ({ + x, y, z: TOP + z, w, d, h, tone, +}); + +/** A stack of pages. */ +const doc = [0, 1, 2, 3].map((i) => on(1 + i * 0.08, 1 - i * 0.08, 2.6, 3, 0.25, i * 0.25)); + +/** A three by three sheet; one cell is selected. */ +const heights = [1.2, 2.4, 1.6, 0.8, 1.8, 1, 0.5, 1.4, 0.7]; +const table = heights.map((h, i) => + on(5 + (i % 3), 1 + Math.floor(i / 3), 0.9, 0.9, h, 0, i === 4 ? ACCENT : PAPER), +); + +/** A slide on its stand. */ +const show = [on(9.4, 1, 3.2, 0.25, 2.3), on(9.4, 1.25, 3.2, 1.6, 0.25)]; + +/** A page with its corner folded down. */ +const pdf = [on(13, 3.2, 2.4, 2.4, 1), on(13, 5.6, 1.6, 0.8, 1), on(14.6, 5.6, 0.8, 0.8, 0.3)]; + +/** A trunk and a branch, joined by a merge. */ +const tree = [on(6.5, 7.6, 0.7, 0.7, 2.8), on(8.7, 7.6, 0.7, 0.7, 1.8), on(7.2, 7.6, 1.5, 0.7, 0.3, 1.2)]; + +/** Headings stepping down. */ +const mark = [0, 1, 2].map((i) => on(1, 7 + i, 3, 1, 2 - i * 0.6)); + +/** Roads: every document exports to PDF; Markdown goes to Git. */ +const road = (x: number, y: number, w: number, d: number) => on(x, y, w, d, 0.08, 0, ACCENT); +const roads = [ + road(2, 4, 0.6, 0.6), + road(6, 4, 0.6, 0.6), + road(10.7, 2.85, 0.6, 1.75), + road(2, 4.6, 11, 0.6), + road(4, 7.6, 2.5, 0.6), +]; + +/** Growth order: desk, the six apps, then roads. */ +export const model: Part[] = [ + { name: "Desk", blocks: [{ x: 0, y: 0, z: 0, w: 16, d: 11, h: TOP, tone: DESK }] }, + { name: "ElyDoc", blocks: doc }, + { name: "ElyTable", blocks: table }, + { name: "ElyShow", blocks: show }, + { name: "ElyPDF", blocks: pdf }, + { name: "ElyTree", blocks: tree }, + { name: "ElyMark", blocks: mark }, + { name: "Roads", blocks: roads }, +]; diff --git a/site/src/style.css b/site/src/style.css index 8bcc3bd..e44c843 100644 --- a/site/src/style.css +++ b/site/src/style.css @@ -12,5 +12,21 @@ body { #app { min-height: 100vh; display: grid; - place-items: center; + place-content: center; + justify-items: center; + gap: 6rem; + padding: 9rem 1.5rem; + box-sizing: border-box; +} + +h1 { + margin: 0; + font-size: clamp(3rem, 8vw, 6rem); + font-weight: 700; + letter-spacing: -0.03em; +} + +.stage { + max-width: 100%; + height: auto; } diff --git a/site/test/model.test.ts b/site/test/model.test.ts new file mode 100644 index 0000000..8686dbd --- /dev/null +++ b/site/test/model.test.ts @@ -0,0 +1,27 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { order } from "../src/iso.ts"; +import { ACCENT, model } from "../src/model.ts"; + +const all = model.flatMap((p) => p.blocks); +const [desk] = model[0].blocks; + +test("the model draws without intersections or cycles", () => { + assert.equal(order(all).length, all.length); +}); + +test("every block stands on the desk", () => { + for (const b of all.slice(1)) { + assert.ok(b.x >= 0 && b.y >= 0 && b.x + b.w <= desk.w && b.y + b.d <= desk.d, JSON.stringify(b)); + assert.ok(b.z >= desk.h); + } +}); + +test("the accent covers at most a tenth of the visible tops", () => { + const area = (list: typeof all) => list.reduce((s, b) => s + b.w * b.d, 0); + assert.ok(area(all.filter((b) => b.tone === ACCENT)) / area([desk]) <= 0.1); +}); + +test("six apps between the desk and the roads", () => { + assert.deepEqual(model.slice(1, -1).map((p) => p.name), ["ElyDoc", "ElyTable", "ElyShow", "ElyPDF", "ElyTree", "ElyMark"]); +}); diff --git a/site/video/demo/make.py b/site/video/demo/make.py index f9f6e61..9d20df5 100644 --- a/site/video/demo/make.py +++ b/site/video/demo/make.py @@ -256,15 +256,15 @@ climbs to the lighthouse and comes back through the dunes. ## Route at a glance -| Section | Distance | Time | -| --- | --- | --- | -| Sea wall | 1.2 km | 15 min | -| Lighthouse climb | 0.8 km | 20 min | -| Dune path | 2.1 km | 30 min | +| Section | Distance | Time | +| ---------------- | -------- | ------ | +| Sea wall | 1.2 km | 15 min | +| Lighthouse climb | 0.8 km | 20 min | +| Dune path | 2.1 km | 30 min | ## Pace -Walking time is distance over pace, plus a minute for every ten metres of climb: +Walking time is distance over pace, plus a minute for every ten meters of climb: $$ t = \frac{d}{v} + \frac{h}{10} diff --git a/site/video/record/all.sh b/site/video/record/all.sh new file mode 100644 index 0000000..39e1bde --- /dev/null +++ b/site/video/record/all.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Builds demo content, then records all six apps. +set -euo pipefail +here=$(cd "$(dirname "$0")" && pwd) +bash -c "source '$here/lib.sh'; trap - EXIT; content" +for app in doc table show pdf tree mark; do + bash "$here/$app.sh" +done diff --git a/site/video/record/lib.sh b/site/video/record/lib.sh index 86409e7..e58f6a6 100644 --- a/site/video/record/lib.sh +++ b/site/video/record/lib.sh @@ -42,8 +42,10 @@ launch() { rm -rf "$home" local data="$home/Library/Application Support/ElyOffice/$app" mkdir -p "$data" + local panels= + [ -z "${PANEL_LEFT:-}" ] || panels=",\"panels\":{\"left\":$PANEL_LEFT}" printf '{"version":1,"window":{"mode":"windowed","x":%s,"y":%s,"width":%s,"height":%s}%s}\n' \ - "$win_x" "$win_y" "$win_w" "$win_h" "${PANEL_LEFT:+,\"panels\":{\"left\":$PANEL_LEFT}}" >"$data/settings.json" + "$win_x" "$win_y" "$win_w" "$win_h" "$panels" >"$data/settings.json" env HOME="$home" ELY_APPEARANCE=light ELY_LOCALE=en ELY_NO_DIALOG=1 ELY_LOG=info ELY_REDUCE_MOTION=0 \ ${EXTRA_ENV:-} "$root/target/release/$app" "$@" >"$out/logs/$app.out" 2>"$out/logs/$app.err" & app_pid=$! diff --git a/site/video/record/mark.sh b/site/video/record/mark.sh new file mode 100644 index 0000000..2a5847c --- /dev/null +++ b/site/video/record/mark.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# ElyMark: visual typing, formula and diagram, source and back. +source "$(dirname "$0")/lib.sh" + +notes=/Users/Shared/Notes +mkdir -p "$notes" +cp "$demo/harbor-loop.md" "$notes/" +us_input +launch ely-mark "$notes/harbor-loop.md" +wait_log 'mark: shown as "visual"' 60 +front +sleep 3 +x=$win_x +y=$win_y +record_start mark +pause 0.8 +mark "click end of the intro paragraph" +mouse click $((x + 1000)) $((y + 235)) +pause 0.4 +mark "typing" +typed " Allow two hours in winter." +pause 0.6 +mark "scroll to formula and Mermaid diagram" +mouse move $((x + 1000)) $((y + 520)) 0.5 +mouse scroll -1000 1.8 +pause 1.8 +mark "Cmd+2: source mode" +keys 'keystroke "2" using command down' +pause 2.2 +mark "Cmd+1: back to visual" +keys 'keystroke "1" using command down' +pause 2.2 +record_stop mark +quit_app +rm -rf "$notes"